From d1f576da449518666d3b535865c5ff1328f7f9f9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 13:45:34 -0700 Subject: [PATCH 01/10] feat(auth): separate source control write permissions --- .../features/projects/AddProjectScreen.tsx | 32 ++++- .../threads/NewTaskContextPickerScreens.tsx | 32 +++-- .../features/threads/ThreadGitControls.tsx | 37 ++++-- .../features/threads/git/GitBranchesSheet.tsx | 14 +- .../features/threads/git/GitCommitSheet.tsx | 13 +- .../features/threads/git/GitConfirmSheet.tsx | 15 ++- .../features/threads/git/GitOverviewSheet.tsx | 52 ++++++-- .../state/use-selected-thread-git-actions.ts | 12 +- apps/server/src/auth/RpcAuthorization.ts | 43 +++--- apps/server/src/auth/http.ts | 2 + apps/server/src/server.test.ts | 125 ++++++++++++++++++ .../BranchToolbarBranchSelector.tsx | 29 +++- apps/web/src/components/ChatView.tsx | 18 ++- apps/web/src/components/CommandPalette.tsx | 10 +- apps/web/src/components/GitActionsControl.tsx | 42 ++++-- .../components/PullRequestThreadDialog.tsx | 3 + .../pullRequest/PullRequestDetailPanel.tsx | 53 ++++++-- .../settings/ConnectionsSettings.tsx | 6 + apps/web/src/hooks/useThreadActions.ts | 13 +- apps/web/src/state/sourceControlActions.ts | 47 +++++-- packages/contracts/src/auth.ts | 3 + 21 files changed, 489 insertions(+), 112 deletions(-) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index cc1e8f4e5799..a3b713123355 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -32,6 +32,7 @@ import { isWindowsPlatform, } from "@t3tools/client-runtime/state/projects"; import { + AuthSourceControlWriteScope, CommandId, type EnvironmentId, type EnvironmentMachineKind, @@ -53,6 +54,7 @@ import { useProjects, useServerConfigs } from "../../state/entities"; import { filesystemEnvironment } from "../../state/filesystem"; import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; +import { useEnvironmentScope } from "../../state/session"; import { sourceControlEnvironment } from "../../state/sourceControl"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; @@ -464,6 +466,10 @@ export function AddProjectSourceScreen() { const navigation = useNavigation(); const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = useSelectedEnvironment(); + const canWriteSourceControl = useEnvironmentScope( + selectedEnvironment?.environmentId ?? null, + AuthSourceControlWriteScope, + ); const discoveryState = useEnvironmentQuery( selectedEnvironment === null ? null @@ -554,11 +560,13 @@ export function AddProjectSourceScreen() { key={candidate} source={candidate} selectedEnvironmentId={selectedEnvironment.environmentId} - ready={readiness[candidate].ready} + ready={canWriteSourceControl && readiness[candidate].ready} hint={ - readiness[candidate].ready - ? addProjectRemoteSourcePathHint(candidate) - : (readiness[candidate].hint ?? "") + !canWriteSourceControl + ? "This connection cannot clone repositories." + : readiness[candidate].ready + ? addProjectRemoteSourcePathHint(candidate) + : (readiness[candidate].hint ?? "") } isFirst={false} /> @@ -908,6 +916,10 @@ export function AddProjectDestinationScreen(props: { reportFailure: false, }); const environment = useEnvironmentFromParam(props.environmentId); + const canWriteSourceControl = useEnvironmentScope( + environment?.environmentId ?? null, + AuthSourceControlWriteScope, + ); const createProject = useCreateProject(environment); const remoteUrl = stringParam(props.remoteUrl); const repositoryTitle = stringParam(props.repositoryTitle); @@ -924,7 +936,9 @@ export function AddProjectDestinationScreen(props: { const [error, setError] = useState(null); const submitPath = useCallback(async () => { - if (!environment || !remoteUrl || isBrowseNavigating || isSubmitting) return; + if (!canWriteSourceControl || !environment || !remoteUrl || isBrowseNavigating || isSubmitting) { + return; + } setError(null); const resolved = resolveAddProjectPath({ rawPath: pathInput, @@ -954,6 +968,7 @@ export function AddProjectDestinationScreen(props: { } setIsSubmitting(false); }, [ + canWriteSourceControl, cloneRepository, createProject, environment, @@ -983,10 +998,15 @@ export function AddProjectDestinationScreen(props: { /> void submitPath()} loading={isSubmitting} /> + {!canWriteSourceControl ? ( + + This connection cannot clone repositories. + + ) : null} { - if (selectingBranchNameRef.current !== null) { + const needsCheckout = shouldCheckoutNewTaskBranch({ + branchIsCurrent: branch.current, + branchWorktreePath: branch.worktreePath, + workspaceMode: flow.workspaceMode, + }); + if (selectingBranchNameRef.current !== null || (needsCheckout && !canWriteSourceControl)) { return; } selectingBranchNameRef.current = branch.name; @@ -258,11 +268,6 @@ export function NewTaskBranchPickerRouteScreen() { try { let selectedBranch = branch; - const needsCheckout = shouldCheckoutNewTaskBranch({ - branchIsCurrent: branch.current, - branchWorktreePath: branch.worktreePath, - workspaceMode: flow.workspaceMode, - }); if (needsCheckout && flow.selectedProject) { setSwitchingBranchName(branch.name); const result = await switchRef({ @@ -309,6 +314,7 @@ export function NewTaskBranchPickerRouteScreen() { } }, [ + canWriteSourceControl, flow.selectBranch, flow.selectedProject, flow.setBranchQuery, @@ -323,7 +329,15 @@ export function NewTaskBranchPickerRouteScreen() { ), [ + canWriteSourceControl, flow.filteredBranches.length, flow.selectedProject, + flow.workspaceMode, selectBranch, selectedBranchName, switchingBranchName, diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx index 31b65f49353a..fc723694c139 100644 --- a/apps/mobile/src/features/threads/ThreadGitControls.tsx +++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx @@ -1,4 +1,5 @@ import { + AuthSourceControlWriteScope, EnvironmentId, type GitRunStackedActionResult, type ProjectScript, @@ -15,6 +16,7 @@ import { NativeHeaderToolbar } from "../../native/StackHeader"; import { useCallback, useMemo } from "react"; import { Alert } from "react-native"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; +import { useEnvironmentScope } from "../../state/session"; import { basename, getTerminalStatusLabel, @@ -109,6 +111,10 @@ type ThreadGitControlsProps = ThreadGitMenuProps & { function useThreadGitControlModel(props: ThreadGitMenuProps) { const navigation = useNavigation(); const environmentId = props.environmentId; + const canWriteSourceControl = useEnvironmentScope( + environmentId ? EnvironmentId.make(String(environmentId)) : null, + AuthSourceControlWriteScope, + ); const threadId = props.threadId; const { gitStatus, gitOperationLabel, onPull, onRunAction } = props; @@ -119,16 +125,25 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) { const isDefaultRef = gitStatus?.isDefaultRef ?? false; const quickAction = useMemo( - () => - isRepo - ? resolveQuickAction(gitStatus, busy, isDefaultRef, hasPrimaryRemote) - : { - label: "Git unavailable", + () => { + 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, - kind: "show_hint" as const, - hint: "This workspace is not a git repository.", - }, - [busy, gitStatus, hasPrimaryRemote, isDefaultRef, isRepo], + hint: "This connection cannot change source control.", + } + : action; + }, + [busy, canWriteSourceControl, gitStatus, hasPrimaryRemote, isDefaultRef, isRepo], ); const quickActionHint = quickAction.disabled @@ -159,6 +174,7 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) { const runActionWithPrompt = useCallback( async (input: GitActionRequestInput) => { + if (!canWriteSourceControl) return; const confirmableAction = input.action === "push" || input.action === "create_pr" || @@ -187,10 +203,11 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) { await onRunAction(input); }, - [environmentId, gitStatus, isDefaultRef, onRunAction, navigation, threadId], + [canWriteSourceControl, environmentId, gitStatus, isDefaultRef, onRunAction, navigation, threadId], ); const runQuickAction = useCallback(async () => { + if (quickAction.disabled) return; if (quickAction.kind === "open_pr") { await openExistingPr(); return; diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index c66fc7887624..7f23ab10fff5 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -27,6 +27,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); + const { canWriteSourceControl } = gitActions; const gitStatus = useEnvironmentQuery( selectedThread !== null && selectedThreadCwd !== null @@ -68,6 +69,11 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} contentContainerClassName="gap-4 px-5 pt-2" > + {!canWriteSourceControl ? ( + + This connection cannot change source control. + + ) : null} New branch @@ -82,8 +88,9 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { icon="plus" label="Create & checkout" tone="primary" - disabled={busy || newBranchName.trim().length === 0} + disabled={!canWriteSourceControl || busy || newBranchName.trim().length === 0} onPress={() => { + if (!canWriteSourceControl) return; const branch = sanitizeFeatureBranchName(newBranchName.trim()); if (branch.length === 0) return; void gitActions.onCreateSelectedThreadBranch(branch).then(() => { @@ -115,11 +122,13 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { label="Create worktree" tone="primary" disabled={ + !canWriteSourceControl || busy || worktreeBaseBranch.trim().length === 0 || worktreeBranchName.trim().length === 0 } onPress={() => { + if (!canWriteSourceControl) return; const baseBranch = worktreeBaseBranch.trim(); const newBranch = worktreeBranchName.trim(); if (baseBranch.length === 0 || newBranch.length === 0) return; @@ -162,8 +171,9 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { "gap-1 rounded-[18px] border px-4 py-3 disabled:opacity-[0.45]", branch.current ? "border-subtle-strong" : "border-border", )} - disabled={busy || disabled} + disabled={!canWriteSourceControl || busy || disabled} onPress={() => { + if (!canWriteSourceControl) return; void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { navigation.goBack(); }); diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index f263372bad22..d8e4d8034b5a 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -26,6 +26,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { const { selectedThreadCwd } = useSelectedThreadWorktree(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); + const { canWriteSourceControl } = gitActions; const gitStatus = useEnvironmentQuery( selectedThread !== null && selectedThreadCwd !== null @@ -53,6 +54,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { const runCommitAction = useCallback( async (featureBranch: boolean) => { + if (!canWriteSourceControl) return; const commitMessage = dialogCommitMessage.trim(); navigation.goBack(); await gitActions.onRunSelectedThreadGitAction({ @@ -62,7 +64,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}), }); }, - [allSelected, dialogCommitMessage, gitActions, navigation, selectedFiles], + [allSelected, canWriteSourceControl, dialogCommitMessage, gitActions, navigation, selectedFiles], ); return ( @@ -208,12 +210,17 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { /> + {!canWriteSourceControl ? ( + + This connection cannot change source control. + + ) : null} void runCommitAction(true)} /> @@ -222,7 +229,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { icon="checkmark.circle" label="Commit" tone="primary" - disabled={noneSelected || busy} + disabled={!canWriteSourceControl || noneSelected || busy} onPress={() => void runCommitAction(false)} /> diff --git a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx index cddf1c614bd0..18e5df44c9f8 100644 --- a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx @@ -29,6 +29,7 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { const insets = useSafeAreaInsets(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); + const { canWriteSourceControl } = gitActions; const params = props.route.params; @@ -56,17 +57,17 @@ 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 (!canWriteSourceControl || !confirmAction) return; navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId })); if (includesCommit) { @@ -91,6 +92,7 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { await gitActions.onCreateSelectedThreadBranch(newBranchName); await gitActions.onRunSelectedThreadGitAction({ action: confirmAction }); }, [ + canWriteSourceControl, confirmAction, gitActions, gitState.selectedThreadBranches, @@ -122,15 +124,22 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { + {!canWriteSourceControl ? ( + + This connection cannot change source control. + + ) : null} void continuePendingAction()} /> void movePendingActionToFeatureBranch()} /> diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 5aefccb4baff..e3f3994a58ea 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -53,6 +53,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); + const { canWriteSourceControl } = gitActions; const theme = useUniwindTheme(); const foregroundColor = theme["--color-foreground"]; const sheetColor = theme["--color-sheet"]; @@ -83,15 +84,21 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { const sheetMenuItems = useMemo( () => menuItems.map((item) => ({ - item, - disabledReason: getGitActionDisabledReason({ - item, - gitStatus: gitStatus.data, - isBusy: busy, - hasOriginRemote: hasPrimaryRemote, - }), + item: { + ...item, + disabled: item.disabled || (!canWriteSourceControl && item.kind !== "open_pr"), + }, + disabledReason: + !canWriteSourceControl && item.kind !== "open_pr" + ? "This connection cannot change source control." + : getGitActionDisabledReason({ + item, + gitStatus: gitStatus.data, + isBusy: busy, + hasOriginRemote: hasPrimaryRemote, + }), })), - [busy, gitStatus.data, hasPrimaryRemote, menuItems], + [busy, canWriteSourceControl, gitStatus.data, hasPrimaryRemote, menuItems], ); useEffect(() => { @@ -111,6 +118,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { const runActionWithPrompt = useCallback( async (input: GitActionRequestInput) => { + if (!canWriteSourceControl) return; const confirmableAction = input.action === "push" || input.action === "create_pr" || @@ -142,7 +150,16 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { } await gitActions.onRunSelectedThreadGitAction(input); }, - [environmentId, gitActions, gitStatus.data, isDefaultRef, isInspector, navigation, threadId], + [ + canWriteSourceControl, + environmentId, + gitActions, + gitStatus.data, + isDefaultRef, + isInspector, + navigation, + threadId, + ], ); const onPressMenuItem = useCallback( @@ -152,6 +169,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { await openExistingPr(); return; } + if (!canWriteSourceControl) return; if (item.dialogAction === "commit") { navigation.navigate("GitCommit", { environmentId: String(environmentId), @@ -167,7 +185,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { await runActionWithPrompt({ action: "create_pr" }); } }, - [environmentId, openExistingPr, navigation, runActionWithPrompt, threadId], + [canWriteSourceControl, environmentId, openExistingPr, navigation, runActionWithPrompt, threadId], ); // Status facts live on the relevant rows instead of crowding the header @@ -249,8 +267,12 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { void gitActions.onPullSelectedThreadBranch()} /> @@ -274,7 +296,11 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { navigation.navigate("GitBranches", { diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.ts b/apps/mobile/src/state/use-selected-thread-git-actions.ts index f320e9da710d..909d5f6f8b34 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,7 @@ import { type VcsActionOperation, type VcsRef, } from "@t3tools/client-runtime/state/vcs"; -import type { GitRunStackedActionResult } from "@t3tools/contracts"; +import { AuthSourceControlWriteScope, type GitRunStackedActionResult } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches, sanitizeFeatureBranchName, @@ -20,6 +20,7 @@ import { threadEnvironment } from "../state/threads"; import { vcsActionManager, vcsEnvironment } from "../state/vcs"; import { uuidv4 } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; +import { 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 +37,10 @@ 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 { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); const runStackedAction = useAtomCommand( vcsActionManager.runStackedAction({ @@ -133,7 +138,7 @@ export function useSelectedThreadGitActions() { }) => Promise>, options?: { readonly managedExternally?: boolean }, ): Promise => { - if (!selectedThread || !selectedThreadProject || !selectedThreadCwd) { + if (!canWriteSourceControl || !selectedThread || !selectedThreadProject || !selectedThreadCwd) { return null; } @@ -161,7 +166,7 @@ export function useSelectedThreadGitActions() { } return result.value; }, - [selectedThread, selectedThreadCwd, selectedThreadProject], + [canWriteSourceControl, selectedThread, selectedThreadCwd, selectedThreadProject], ); const refreshSelectedThreadBranches = useCallback(async (): Promise> => { @@ -373,6 +378,7 @@ export function useSelectedThreadGitActions() { ); return { + canWriteSourceControl, refreshSelectedThreadGitStatus, refreshSelectedThreadBranches, onCheckoutSelectedThreadBranch, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index d2e3b6b5d68c..f4dfbacfdc0c 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -8,6 +8,7 @@ import { AuthRelayReadScope, AuthRelayWriteScope, AuthReviewWriteScope, + AuthSourceControlWriteScope, AuthTerminalOperateScope, ORCHESTRATION_WS_METHODS, type AuthEnvironmentScope, @@ -74,14 +75,14 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope, - [WS_METHODS.pullRequestsRunAction]: AuthOrchestrationOperateScope, - [WS_METHODS.pullRequestsUpdate]: AuthOrchestrationOperateScope, - [WS_METHODS.pullRequestsComment]: AuthOrchestrationOperateScope, - [WS_METHODS.pullRequestsUpdateComment]: AuthOrchestrationOperateScope, - [WS_METHODS.pullRequestsSubmitReview]: AuthOrchestrationOperateScope, - [WS_METHODS.pullRequestsReplyToThread]: AuthOrchestrationOperateScope, - [WS_METHODS.pullRequestsSetThreadResolution]: AuthOrchestrationOperateScope, - [WS_METHODS.pullRequestsSetReaction]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope, + [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope, + [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope, + [WS_METHODS.pullRequestsUpdateComment]: AuthSourceControlWriteScope, + [WS_METHODS.pullRequestsSubmitReview]: AuthSourceControlWriteScope, + [WS_METHODS.pullRequestsReplyToThread]: AuthSourceControlWriteScope, + [WS_METHODS.pullRequestsSetThreadResolution]: AuthSourceControlWriteScope, + [WS_METHODS.pullRequestsSetReaction]: AuthSourceControlWriteScope, // Read scope like the reads it un-caches: refreshing is part of reading, and a read-only // client pressing refresh must not be told it may not look again. [WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope, @@ -89,12 +90,12 @@ export const RPC_REQUIRED_SCOPES = { // The candidate list is a read like the detail beside it; asking somebody for a review is a // write like every other one. [WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope, - [WS_METHODS.pullRequestsRequestReviewers]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsRequestReviewers]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope, - [WS_METHODS.pullRequestsSetLabels]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsSetLabels]: AuthSourceControlWriteScope, [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, - [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, - [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, + [WS_METHODS.sourceControlCloneRepository]: AuthSourceControlWriteScope, + [WS_METHODS.sourceControlPublishRepository]: AuthSourceControlWriteScope, [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, @@ -109,16 +110,16 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, - [WS_METHODS.vcsPull]: AuthOrchestrationOperateScope, - [WS_METHODS.gitRunStackedAction]: AuthOrchestrationOperateScope, - [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationOperateScope, - [WS_METHODS.gitPreparePullRequestThread]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsPull]: AuthSourceControlWriteScope, + [WS_METHODS.gitRunStackedAction]: AuthSourceControlWriteScope, + [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope, + [WS_METHODS.gitPreparePullRequestThread]: AuthSourceControlWriteScope, [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, - [WS_METHODS.vcsCreateWorktree]: AuthOrchestrationOperateScope, - [WS_METHODS.vcsRemoveWorktree]: AuthOrchestrationOperateScope, - [WS_METHODS.vcsCreateRef]: AuthOrchestrationOperateScope, - [WS_METHODS.vcsSwitchRef]: AuthOrchestrationOperateScope, - [WS_METHODS.vcsInit]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsCreateWorktree]: AuthSourceControlWriteScope, + [WS_METHODS.vcsRemoveWorktree]: AuthSourceControlWriteScope, + [WS_METHODS.vcsCreateRef]: AuthSourceControlWriteScope, + [WS_METHODS.vcsSwitchRef]: AuthSourceControlWriteScope, + [WS_METHODS.vcsInit]: AuthSourceControlWriteScope, [WS_METHODS.reviewGetDiffPreview]: AuthReviewWriteScope, [WS_METHODS.reviewGetDiffFileContents]: AuthReviewWriteScope, [WS_METHODS.terminalOpen]: AuthTerminalOperateScope, diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 773991d6c51a..d841f84265f1 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -10,6 +10,7 @@ import { AuthRelayReadScope, AuthRelayWriteScope, AuthReviewWriteScope, + AuthSourceControlWriteScope, AuthTerminalOperateScope, EnvironmentAuthInvalidError, type EnvironmentAuthInvalidReason, @@ -314,6 +315,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( AuthEnvironmentMaintainScope, AuthTerminalOperateScope, AuthReviewWriteScope, + AuthSourceControlWriteScope, AuthAccessReadScope, AuthAccessWriteScope, AuthRelayReadScope, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 65a078da42c8..c86ce631683a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -7,6 +7,7 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hos import { AuthAccessTokenType, AuthAdministrativeScopes, + AuthSourceControlWriteScope, AuthStandardClientScopes, AuthEnvironmentBootstrapTokenType, AuthTokenExchangeGrantType, @@ -5571,6 +5572,130 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("requires source-control write scope for mutations while keeping repository reads available", () => + Effect.gen(function* () { + const calls: string[] = []; + const cloneResult = { + cwd: "/tmp/scoped-repository", + remoteUrl: "https://example.com/owner/repository.git", + repository: null, + }; + const actionResult = { + action: "push" as const, + branch: { status: "skipped_not_requested" as const }, + commit: { status: "skipped_not_requested" as const }, + push: { status: "skipped_up_to_date" as const }, + pr: { status: "skipped_not_requested" as const }, + toast: { + title: "Already up to date", + description: "No changes to push.", + cta: { kind: "none" as const }, + }, + }; + yield* buildAppUnderTest({ + layers: { + sourceControlRepositoryService: { + cloneRepository: () => + Effect.sync(() => { + calls.push("clone"); + return cloneResult; + }), + }, + gitManager: { + resolvePullRequest: () => + Effect.succeed({ + pullRequest: { + number: 1, + title: "A change", + url: "https://example.com/owner/repository/pull/1", + baseBranch: "main", + headBranch: "feature", + state: "open", + }, + }), + runStackedAction: () => + Effect.sync(() => { + calls.push("push"); + return actionResult; + }), + }, + vcsStatusBroadcaster: { + refreshStatus: () => + Effect.succeed({ + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + }, + }, + }); + for (const canWrite of [false, true]) { + const token = yield* exchangeAccessToken(defaultDesktopBootstrapToken, { + scope: canWrite + ? `orchestration:read ${AuthSourceControlWriteScope}` + : "orchestration:read orchestration:operate", + }); + assert.equal(token.response.status, 200); + const ticketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { + headers: { authorization: `Bearer ${token.body.access_token ?? ""}` }, + }); + const { ticket } = yield* responseJsonEffect<{ readonly ticket: string }>(ticketResponse); + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(ticket)}`; + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const resolved = yield* client[WS_METHODS.gitResolvePullRequest]({ + cwd: cloneResult.cwd, + reference: "1", + }); + assert.equal(resolved.pullRequest.number, 1); + const clone = client[WS_METHODS.sourceControlCloneRepository]({ + remoteUrl: cloneResult.remoteUrl, + destinationPath: cloneResult.cwd, + }); + const push = client[WS_METHODS.gitRunStackedAction]({ + cwd: cloneResult.cwd, + actionId: "scoped-push", + action: "push", + }).pipe(Stream.runDrain); + if (canWrite) { + assert.deepEqual(yield* clone, cloneResult); + yield* push; + } else { + const comment = client[WS_METHODS.pullRequestsComment]({ + projectId: ProjectId.make("scoped-project"), + repository: "owner/repository", + number: 1, + body: "A comment", + }); + const errors = [ + yield* clone.pipe(Effect.flip), + yield* push.pipe(Effect.flip), + yield* comment.pipe(Effect.flip), + ]; + for (const error of errors) { + assert.equal(error._tag, "EnvironmentAuthorizationError"); + if (error._tag === "EnvironmentAuthorizationError") { + assert.equal(error.requiredScope, AuthSourceControlWriteScope); + } + } + assert.deepEqual(calls, []); + } + }), + ), + ); + } + assert.deepEqual(calls, ["clone", "push"]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("provider setup lets read-only clients observe installation but not change setup", () => Effect.gen(function* () { let installStarts = 0; diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 27bf2ede9b9a..aaa7232d3c3d 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -3,7 +3,13 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import type { ContextMenuItem, EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; +import { + AuthSourceControlWriteScope, + type ContextMenuItem, + type EnvironmentId, + type VcsRef, + type ThreadId, +} from "@t3tools/contracts"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { ChevronDownIcon, GitBranchIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; import { @@ -28,6 +34,7 @@ import { shouldLoadNextBranchPageAfterScroll } from "../state/paginatedBranches" import { usePaginatedBranches } from "../state/queries"; import { useProject, useThread } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; +import { useEnvironmentScope } from "~/state/session"; import { threadEnvironment } from "../state/threads"; import { useAtomCommand } from "../state/use-atom-command"; import { vcsEnvironment } from "../state/vcs"; @@ -100,6 +107,7 @@ export function BranchToolbarBranchSelector({ onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { + const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); const startFromOriginSwitchId = useId(); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); const updateThreadMetadata = useAtomCommand( @@ -264,8 +272,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; + canWriteSourceControl && prReference && onCheckoutPullRequestRequest + ? `__checkout_pull_request__:${prReference}` + : null; + const canCreateBranch = + canWriteSourceControl && !isSelectingWorktreeBase && trimmedBranchQuery.length > 0; // The ref is created under its sanitized name, so the collision check has to // use that name too. Matching on the raw query would offer to create a ref // that already exists whenever sanitizing changes the name. @@ -383,6 +394,7 @@ export function BranchToolbarBranchSelector({ ); const runBranchAction = (action: () => Promise) => { + if (!canWriteSourceControl) return; startBranchActionTransition(async () => { await action(); branchRefState.refresh(); @@ -452,6 +464,7 @@ export function BranchToolbarBranchSelector({ }; const createRef = (rawName: string) => { + if (!canWriteSourceControl) return; const name = sanitizeNewRefName(rawName); if (!branchCwd || !name || isBranchActionPending) return; @@ -696,6 +709,16 @@ export function BranchToolbarBranchSelector({ index={index} value={itemValue} className="pe-1.5" + disabled={ + !canWriteSourceControl && + !isSelectingWorktreeBase && + (!activeProjectCwd || + !resolveBranchSelectionTarget({ + activeProjectCwd, + activeWorktreePath, + refName, + }).reuseExistingWorktree) + } onClick={() => selectBranch(refName)} onContextMenu={(event) => handleBranchContextMenu(event, itemValue)} > diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9b80b5a976cb..db315ba327b6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,5 +1,6 @@ import { AuthSettingsWriteScope, + AuthSourceControlWriteScope, type AssistantCitation, type ApprovalRequestId, type ChatFileAttachment, @@ -272,6 +273,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; +import { useEnvironmentScope } from "~/state/session"; import { environmentServerConfigsAtom, primaryServerAvailableEditorsAtom, @@ -1395,6 +1397,7 @@ export default function ChatView(props: ChatViewProps) { const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { reportFailure: false, @@ -1780,7 +1783,7 @@ export default function ChatView(props: ChatViewProps) { const [, setThreadErrorBannerDismissTick] = useState(0); const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; - const canCheckoutPullRequestIntoThread = isLocalDraftThread; + const canCheckoutPullRequestIntoThread = canWriteSourceControl && isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; const activeThreadEnvironmentId = activeThread?.environmentId ?? null; const runningTerminalIds = useThreadRunningTerminalIds({ @@ -5315,6 +5318,7 @@ export default function ChatView(props: ChatViewProps) { }); }, [activeBranchMismatchKey, showBranchMismatchBanner]); const handleSwitchCheckoutToThread = useCallback(async () => { + if (!canWriteSourceControl) return; if ( !activeProjectCwd || !activeThread || @@ -5370,6 +5374,7 @@ export default function ChatView(props: ChatViewProps) { setIsRestoringThreadBranch(false); scheduleComposerFocus(); }, [ + canWriteSourceControl, activeProjectCwd, activeThread, environmentId, @@ -5622,12 +5627,17 @@ export default function ChatView(props: ChatViewProps) { selectedProvider, ]); const handleRestoreThreadBranch = useCallback(() => { + if (!canWriteSourceControl) return; if (gitStatusQuery.data?.hasWorkingTreeChanges) { setBranchRestoreConfirmOpen(true); return; } void handleSwitchCheckoutToThread(); - }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); + }, [ + canWriteSourceControl, + gitStatusQuery.data?.hasWorkingTreeChanges, + handleSwitchCheckoutToThread, + ]); const composerBannerItems = useMemo(() => { const backgroundLivenessItems = backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem]; @@ -5675,7 +5685,7 @@ export default function ChatView(props: ChatViewProps) { - diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index 4004b4930c27..758b92056438 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -131,6 +131,7 @@ export function PullRequestThreadDialog({ const handleConfirm = useCallback( async (mode: "local" | "worktree") => { + if (!preparePullRequestThreadAction.isAllowed) return; if (!parsedReference) { setReferenceDirty(true); return; @@ -274,6 +275,7 @@ export function PullRequestThreadDialog({ void handleConfirm("local"); }} disabled={ + !preparePullRequestThreadAction.isAllowed || !cwd || !resolvedPullRequest || isResolving || @@ -289,6 +291,7 @@ export function PullRequestThreadDialog({ void handleConfirm("worktree"); }} disabled={ + !preparePullRequestThreadAction.isAllowed || !cwd || !resolvedPullRequest || isResolving || diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 4a3af4b02c52..cc5e8e294e77 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1,6 +1,7 @@ import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { + AuthSourceControlWriteScope, type EnvironmentId, type PullRequestAction, type PullRequestMergeMethod, @@ -63,6 +64,7 @@ import type { ReviewCommentContext } from "~/reviewCommentContext"; import { useProjects } from "~/state/entities"; import { useEnvironments } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; +import { useEnvironmentScope } from "~/state/session"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; import { pullRequestEnvironment, @@ -574,6 +576,7 @@ export function PullRequestDetailPanel({ void loadCodeTab(); }, []); + const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); const detailQuery = useEnvironmentQuery( pullRequestEnvironment.detail({ environmentId, input: reference }), ); @@ -640,6 +643,25 @@ export function PullRequestDetailPanel({ ? null : { ...coreDetail, + capabilities: canWriteSourceControl + ? coreDetail.capabilities + : { + ...coreDetail.capabilities, + reactions: false, + edit: { changeRequest: false, comment: false }, + }, + viewerPermissions: canWriteSourceControl + ? coreDetail.viewerPermissions + : { + ...coreDetail.viewerPermissions, + actions: [], + comment: false, + resolve: false, + verdicts: [], + requestReviewers: false, + updateMethods: [], + labels: false, + }, author: activity?.author ?? coreDetail.author, reviewers: activity?.reviewers ?? coreDetail.reviewers, comments: activity?.comments ?? [], @@ -649,7 +671,7 @@ export function PullRequestDetailPanel({ commits: activity?.commits ?? [], reactions: activity?.reactions ?? [], }, - [activity, coreDetail], + [activity, canWriteSourceControl, coreDetail], ); useEffect(() => { if (detail?.autoMergeMethod !== undefined) setMergeMethod(detail.autoMergeMethod); @@ -854,13 +876,13 @@ export function PullRequestDetailPanel({ method?: PullRequestMergeMethod, updateMethod?: PullRequestUpdateMethod, ) => { - if (pendingAction !== null) return false; + if (!canWriteSourceControl || pendingAction !== null) return false; setPendingAction(action); return finishAction(action, method, updateMethod); }; const performCommentAction = async (body: string, action: "close" | "reopen") => { - if (pendingAction !== null) return { commentPosted: false }; + if (!canWriteSourceControl || pendingAction !== null) return { commentPosted: false }; setPendingAction(action); const commentResult = await postComment({ environmentId, @@ -880,7 +902,7 @@ export function PullRequestDetailPanel({ const saveTitle = async (next: string) => { const title = next.trim(); - if (detail === null || titleSaving) return; + if (!canWriteSourceControl || detail === null || titleSaving) return; if (title.length === 0 || title === detail.title) { setTitleScope(null); return; @@ -914,6 +936,7 @@ export function PullRequestDetailPanel({ // the branch is already checked out under it, so opening a second thread would only scatter // the work. const attachTarget = pullRequestComposerTarget(context, composerDraftTarget); + const canFixFindings = attachTarget !== null || prepareThread.isAllowed; const handoffLabels = pullRequestHandoffLabels(attachTarget !== null); const writeTaskToComposer = (target: ScopedThreadRef | DraftId, task: ThreadTask) => { @@ -1020,6 +1043,7 @@ export function PullRequestDetailPanel({ }); return; } + if (!prepareThread.isAllowed) return; setHandoff(kind); // The menu closes on the press and takes its "Preparing..." label with it, so this is the // only thing answering for the checkout. It carries no timeout of its own: a loading toast @@ -1471,7 +1495,10 @@ export function PullRequestDetailPanel({ } /> - startCheckout("worktree")}> + startCheckout("worktree")} + > In a separate worktree @@ -1480,7 +1507,10 @@ export function PullRequestDetailPanel({ - startCheckout("local")}> + startCheckout("local")} + > In this repository @@ -1531,7 +1561,7 @@ export function PullRequestDetailPanel({ - From 0029911efea0d595922f589bf8e01ba85fc92793 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 14:26:09 -0700 Subject: [PATCH 03/10] fix(web): settle source permissions before destructive actions --- apps/web/src/components/GitActionsControl.tsx | 2 ++ apps/web/src/hooks/useThreadActions.ts | 21 ++++++++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index e1b74b23170c..e1d85a6ca295 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -2055,6 +2055,7 @@ export default function GitActionsControl({ variant="outline" size="sm" onClick={continuePendingDefaultBranchAction} + disabled={!canWriteSourceControl} > {pendingDefaultBranchActionCopy?.continueLabel ?? "Continue"} @@ -2062,6 +2063,7 @@ export default function GitActionsControl({ className="min-h-8 w-full max-w-full whitespace-normal py-1.5 leading-snug sm:min-h-7 sm:w-auto" size="sm" onClick={checkoutFeatureBranchAndContinuePendingAction} + disabled={!canWriteSourceControl} > Checkout feature branch & continue diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 6c59ca343600..e6fe6df4f6ae 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -20,7 +20,7 @@ import { useCallback, useMemo, useRef } from "react"; import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic"; import { useComposerDraftStore } from "../composerDraftStore"; -import { readEnvironmentScope } from "../state/session"; +import { environmentSession } from "../state/session"; import { terminalEnvironment } from "../state/terminal"; import { threadEnvironment } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; @@ -45,6 +45,7 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from " import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useClientSettings } from "./useSettings"; import { useAtomCommand } from "../state/use-atom-command"; +import { useAtomQueryRunner } from "../state/use-atom-query-runner"; export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass()( "ThreadArchiveBlockedError", @@ -186,6 +187,9 @@ export function useThreadActions() { const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { reportFailure: false, }); + const loadSessionState = useAtomQueryRunner(environmentSession.sessionStateAtom, { + reportFailure: false, + }); const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false, }); @@ -328,11 +332,17 @@ export function useThreadActions() { const displayWorktreePath = orphanedWorktreePath ? formatWorktreePathForDisplay(orphanedWorktreePath) : null; - const canDeleteWorktree = - orphanedWorktreePath !== null && - threadProject !== null && - readEnvironmentScope(threadRef.environmentId, AuthSourceControlWriteScope); const localApi = readLocalApi(); + let canDeleteWorktree = false; + if (orphanedWorktreePath !== null && threadProject !== null && localApi) { + const sessionResult = await loadSessionState(threadRef.environmentId); + if (sessionResult._tag === "Failure") { + return sessionResult; + } + canDeleteWorktree = + sessionResult.value.authenticated && + sessionResult.value.scopes?.includes(AuthSourceControlWriteScope) === true; + } let shouldDeleteWorktree = false; if (canDeleteWorktree && localApi) { const confirmationResult = await settlePromise(() => @@ -479,6 +489,7 @@ export function useThreadActions() { closeTerminal, deleteThreadMutation, getCurrentRouteThreadRef, + loadSessionState, refreshVcsStatus, removeWorktree, router, From 62ef847071f87bd278ac6b87958fbb7945b546df Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 14:43:50 -0700 Subject: [PATCH 04/10] style(auth): format source control permission changes --- .../features/projects/AddProjectScreen.tsx | 8 +- apps/server/src/server.test.ts | 234 +++++++++--------- 2 files changed, 125 insertions(+), 117 deletions(-) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index a3b713123355..63ae5eee6dc0 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -936,7 +936,13 @@ export function AddProjectDestinationScreen(props: { const [error, setError] = useState(null); const submitPath = useCallback(async () => { - if (!canWriteSourceControl || !environment || !remoteUrl || isBrowseNavigating || isSubmitting) { + if ( + !canWriteSourceControl || + !environment || + !remoteUrl || + isBrowseNavigating || + isSubmitting + ) { return; } setError(null); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index c86ce631683a..06216187ac80 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5572,128 +5572,130 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("requires source-control write scope for mutations while keeping repository reads available", () => - Effect.gen(function* () { - const calls: string[] = []; - const cloneResult = { - cwd: "/tmp/scoped-repository", - remoteUrl: "https://example.com/owner/repository.git", - repository: null, - }; - const actionResult = { - action: "push" as const, - branch: { status: "skipped_not_requested" as const }, - commit: { status: "skipped_not_requested" as const }, - push: { status: "skipped_up_to_date" as const }, - pr: { status: "skipped_not_requested" as const }, - toast: { - title: "Already up to date", - description: "No changes to push.", - cta: { kind: "none" as const }, - }, - }; - yield* buildAppUnderTest({ - layers: { - sourceControlRepositoryService: { - cloneRepository: () => - Effect.sync(() => { - calls.push("clone"); - return cloneResult; - }), - }, - gitManager: { - resolvePullRequest: () => - Effect.succeed({ - pullRequest: { - number: 1, - title: "A change", - url: "https://example.com/owner/repository/pull/1", - baseBranch: "main", - headBranch: "feature", - state: "open", - }, - }), - runStackedAction: () => - Effect.sync(() => { - calls.push("push"); - return actionResult; - }), + it.effect( + "requires source-control write scope for mutations while keeping repository reads available", + () => + Effect.gen(function* () { + const calls: string[] = []; + const cloneResult = { + cwd: "/tmp/scoped-repository", + remoteUrl: "https://example.com/owner/repository.git", + repository: null, + }; + const actionResult = { + action: "push" as const, + branch: { status: "skipped_not_requested" as const }, + commit: { status: "skipped_not_requested" as const }, + push: { status: "skipped_up_to_date" as const }, + pr: { status: "skipped_not_requested" as const }, + toast: { + title: "Already up to date", + description: "No changes to push.", + cta: { kind: "none" as const }, }, - vcsStatusBroadcaster: { - refreshStatus: () => - Effect.succeed({ - isRepo: true, - hasPrimaryRemote: true, - isDefaultRef: false, - refName: "feature", - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, - }), + }; + yield* buildAppUnderTest({ + layers: { + sourceControlRepositoryService: { + cloneRepository: () => + Effect.sync(() => { + calls.push("clone"); + return cloneResult; + }), + }, + gitManager: { + resolvePullRequest: () => + Effect.succeed({ + pullRequest: { + number: 1, + title: "A change", + url: "https://example.com/owner/repository/pull/1", + baseBranch: "main", + headBranch: "feature", + state: "open", + }, + }), + runStackedAction: () => + Effect.sync(() => { + calls.push("push"); + return actionResult; + }), + }, + vcsStatusBroadcaster: { + refreshStatus: () => + Effect.succeed({ + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + }, }, - }, - }); - for (const canWrite of [false, true]) { - const token = yield* exchangeAccessToken(defaultDesktopBootstrapToken, { - scope: canWrite - ? `orchestration:read ${AuthSourceControlWriteScope}` - : "orchestration:read orchestration:operate", - }); - assert.equal(token.response.status, 200); - const ticketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { - headers: { authorization: `Bearer ${token.body.access_token ?? ""}` }, }); - const { ticket } = yield* responseJsonEffect<{ readonly ticket: string }>(ticketResponse); - const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(ticket)}`; - yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - Effect.gen(function* () { - const resolved = yield* client[WS_METHODS.gitResolvePullRequest]({ - cwd: cloneResult.cwd, - reference: "1", - }); - assert.equal(resolved.pullRequest.number, 1); - const clone = client[WS_METHODS.sourceControlCloneRepository]({ - remoteUrl: cloneResult.remoteUrl, - destinationPath: cloneResult.cwd, - }); - const push = client[WS_METHODS.gitRunStackedAction]({ - cwd: cloneResult.cwd, - actionId: "scoped-push", - action: "push", - }).pipe(Stream.runDrain); - if (canWrite) { - assert.deepEqual(yield* clone, cloneResult); - yield* push; - } else { - const comment = client[WS_METHODS.pullRequestsComment]({ - projectId: ProjectId.make("scoped-project"), - repository: "owner/repository", - number: 1, - body: "A comment", + for (const canWrite of [false, true]) { + const token = yield* exchangeAccessToken(defaultDesktopBootstrapToken, { + scope: canWrite + ? `orchestration:read ${AuthSourceControlWriteScope}` + : "orchestration:read orchestration:operate", + }); + assert.equal(token.response.status, 200); + const ticketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { + headers: { authorization: `Bearer ${token.body.access_token ?? ""}` }, + }); + const { ticket } = yield* responseJsonEffect<{ readonly ticket: string }>(ticketResponse); + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(ticket)}`; + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const resolved = yield* client[WS_METHODS.gitResolvePullRequest]({ + cwd: cloneResult.cwd, + reference: "1", + }); + assert.equal(resolved.pullRequest.number, 1); + const clone = client[WS_METHODS.sourceControlCloneRepository]({ + remoteUrl: cloneResult.remoteUrl, + destinationPath: cloneResult.cwd, }); - const errors = [ - yield* clone.pipe(Effect.flip), - yield* push.pipe(Effect.flip), - yield* comment.pipe(Effect.flip), - ]; - for (const error of errors) { - assert.equal(error._tag, "EnvironmentAuthorizationError"); - if (error._tag === "EnvironmentAuthorizationError") { - assert.equal(error.requiredScope, AuthSourceControlWriteScope); + const push = client[WS_METHODS.gitRunStackedAction]({ + cwd: cloneResult.cwd, + actionId: "scoped-push", + action: "push", + }).pipe(Stream.runDrain); + if (canWrite) { + assert.deepEqual(yield* clone, cloneResult); + yield* push; + } else { + const comment = client[WS_METHODS.pullRequestsComment]({ + projectId: ProjectId.make("scoped-project"), + repository: "owner/repository", + number: 1, + body: "A comment", + }); + const errors = [ + yield* clone.pipe(Effect.flip), + yield* push.pipe(Effect.flip), + yield* comment.pipe(Effect.flip), + ]; + for (const error of errors) { + assert.equal(error._tag, "EnvironmentAuthorizationError"); + if (error._tag === "EnvironmentAuthorizationError") { + assert.equal(error.requiredScope, AuthSourceControlWriteScope); + } } + assert.deepEqual(calls, []); } - assert.deepEqual(calls, []); - } - }), - ), - ); - } - assert.deepEqual(calls, ["clone", "push"]); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), + }), + ), + ); + } + assert.deepEqual(calls, ["clone", "push"]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); it.effect("provider setup lets read-only clients observe installation but not change setup", () => From b6ec090c59f82bb3f212a6a74371eb317834a7b7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 15:55:59 -0700 Subject: [PATCH 05/10] fix(auth): gate thread git changes on task permissions --- .../features/threads/git/GitBranchesSheet.tsx | 18 +- .../features/threads/git/GitCommitSheet.tsx | 7 +- .../features/threads/git/GitConfirmSheet.tsx | 11 +- .../use-selected-thread-git-actions.test.ts | 160 ++++++++++++++++++ .../state/use-selected-thread-git-actions.ts | 31 +++- .../BranchToolbarBranchSelector.tsx | 44 +++-- apps/web/src/components/GitActionsControl.tsx | 30 +++- 7 files changed, 253 insertions(+), 48 deletions(-) create mode 100644 apps/mobile/src/state/use-selected-thread-git-actions.test.ts diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index 7f23ab10fff5..2d1757cee63c 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -27,7 +27,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); - const { canWriteSourceControl } = gitActions; + const { canChangeThreadBranch } = gitActions; const gitStatus = useEnvironmentQuery( selectedThread !== null && selectedThreadCwd !== null @@ -69,9 +69,9 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} contentContainerClassName="gap-4 px-5 pt-2" > - {!canWriteSourceControl ? ( + {!canChangeThreadBranch ? ( - This connection cannot change source control. + This connection cannot change this thread's branch or worktree. ) : null} @@ -88,9 +88,9 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { icon="plus" label="Create & checkout" tone="primary" - disabled={!canWriteSourceControl || busy || newBranchName.trim().length === 0} + disabled={!canChangeThreadBranch || busy || newBranchName.trim().length === 0} onPress={() => { - if (!canWriteSourceControl) return; + if (!canChangeThreadBranch) return; const branch = sanitizeFeatureBranchName(newBranchName.trim()); if (branch.length === 0) return; void gitActions.onCreateSelectedThreadBranch(branch).then(() => { @@ -122,13 +122,13 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { label="Create worktree" tone="primary" disabled={ - !canWriteSourceControl || + !canChangeThreadBranch || busy || worktreeBaseBranch.trim().length === 0 || worktreeBranchName.trim().length === 0 } onPress={() => { - if (!canWriteSourceControl) return; + if (!canChangeThreadBranch) return; const baseBranch = worktreeBaseBranch.trim(); const newBranch = worktreeBranchName.trim(); if (baseBranch.length === 0 || newBranch.length === 0) return; @@ -171,9 +171,9 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { "gap-1 rounded-[18px] border px-4 py-3 disabled:opacity-[0.45]", branch.current ? "border-subtle-strong" : "border-border", )} - disabled={!canWriteSourceControl || busy || disabled} + disabled={!canChangeThreadBranch || busy || disabled} onPress={() => { - if (!canWriteSourceControl) return; + if (!canChangeThreadBranch) return; void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { navigation.goBack(); }); diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index 7017a9c8d1f1..3fb5473c87a9 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -26,7 +26,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { const { selectedThreadCwd } = useSelectedThreadWorktree(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); - const { canWriteSourceControl } = gitActions; + const { canWriteSourceControl, canChangeThreadBranch } = gitActions; const gitStatus = useEnvironmentQuery( selectedThread !== null && selectedThreadCwd !== null @@ -54,7 +54,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { const runCommitAction = useCallback( async (featureBranch: boolean) => { - if (!canWriteSourceControl) return; + if (!canWriteSourceControl || (featureBranch && !canChangeThreadBranch)) return; const commitMessage = dialogCommitMessage.trim(); navigation.goBack(); await gitActions.onRunSelectedThreadGitAction({ @@ -67,6 +67,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { [ allSelected, canWriteSourceControl, + canChangeThreadBranch, dialogCommitMessage, gitActions, navigation, @@ -227,7 +228,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { void runCommitAction(true)} /> diff --git a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx index 76d1c0610caf..5a28840a8650 100644 --- a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx @@ -29,7 +29,7 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { const insets = useSafeAreaInsets(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); - const { canWriteSourceControl } = gitActions; + const { canWriteSourceControl, canChangeThreadBranch } = gitActions; const params = props.route.params; @@ -75,7 +75,7 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { ]); const movePendingActionToFeatureBranch = useCallback(async () => { - if (!canWriteSourceControl || !confirmAction) return; + if (!canChangeThreadBranch || !confirmAction) return; navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId })); if (includesCommit) { @@ -97,10 +97,11 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { branch.isRemote ? Result.failVoid : Result.succeed(branch.name), ), ); - await gitActions.onCreateSelectedThreadBranch(newBranchName); + const created = await gitActions.onCreateSelectedThreadBranch(newBranchName); + if (created === null) return; await gitActions.onRunSelectedThreadGitAction({ action: confirmAction }); }, [ - canWriteSourceControl, + canChangeThreadBranch, confirmAction, gitActions, gitState.selectedThreadBranches, @@ -147,7 +148,7 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { icon="arrow.branch" label="Feature branch & continue" tone="primary" - disabled={!canWriteSourceControl} + disabled={!canChangeThreadBranch} onPress={() => void movePendingActionToFeatureBranch()} /> diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.test.ts b/apps/mobile/src/state/use-selected-thread-git-actions.test.ts new file mode 100644 index 000000000000..6f7241d3aee6 --- /dev/null +++ b/apps/mobile/src/state/use-selected-thread-git-actions.test.ts @@ -0,0 +1,160 @@ +import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + scopes: new Set(), + branch: "main", + worktrees: [] as string[], + thread: { + id: "thread", + environmentId: "environment", + branch: "main", + worktreePath: null as string | null, + }, + commits: 0, + pushes: 0, +})); + +vi.mock("react", () => ({ + useCallback: (callback: unknown) => callback, + useMemo: (factory: () => unknown) => factory(), + useEffect: () => {}, +})); +vi.mock("./session", () => ({ + useEnvironmentScope: (_environmentId: unknown, scope: string) => state.scopes.has(scope), + readEnvironmentScope: (_environmentId: unknown, scope: string) => state.scopes.has(scope), +})); +vi.mock("./use-thread-selection", () => ({ + useThreadSelection: () => ({ + selectedThread: state.thread, + selectedThreadProject: { workspaceRoot: "/repo" }, + }), +})); +vi.mock("./use-selected-thread-worktree", () => ({ + useSelectedThreadWorktree: () => ({ + selectedThreadCwd: "/repo", + selectedThreadWorktreePath: null, + }), +})); +vi.mock("./queries", () => ({ + useBranches: () => ({ data: { refs: [] }, refresh: () => {} }), +})); +vi.mock("./use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); +vi.mock("./atom-registry", () => ({ appAtomRegistry: {} })); +vi.mock("./use-remote-environment-registry", () => ({ setPendingConnectionError: () => {} })); +vi.mock("./use-vcs-action-state", () => ({ showGitActionResult: () => {} })); +vi.mock("../lib/uuid", () => ({ uuidv4: () => "action" })); +vi.mock("./threads", () => ({ + threadEnvironment: { + updateMetadata: async ({ + input, + }: { + input: { branch: string; worktreePath: string | null }; + }) => { + if (!state.scopes.has(AuthOrchestrationOperateScope)) { + return AsyncResult.failure(Cause.fail(new Error("Task operation denied"))); + } + Object.assign(state.thread, input); + return AsyncResult.success(undefined); + }, + }, +})); +vi.mock("./vcs", () => ({ + vcsEnvironment: { + refreshStatus: async () => AsyncResult.success({ refName: state.branch }), + switchRef: async ({ input }: { input: { refName: string } }) => { + state.branch = input.refName; + return AsyncResult.success({ refName: state.branch }); + }, + createRef: async ({ input }: { input: { refName: string } }) => { + state.branch = input.refName; + return AsyncResult.success({ refName: state.branch }); + }, + createWorktree: async ({ input }: { input: { newRefName: string } }) => { + state.worktrees.push("/repo-worktree"); + return AsyncResult.success({ + worktree: { path: "/repo-worktree", refName: input.newRefName }, + }); + }, + pull: async () => AsyncResult.success({ status: "pulled", refName: state.branch }), + }, + vcsActionManager: { + track: (_registry: unknown, _target: unknown, _operation: unknown, run: () => unknown) => run(), + runStackedAction: () => async (input: { action: string; featureBranch?: boolean }) => { + if (input.featureBranch) state.branch = "feature"; + if (input.action === "commit") state.commits += 1; + if (input.action === "push") state.pushes += 1; + return AsyncResult.success({ + branch: input.featureBranch + ? { status: "created", name: "feature" } + : { status: "skipped_not_requested" }, + toast: { title: "Done", description: "Done", cta: { kind: "none" } }, + }); + }, + }, +})); + +import { useSelectedThreadGitActions } from "./use-selected-thread-git-actions"; + +describe("thread Git mutation permissions", () => { + beforeEach(() => { + state.scopes = new Set([AuthSourceControlWriteScope]); + state.branch = "main"; + state.worktrees = []; + state.thread.branch = "main"; + state.thread.worktreePath = null; + state.commits = 0; + state.pushes = 0; + }); + + it.each([false, true])( + "requires task permission before creating and attaching a worktree: %s", + async (canOperate) => { + if (canOperate) state.scopes.add(AuthOrchestrationOperateScope); + const actions = useSelectedThreadGitActions(); + await actions.onCreateSelectedThreadWorktree({ + baseBranch: "main", + newBranch: "feature/task", + }); + expect(state.worktrees).toEqual(canOperate ? ["/repo-worktree"] : []); + expect(state.thread.worktreePath).toBe(canOperate ? "/repo-worktree" : null); + expect(state.thread.branch).toBe(canOperate ? "feature/task" : "main"); + }, + ); + + it.each(["create", "checkout", "commit on new branch"] as const)( + "requires task permission before %s", + async (operation) => { + const actions = useSelectedThreadGitActions(); + if (operation === "create") { + expect(await actions.onCreateSelectedThreadBranch("feature")).toBeNull(); + } + if (operation === "checkout") await actions.onCheckoutSelectedThreadBranch("feature"); + if (operation === "commit on new branch") + await actions.onRunSelectedThreadGitAction({ action: "commit", featureBranch: true }); + expect(state.branch).toBe("main"); + expect(state.thread.branch).toBe("main"); + expect(state.commits).toBe(0); + }, + ); + + it("rechecks task permission when a retained menu callback runs", async () => { + state.scopes.add(AuthOrchestrationOperateScope); + const actions = useSelectedThreadGitActions(); + state.scopes.delete(AuthOrchestrationOperateScope); + await actions.onCreateSelectedThreadWorktree({ baseBranch: "main", newBranch: "feature/task" }); + expect(state.worktrees).toEqual([]); + expect(state.thread.worktreePath).toBeNull(); + }); + + it("keeps ordinary commits and pushes available without task permission", async () => { + const actions = useSelectedThreadGitActions(); + await actions.onRunSelectedThreadGitAction({ action: "commit" }); + await actions.onRunSelectedThreadGitAction({ action: "push" }); + expect(state.commits).toBe(1); + expect(state.pushes).toBe(1); + expect(state.thread.branch).toBe("main"); + }); +}); diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.ts b/apps/mobile/src/state/use-selected-thread-git-actions.ts index f7883d235970..83978dcecd7b 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -7,7 +7,11 @@ import { type VcsActionOperation, type VcsRef, } from "@t3tools/client-runtime/state/vcs"; -import { AuthSourceControlWriteScope, type GitRunStackedActionResult } from "@t3tools/contracts"; +import { + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, + type GitRunStackedActionResult, +} from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches, sanitizeFeatureBranchName, @@ -20,7 +24,7 @@ import { threadEnvironment } from "../state/threads"; import { vcsActionManager, vcsEnvironment } from "../state/vcs"; import { uuidv4 } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; -import { useEnvironmentScope } from "./session"; +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"; @@ -41,6 +45,11 @@ export function useSelectedThreadGitActions() { selectedThread?.environmentId ?? null, AuthSourceControlWriteScope, ); + const canOperateThread = useEnvironmentScope( + selectedThread?.environmentId ?? null, + AuthOrchestrationOperateScope, + ); + const canChangeThreadBranch = canWriteSourceControl && canOperateThread; const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); const runStackedAction = useAtomCommand( vcsActionManager.runStackedAction({ @@ -136,13 +145,15 @@ export function useSelectedThreadGitActions() { readonly project: EnvironmentProject; readonly cwd: string; }) => Promise>, - options?: { readonly managedExternally?: boolean }, + options?: { readonly managedExternally?: boolean; readonly changesThreadBranch?: boolean }, ): Promise => { if ( - !canWriteSourceControl || !selectedThread || !selectedThreadProject || - !selectedThreadCwd + !selectedThreadCwd || + !readEnvironmentScope(selectedThread.environmentId, AuthSourceControlWriteScope) || + (options?.changesThreadBranch === true && + !readEnvironmentScope(selectedThread.environmentId, AuthOrchestrationOperateScope)) ) { return null; } @@ -171,7 +182,7 @@ export function useSelectedThreadGitActions() { } return result.value; }, - [canWriteSourceControl, selectedThread, selectedThreadCwd, selectedThreadProject], + [selectedThread, selectedThreadCwd, selectedThreadProject], ); const refreshSelectedThreadBranches = useCallback(async (): Promise> => { @@ -226,6 +237,7 @@ export function useSelectedThreadGitActions() { }); return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; }, + { changesThreadBranch: true }, ); }, [ @@ -238,7 +250,7 @@ export function useSelectedThreadGitActions() { const onCreateSelectedThreadBranch = useCallback( async (branch: string) => { - await runSelectedThreadGitMutation( + return runSelectedThreadGitMutation( "create_ref", "Creating branch", async ({ thread, cwd }) => { @@ -259,6 +271,7 @@ export function useSelectedThreadGitActions() { }); return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; }, + { changesThreadBranch: true }, ); }, [ @@ -297,6 +310,7 @@ export function useSelectedThreadGitActions() { }); return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; }, + { changesThreadBranch: true }, ); }, [createWorktree, runSelectedThreadGitMutation, syncSelectedThreadBranchState], @@ -370,7 +384,7 @@ export function useSelectedThreadGitActions() { } return result; }, - { managedExternally: true }, + { managedExternally: true, changesThreadBranch: input.featureBranch === true }, ); }, [ @@ -384,6 +398,7 @@ export function useSelectedThreadGitActions() { return { canWriteSourceControl, + canChangeThreadBranch, refreshSelectedThreadGitStatus, refreshSelectedThreadBranches, onCheckoutSelectedThreadBranch, diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index aaa7232d3c3d..73552e82f414 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -4,6 +4,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { + AuthOrchestrationOperateScope, AuthSourceControlWriteScope, type ContextMenuItem, type EnvironmentId, @@ -34,7 +35,7 @@ import { shouldLoadNextBranchPageAfterScroll } from "../state/paginatedBranches" import { usePaginatedBranches } from "../state/queries"; import { useProject, useThread } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; -import { useEnvironmentScope } from "~/state/session"; +import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { threadEnvironment } from "../state/threads"; import { useAtomCommand } from "../state/use-atom-command"; import { vcsEnvironment } from "../state/vcs"; @@ -108,6 +109,7 @@ export function BranchToolbarBranchSelector({ onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); + const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); const startFromOriginSwitchId = useId(); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); const updateThreadMetadata = useAtomCommand( @@ -150,6 +152,8 @@ export function BranchToolbarBranchSelector({ const activeProjectCwd = activeProject?.workspaceRoot ?? null; const branchCwd = activeWorktreePath ?? activeProjectCwd; const hasServerThread = serverThread !== null; + const canUpdateThreadBranch = !hasServerThread || canOperateThread; + const canChangeThreadBranch = canWriteSourceControl && canUpdateThreadBranch; const effectiveEnvMode = effectiveEnvModeOverride ?? resolveEffectiveEnvMode({ @@ -163,7 +167,12 @@ export function BranchToolbarBranchSelector({ // --------------------------------------------------------------------------- const setThreadBranch = useCallback( (branch: string | null, worktreePath: string | null) => { - if (!activeThreadId || !activeProject) return; + if ( + !activeThreadId || + !activeProject || + (hasServerThread && !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) + ) + return; if (serverSession && worktreePath !== activeWorktreePath) { void stopThreadSession({ environmentId, @@ -272,11 +281,11 @@ export function BranchToolbarBranchSelector({ const isSelectingWorktreeBase = effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath; const checkoutPullRequestItemValue = - canWriteSourceControl && prReference && onCheckoutPullRequestRequest + canChangeThreadBranch && prReference && onCheckoutPullRequestRequest ? `__checkout_pull_request__:${prReference}` : null; const canCreateBranch = - canWriteSourceControl && !isSelectingWorktreeBase && trimmedBranchQuery.length > 0; + 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. @@ -394,7 +403,11 @@ export function BranchToolbarBranchSelector({ ); const runBranchAction = (action: () => Promise) => { - if (!canWriteSourceControl) return; + if ( + !readEnvironmentScope(environmentId, AuthSourceControlWriteScope) || + (hasServerThread && !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) + ) + return; startBranchActionTransition(async () => { await action(); branchRefState.refresh(); @@ -403,7 +416,7 @@ export function BranchToolbarBranchSelector({ }; const selectBranch = (refName: VcsRef) => { - if (!branchCwd || !activeProjectCwd || isBranchActionPending) return; + if (!canUpdateThreadBranch || !branchCwd || !activeProjectCwd || isBranchActionPending) return; if (isSelectingWorktreeBase) { setThreadBranch(refName.name, null); @@ -464,7 +477,7 @@ export function BranchToolbarBranchSelector({ }; const createRef = (rawName: string) => { - if (!canWriteSourceControl) return; + if (!canChangeThreadBranch) return; const name = sanitizeNewRefName(rawName); if (!branchCwd || !name || isBranchActionPending) return; @@ -710,14 +723,15 @@ export function BranchToolbarBranchSelector({ value={itemValue} className="pe-1.5" disabled={ - !canWriteSourceControl && - !isSelectingWorktreeBase && - (!activeProjectCwd || - !resolveBranchSelectionTarget({ - activeProjectCwd, - activeWorktreePath, - refName, - }).reuseExistingWorktree) + !canUpdateThreadBranch || + (!canWriteSourceControl && + !isSelectingWorktreeBase && + (!activeProjectCwd || + !resolveBranchSelectionTarget({ + activeProjectCwd, + activeWorktreePath, + refName, + }).reuseExistingWorktree)) } onClick={() => selectBranch(refName)} onContextMenu={(event) => handleBranchContextMenu(event, itemValue)} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index e1d85a6ca295..83f0df5da3b9 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1,5 +1,9 @@ import { useAtomValue } from "@effect/atom-react"; -import { AuthSourceControlWriteScope, type ScopedThreadRef } from "@t3tools/contracts"; +import { + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, + type ScopedThreadRef, +} from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -88,7 +92,7 @@ import { useVcsPullAction, } from "~/lib/sourceControlActions"; import { useThread } from "~/state/entities"; -import { useEnvironmentScope } from "~/state/session"; +import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { useEnvironmentQuery } from "~/state/query"; import { serverEnvironment } from "~/state/server"; import { sourceControlEnvironment } from "~/state/sourceControl"; @@ -1001,6 +1005,7 @@ export default function GitActionsControl({ activeEnvironmentId, AuthSourceControlWriteScope, ); + const canOperateThread = useEnvironmentScope(activeEnvironmentId, AuthOrchestrationOperateScope); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(activeEnvironmentId)); const openInPreferredEditor = useOpenInPreferredEditor( activeEnvironmentId, @@ -1022,6 +1027,7 @@ export default function GitActionsControl({ const activeServerThread = useThread(activeThreadRef, { waitForShell: activeDraftThread !== null, }); + const canChangeThreadBranch = canWriteSourceControl && (!activeServerThread || canOperateThread); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); @@ -1058,7 +1064,7 @@ export default function GitActionsControl({ } if (activeServerThread) { - if (activeServerThread.branch === branch) { + if (!canOperateThread || activeServerThread.branch === branch) { return; } @@ -1083,6 +1089,7 @@ export default function GitActionsControl({ }); }, [ + canOperateThread, activeDraftThread, activeServerThread, activeThreadRef, @@ -1282,7 +1289,14 @@ export default function GitActionsControl({ progressToastId, filePaths, }: RunGitActionWithToastInput) => { - if (!canWriteSourceControl) return; + if ( + activeEnvironmentId === null || + !readEnvironmentScope(activeEnvironmentId, AuthSourceControlWriteScope) || + (featureBranch && + activeServerThread && + !readEnvironmentScope(activeEnvironmentId, AuthOrchestrationOperateScope)) + ) + return; const actionStatus = statusOverride ?? gitStatusForActions; const actionBranch = actionStatus?.refName ?? null; const actionIsDefaultBranch = featureBranch ? false : isDefaultRef; @@ -1519,7 +1533,7 @@ export default function GitActionsControl({ }; const checkoutFeatureBranchAndContinuePendingAction = () => { - if (!pendingDefaultBranchAction) return; + if (!canChangeThreadBranch || !pendingDefaultBranchAction) return; const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); void runGitActionWithToast({ @@ -1533,7 +1547,7 @@ export default function GitActionsControl({ }; const runDialogActionOnNewBranch = () => { - if (!isCommitDialogOpen) return; + if (!canChangeThreadBranch || !isCommitDialogOpen) return; const commitMessage = dialogCommitMessage.trim(); setIsCommitDialogOpen(false); @@ -2002,7 +2016,7 @@ export default function GitActionsControl({ From d5f1e7155365f70ae3f055b90283abb57b97f564 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 17:03:37 -0700 Subject: [PATCH 06/10] fix(auth): require project permission before cloning --- .../projects/AddProjectScreen.test.ts | 170 ++++++++++++++++++ .../features/projects/AddProjectScreen.tsx | 37 ++-- apps/web/src/components/CommandPalette.tsx | 19 +- 3 files changed, 207 insertions(+), 19 deletions(-) create mode 100644 apps/mobile/src/features/projects/AddProjectScreen.test.ts diff --git a/apps/mobile/src/features/projects/AddProjectScreen.test.ts b/apps/mobile/src/features/projects/AddProjectScreen.test.ts new file mode 100644 index 000000000000..a9783d3a7c47 --- /dev/null +++ b/apps/mobile/src/features/projects/AddProjectScreen.test.ts @@ -0,0 +1,170 @@ +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { isValidElement, type ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + scopes: new Set(), + baseDirectory: "", + projects: [] as string[], +})); + +vi.mock("react", async (importOriginal) => ({ + ...(await importOriginal()), + useCallback: (callback: unknown) => callback, + useMemo: (factory: () => unknown) => factory(), + useState: (initial: unknown) => [typeof initial === "function" ? initial() : initial, () => {}], + useRef: (current: unknown) => ({ current }), + useEffect: () => {}, +})); +vi.mock("react-native", () => ({ + ActivityIndicator: "ActivityIndicator", + Alert: { alert: () => {} }, + Pressable: "Pressable", + ScrollView: "ScrollView", + View: "View", +})); +vi.mock("@react-navigation/native", () => ({ + useNavigation: () => ({ dispatch: () => {} }), + CommonActions: { reset: (input: unknown) => input }, + StackActions: {}, +})); +vi.mock("react-native-safe-area-context", () => ({ + useSafeAreaInsets: () => ({ bottom: 0 }), +})); +vi.mock("../../components/AppSymbol", () => ({ SymbolView: "SymbolView" })); +vi.mock("../../components/AppText", () => ({ AppText: "Text", AppTextInput: "TextInput" })); +vi.mock("../../components/EnvironmentMachineSymbol", () => ({ + EnvironmentMachineSymbol: "EnvironmentMachineSymbol", +})); +vi.mock("../../components/ErrorBanner", () => ({ ErrorBanner: "ErrorBanner" })); +vi.mock("../../components/SourceControlIcon", () => ({ SourceControlIcon: "SourceControlIcon" })); +vi.mock("../../lib/uuid", () => ({ uuidv4: () => "project" })); +vi.mock("../../state/session", () => ({ + useEnvironmentScope: (_environmentId: unknown, scope: string) => state.scopes.has(scope), + readEnvironmentScope: (_environmentId: unknown, scope: string) => state.scopes.has(scope), +})); +vi.mock("../../state/entities", () => ({ + useProjects: () => [], + useServerConfigs: () => + new Map([ + [ + "environment", + { + environment: { platform: { os: "linux" } }, + settings: { addProjectBaseDirectory: state.baseDirectory }, + }, + ], + ]), +})); +vi.mock("../../state/use-remote-environment-registry", () => ({ + useRemoteEnvironmentRuntime: () => ({ connectionState: "connected" }), + useRemoteConnectionStatus: () => ({ + connectedEnvironments: [{ environmentId: "environment", connectionState: "connected" }], + }), + useSavedRemoteConnections: () => ({ + savedConnectionsById: { + connection: { environmentId: "environment", environmentLabel: "Environment" }, + }, + }), +})); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); +vi.mock("../../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => () => {} })); +vi.mock("../../state/query", () => ({ useEnvironmentQuery: () => ({ data: null }) })); +vi.mock("../../state/filesystem", () => ({ filesystemEnvironment: {} })); +vi.mock("../../state/sourceControl", () => ({ + sourceControlEnvironment: { + cloneRepository: async ({ input }: { input: { destinationPath: string } }) => { + NodeFS.mkdirSync(input.destinationPath); + return AsyncResult.success({ cwd: input.destinationPath }); + }, + }, +})); +vi.mock("../../state/projects", () => ({ + projectEnvironment: { + create: async ({ input }: { input: { workspaceRoot: string } }) => { + if (!state.scopes.has(AuthOrchestrationOperateScope)) { + return AsyncResult.failure(Cause.fail(new Error("Project creation denied"))); + } + state.projects.push(input.workspaceRoot); + return AsyncResult.success(undefined); + }, + }, +})); + +import { AddProjectDestinationScreen } from "./AddProjectScreen"; + +function findCloneAction(node: ReactNode): (() => unknown) | null { + if (Array.isArray(node)) { + for (const child of node) { + const action = findCloneAction(child); + if (action) return action; + } + return null; + } + if (!isValidElement<{ label?: string; onPress?: () => unknown; children?: ReactNode }>(node)) { + return null; + } + if (node.props.label === "Clone project") return node.props.onPress ?? null; + return findCloneAction(node.props.children); +} + +function cloneAction() { + const action = findCloneAction( + AddProjectDestinationScreen({ + environmentId: "environment", + remoteUrl: "https://example.com/repo.git", + repositoryName: "repo", + }), + ); + if (!action) throw new Error("Clone action missing"); + return action; +} + +describe("clone project permissions", () => { + beforeEach(async () => { + state.baseDirectory = await NodeFSP.mkdtemp( + NodePath.join(NodeOS.tmpdir(), "t3-clone-permissions-"), + ); + state.scopes = new Set([AuthSourceControlWriteScope]); + state.projects = []; + }); + + afterEach(async () => { + await NodeFSP.rm(state.baseDirectory, { recursive: true, force: true }); + }); + + it("does not leave a clone on disk when project creation is denied", async () => { + await cloneAction()(); + + expect(NodeFS.existsSync(NodePath.join(state.baseDirectory, "repo"))).toBe(false); + expect(state.projects).toEqual([]); + }); + + it("clones and registers the project when both permissions are granted", async () => { + state.scopes.add(AuthOrchestrationOperateScope); + await cloneAction()(); + + const destination = NodePath.join(state.baseDirectory, "repo"); + expect(NodeFS.existsSync(destination)).toBe(true); + expect(state.projects).toEqual([destination]); + }); + + it.each([AuthSourceControlWriteScope, AuthOrchestrationOperateScope])( + "rechecks %s before a retained clone action creates a directory", + async (scope) => { + state.scopes.add(AuthOrchestrationOperateScope); + const submit = cloneAction(); + state.scopes.delete(scope); + await submit(); + + expect(NodeFS.existsSync(NodePath.join(state.baseDirectory, "repo"))).toBe(false); + expect(state.projects).toEqual([]); + }, + ); +}); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 63ae5eee6dc0..015dfd24b478 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -32,6 +32,7 @@ import { isWindowsPlatform, } from "@t3tools/client-runtime/state/projects"; import { + AuthOrchestrationOperateScope, AuthSourceControlWriteScope, CommandId, type EnvironmentId, @@ -54,7 +55,7 @@ import { useProjects, useServerConfigs } from "../../state/entities"; import { filesystemEnvironment } from "../../state/filesystem"; import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; -import { useEnvironmentScope } from "../../state/session"; +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"; @@ -470,6 +471,11 @@ export function AddProjectSourceScreen() { selectedEnvironment?.environmentId ?? null, AuthSourceControlWriteScope, ); + const canCreateProject = useEnvironmentScope( + selectedEnvironment?.environmentId ?? null, + AuthOrchestrationOperateScope, + ); + const canCloneProject = canWriteSourceControl && canCreateProject; const discoveryState = useEnvironmentQuery( selectedEnvironment === null ? null @@ -560,10 +566,10 @@ export function AddProjectSourceScreen() { key={candidate} source={candidate} selectedEnvironmentId={selectedEnvironment.environmentId} - ready={canWriteSourceControl && readiness[candidate].ready} + ready={canCloneProject && readiness[candidate].ready} hint={ - !canWriteSourceControl - ? "This connection cannot clone repositories." + !canCloneProject + ? "This connection cannot clone projects." : readiness[candidate].ready ? addProjectRemoteSourcePathHint(candidate) : (readiness[candidate].hint ?? "") @@ -920,6 +926,11 @@ export function AddProjectDestinationScreen(props: { environment?.environmentId ?? null, AuthSourceControlWriteScope, ); + const canCreateProject = useEnvironmentScope( + environment?.environmentId ?? null, + AuthOrchestrationOperateScope, + ); + const canCloneProject = canWriteSourceControl && canCreateProject; const createProject = useCreateProject(environment); const remoteUrl = stringParam(props.remoteUrl); const repositoryTitle = stringParam(props.repositoryTitle); @@ -937,8 +948,9 @@ export function AddProjectDestinationScreen(props: { const submitPath = useCallback(async () => { if ( - !canWriteSourceControl || !environment || + !readEnvironmentScope(environment.environmentId, AuthSourceControlWriteScope) || + !readEnvironmentScope(environment.environmentId, AuthOrchestrationOperateScope) || !remoteUrl || isBrowseNavigating || isSubmitting @@ -974,7 +986,6 @@ export function AddProjectDestinationScreen(props: { } setIsSubmitting(false); }, [ - canWriteSourceControl, cloneRepository, createProject, environment, @@ -997,20 +1008,16 @@ export function AddProjectDestinationScreen(props: { ) : null} {environment ? ( <> - void submitPath()} - /> + void submitPath()} + disabled={!canCloneProject || isBrowseNavigating || isSubmitting || !remoteUrl} + onPress={submitPath} loading={isSubmitting} /> - {!canWriteSourceControl ? ( + {!canCloneProject ? ( - This connection cannot clone repositories. + This connection cannot clone projects. ) : null} { event.preventDefault(); From 3dcd518909d43dd8623813f74494ce634c0415ba Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 17:09:28 -0700 Subject: [PATCH 07/10] test(mobile): isolate clone actions from connection presentation --- apps/mobile/src/features/projects/AddProjectScreen.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.test.ts b/apps/mobile/src/features/projects/AddProjectScreen.test.ts index a9783d3a7c47..465bd8d71f8b 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.test.ts +++ b/apps/mobile/src/features/projects/AddProjectScreen.test.ts @@ -76,6 +76,9 @@ vi.mock("../../state/use-remote-environment-registry", () => ({ 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: { From 4203d6416c528af5165086d16242d7a31c5c0577 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 17:15:35 -0700 Subject: [PATCH 08/10] fix(auth): recognize server threads before details load --- .../src/components/GitActionsControl.test.ts | 201 ++++++++++++++++++ apps/web/src/components/GitActionsControl.tsx | 11 +- 2 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/GitActionsControl.test.ts diff --git a/apps/web/src/components/GitActionsControl.test.ts b/apps/web/src/components/GitActionsControl.test.ts new file mode 100644 index 000000000000..442fdfe10895 --- /dev/null +++ b/apps/web/src/components/GitActionsControl.test.ts @@ -0,0 +1,201 @@ +import { + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, + EnvironmentId, + ThreadId, +} from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + scopes: new Set(), + shell: { branch: "main" } as { branch: string } | null, + draft: null as { branch: string; worktreePath: null; envMode: "local" } | null, + branch: "main", + commits: 0, + run: null as ((input: { action: "commit"; featureBranch?: boolean }) => Promise) | null, +})); + +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: () => {}, + useEffectEvent: (callback: typeof state.run) => { + state.run = callback; + return callback; + }, +})); +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); +vi.mock("~/state/entities", () => ({ + useThread: () => null, + useThreadShell: () => state.shell, +})); +vi.mock("~/state/session", () => ({ + useEnvironmentScope: (_environmentId: unknown, scope: string) => state.scopes.has(scope), + readEnvironmentScope: (_environmentId: unknown, scope: string) => state.scopes.has(scope), +})); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); +vi.mock("~/state/server", () => ({ serverEnvironment: { configValueAtom: () => null } })); +vi.mock("~/state/sourceControl", () => ({ sourceControlEnvironment: {} })); +vi.mock("~/state/vcs", () => ({ vcsEnvironment: { status: () => null } })); +vi.mock("~/state/threads", () => ({ + threadEnvironment: { + updateMetadata: async ({ input }: { input: { branch: string } }) => { + if (!state.scopes.has(AuthOrchestrationOperateScope)) throw new Error("Task denied"); + if (state.shell) state.shell.branch = input.branch; + }, + }, +})); +vi.mock("~/state/query", () => ({ + useEnvironmentQuery: () => ({ + data: { + isRepo: true, + refName: "main", + isDefaultRef: false, + hasPrimaryRemote: true, + hasWorkingTreeChanges: true, + workingTree: { files: [{ path: "file.ts", status: "modified" }] }, + }, + error: null, + }), +})); +vi.mock("~/composerDraftStore", () => ({ + useComposerDraftStore: ( + select: (store: { + getDraftSession: () => typeof state.draft; + getDraftThreadByRef: () => typeof state.draft; + setDraftThreadContext: (_target: unknown, input: { branch: string }) => void; + }) => unknown, + ) => + select({ + getDraftSession: () => state.draft, + getDraftThreadByRef: () => state.draft, + setDraftThreadContext: (_target, input) => { + if (state.draft) state.draft.branch = input.branch; + }, + }), +})); +vi.mock("~/lib/sourceControlActions", () => ({ + useSourceControlActionRunning: () => false, + useVcsInitAction: () => ({}), + useVcsPullAction: () => ({}), + useSourceControlPublishRepositoryAction: () => ({}), + useGitStackedAction: () => ({ + run: async ({ featureBranch }: { featureBranch?: boolean }) => { + state.commits += 1; + if (featureBranch) state.branch = "feature"; + return { + _tag: "Success", + value: { + branch: featureBranch + ? { status: "created", name: "feature" } + : { status: "skipped_not_requested" }, + toast: { title: "Committed", description: "Committed", cta: { kind: "none" } }, + }, + }; + }, + }), +})); +vi.mock("~/lib/utils", () => ({ cn: () => "", randomUUID: () => "action" })); +vi.mock("~/editorPreferences", () => ({ useOpenInPreferredEditor: () => () => {} })); +vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => () => {} })); +vi.mock("~/lib/openPullRequestLink", () => ({ useOpenPrLink: () => () => {} })); +vi.mock("~/components/ui/toast", () => ({ + stackedThreadToast: (input: unknown) => input, + toastManager: { add: () => "toast", update: () => {}, close: () => {} }, +})); +vi.mock("~/components/ui/dialog", () => ({ + Dialog: "Dialog", + DialogDescription: "DialogDescription", + DialogFooter: "DialogFooter", + DialogHeader: "DialogHeader", + DialogPanel: "DialogPanel", + DialogPopup: "DialogPopup", + DialogTitle: "DialogTitle", +})); +vi.mock("~/components/ui/group", () => ({ Group: "Group", GroupSeparator: "GroupSeparator" })); +vi.mock("~/components/ui/menu", () => ({ + Menu: "Menu", + MenuItem: "MenuItem", + MenuPopup: "MenuPopup", + MenuTrigger: "MenuTrigger", +})); +vi.mock("~/components/ui/popover", () => ({ + Popover: "Popover", + PopoverPopup: "PopoverPopup", + PopoverTrigger: "PopoverTrigger", +})); +vi.mock("~/components/ui/tooltip", () => ({ + Tooltip: "Tooltip", + TooltipPopup: "TooltipPopup", + TooltipTrigger: "TooltipTrigger", +})); +vi.mock("~/components/ui/button", () => ({ Button: "Button" })); +vi.mock("~/components/ui/checkbox", () => ({ Checkbox: "Checkbox" })); +vi.mock("~/components/ui/input", () => ({ Input: "Input" })); +vi.mock("~/components/ui/radio-group", () => ({ RadioGroup: "RadioGroup" })); +vi.mock("~/components/ui/scroll-area", () => ({ ScrollArea: "ScrollArea" })); +vi.mock("~/components/ui/spinner", () => ({ Spinner: "Spinner" })); +vi.mock("~/components/ui/textarea", () => ({ Textarea: "Textarea" })); +vi.mock("~/components/ui/toggle", () => ({ toggleVariants: () => "" })); +vi.mock("./AnimatedHeight", () => ({ AnimatedHeight: "AnimatedHeight" })); + +import GitActionsControl from "./GitActionsControl"; + +function renderActions() { + GitActionsControl({ + gitCwd: "/repo", + activeThreadRef: { + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("thread"), + }, + }); + if (!state.run) throw new Error("Git action missing"); + return state.run; +} + +describe("Git actions while thread details load", () => { + beforeEach(() => { + state.scopes = new Set([AuthSourceControlWriteScope]); + state.shell = { branch: "main" }; + state.draft = null; + state.branch = "main"; + state.commits = 0; + state.run = null; + }); + + it("does not create a feature branch for a server thread without task permission", async () => { + await renderActions()({ action: "commit", featureBranch: true }); + + expect(state.branch).toBe("main"); + expect(state.commits).toBe(0); + expect(state.shell?.branch).toBe("main"); + }); + + it("commits and synchronizes the server thread before details finish loading", async () => { + state.scopes.add(AuthOrchestrationOperateScope); + await renderActions()({ action: "commit", featureBranch: true }); + + expect(state.branch).toBe("feature"); + expect(state.commits).toBe(1); + expect(state.shell?.branch).toBe("feature"); + }); + + it("keeps ordinary commits available while details load", async () => { + await renderActions()({ action: "commit" }); + + expect(state.commits).toBe(1); + expect(state.branch).toBe("main"); + }); + + it("keeps feature-branch commits available for a local draft", async () => { + state.shell = null; + state.draft = { branch: "main", worktreePath: null, envMode: "local" }; + await renderActions()({ action: "commit", featureBranch: true }); + + expect(state.commits).toBe(1); + expect(state.draft.branch).toBe("feature"); + }); +}); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 83f0df5da3b9..798874b5a10e 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -91,7 +91,7 @@ import { useVcsInitAction, useVcsPullAction, } from "~/lib/sourceControlActions"; -import { useThread } from "~/state/entities"; +import { useThreadShell } from "~/state/entities"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { useEnvironmentQuery } from "~/state/query"; import { serverEnvironment } from "~/state/server"; @@ -1024,10 +1024,9 @@ export default function GitActionsControl({ ? store.getDraftThreadByRef(activeThreadRef) : null, ); - const activeServerThread = useThread(activeThreadRef, { - waitForShell: activeDraftThread !== null, - }); - const canChangeThreadBranch = canWriteSourceControl && (!activeServerThread || canOperateThread); + const activeServerThread = useThreadShell(activeThreadRef); + const isLocalDraftThread = activeDraftThread !== null && activeServerThread === null; + const canChangeThreadBranch = canWriteSourceControl && (isLocalDraftThread || canOperateThread); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); @@ -1293,7 +1292,7 @@ export default function GitActionsControl({ activeEnvironmentId === null || !readEnvironmentScope(activeEnvironmentId, AuthSourceControlWriteScope) || (featureBranch && - activeServerThread && + !isLocalDraftThread && !readEnvironmentScope(activeEnvironmentId, AuthOrchestrationOperateScope)) ) return; From cf753b92fecaf2e419e3f14aeb439f968cb0aab4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 17:35:04 -0700 Subject: [PATCH 09/10] fix(auth): require task permission for PR worktree setup --- apps/server/src/server.test.ts | 99 ++++++++++++++++++++++++++++++++++ apps/server/src/ws.ts | 7 +++ 2 files changed, 106 insertions(+) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 06216187ac80..df00fcc51613 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -7,6 +7,7 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hos import { AuthAccessTokenType, AuthAdministrativeScopes, + AuthOrchestrationOperateScope, AuthSourceControlWriteScope, AuthStandardClientScopes, AuthEnvironmentBootstrapTokenType, @@ -5698,6 +5699,104 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect.each([ + { + name: "requires task permission before preparing a worktree for a thread", + scope: "source-control:write", + mode: "worktree", + withThread: true, + requiredScope: AuthOrchestrationOperateScope, + }, + { + name: "allows worktree setup with source-control and task permissions", + scope: "source-control:write orchestration:operate", + mode: "worktree", + withThread: true, + requiredScope: null, + }, + { + name: "keeps source-control permission required for worktree setup", + scope: "orchestration:operate", + mode: "worktree", + withThread: true, + requiredScope: AuthSourceControlWriteScope, + }, + { + name: "allows local checkout without task permission", + scope: "source-control:write", + mode: "local", + withThread: true, + requiredScope: null, + }, + { + name: "allows worktree preparation without a thread under source-control permission", + scope: "source-control:write", + mode: "worktree", + withThread: false, + requiredScope: null, + }, + ] as const)("pull request preparation $name", (testCase) => + Effect.gen(function* () { + let preparations = 0; + const result = { + pullRequest: { + number: 77, + title: "A change", + url: "https://example.com/owner/repository/pull/77", + baseBranch: "main", + headBranch: "feature", + state: "open" as const, + }, + branch: "feature", + worktreePath: testCase.mode === "worktree" ? "/workspace/pr-worktree" : null, + isOnPullRequestHead: true, + }; + yield* buildAppUnderTest({ + layers: { + gitManager: { + preparePullRequestThread: () => + Effect.sync(() => { + preparations += 1; + return result; + }), + }, + }, + }); + const token = yield* exchangeAccessToken(defaultDesktopBootstrapToken, { + scope: testCase.scope, + }); + assert.equal(token.response.status, 200); + const ticketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { + headers: { authorization: `Bearer ${token.body.access_token ?? ""}` }, + }); + const { ticket } = yield* responseJsonEffect<{ readonly ticket: string }>(ticketResponse); + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(ticket)}`; + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const prepare = client[WS_METHODS.gitPreparePullRequestThread]({ + cwd: "/workspace", + reference: "77", + mode: testCase.mode, + ...(testCase.withThread ? { threadId: ThreadId.make("thread-pr-setup") } : {}), + }); + if (testCase.requiredScope === null) { + assert.deepEqual(yield* prepare, result); + assert.equal(preparations, 1); + } else { + const error = yield* prepare.pipe(Effect.flip); + assert.equal(error._tag, "EnvironmentAuthorizationError"); + if (error._tag === "EnvironmentAuthorizationError") { + assert.equal(error.requiredScope, testCase.requiredScope); + } + assert.equal(preparations, 0); + } + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("provider setup lets read-only clients observe installation but not change setup", () => Effect.gen(function* () { let installStarts = 0; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index aaa607bc044b..e163b82b4f1c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -14,6 +14,7 @@ import { AuthAccessStreamError, type AuthAccessStreamEvent, type AuthEnvironmentScope, + AuthOrchestrationOperateScope, AuthSessionId, ClientConnectionMethod, ClientDeviceType, @@ -2460,6 +2461,12 @@ const makeWsRpcLayer = ( .preparePullRequestThread(input) .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "git" }, + input.mode === "worktree" && input.threadId !== undefined + ? [ + requiredScopeForRpcMethod(WS_METHODS.gitPreparePullRequestThread), + AuthOrchestrationOperateScope, + ] + : undefined, ), [WS_METHODS.vcsListRefs]: (input) => observeRpcEffect(WS_METHODS.vcsListRefs, gitWorkflow.listRefs(input), { From 97dccdb35bf782536a4975bb8f92613a5b6695bc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 17:35:02 -0700 Subject: [PATCH 10/10] fix(auth): gate pull request worktrees on task permission --- .../PullRequestThreadDialog.test.ts | 140 ++++++++++++++++++ .../components/PullRequestThreadDialog.tsx | 24 ++- .../pullRequest/PullRequestDetailPanel.tsx | 15 +- 3 files changed, 169 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/components/PullRequestThreadDialog.test.ts diff --git a/apps/web/src/components/PullRequestThreadDialog.test.ts b/apps/web/src/components/PullRequestThreadDialog.test.ts new file mode 100644 index 000000000000..5736efd61ca5 --- /dev/null +++ b/apps/web/src/components/PullRequestThreadDialog.test.ts @@ -0,0 +1,140 @@ +import { AuthOrchestrationOperateScope, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { isValidElement, type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + canOperate: false, + checkouts: [] as string[], + setupScripts: 0, + prepared: [] as { branch: string; worktreePath: string | null }[], +})); + +vi.mock("react", async (importOriginal) => ({ + ...(await importOriginal()), + useCallback: (callback: unknown) => callback, + useMemo: (factory: () => unknown) => factory(), + useState: (initial: unknown) => [initial, () => {}], + useRef: (current: unknown) => ({ current }), + useEffect: () => {}, +})); +vi.mock("@tanstack/react-pacer", () => ({ + useDebouncedValue: (value: unknown) => [value, { state: { isPending: false } }], +})); +vi.mock("~/state/session", () => ({ + useEnvironmentScope: (_environmentId: unknown, scope: string) => + scope === AuthOrchestrationOperateScope && state.canOperate, + readEnvironmentScope: (_environmentId: unknown, scope: string) => + scope === AuthOrchestrationOperateScope && state.canOperate, +})); +vi.mock("~/lib/sourceControlActions", () => ({ + readCachedPullRequestResolution: () => null, + usePullRequestResolution: () => ({ + data: { pullRequest: { number: 123, title: "Pull request", state: "open" } }, + }), + usePreparePullRequestThreadAction: () => ({ + isAllowed: true, + isPending: false, + error: null, + run: async ({ mode, threadId }: { mode: string; threadId?: string }) => { + state.checkouts.push(mode); + if (mode === "worktree" && threadId) state.setupScripts += 1; + return { + _tag: "Success", + value: { branch: "feature/pr", worktreePath: mode === "worktree" ? "/worktree" : null }, + }; + }, + }), +})); +vi.mock("~/lib/utils", () => ({ cn: () => "" })); +vi.mock("~/state/query", () => ({ useEnvironmentQuery: () => ({ data: null }) })); +vi.mock("~/state/vcs", () => ({ vcsEnvironment: { status: () => null } })); +vi.mock("./ui/button", () => ({ Button: "Button" })); +vi.mock("./ui/input", () => ({ Input: "Input" })); +vi.mock("./ui/spinner", () => ({ Spinner: "Spinner" })); +vi.mock("./ui/dialog", () => ({ + Dialog: "Dialog", + DialogDescription: "DialogDescription", + DialogFooter: "DialogFooter", + DialogHeader: "DialogHeader", + DialogPanel: "DialogPanel", + DialogPopup: "DialogPopup", + DialogTitle: "DialogTitle", +})); + +import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; + +function findAction(node: ReactNode, label: string): (() => unknown) | null { + if (Array.isArray(node)) { + for (const child of node) { + const action = findAction(child, label); + if (action) return action; + } + return null; + } + if (!isValidElement<{ children?: ReactNode; onClick?: () => unknown }>(node)) return null; + if (node.props.children === label) return node.props.onClick ?? null; + return findAction(node.props.children, label); +} + +function prepareAction(label: "Local" | "Worktree") { + const action = findAction( + PullRequestThreadDialog({ + open: true, + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("thread"), + cwd: "/repo", + initialReference: "123", + onOpenChange: () => {}, + onPrepared: (input) => { + state.prepared.push(input); + }, + }), + label, + ); + if (!action) throw new Error(`${label} action missing`); + return action; +} + +describe("pull request worktree permissions", () => { + beforeEach(() => { + state.canOperate = false; + state.checkouts = []; + state.setupScripts = 0; + state.prepared = []; + }); + + it("does not prepare a worktree or run setup with only source-control permission", async () => { + await prepareAction("Worktree")(); + + expect(state.checkouts).toEqual([]); + expect(state.setupScripts).toBe(0); + expect(state.prepared).toEqual([]); + }); + + it("prepares the worktree and thread when task permission is also granted", async () => { + state.canOperate = true; + await prepareAction("Worktree")(); + + expect(state.checkouts).toEqual(["worktree"]); + expect(state.setupScripts).toBe(1); + expect(state.prepared).toEqual([{ branch: "feature/pr", worktreePath: "/worktree" }]); + }); + + it("rechecks task permission before invoking a retained worktree action", async () => { + state.canOperate = true; + const prepare = prepareAction("Worktree"); + state.canOperate = false; + await prepare(); + + expect(state.checkouts).toEqual([]); + expect(state.setupScripts).toBe(0); + }); + + it("keeps local checkout available without task permission", async () => { + await prepareAction("Local")(); + + expect(state.checkouts).toEqual(["local"]); + expect(state.setupScripts).toBe(0); + expect(state.prepared).toEqual([{ branch: "feature/pr", worktreePath: null }]); + }); +}); diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index 758b92056438..c696c32639d5 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -1,4 +1,8 @@ -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + AuthOrchestrationOperateScope, + type EnvironmentId, + type ThreadId, +} from "@t3tools/contracts"; import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -12,6 +16,7 @@ import { cn } from "~/lib/utils"; import { parsePullRequestReference } from "~/pullRequestReference"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; import { useEnvironmentQuery } from "~/state/query"; +import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { vcsEnvironment } from "~/state/vcs"; import { Button } from "./ui/button"; import { @@ -102,6 +107,7 @@ export function PullRequestThreadDialog({ ); }, [parsedReference, sourceControlScope]); const preparePullRequestThreadAction = usePreparePullRequestThreadAction(sourceControlScope); + const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); const liveResolvedPullRequest = parsedReference !== null && parsedReference === parsedDebouncedReference @@ -132,6 +138,12 @@ 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; @@ -160,6 +172,7 @@ export function PullRequestThreadDialog({ }, [ cwd, + environmentId, onOpenChange, onPrepared, parsedReference, @@ -271,9 +284,7 @@ export function PullRequestThreadDialog({ type="button" size="sm" variant="outline" - onClick={() => { - void handleConfirm("local"); - }} + onClick={() => handleConfirm("local")} disabled={ !preparePullRequestThreadAction.isAllowed || !cwd || @@ -287,11 +298,10 @@ export function PullRequestThreadDialog({