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 0000000000000000000000000000000000000000..cc3bd1b2adcc39713219e33a2c6ee558b879a28a --- /dev/null +++ b/apps/mobile/src/features/projects/AddProjectScreen.test.ts @@ -0,0 +1,273 @@ +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/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("../../components/MaterialListRow", () => ({ MaterialListRow: "MaterialListRow" })); +vi.mock("../../components/MaterialButton", () => ({ MaterialButton: "MaterialButton" })); +vi.mock("../../components/ScreenScrollView", () => ({ ScreenScrollView: "ScrollView" })); +vi.mock("../settings/components/SettingsScreen", () => ({ SettingsScreen: "SettingsScreen" })); +vi.mock("../../components/ThemedSwitch", () => ({ ThemedSwitch: "Switch" })); +vi.mock("react-native", () => ({ + Platform: { OS: "ios" }, + 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" }, capabilities: {} }, + settings: { addProjectBaseDirectory: state.baseDirectory }, + }, + ], + [ + "other-environment", + { + environment: { platform: { os: "linux" }, capabilities: {} }, + 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 8ccc7508cee6fd5ba6f3deba62b4f75e3c58b553..f9d7209f69923b6f530635b98cc997c6af807d77 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -39,6 +39,8 @@ import { isWindowsPlatform, } from "@t3tools/client-runtime/state/projects"; import { + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, CommandId, type EnvironmentId, type EnvironmentMachineKind, @@ -59,6 +61,7 @@ import { useProjects, useServerConfigs, waitForProject } from "../../state/entit 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"; @@ -522,6 +525,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 @@ -610,7 +622,11 @@ export function AddProjectSourceScreen() { ) : null} } isFirst={selectedEnvironment.newProjectsRoot === null} + disabled={!canCreateProject} onPress={() => navigation.dispatch( StackActions.push("AddProjectLocal", { @@ -634,11 +651,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} /> @@ -670,7 +689,17 @@ 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) + ) { + Alert.alert( + "Project not added", + `This connection cannot add projects right now. Any existing files remain at ${workspaceRoot}.`, + ); + return; + } const existing = findExistingAddProject({ projects, @@ -1159,6 +1188,10 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s 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); @@ -1191,15 +1224,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, @@ -1342,17 +1392,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} (null); @@ -291,7 +297,12 @@ export function NewTaskBranchPickerRouteScreen() { const selectBranch = useCallback( async (branch: VcsRef) => { - 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; @@ -336,6 +347,7 @@ export function NewTaskBranchPickerRouteScreen() { } }, [ + canWriteSourceControl, flow.selectBranch, flow.selectedProject, flow.setBranchQuery, diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx index 18471f88af09dea9ac32791f3e8847b25196cda3..ccc1e702d463dc411b052bad36e56d6e9c1cee87 100644 --- a/apps/mobile/src/features/threads/ThreadGitControls.tsx +++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx @@ -1,6 +1,7 @@ import { createNativeHeaderMenu } from "../../components/nativeHeaderMenu.ios"; import type { ScreenHeaderMenu } from "../../components/ScreenHeader.types"; import { + AuthSourceControlWriteScope, EnvironmentId, type GitRunStackedActionResult, type ProjectScript, @@ -17,6 +18,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, @@ -113,6 +115,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; @@ -122,18 +128,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.") @@ -163,6 +175,7 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) { const runActionWithPrompt = useCallback( async (input: GitActionRequestInput) => { + if (!canWriteSourceControl) return; const confirmableAction = input.action === "push" || input.action === "create_pr" || @@ -191,10 +204,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 0000000000000000000000000000000000000000..b4a6bcd13d3578c1f0e4a38e41b3416daa57bc7e --- /dev/null +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx @@ -0,0 +1,172 @@ +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" }, + useWindowDimensions: () => ({ height: 800, width: 400 }), + 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 d16b72d13d81c6d33638c107ca4a97fd826b171b..13d1c37f0fcc0fbea6d709550ef9dd5be3132928 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -30,6 +30,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 @@ -109,11 +110,12 @@ 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={() => { const branch = sanitizeFeatureBranchName(newBranchName.trim()); if (branch.length === 0) return; - void gitActions.onCreateSelectedThreadBranch(branch).then(() => { + void gitActions.onCreateSelectedThreadBranch(branch).then((result) => { + if (result === null) return; setNewBranchName(""); navigation.goBack(); }); @@ -150,6 +152,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { label="Create worktree" tone="primary" disabled={ + !canChangeThreadBranch || busy || worktreeBaseBranch.trim().length === 0 || worktreeBranchName.trim().length === 0 @@ -160,7 +163,8 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { if (baseBranch.length === 0 || newBranch.length === 0) return; void gitActions .onCreateSelectedThreadWorktree({ baseBranch, newBranch }) - .then(() => { + .then((result) => { + if (result === null) return; setWorktreeBranchName(""); navigation.goBack(); }); @@ -203,9 +207,10 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { )} accessibilityRole="button" accessibilityState={{ selected: branch.current, disabled: busy || disabled }} - disabled={busy || disabled} + disabled={!canChangeThreadBranch || busy || disabled} onPress={() => { - void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { + void gitActions.onCheckoutSelectedThreadBranch(branch.name).then((result) => { + 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 19d77a327deff79e83213abea200840a4634f4e8..5a04951b4665ee4c16b9cf5413b2cb2c1ad31e77 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -30,6 +30,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 @@ -57,6 +58,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({ @@ -66,7 +68,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 ( @@ -274,7 +284,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { void runCommitAction(true)} /> @@ -283,7 +293,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 06cce85791f6fe94452a9fd969c430015c9e6927..3c695fef8616a2e625be7f348c8e0af04c9a7b94 100644 --- a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx @@ -32,6 +32,7 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { const { height: windowHeight } = useWindowDimensions(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); + const { canWriteSourceControl, canChangeThreadBranch } = gitActions; const params = props.route.params; @@ -59,17 +60,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) { @@ -91,9 +100,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, @@ -164,11 +175,13 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { 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 31ad85873d1c5f50b6a50189bc77936bbf396a81..54baa4467377d63d1bf9229b30d4b0546750e23d 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -87,6 +87,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { ); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); + const { canWriteSourceControl, canChangeThreadBranch } = gitActions; const theme = useUniwindTheme(); const foregroundColor = theme["--color-foreground"]; const sheetColor = theme["--color-sheet"]; @@ -117,15 +118,21 @@ function GitOverviewSheetContent(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(() => { @@ -145,6 +152,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { const runActionWithPrompt = useCallback( async (input: GitActionRequestInput) => { + if (!canWriteSourceControl) return; const confirmableAction = input.action === "push" || input.action === "create_pr" || @@ -176,7 +184,16 @@ function GitOverviewSheetContent(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( @@ -186,6 +203,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { await openExistingPr(); return; } + if (!canWriteSourceControl) return; if (item.dialogAction === "commit") { navigation.navigate("GitCommit", { environmentId: String(environmentId), @@ -201,7 +219,14 @@ function GitOverviewSheetContent(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 @@ -284,8 +309,12 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { void gitActions.onPullSelectedThreadBranch()} /> @@ -309,7 +338,11 @@ function GitOverviewSheetContent(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 0000000000000000000000000000000000000000..5867021e347e1d44e0dd9a4966680a1c6a72f618 --- /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/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 fde1b795d34f3d45330bb0cbe7c8c83501877657..be19904ff95e2025aa3a18804b0888b7f056b48b 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], @@ -337,14 +374,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, @@ -360,9 +389,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 }, ); }, [ @@ -375,6 +411,8 @@ export function useSelectedThreadGitActions() { ); return { + canWriteSourceControl, + canChangeThreadBranch, refreshSelectedThreadGitStatus, refreshSelectedThreadBranches, onCheckoutSelectedThreadBranch, diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index cc3695099397baed6a760a8651a1ae4a3fe565fa..15f0d63de20b2ff329348c0ba83d86354053d0e0 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -4,8 +4,10 @@ import { AuthSettingsWriteScope, DEFAULT_SERVER_SETTINGS, ProviderInstanceId, + ThreadId, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, + AuthSourceControlWriteScope, AuthRelayReadScope, AuthRelayWriteScope, WS_METHODS, @@ -113,6 +115,28 @@ describe("RPC authorization scopes", () => { ); }); + it("requires source control writes to start, retry, or cancel project clones", () => { + for (const method of [ + WS_METHODS.projectCloneStart, + WS_METHODS.projectCloneRetry, + WS_METHODS.projectCloneCancel, + ]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthSourceControlWriteScope); + } + expect(requiredScopeForRpcMethod(WS_METHODS.subscribeProjectClones)).toBe( + AuthOrchestrationReadScope, + ); + }); + + it("separates viewing pull request file progress from writing it", () => { + expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsFilesViewed)).toBe( + AuthOrchestrationReadScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsSetFilesViewed)).toBe( + AuthSourceControlWriteScope, + ); + }); + it("rejects unknown RPC method names", () => { for (const method of ["server.notRegistered", "toString", "constructor"]) { expect(() => requiredScopeForRpcMethod(method)).toThrow( @@ -240,3 +264,40 @@ describe("settings mutation authorization", () => { }).pipe(Effect.scoped), ); }); + +it.effect("requires task permission before attaching a prepared worktree to a thread", () => + Effect.gen(function* () { + const group = WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + ( + tag, + ): tag is Exclude< + keyof typeof RPC_REQUIRED_SCOPES, + typeof WS_METHODS.gitPreparePullRequestThread + > => tag !== WS_METHODS.gitPreparePullRequestThread, + ), + ); + let handled = false; + const client = yield* RpcTest.makeClient(group).pipe( + Effect.provide( + Layer.mergeAll( + group.toLayerHandler(WS_METHODS.gitPreparePullRequestThread, () => + Effect.sync(() => { + handled = true; + }).pipe(Effect.andThen(Effect.never)), + ), + RpcAuthorization.layer([AuthSourceControlWriteScope]), + ), + ), + ); + expect( + yield* client[WS_METHODS.gitPreparePullRequestThread]({ + cwd: "/repo", + reference: "42", + mode: "worktree", + threadId: ThreadId.make("thread"), + }).pipe(Effect.flip), + ).toMatchObject({ requiredScope: AuthOrchestrationOperateScope }); + expect(handled).toBe(false); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 355668e1b196b060903cfa81c7d13ee53e0b436a..1049a80cb633f5e8d75ab45f8657ffb4f6478a57 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -1,6 +1,7 @@ import { CLIENT_GUARDED_RPC_SCOPES, type DeviceListInput, + clientRpcRequiredScopes, AuthAccessReadScope, ServerSettingsPatch, ProviderInstanceMutation, @@ -120,15 +121,6 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsFilesViewed]: 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.pullRequestsSetFilesViewed]: AuthOrchestrationOperateScope, // 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, @@ -136,15 +128,8 @@ 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.pullRequestsLabelCandidates]: AuthOrchestrationReadScope, - [WS_METHODS.pullRequestsSetLabels]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, - [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, - [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, - [WS_METHODS.projectCloneStart]: AuthOrchestrationOperateScope, - [WS_METHODS.projectCloneCancel]: AuthOrchestrationOperateScope, - [WS_METHODS.projectCloneRetry]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope, [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, @@ -167,16 +152,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope, [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.gitResolvePullRequest]: AuthOrchestrationReadScope, [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.reviewGetDiffPreview]: AuthReviewWriteScope, [WS_METHODS.reviewGetDiffFileContents]: AuthReviewWriteScope, [WS_METHODS.terminalOpen]: AuthTerminalOperateScope, @@ -246,13 +223,20 @@ const requiredScopesForSettingsUpdate = (payload: unknown) => { : [...new Set([...scopes, AuthProvidersManageScope])]; }; +const requiredScopesForRpcCall = ( + method: string, + payload: unknown, +): ReadonlyArray => { + if (method === WS_METHODS.serverUpdateSettings) return requiredScopesForSettingsUpdate(payload); + const guarded = clientRpcRequiredScopes(method, payload); + if (guarded.length > 0) return guarded; + return [requiredScopeForRpcMethod(method)]; +}; + /** Authorizes every RPC on one connection against that connection's session scopes. */ export const layer = (scopes: ReadonlyArray) => Layer.succeed(RpcScopeAuthorization)((effect, { rpc, payload }) => { - const requiredScopes = - rpc._tag === WS_METHODS.serverUpdateSettings - ? requiredScopesForSettingsUpdate(payload) - : [requiredScopeForRpcMethod(rpc._tag)]; + const requiredScopes = requiredScopesForRpcCall(rpc._tag, payload); const requiredScope = requiredScopes.find((scope) => !scopes.includes(scope)); return requiredScope === undefined ? effect : Effect.fail(rpcAuthorizationError(requiredScope)); }); diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 1a74467edfc880e8ee8487f28329c3bdbf59494e..9cda7aaa5a59f27c4fc32fad155627e868b2afae 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, @@ -374,6 +375,7 @@ export const layer = HttpApiBuilder.group( AuthEnvironmentMaintainScope, AuthTerminalOperateScope, AuthReviewWriteScope, + AuthSourceControlWriteScope, AuthAccessReadScope, AuthAccessWriteScope, AuthRelayReadScope, diff --git a/apps/web/src/components/BranchPicker.tsx b/apps/web/src/components/BranchPicker.tsx index c8c37315b2dc94cc4a6a10b860503506eabe3803..7a0598ad90c9b5cdc36b975ecb9a236414afb6dc 100644 --- a/apps/web/src/components/BranchPicker.tsx +++ b/apps/web/src/components/BranchPicker.tsx @@ -264,6 +264,7 @@ export function BranchPickerRefItem({ projectCwd: activeProjectCwd, index, value, + disabled, onClick, onContextMenu, }: { @@ -271,6 +272,7 @@ export function BranchPickerRefItem({ projectCwd: string | null; index: number; value?: string; + disabled?: boolean; onClick: ComponentProps["onClick"]; onContextMenu?: ComponentProps["onContextMenu"]; }) { @@ -288,6 +290,7 @@ export function BranchPickerRefItem({ : null; return ( { - if (!activeThreadId || !activeProject) return; + if ( + !activeThreadId || + !activeProject || + (hasServerThread && !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) + ) + return; if (serverSession && worktreePath !== activeWorktreePath) { void stopThreadSession({ environmentId, @@ -273,8 +290,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. @@ -392,6 +412,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(); @@ -400,7 +434,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); @@ -461,6 +495,7 @@ export function BranchToolbarBranchSelector({ }; const createRef = (rawName: string) => { + if (!canChangeThreadBranch) return; const name = sanitizeNewRefName(rawName); if (!branchCwd || !name || isBranchActionPending) return; @@ -647,6 +682,17 @@ export function BranchToolbarBranchSelector({ projectCwd={activeProjectCwd} index={index} value={itemValue} + disabled={ + !canUpdateThreadBranch || + (!canWriteSourceControl && + !isSelectingWorktreeBase && + (!activeProjectCwd || + !resolveBranchSelectionTarget({ + activeProjectCwd, + activeWorktreePath, + refName, + }).reuseExistingWorktree)) + } onClick={() => selectPickerItem(itemValue)} onContextMenu={(event) => handleBranchContextMenu(event, itemValue)} /> diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bc383266d5c769f509f148dd821c0a0bae0486d8..25cf8b7e7765c2ed48bcf0304add9b4e94544323 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -50,6 +50,7 @@ import { useAttachmentUploadStore } from "../lib/attachmentUploadQueue"; import { AuthOrchestrationOperateScope, AuthSettingsWriteScope, + AuthSourceControlWriteScope, type AssistantCitation, type ChatFileAttachment, CommandId, @@ -397,6 +398,7 @@ import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnect import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useEnvironmentQuery } from "../state/query"; +import { useEnvironmentScope } from "~/state/session"; import { environmentServerConfigsAtom, primaryServerAvailableEditorsAtom, @@ -559,11 +561,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"; @@ -1577,6 +1575,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, @@ -2164,7 +2163,7 @@ export default function ChatView(props: ChatViewProps) { (isServerThread ? activeThread?.runtimeMode : undefined) ?? defaultRuntimeMode; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; - const canCheckoutPullRequestIntoThread = isLocalDraftThread; + const canCheckoutPullRequestIntoThread = canWriteSourceControl && isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; // Prefer the larger of turn-item-committed ids and projection messages so // env lock does not unlock while turn items lag projection hydration. @@ -7161,6 +7160,7 @@ export default function ChatView(props: ChatViewProps) { }); }, [activeBranchMismatchKey, showBranchMismatchBanner]); const handleSwitchCheckoutToThread = useCallback(async () => { + if (!canWriteSourceControl) return; if ( !activeProjectCwd || !activeThread || @@ -7191,6 +7191,10 @@ export default function ChatView(props: ChatViewProps) { return; } + if (!readEnvironmentScope(environmentId, AuthSourceControlWriteScope)) { + setIsRestoringThreadBranch(false); + return; + } const nextBranch = checkoutResult.value.refName ?? localCheckoutBranchMismatch.threadBranch; if (nextBranch !== activeThread.branch) { const updateResult = await updateThreadMetadata({ @@ -7216,6 +7220,7 @@ export default function ChatView(props: ChatViewProps) { setIsRestoringThreadBranch(false); scheduleComposerFocus(); }, [ + canWriteSourceControl, activeProjectCwd, activeThread, environmentId, @@ -7622,12 +7627,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 feedbackBannerItems = useMemo( () => feedbackSubmissions.flatMap((submission) => { @@ -7722,7 +7732,7 @@ export default function ChatView(props: ChatViewProps) { - @@ -2151,6 +2193,7 @@ export default function GitActionsControl({ variant="outline" size="sm-multiline" onClick={continuePendingDefaultBranchAction} + disabled={!canWriteSourceControl} > {pendingDefaultBranchActionCopy?.continueLabel ?? "Continue"} @@ -2158,6 +2201,7 @@ export default function GitActionsControl({ className="w-full max-w-full sm:w-auto" size="sm-multiline" onClick={checkoutFeatureBranchAndContinuePendingAction} + disabled={!canChangeThreadBranch} > Check out 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 0000000000000000000000000000000000000000..c852a853da39c545e0f147202ae894c23b54a89e --- /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/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 a2adddf296c8d9f3a2d51b068bfb9c38e4c7f864..47e4b361aaeeb386ba7d96b3caf1f26d7d2b605c 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 @@ -273,10 +301,9 @@ export function PullRequestThreadDialog({ type="button" size="sm" variant="outline" - onClick={() => { - void handleConfirm("local"); - }} + onClick={() => handleConfirm("local")} disabled={ + !preparePullRequestThreadAction.isAllowed || !cwd || !resolvedPullRequest || isResolving || @@ -288,10 +315,10 @@ export function PullRequestThreadDialog({ - diff --git a/apps/web/src/components/pullRequest/PullRequestReactions.tsx b/apps/web/src/components/pullRequest/PullRequestReactions.tsx index f653dcea94d2def854a4210960559f9134f563b3..6f0b6ab9258ca41a177bc85fa4ffb0152280469f 100644 --- a/apps/web/src/components/pullRequest/PullRequestReactions.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReactions.tsx @@ -1,3 +1,4 @@ +import { useAtomCommand } from "~/state/use-atom-command"; import type { EnvironmentId, PullRequestReaction, @@ -9,7 +10,6 @@ import { useState } from "react"; import { cn } from "~/lib/utils"; import { pullRequestEnvironment } from "~/state/pullRequests"; -import { useAtomCommand } from "~/state/use-atom-command"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { toastManager } from "../ui/toast"; @@ -58,7 +58,9 @@ export function PullRequestReactionBar({ readonly signature: string; readonly values: ReadonlyMap; }>({ signature: "", values: EMPTY_PENDING }); - const setReaction = useAtomCommand(pullRequestEnvironment.setReaction, { reportFailure: false }); + const setReaction = useAtomCommand(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/PullRequestReviewForm.tsx b/apps/web/src/components/pullRequest/PullRequestReviewForm.tsx index f4f9ed18381bb02fc63045910c6de3f2c4f72229..f4359d3dfbdd36f3a12b449e11440bbe45aabb0b 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewForm.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewForm.tsx @@ -1,3 +1,4 @@ +import { useAtomCommand } from "~/state/use-atom-command"; /** * The review half of the floating composer: the summary and the verdict that sends it, together * with whatever line comments the review is holding. The count of those lives on the composer's @@ -9,7 +10,6 @@ import { CheckIcon, MessageSquareIcon, XCircleIcon } from "lucide-react"; import { useState, type ReactNode, type RefObject } from "react"; import { pullRequestEnvironment } from "~/state/pullRequests"; -import { useAtomCommand } from "~/state/use-atom-command"; import { Button } from "../ui/button"; import { Select, SelectItem, SelectPopup, SelectTrigger } from "../ui/select"; diff --git a/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx b/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx index aeefa28a9c50eab7d9084905cf969085166b75b6..bccddc895d1d68f002fc627bf51b4b7f44fe9faa 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx @@ -1,3 +1,4 @@ +import { useAtomCommand } from "~/state/use-atom-command"; /** * Asking someone to review, from the row that says who is already reviewing. * @@ -15,7 +16,6 @@ import { useMemo, useState } from "react"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; -import { useAtomCommand } from "~/state/use-atom-command"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { toastManager } from "../ui/toast"; diff --git a/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx b/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx index 319e73d20a3190980c4b6d1c3897b2c4eef4f379..cd0616ddbb6fe103e11a3e72bc64b56f4dba9286 100644 --- a/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx @@ -1,4 +1,6 @@ +import { useAtomValue } from "@effect/atom-react"; import type { PullRequestAction } from "@t3tools/contracts"; +import { pullRequestEnvironment } from "~/state/pullRequests"; import { useUiStateStore } from "~/uiStateStore"; import { Button } from "../ui/button"; import { Spinner } from "../ui/spinner"; @@ -37,6 +39,9 @@ export function PullRequestSpeedActions void; }) { + const canWrite = useAtomValue( + pullRequestEnvironment.runAction.permissionAtom(entry.environmentId), + ); const resolveProjectDefault = usePullRequestDefaultMergeMethodResolver( entry.environmentId, entry.projectId, @@ -90,7 +95,7 @@ export function PullRequestSpeedActions void perform(action)} onPointerDown={(event) => { diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index cd2eaf6de5015267346ae9e28db2ca0e027ddd45..581c4d0741d7f100433d71ff44d066a09c19b12f 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -1,3 +1,4 @@ +import { useAtomCommand } from "~/state/use-atom-command"; import type { EnvironmentId, PullRequestComment, @@ -16,7 +17,6 @@ import { } from "lucide-react"; import { useRef, useState, type ReactNode } from "react"; -import { useAtomCommand } from "~/state/use-atom-command"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { cn } from "~/lib/utils"; import { useOpenLink } from "~/browser/useOpenLink"; diff --git a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx index 9435304f6d7eee7ca8e8d18aea7eb34c78d34b81..4b3997bf5f62653c06be458efb97d7fd172bd909 100644 --- a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx @@ -1,3 +1,4 @@ +import { useAtomCommand } from "~/state/use-atom-command"; import type { EnvironmentId, PullRequestActor, @@ -18,7 +19,6 @@ 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 { formatRelativeTimeLabel } from "~/timestampFormat"; import { Button } from "../ui/button"; diff --git a/apps/web/src/components/pullRequest/usePullRequestActions.ts b/apps/web/src/components/pullRequest/usePullRequestActions.ts index 5cff68b531e1b2804cd19379ba1a1f3e64b6f60c..59c1416aecc90f0c870e2acffd93fbea8f95adb9 100644 --- a/apps/web/src/components/pullRequest/usePullRequestActions.ts +++ b/apps/web/src/components/pullRequest/usePullRequestActions.ts @@ -1,3 +1,4 @@ +import { useAtomCommand } from "~/state/use-atom-command"; /** * The actions a pull request offers, extracted from the detail panel so smaller surfaces — the * thread details panel's pull request row — perform them through the very same code. Two callers @@ -31,7 +32,6 @@ import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; import type { ReviewCommentContext } from "~/reviewCommentContext"; import { pullRequestEnvironment } from "~/state/pullRequests"; -import { useAtomCommand } from "~/state/use-atom-command"; import { toastManager } from "../ui/toast"; import { handoffPrompt, handoffReviewComments, readableFailure } from "./pullRequestDetail.logic"; @@ -140,7 +140,9 @@ export function usePullRequestActionRunner({ /** Small surfaces resolve repository settings on the click, not for every visible row. */ resolveMergeMethod?: (detail: PullRequestDetail) => PullRequestMergeMethod; }) { - const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false }); + const runAction = useAtomCommand(pullRequestEnvironment.runAction, { + reportFailure: false, + }); const [actionPending, setActionPending] = useState(false); const pendingRef = useRef(false); diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 4877d39ff771dd6621fad9beff30df598ab5e40e..263084c2d3dcd6b5fc02cf597171801bb6144aa1 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -31,6 +31,7 @@ import { AuthRelayReadScope, AuthRelayWriteScope, AuthReviewWriteScope, + AuthSourceControlWriteScope, AuthStandardClientScopes, AuthTerminalOperateScope, type AuthClientSession, @@ -259,6 +260,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 1f85eb1a4e2ee68db4e80d54f5b41c9b9d517e0a..6bae63cffec6c0e7133f2440b3bff7e3d0e59a8c 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -7,6 +7,7 @@ import { } from "./threadActionMenu.logic"; const baseState: ThreadActionMenuState = { + canOperate: true, branch: null, projectFilter: null, isPinned: false, @@ -39,6 +40,57 @@ 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", + "auto-settle", + "archive", + "delete", + ] + : [ + "pin", + "settle", + "snooze", + "rename", + "regenerate-title", + "auto-settle", + "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 1b9ada254f1422a3715bb8486c3e9e80b09148e6..f51328266cabd8eb63a4ea436db357743a491323 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -70,6 +70,7 @@ export function buildDraftActionMenuItems(options: { } export interface ThreadActionMenuState { + readonly canOperate: boolean; readonly branch: string | null; /** * Project scoping for the thread list. Null on surfaces with no scoped @@ -100,6 +101,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 share labels, ordering, and capability gating. @@ -108,7 +122,7 @@ export interface ThreadActionMenuState { export function buildThreadActionMenuItems( state: ThreadActionMenuState, ): ReadonlyArray> { - return [ + const items: ReadonlyArray> = [ ...(state.branch ? [ { @@ -235,4 +249,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 0000000000000000000000000000000000000000..4b8054c986fae3ccb1748de3459ec60ffecaddf0 --- /dev/null +++ b/apps/web/src/hooks/useThreadActionMenu.test.ts @@ -0,0 +1,221 @@ +import { + AuthOrchestrationOperateScope, + EnvironmentId, + ThreadId, + type ContextMenuItem, +} from "@t3tools/contracts"; +import { AsyncResult } from "effect/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("../components/CustomSnoozeDialog", () => ({ requestCustomSnooze: vi.fn() })); +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", () => ({ + readEnvironmentSupportsAutoSettleOptOut: () => true, + readEnvironmentSupportsPinning: () => true, + readEnvironmentSupportsSettlement: () => true, + readEnvironmentSupportsSnooze: () => true, + readEnvironmentSupportsTitleRegeneration: () => true, + readThreadShell: () => ({ + id: "thread", + environmentId: "secondary", + projectId: "project", + title: "Thread", + branch: "main", + worktreePath: null, + runtime: null, + latestRun: 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( + [ + "markThreadUnread", + "settleThread", + "unsettleThread", + "snoozeThread", + "unsnoozeThread", + "pinThread", + "confirmAndUnpinThread", + "archiveThread", + "deleteThread", + ].map((action) => [ + action, + async () => { + recordEffect(action === "markThreadUnread" ? "mark-unread" : 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 793ce7e974e1f45104ea2eaccfbac5de43301932..2fda09240b6b27e2d426d98d37a5e8758715ecd4 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -7,18 +7,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 } 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 { readEnvironmentSupportsAutoSettleOptOut, readEnvironmentSupportsPinning, @@ -94,7 +100,7 @@ export function useThreadActionMenu(input: { deleteThread, markThreadUnread, } = useThreadActions(); - const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); const handleNewThread = useNewThreadHandler(); @@ -142,6 +148,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, projectFilter: null, isPinned: thread.pinnedAt != null, @@ -157,6 +164,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 = action === "snooze:custom" 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 0000000000000000000000000000000000000000..55cdb40d5903b44bb1cbb2be02f41d0295cddc27 --- /dev/null +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -0,0 +1,354 @@ +import { + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, + EnvironmentAuthorizationError, + EnvironmentId, + ProjectId, + ThreadId, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/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>(), + toasts: [] as string[], + 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: (toast: { title?: string }) => { + if (toast.title) state.toasts.push(toast.title); + }, + }, +})); +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.toasts = []; + 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(["stopSession", "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 { + // The thread is already gone, so cleanup reports itself in a toast + // instead of failing the deletion. + const result = await useThreadActions().deleteThread(target); + expect(result._tag).toBe("Success"); + expect(state.requests.map((request) => request.action)).toEqual(["stopSession", "delete"]); + expect(state.toasts).toContain("Failed to delete worktree"); + 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 fdf08e6c8c6ebbc49b1dc755b30643c4fb0a0d3e..6592a48a1f4c06a32a0feaa4d325eba3693852a0 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -7,7 +7,14 @@ import { import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models"; -import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, + EnvironmentAuthorizationError, + EnvironmentId, + type ScopedThreadRef, + ThreadId, +} from "@t3tools/contracts"; import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; @@ -17,6 +24,7 @@ import { useCallback, useMemo, useRef } from "react"; import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic"; import { useComposerDraftStore } from "../composerDraftStore"; +import { environmentSession, readEnvironmentScope } from "../state/session"; import { terminalEnvironment } from "../state/terminal"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentServerConfigsAtom } from "../state/server"; @@ -49,6 +57,8 @@ import { useClientSettings } from "./useSettings"; import * as ThreadUndo from "./threadUndo"; import { showThreadUndoNotice } from "./showThreadUndoNotice"; 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.TaggedError()( "ThreadArchiveBlockedError", @@ -243,49 +253,64 @@ export function useAcknowledgeThreadWoke() { ); } +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 setThreadAutoSettleMutation = useAtomCommand(threadEnvironment.setAutoSettle, { + const setThreadAutoSettleMutation = useOrchestrationCommand(threadEnvironment.setAutoSettle, { reportFailure: false, }); - const reorderPinnedThreadMutation = useAtomCommand(threadEnvironment.reorderPin, { + const reorderPinnedThreadMutation = useOrchestrationCommand(threadEnvironment.reorderPin, { reportFailure: false, }); - const reorderActiveThreadMutation = useAtomCommand(threadEnvironment.reorderActive, { + const reorderActiveThreadMutation = useOrchestrationCommand(threadEnvironment.reorderActive, { 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 markThreadUnread = useMarkThreadUnread(); - 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, }); @@ -412,6 +437,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. @@ -456,13 +483,22 @@ export function useThreadActions() { const environmentConfig = appAtomRegistry .get(environmentServerConfigsAtom) .get(threadRef.environmentId); - // A Scratch thread's folder is not a git worktree, and deleting the - // thread keeps its files. - const canDeleteWorktree = + const localApi = readLocalApi(); + let canDeleteWorktree = false; + if ( orphanedWorktreePath !== null && threadProject !== null && - !isScratchProject(threadProject, environmentConfig?.scratchWorkspaceRoot); - const localApi = readLocalApi(); + !isScratchProject(threadProject, environmentConfig?.scratchWorkspaceRoot) && + localApi + ) { + 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; const environmentSettings = environmentConfig?.settings; const automaticWorktreeCleanup = environmentSettings @@ -493,11 +529,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 = @@ -546,14 +577,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({ @@ -597,9 +640,9 @@ export function useThreadActions() { clearComposerDraftForThread, clearProjectDraftThreadById, clearTerminalUiState, - closeTerminal, deleteThreadMutation, getCurrentRouteThreadRef, + loadSessionState, refreshVcsStatus, removeWorktree, router, @@ -797,6 +840,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({ @@ -930,6 +975,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/hooks/useThreadActions.undo.test.ts b/apps/web/src/hooks/useThreadActions.undo.test.ts index 6e61b298be51c5d432a8390b508fff61aeaf5c1c..da65bf2ef931feb8619739b395068cd18af03d8a 100644 --- a/apps/web/src/hooks/useThreadActions.undo.test.ts +++ b/apps/web/src/hooks/useThreadActions.undo.test.ts @@ -20,6 +20,11 @@ const router = vi.hoisted(() => ({ navigate: vi.fn(async () => {}), state: { matches: [{ params: {} as Record }] }, })); +vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); +vi.mock("../state/session", async (original) => ({ + ...(await original()), + readEnvironmentScope: () => true, +})); vi.mock("react", async (original) => ({ ...(await original()), useCallback: (callback: unknown) => callback, diff --git a/apps/web/src/state/sourceControlActions.test.ts b/apps/web/src/state/sourceControlActions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1747f79963453202f9ac948812db7bc4df91dbc0 --- /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/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 107d4294a64bbdc6cdcc99cd12351878922d56fe..23b44f7992bbe9607410f180471ae011a0d30cae 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -9,12 +9,15 @@ import { type VcsActionOperation, type RunVcsStackedActionInput, } from "@t3tools/client-runtime/state/vcs"; -import type { - EnvironmentId, - GitResolvePullRequestResult, - SourceControlCloneProtocol, - SourceControlRepositoryVisibility, - ThreadId, +import { + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, + EnvironmentAuthorizationError, + type EnvironmentId, + type GitResolvePullRequestResult, + type SourceControlCloneProtocol, + type SourceControlRepositoryVisibility, + type ThreadId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; @@ -24,6 +27,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"; @@ -45,11 +49,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; } @@ -73,6 +81,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; @@ -83,6 +92,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> > => { @@ -108,6 +130,7 @@ function useAction< ); return { + isAllowed, error: ownsState ? state.error : null, isPending: ownsState && state.isRunning, resetError, @@ -307,6 +330,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/docs/user/remote-access.md b/docs/user/remote-access.md index 04a4a4c0f01e2c94dbb3b47660c060a07a0966e0..356e3c5dcf5bead5eba027ac724866fe9c46d414 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -281,6 +281,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/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index d8ffc24c458972ccd601db1f662cfa5d0fb2621a..dbf8150542f334b8650e847ab2f6916ebd177410 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -203,7 +203,7 @@ export const requestGuarded = Effect.fn("EnvironmentRpc.request")(function* < return yield* method(input).pipe(Effect.ensuring(completeObservation)); }); -function runStreamGuarded( +export function runStreamGuarded( tag: TTag, input: EnvironmentRpcInput, ): Stream.Stream< diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index d3142d047f4de46a9ba0f75d6a464b2a437a26ad..3ae08c2e38c1289397ba37a42f9521fcb191c0ac 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import { AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, + ThreadId, EnvironmentId, ScheduledTaskId, WS_METHODS, @@ -150,3 +152,44 @@ describe("command permissions", () => { } }); }); + +it.effect( + "requires source control alone for git, and both grants when attaching a worktree to a thread", + () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + registry.set(sessions(env), AsyncResult.success(grant(true))); + const git = createCommandPermissions(runtime, WS_METHODS.vcsInit); + expect((yield* git.authorize(registry, env).pipe(Effect.flip)).requiredScope).toBe( + AuthSourceControlWriteScope, + ); + registry.set( + sessions(env), + AsyncResult.success({ ...grant(false), scopes: [AuthSourceControlWriteScope] }), + ); + yield* git.authorize(registry, env); + const prepare = createCommandPermissions(runtime, WS_METHODS.gitPreparePullRequestThread); + const input = { + cwd: "/repo", + reference: "123", + mode: "worktree", + threadId: ThreadId.make("thread"), + }; + expect(registry.get(prepare.permissionAtom(env, input))).toBe(false); + expect( + (yield* prepare.authorize(registry, env, input).pipe(Effect.flip)).requiredScope, + ).toBe(AuthOrchestrationOperateScope); + yield* prepare.authorize(registry, env, { ...input, threadId: undefined }); + registry.set( + sessions(env), + AsyncResult.success({ + ...grant(true), + scopes: [AuthSourceControlWriteScope, AuthOrchestrationOperateScope], + }), + ); + expect(registry.get(prepare.permissionAtom(env, input))).toBe(true); + yield* prepare.authorize(registry, env, input); + }), + ), +); diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index dda824e40c4216d5ec8a71c89d98534feaf36761..8762e9edb39996a76f5848c25e18f81c50905608 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -1,9 +1,14 @@ +import { createCommandPermissions } from "./commandPermissions.ts"; +import { RpcPermissionGuard } from "../rpc/client.ts"; +import { vi } from "vite-plus/test"; import { EnvironmentId, + AuthSourceControlWriteScope, ProjectId, PullRequestOperationError, WS_METHODS, type PullRequestStack, + type AuthSessionState, } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Data from "effect/Data"; @@ -194,6 +199,7 @@ for (const scenario of [ : route(WS_METHODS.pullRequestsRunAction, input) ).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), Effect.provideService(GitHubRoutingPermissions, { ...trustedRouting, get: () => Effect.succeed(trusted ? "read-write" : "off"), @@ -301,6 +307,7 @@ it.effect("shares active read identity probes while keeping later reads and muta Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), ); }), ), @@ -554,6 +561,7 @@ it.effect.each(["github", "gitlab", "bitbucket", "azure-devops"] as const)( }).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), ); const environment = provider === "github" ? "local" : "origin"; @@ -720,10 +728,12 @@ it.effect.each(["default", "origin-off", "destination-off", "read-only"] as cons }).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), ); yield* permission === "default" ? request : request.pipe( + Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), Effect.provideService(GitHubRoutingPermissions, { ...trustedRouting, get: (entry) => @@ -842,6 +852,7 @@ it.effect.each( Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), // This also represents a user enabling the stale catalog entry after re-resolution. Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), ); yield* stored === "unavailable" ? route @@ -897,6 +908,7 @@ it.live.each(["origin", "alternate"] as const)( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), Effect.flip, ); expect(error._tag).toBe("PullRequestOperationError"); @@ -958,6 +970,7 @@ it.effect.each( }).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), ); const fiber = yield* request.pipe(Effect.forkChild); @@ -1014,6 +1027,7 @@ it.live("keeps source workspace metadata when an alternate answers a detail read }).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), ); expect(result).toEqual({ @@ -1101,6 +1115,7 @@ it.live( }).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), ); }), @@ -1649,6 +1664,7 @@ it.effect.each([false, true])( }).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), ); expect(result).toEqual({ state: "open", checks: [] }); @@ -1980,3 +1996,78 @@ it.effect("refreshes stack state after reopening and head SHAs after a turn", () }), ), ); + +// Transport fixtures have a source-control-only session; authorization edge cases +// are exercised by commandPermissions.test.ts. +vi.mock("./session.ts", () => ({ + createEnvironmentSessionAtoms: () => ({ sessionStateAtom: grantedSessions }), +})); +const grantedSessions = Atom.family((_id: EnvironmentId) => + Atom.make>( + AsyncResult.success({ + authenticated: true, + auth: { + policy: "remote-reachable" as const, + bootstrapMethods: [], + sessionMethods: [], + sessionCookieName: "test", + }, + scopes: [AuthSourceControlWriteScope], + }), + ), +); + +it.effect("denies a routed write when only the origin has source-control permission", () => + Effect.scoped( + Effect.gen(function* () { + let writes = 0; + const identity = { host: "github.com", provider: "github", viewer: "test", accountId: "123" }; + const client = { + [WS_METHODS.pullRequestsRouting]: () => Effect.succeed(identity), + [WS_METHODS.pullRequestsRoutingIdentity]: () => Effect.succeed(identity), + [WS_METHODS.pullRequestsRunAction]: () => + Effect.sync(() => { + writes++; + }), + } as unknown as WsRpcProtocolClient; + const fixture = yield* makeTestRuntime(client, client); + const local = EnvironmentId.make("local-environment"); + const stop = fixture.registry.mount(grantedSessions(local)); + yield* Effect.addFinalizer(() => Effect.sync(stop)); + fixture.registry.set( + grantedSessions(local), + AsyncResult.success({ + authenticated: true, + scopes: [], + auth: { + policy: "remote-reachable" as const, + bootstrapMethods: [], + sessionMethods: [], + sessionCookieName: "test", + }, + }), + ); + const error = yield* createPullRequestRouter()(WS_METHODS.pullRequestsRunAction, { + projectId: ProjectId.make("project"), + repository: "acme/repo", + number: 1, + action: "merge", + }).pipe( + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, fixture.environmentRegistry), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, fixture.supervisor), + Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(RpcPermissionGuard, { + authorize: (id, method, input) => + createCommandPermissions(fixture.runtime, method).authorize( + fixture.registry, + id, + input, + ), + }), + Effect.flip, + ); + expect(error._tag).toBe("EnvironmentAuthorizationError"); + expect(writes).toBe(0); + }), + ), +); diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index b8545146e207eb39c30050ac47dce67755a7613a..d817b88ebbe8bcd022995f82f2c75f67ff8cf53c 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -19,7 +19,6 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { createAtomCommandScheduler, - createEnvironmentCommand, createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily, createEnvironmentRpcSubscriptionAtomFamily, @@ -320,7 +319,8 @@ export function createPullRequestEnvironmentAtoms( JSON.stringify([environmentId, input.projectId, input.repository, input.number]), }, }), - runAction: createEnvironmentCommand(runtime, { + runAction: createEnvironmentRpcCommand(runtime, { + tag: WS_METHODS.pullRequestsRunAction, label: "environment-data:pull-requests:run-action", // Preparation belongs to the write's lane. Refreshable queries would restart it after // every preceding action, and preparing outside the lane could reorder the clicks. @@ -328,8 +328,6 @@ export function createPullRequestEnvironmentAtoms( input: PullRequestActionInput & { readonly resolveMergeMethod?: (detail: PullRequestDetail) => PullRequestMergeMethod; }, - registry, - environmentId, ) => Effect.gen(function* () { const { resolveMergeMethod, ...actionInput } = input; @@ -369,9 +367,10 @@ export function createPullRequestEnvironmentAtoms( }); preparedInput = { ...actionInput, mergeMethod }; } - yield* routedRequest(WS_METHODS.pullRequestsRunAction, preparedInput); - registry.refresh(preview({ environmentId, input: actionInput })); + return yield* routedRequest(WS_METHODS.pullRequestsRunAction, preparedInput); }), + onSuccess: ({ environmentId, input }, registry) => + Effect.sync(() => registry.refresh(preview({ environmentId, input }))), scheduler: commandScheduler, concurrency: serialPerEnvironment, }), diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 0d416362d46b17c2127a61acd5cd96724d5c9299..2f224e80b661c4142d959780a4943920a83b5be8 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -1,5 +1,5 @@ import { createCommandPermissions } from "./commandPermissions.ts"; -import { runStreamInEnvironment, followStreamInEnvironment } from "./environmentStreams.ts"; +import { followStreamInEnvironment } from "./environmentStreams.ts"; export { runStreamInEnvironment, followStreamInEnvironment } from "./environmentStreams.ts"; import { type ClientGuardedRpcTag, @@ -717,13 +717,18 @@ export function createEnvironmentRpcSubscriptionAtomFamily< }); } -export function createEnvironmentRpcCommand( +export function createEnvironmentRpcCommand< + R, + ER, + TTag extends EnvironmentUnaryRpcTag, + Input extends EnvironmentRpcInput = EnvironmentRpcInput, +>( runtime: Atom.AtomRuntime, options: { readonly label: string; readonly tag: TTag; readonly execute?: ( - input: EnvironmentRpcInput, + input: Input, ) => Effect.Effect< EnvironmentRpcSuccess, EnvironmentRpcFailure | EnvironmentRpcUnavailableError, @@ -732,19 +737,19 @@ export function createEnvironmentRpcCommand; + readonly input: NoInfer; }>; readonly onSuccess?: ( target: { readonly environmentId: EnvironmentIdType; - readonly input: EnvironmentRpcInput; + readonly input: NoInfer; }, registry: AtomRegistry.AtomRegistry, ) => Effect.Effect; readonly onSettled?: ( target: { readonly environmentId: EnvironmentIdType; - readonly input: EnvironmentRpcInput; + readonly input: NoInfer; }, registry: AtomRegistry.AtomRegistry, ) => Effect.Effect; @@ -755,7 +760,7 @@ export function createEnvironmentRpcCommand, registry, environmentId) => { + execute: (input: Input, registry, environmentId) => { const target = { environmentId, input, diff --git a/packages/client-runtime/src/state/sourceControl.test.ts b/packages/client-runtime/src/state/sourceControl.test.ts index ed2de3b3cf147845613321ad36829667080c4e05..24c258e2ae215bf384a8d3908ddd81c8c1cc6fbb 100644 --- a/packages/client-runtime/src/state/sourceControl.test.ts +++ b/packages/client-runtime/src/state/sourceControl.test.ts @@ -1,5 +1,7 @@ +import { vi } from "vite-plus/test"; import { EnvironmentId, + AuthSourceControlWriteScope, WS_METHODS, type SourceControlPublishRepositoryResult, } from "@t3tools/contracts"; @@ -168,3 +170,23 @@ describe("source control environment atoms", () => { ), ); }); + +// Transport fixtures have a source-control-only session; authorization edge cases +// are exercised by commandPermissions.test.ts. +vi.mock("./session.ts", () => ({ + createEnvironmentSessionAtoms: () => ({ sessionStateAtom: grantedSessions }), +})); +const grantedSessions = Atom.family((_id: EnvironmentId) => + Atom.make( + AsyncResult.success({ + authenticated: true, + auth: { + policy: "remote-reachable", + bootstrapMethods: [], + sessionMethods: [], + sessionCookieName: "test", + }, + scopes: [AuthSourceControlWriteScope], + }), + ), +); diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index 94ead9bf63a4949b711eb7b4797288f489ce274a..b72f804194ca6a14ebd204ec34eae43fe226e371 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -1,5 +1,7 @@ +import { vi } from "vite-plus/test"; import { EnvironmentId, + AuthSourceControlWriteScope, WS_METHODS, type VcsListRefsInput, type VcsListRefsResult, @@ -643,3 +645,23 @@ describe("cached VCS refs", () => { ), ); }); + +// Transport fixtures have a source-control-only session; authorization edge cases +// are exercised by commandPermissions.test.ts. +vi.mock("./session.ts", () => ({ + createEnvironmentSessionAtoms: () => ({ sessionStateAtom: grantedSessions }), +})); +const grantedSessions = Atom.family((_id: EnvironmentId) => + Atom.make( + AsyncResult.success({ + authenticated: true, + auth: { + policy: "remote-reachable", + bootstrapMethods: [], + sessionMethods: [], + sessionCookieName: "test", + }, + scopes: [AuthSourceControlWriteScope], + }), + ), +); diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index 1564f521e5b0e7ac90c04fa7850beca3dfba213d..32dda6f6cba2471725c42854c206faf267674aad 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -1,5 +1,7 @@ +import { vi } from "vite-plus/test"; import { EnvironmentId, + AuthSourceControlWriteScope, ThreadId, ProjectId, WS_METHODS, @@ -718,3 +720,23 @@ describe("vcsActionState", () => { ), ); }); + +// Transport fixtures have a source-control-only session; authorization edge cases +// are exercised by commandPermissions.test.ts. +vi.mock("./session.ts", () => ({ + createEnvironmentSessionAtoms: () => ({ sessionStateAtom: grantedSessions }), +})); +const grantedSessions = Atom.family((_id: EnvironmentId) => + Atom.make( + AsyncResult.success({ + authenticated: true, + auth: { + policy: "remote-reachable", + bootstrapMethods: [], + sessionMethods: [], + sessionCookieName: "test", + }, + scopes: [AuthSourceControlWriteScope], + }), + ), +); diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts index d03a7cd04dfe4dcf363422cd1c77b886dc1797db..57885838c1e5e26dce2337ec297b3c8d21d1f465 100644 --- a/packages/client-runtime/src/state/vcsAction.ts +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -19,13 +19,14 @@ import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import * as Persistence from "../platform/persistence.ts"; -import { runStream } from "../rpc/client.ts"; +import { runStreamGuarded, RpcPermissionGuard } from "../rpc/client.ts"; import { createRuntimeCommand, runStreamInEnvironment, type AtomCommand, type AtomCommandResult, } from "./runtime.ts"; +import { createCommandPermissions } from "./commandPermissions.ts"; import { vcsCommandScheduler } from "./vcsCommandScheduler.ts"; import { invalidateCachedVcsRefs } from "./vcsRefInvalidation.ts"; @@ -483,7 +484,7 @@ export function createVcsActionManager( return consumeVcsActionProgress( runStreamInEnvironment( target.environmentId, - runStream(WS_METHODS.gitRunStackedAction, rpcInput), + runStreamGuarded(WS_METHODS.gitRunStackedAction, rpcInput), ), { target, @@ -507,6 +508,10 @@ export function createVcsActionManager( }), }, ).pipe( + Effect.provideService(RpcPermissionGuard, { + authorize: (id, method, payload) => + createCommandPermissions(runtime, method).authorize(registry, id, payload), + }), Effect.ensuring(invalidateCachedVcsRefs(registry, target)), Effect.tap(() => clearOwnedState), Effect.tapError((error) => diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index f811a520c238f752e96b9793cc57bbbe60eb562a..a75bc1c777e03c1d3edf2cbc56360db5d475bcbf 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 = [ diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index e4f535d792daf0f20c385b36d79ecfaf5fc2548f..9605829e47d7c7abf1e031d75ddb5c24de06ab37 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -1,8 +1,39 @@ -import { AuthOrchestrationOperateScope, type AuthEnvironmentScope } from "./auth.ts"; +import * as Schema from "effect/Schema"; +import { GitPreparePullRequestThreadInput } from "./git.ts"; +import { + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, + type AuthEnvironmentScope, +} from "./auth.ts"; import { WS_METHODS } from "./rpc.ts"; /** Incremental client enforcement; the server still authorizes every request. */ export const CLIENT_GUARDED_RPC_SCOPES = { + [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, + [WS_METHODS.pullRequestsSetFilesViewed]: AuthSourceControlWriteScope, + [WS_METHODS.pullRequestsRequestReviewers]: AuthSourceControlWriteScope, + [WS_METHODS.pullRequestsSetLabels]: AuthSourceControlWriteScope, + [WS_METHODS.sourceControlCloneRepository]: AuthSourceControlWriteScope, + [WS_METHODS.sourceControlPublishRepository]: AuthSourceControlWriteScope, + [WS_METHODS.projectCloneStart]: AuthSourceControlWriteScope, + [WS_METHODS.projectCloneCancel]: AuthSourceControlWriteScope, + [WS_METHODS.projectCloneRetry]: AuthSourceControlWriteScope, + [WS_METHODS.vcsPull]: AuthSourceControlWriteScope, + [WS_METHODS.gitRunStackedAction]: AuthSourceControlWriteScope, + [WS_METHODS.gitPreparePullRequestThread]: AuthSourceControlWriteScope, + [WS_METHODS.vcsCreateWorktree]: AuthSourceControlWriteScope, + [WS_METHODS.vcsRemoveWorktree]: AuthSourceControlWriteScope, + [WS_METHODS.vcsCreateRef]: AuthSourceControlWriteScope, + [WS_METHODS.vcsSwitchRef]: AuthSourceControlWriteScope, + [WS_METHODS.vcsInit]: AuthSourceControlWriteScope, + [WS_METHODS.scheduledTasksUpsert]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksSetEnabled]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope, @@ -11,10 +42,17 @@ export const CLIENT_GUARDED_RPC_SCOPES = { } as const; export type ClientGuardedRpcTag = keyof typeof CLIENT_GUARDED_RPC_SCOPES; +const decodePrepareThread = Schema.decodeUnknownSync(GitPreparePullRequestThreadInput); + export function clientRpcRequiredScopes( method: string, - _input: unknown, + input: unknown, ): readonly AuthEnvironmentScope[] { + if (method === WS_METHODS.gitPreparePullRequestThread && input !== undefined) { + const payload = decodePrepareThread(input); + if (payload.mode === "worktree" && payload.threadId !== undefined) + return [AuthSourceControlWriteScope, AuthOrchestrationOperateScope]; + } return Object.hasOwn(CLIENT_GUARDED_RPC_SCOPES, method) ? [CLIENT_GUARDED_RPC_SCOPES[method as ClientGuardedRpcTag]] : [];