From 99f3318071db9c01c8de429fe02793852a796e7d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 13:45:34 -0700 Subject: [PATCH 01/26] 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 d16bbbbe1471..263cf8bf840a 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -8,6 +8,7 @@ import { AuthRelayReadScope, AuthRelayWriteScope, AuthReviewWriteScope, + AuthSourceControlWriteScope, AuthTerminalOperateScope, ORCHESTRATION_WS_METHODS, type AuthEnvironmentScope, @@ -74,14 +75,14 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope, - [WS_METHODS.pullRequestsRunAction]: AuthOrchestrationOperateScope, - [WS_METHODS.pullRequestsUpdate]: AuthOrchestrationOperateScope, - [WS_METHODS.pullRequestsComment]: AuthOrchestrationOperateScope, - [WS_METHODS.pullRequestsUpdateComment]: AuthOrchestrationOperateScope, - [WS_METHODS.pullRequestsSubmitReview]: AuthOrchestrationOperateScope, - [WS_METHODS.pullRequestsReplyToThread]: AuthOrchestrationOperateScope, - [WS_METHODS.pullRequestsSetThreadResolution]: AuthOrchestrationOperateScope, - [WS_METHODS.pullRequestsSetReaction]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope, + [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope, + [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope, + [WS_METHODS.pullRequestsUpdateComment]: AuthSourceControlWriteScope, + [WS_METHODS.pullRequestsSubmitReview]: AuthSourceControlWriteScope, + [WS_METHODS.pullRequestsReplyToThread]: AuthSourceControlWriteScope, + [WS_METHODS.pullRequestsSetThreadResolution]: AuthSourceControlWriteScope, + [WS_METHODS.pullRequestsSetReaction]: AuthSourceControlWriteScope, // Read scope like the reads it un-caches: refreshing is part of reading, and a read-only // client pressing refresh must not be told it may not look again. [WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope, @@ -89,12 +90,12 @@ export const RPC_REQUIRED_SCOPES = { // The candidate list is a read like the detail beside it; asking somebody for a review is a // write like every other one. [WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope, - [WS_METHODS.pullRequestsRequestReviewers]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsRequestReviewers]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope, - [WS_METHODS.pullRequestsSetLabels]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsSetLabels]: AuthSourceControlWriteScope, [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, - [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, - [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, + [WS_METHODS.sourceControlCloneRepository]: AuthSourceControlWriteScope, + [WS_METHODS.sourceControlPublishRepository]: AuthSourceControlWriteScope, [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, @@ -111,16 +112,16 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, - [WS_METHODS.vcsPull]: AuthOrchestrationOperateScope, - [WS_METHODS.gitRunStackedAction]: AuthOrchestrationOperateScope, - [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationOperateScope, - [WS_METHODS.gitPreparePullRequestThread]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsPull]: AuthSourceControlWriteScope, + [WS_METHODS.gitRunStackedAction]: AuthSourceControlWriteScope, + [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope, + [WS_METHODS.gitPreparePullRequestThread]: AuthSourceControlWriteScope, [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, - [WS_METHODS.vcsCreateWorktree]: AuthOrchestrationOperateScope, - [WS_METHODS.vcsRemoveWorktree]: AuthOrchestrationOperateScope, - [WS_METHODS.vcsCreateRef]: AuthOrchestrationOperateScope, - [WS_METHODS.vcsSwitchRef]: AuthOrchestrationOperateScope, - [WS_METHODS.vcsInit]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsCreateWorktree]: AuthSourceControlWriteScope, + [WS_METHODS.vcsRemoveWorktree]: AuthSourceControlWriteScope, + [WS_METHODS.vcsCreateRef]: AuthSourceControlWriteScope, + [WS_METHODS.vcsSwitchRef]: AuthSourceControlWriteScope, + [WS_METHODS.vcsInit]: AuthSourceControlWriteScope, [WS_METHODS.reviewGetDiffPreview]: AuthReviewWriteScope, [WS_METHODS.reviewGetDiffFileContents]: AuthReviewWriteScope, [WS_METHODS.terminalOpen]: AuthTerminalOperateScope, diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 6123be6ccf25..be9c65ab98f6 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -10,6 +10,7 @@ import { AuthRelayReadScope, AuthRelayWriteScope, AuthReviewWriteScope, + AuthSourceControlWriteScope, AuthTerminalOperateScope, EnvironmentAuthInvalidError, type EnvironmentAuthInvalidReason, @@ -323,6 +324,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( AuthEnvironmentMaintainScope, AuthTerminalOperateScope, AuthReviewWriteScope, + AuthSourceControlWriteScope, AuthAccessReadScope, AuthAccessWriteScope, AuthRelayReadScope, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 60d7a1b2749a..ddb4f76b8797 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, @@ -5859,6 +5860,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 52d6bbace863..43433971e7b8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,6 +1,7 @@ import { AuthOrchestrationOperateScope, AuthSettingsWriteScope, + AuthSourceControlWriteScope, type AssistantCitation, type ApprovalRequestId, type ChatFileAttachment, @@ -277,6 +278,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; +import { useEnvironmentScope } from "~/state/session"; import { environmentServerConfigsAtom, primaryServerAvailableEditorsAtom, @@ -1407,6 +1409,7 @@ export default function ChatView(props: ChatViewProps) { const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); const setThreadRuntimeMode = useOrchestrationCommand(threadEnvironment.setRuntimeMode, { reportFailure: false, @@ -1794,7 +1797,7 @@ export default function ChatView(props: ChatViewProps) { const [, setThreadErrorBannerDismissTick] = useState(0); const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; - const canCheckoutPullRequestIntoThread = isLocalDraftThread; + const canCheckoutPullRequestIntoThread = canWriteSourceControl && isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; const activeThreadEnvironmentId = activeThread?.environmentId ?? null; const runningTerminalIds = useThreadRunningTerminalIds({ @@ -5382,6 +5385,7 @@ export default function ChatView(props: ChatViewProps) { }); }, [activeBranchMismatchKey, showBranchMismatchBanner]); const handleSwitchCheckoutToThread = useCallback(async () => { + if (!canWriteSourceControl) return; if ( !activeProjectCwd || !activeThread || @@ -5437,6 +5441,7 @@ export default function ChatView(props: ChatViewProps) { setIsRestoringThreadBranch(false); scheduleComposerFocus(); }, [ + canWriteSourceControl, activeProjectCwd, activeThread, environmentId, @@ -5695,12 +5700,17 @@ export default function ChatView(props: ChatViewProps) { selectedProvider, ]); const handleRestoreThreadBranch = useCallback(() => { + if (!canWriteSourceControl) return; if (gitStatusQuery.data?.hasWorkingTreeChanges) { setBranchRestoreConfirmOpen(true); return; } void handleSwitchCheckoutToThread(); - }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); + }, [ + canWriteSourceControl, + gitStatusQuery.data?.hasWorkingTreeChanges, + handleSwitchCheckoutToThread, + ]); const composerBannerItems = useMemo(() => { const backgroundLivenessItems = backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem]; @@ -5748,7 +5758,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 76e50b60003b..49da427deeb8 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({ // Which handoff is preparing, keyed so a per-finding button can say "Preparing..." on itself // alone. One at a time whatever the key: they all check the same pull request out. const [handoff, setHandoff] = useState(null); + 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); @@ -853,13 +875,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, @@ -879,7 +901,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; @@ -913,6 +935,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) => { @@ -1019,6 +1042,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 @@ -1470,7 +1494,10 @@ export function PullRequestDetailPanel({ } /> - startCheckout("worktree")}> + startCheckout("worktree")} + > In a separate worktree @@ -1479,7 +1506,10 @@ export function PullRequestDetailPanel({ - startCheckout("local")}> + startCheckout("local")} + > In this repository @@ -1530,7 +1560,7 @@ export function PullRequestDetailPanel({ - From fe5ee1ea71811cea919085ef5a4589e6a61bab48 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 14:26:09 -0700 Subject: [PATCH 03/26] 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 8174933e50b5b9f56350214710796411873a26ff Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 14:43:50 -0700 Subject: [PATCH 04/26] 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 ddb4f76b8797..572edc995b91 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5860,128 +5860,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 c85fa48ba9a140a6f34150ae1e576d0963e7df74 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 15:55:59 -0700 Subject: [PATCH 05/26] 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 75415f795cb36a5e22150bcd7c9b9e300cccee64 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 17:03:37 -0700 Subject: [PATCH 06/26] 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 f9cdb01ef52ef5e21d8c0e330705d0357218ccc3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 17:09:28 -0700 Subject: [PATCH 07/26] 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 a2e6afe7092568be14fc5a1d78dd3a172e608970 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 17:15:35 -0700 Subject: [PATCH 08/26] 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 011895dcaa1cb01f4132ae379f2ab74b2f06eb8d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 17:35:04 -0700 Subject: [PATCH 09/26] 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 572edc995b91..77d46a5c7e0e 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, @@ -5986,6 +5987,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 79a4a9667c72..f432c2cc13a9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -14,6 +14,7 @@ import { AuthAccessStreamError, type AuthAccessStreamEvent, type AuthEnvironmentScope, + AuthOrchestrationOperateScope, AuthSessionId, ClientConnectionMethod, ClientDeviceType, @@ -2507,6 +2508,12 @@ const makeWsRpcLayer = ( .preparePullRequestThread(input) .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "git" }, + input.mode === "worktree" && input.threadId !== undefined + ? [ + requiredScopeForRpcMethod(WS_METHODS.gitPreparePullRequestThread), + AuthOrchestrationOperateScope, + ] + : undefined, ), [WS_METHODS.vcsListRefs]: (input) => observeRpcEffect(WS_METHODS.vcsListRefs, gitWorkflow.listRefs(input), { From c3bf3080f78b4e09c48a7c9f044a6d629611aa44 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 17:35:02 -0700 Subject: [PATCH 10/26] 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({ diff --git a/apps/web/src/components/pullRequest/PullRequestReactions.tsx b/apps/web/src/components/pullRequest/PullRequestReactions.tsx index 2ef90b09a55c..bb02faaa4107 100644 --- a/apps/web/src/components/pullRequest/PullRequestReactions.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReactions.tsx @@ -9,7 +9,7 @@ import { useState } from "react"; import { cn } from "~/lib/utils"; import { pullRequestEnvironment } from "~/state/pullRequests"; -import { useAtomCommand } from "~/state/use-atom-command"; +import { useSourceControlCommand } from "~/state/use-source-control-command"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { toastManager } from "../ui/toast"; @@ -66,7 +66,9 @@ export function PullRequestReactionBar({ readonly signature: string; readonly values: ReadonlyMap; }>({ signature: "", values: EMPTY_PENDING }); - const setReaction = useAtomCommand(pullRequestEnvironment.setReaction, { reportFailure: false }); + const setReaction = useSourceControlCommand(pullRequestEnvironment.setReaction, { + reportFailure: false, + }); const signature = reactionsSignature(reactions); const values = pending.signature === signature ? pending.values : EMPTY_PENDING; diff --git a/apps/web/src/components/pullRequest/PullRequestReviewBar.tsx b/apps/web/src/components/pullRequest/PullRequestReviewBar.tsx index f32352734685..6fd03dd387d1 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewBar.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewBar.tsx @@ -9,7 +9,7 @@ import { CheckIcon, MessageSquareIcon, XCircleIcon } from "lucide-react"; import { useState, type ReactNode } from "react"; import { pullRequestEnvironment } from "~/state/pullRequests"; -import { useAtomCommand } from "~/state/use-atom-command"; +import { useSourceControlCommand } from "~/state/use-source-control-command"; import { Button } from "../ui/button"; import { Textarea } from "../ui/textarea"; @@ -68,7 +68,7 @@ export function PullRequestReviewBar({ const removeComments = usePullRequestReviewStore((store) => store.removeComments); const setSummary = usePullRequestReviewStore((store) => store.setSummary); const clearSummary = usePullRequestReviewStore((store) => store.clearSummary); - const submitReview = useAtomCommand(pullRequestEnvironment.submitReview, { + const submitReview = useSourceControlCommand(pullRequestEnvironment.submitReview, { reportFailure: false, }); diff --git a/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx b/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx index a4da0d99514d..63e42df5da5b 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx @@ -15,7 +15,7 @@ import { useMemo, useState } from "react"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; -import { useAtomCommand } from "~/state/use-atom-command"; +import { useSourceControlCommand } from "~/state/use-source-control-command"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { toastManager } from "../ui/toast"; @@ -56,7 +56,7 @@ export function PullRequestReviewerPicker({ const candidatesQuery = useEnvironmentQuery( open ? pullRequestEnvironment.reviewerCandidates({ environmentId, input: reference }) : null, ); - const requestReviewers = useAtomCommand(pullRequestEnvironment.requestReviewers, { + const requestReviewers = useSourceControlCommand(pullRequestEnvironment.requestReviewers, { reportFailure: false, }); diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index b06630f3bd04..018d0681e5f1 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -21,7 +21,7 @@ import { } from "lucide-react"; import { useRef, useState, type ReactNode } from "react"; -import { useAtomCommand } from "~/state/use-atom-command"; +import { useSourceControlCommand } from "~/state/use-source-control-command"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { cn } from "~/lib/utils"; import { useOpenLink } from "~/browser/useOpenLink"; @@ -330,7 +330,9 @@ function CommentComposer({ }) { const [body, setBody] = useState(""); const [submitting, setSubmitting] = useState<"comment" | "close" | "reopen" | null>(null); - const postComment = useAtomCommand(pullRequestEnvironment.comment, { reportFailure: false }); + const postComment = useSourceControlCommand(pullRequestEnvironment.comment, { + reportFailure: false, + }); const followUpAction = detail.state === "open" && detail.capabilities.actions.includes("close") && @@ -534,8 +536,8 @@ export function PullRequestSummaryTab({ }); }; - const update = useAtomCommand(pullRequestEnvironment.update, { reportFailure: false }); - const updateComment = useAtomCommand(pullRequestEnvironment.updateComment, { + const update = useSourceControlCommand(pullRequestEnvironment.update, { reportFailure: false }); + const updateComment = useSourceControlCommand(pullRequestEnvironment.updateComment, { reportFailure: false, }); // Keyed by the pull request, like the comment window above it, so an editor left open never diff --git a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx index 946f3cdbc54f..2efbf0754d32 100644 --- a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx @@ -22,7 +22,7 @@ import { useState, type ReactNode } from "react"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import { pullRequestEnvironment } from "~/state/pullRequests"; -import { useAtomCommand } from "~/state/use-atom-command"; +import { useSourceControlCommand } from "~/state/use-source-control-command"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { Button } from "../ui/button"; @@ -194,7 +194,7 @@ function ConversationCard({ }) { const [editing, setEditing] = useState(false); const [saving, setSaving] = useState(false); - const updateComment = useAtomCommand(pullRequestEnvironment.updateComment, { + const updateComment = useSourceControlCommand(pullRequestEnvironment.updateComment, { reportFailure: false, }); diff --git a/apps/web/src/state/use-source-control-command.test.ts b/apps/web/src/state/use-source-control-command.test.ts new file mode 100644 index 000000000000..2bc5732821fa --- /dev/null +++ b/apps/web/src/state/use-source-control-command.test.ts @@ -0,0 +1,123 @@ +import type { AtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, + EnvironmentAuthorizationError, + EnvironmentId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { beforeEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + grants: new Map>(), + run: vi.fn(), +})); + +vi.mock("react", () => ({ useCallback: (callback: unknown) => callback })); +vi.mock("./session", () => ({ + readEnvironmentScope: (environmentId: string, scope: string) => + state.grants.get(environmentId)?.has(scope) === true, +})); +vi.mock("./use-atom-command", () => ({ useAtomCommand: () => state.run })); + +import { useSourceControlCommand } from "./use-source-control-command"; + +const primary = EnvironmentId.make("primary"); +const secondary = EnvironmentId.make("secondary"); +type Target = { environmentId: EnvironmentId; input: { action: string } }; +const command: AtomCommand = { + label: "pull request mutation", + run: state.run, +}; +const target = (environmentId: EnvironmentId, action = "comment"): Target => ({ + environmentId, + input: { action }, +}); + +beforeEach(() => { + state.grants.clear(); + state.run.mockReset().mockResolvedValue(AsyncResult.success("receipt")); +}); + +it("does not borrow the primary environment's grant for a secondary pull request", async () => { + state.grants.set(primary, new Set([AuthSourceControlWriteScope])); + const mutate = useSourceControlCommand(command, { reportFailure: false }); + + const result = await mutate(target(secondary)); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(Cause.squash(result.cause)).toBeInstanceOf(EnvironmentAuthorizationError); + expect(Cause.squash(result.cause)).toMatchObject({ + requiredScope: AuthSourceControlWriteScope, + }); + } + expect(state.run).not.toHaveBeenCalled(); +}); + +it("allows source control on the target without task or primary-environment permission", async () => { + state.grants.set(secondary, new Set([AuthSourceControlWriteScope])); + + const result = await useSourceControlCommand(command)(target(secondary)); + + expect(result).toMatchObject({ _tag: "Success", value: "receipt" }); + expect(state.run).toHaveBeenCalledWith(target(secondary)); +}); + +it("does not accept task permission in place of source-control permission", async () => { + state.grants.set(secondary, new Set([AuthOrchestrationOperateScope])); + + expect((await useSourceControlCommand(command)(target(secondary)))._tag).toBe("Failure"); + expect(state.run).not.toHaveBeenCalled(); +}); + +it("blocks a retained menu callback after revocation", async () => { + state.grants.set(secondary, new Set([AuthSourceControlWriteScope])); + const mutate = useSourceControlCommand(command); + state.grants.delete(secondary); + + expect((await mutate(target(secondary, "set-labels")))._tag).toBe("Failure"); + expect(state.run).not.toHaveBeenCalled(); +}); + +it("allows a retained callback after the target gains its grant", async () => { + const mutate = useSourceControlCommand(command); + expect((await mutate(target(secondary, "submit-review")))._tag).toBe("Failure"); + state.grants.set(secondary, new Set([AuthSourceControlWriteScope])); + + expect((await mutate(target(secondary, "submit-review")))._tag).toBe("Success"); + expect(state.run).toHaveBeenCalledTimes(1); +}); + +it.each(["close", "reopen"])( + "keeps an accepted comment but does not dispatch %s after access changes during posting", + async (action) => { + state.grants.set(secondary, new Set([AuthSourceControlWriteScope])); + const commentReceipt = AsyncResult.success("comment receipt"); + const posted = Promise.withResolvers(); + state.run.mockReturnValueOnce(posted.promise); + const mutate = useSourceControlCommand(command); + const commentThenAction = async () => { + const comment = await mutate(target(secondary, "comment")); + if (comment._tag === "Failure") return { commentPosted: false, action }; + const result = await mutate(target(secondary, action)); + return { commentPosted: true, action: result._tag }; + }; + + const pending = commentThenAction(); + state.grants.delete(secondary); + posted.resolve(commentReceipt); + + expect(await pending).toEqual({ commentPosted: true, action: "Failure" }); + expect(state.run).toHaveBeenCalledExactlyOnceWith(target(secondary, "comment")); + }, +); + +it("preserves an allowed host failure for the caller's existing error handling", async () => { + state.grants.set(secondary, new Set([AuthSourceControlWriteScope])); + const failure = AsyncResult.failure(Cause.fail(new Error("Branch protection refused the merge"))); + state.run.mockResolvedValue(failure); + + expect(await useSourceControlCommand(command)(target(secondary, "merge"))).toBe(failure); +}); diff --git a/apps/web/src/state/use-source-control-command.ts b/apps/web/src/state/use-source-control-command.ts new file mode 100644 index 000000000000..3ecc928e21b6 --- /dev/null +++ b/apps/web/src/state/use-source-control-command.ts @@ -0,0 +1,40 @@ +import type { + AtomCommand, + AtomCommandOptions, + AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { + AuthSourceControlWriteScope, + EnvironmentAuthorizationError, + type EnvironmentId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useCallback } from "react"; + +import { readEnvironmentScope } from "./session"; +import { useAtomCommand } from "./use-atom-command"; + +/** Recheck the target grant for each mutation, including steps after a host response. */ +export function useSourceControlCommand( + command: AtomCommand, + options?: string | AtomCommandOptions, +): (value: W) => Promise> { + const run = useAtomCommand(command, options); + return useCallback( + async (value: W): Promise> => { + if (!readEnvironmentScope(value.environmentId, AuthSourceControlWriteScope)) { + return AsyncResult.failure( + Cause.fail( + new EnvironmentAuthorizationError({ + requiredScope: AuthSourceControlWriteScope, + message: "This connection cannot change source control.", + }), + ), + ); + } + return run(value); + }, + [run], + ); +} From 3ebe5b531079e4fc1053749af6a1e340b7a344b6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 23:53:00 -0700 Subject: [PATCH 17/26] test(web): use compatible deferred promises in scope tests --- apps/web/src/hooks/useThreadActionMenu.test.ts | 14 +++++++++++--- .../src/hooks/useThreadActions.permissions.test.ts | 10 +++++++++- .../src/state/use-source-control-command.test.ts | 10 +++++++++- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/apps/web/src/hooks/useThreadActionMenu.test.ts b/apps/web/src/hooks/useThreadActionMenu.test.ts index 06033bb5ed17..d12624c3d81c 100644 --- a/apps/web/src/hooks/useThreadActionMenu.test.ts +++ b/apps/web/src/hooks/useThreadActionMenu.test.ts @@ -9,10 +9,18 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import type { ThreadActionMenuId } from "../components/threadActionMenu.logic"; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + const state = vi.hoisted(() => ({ granted: new Set(), effects: [] as string[], - completed: Promise.withResolvers(), + completed: deferred(), show: vi.fn< ( items: ReadonlyArray>, @@ -150,7 +158,7 @@ const createMenu = () => beforeEach(() => { state.granted = new Set(["primary"]); state.effects = []; - state.completed = Promise.withResolvers(); + state.completed = deferred(); state.show.mockReset().mockResolvedValue(null); }); @@ -186,7 +194,7 @@ describe("thread menu permissions", () => { "%s rechecks after the native menu closes", async (action) => { state.granted.add("secondary"); - const choice = Promise.withResolvers(); + const choice = deferred(); state.show.mockReturnValue(choice.promise); createMenu().openMenu(position); state.granted.delete("secondary"); diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index 28de9885e044..fef5284ddd0c 100644 --- a/apps/web/src/hooks/useThreadActions.permissions.test.ts +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -10,6 +10,14 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + const state = vi.hoisted(() => ({ scopes: new Map>(), threads: [] as { @@ -254,7 +262,7 @@ describe("thread action permissions", () => { "%s rechecks after the confirmation", async (action) => { state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); - const confirmation = Promise.withResolvers(); + const confirmation = deferred(); state.confirm.mockReturnValue(confirmation.promise); const result = useThreadActions()[action](target); expect(state.confirm).toHaveBeenCalledOnce(); diff --git a/apps/web/src/state/use-source-control-command.test.ts b/apps/web/src/state/use-source-control-command.test.ts index 2bc5732821fa..603ad072c7a0 100644 --- a/apps/web/src/state/use-source-control-command.test.ts +++ b/apps/web/src/state/use-source-control-command.test.ts @@ -9,6 +9,14 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { beforeEach, expect, it, vi } from "vite-plus/test"; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + const state = vi.hoisted(() => ({ grants: new Map>(), run: vi.fn(), @@ -95,7 +103,7 @@ it.each(["close", "reopen"])( async (action) => { state.grants.set(secondary, new Set([AuthSourceControlWriteScope])); const commentReceipt = AsyncResult.success("comment receipt"); - const posted = Promise.withResolvers(); + const posted = deferred(); state.run.mockReturnValueOnce(posted.promise); const mutate = useSourceControlCommand(command); const commentThenAction = async () => { From ab35f3af72e7799132be7eccfeed5bc9713e0334 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:14:24 -0700 Subject: [PATCH 18/26] fix(web): remove duplicate scope import at source-control layer --- apps/web/src/components/ChatView.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 43433971e7b8..1e25fe73ba52 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -423,11 +423,7 @@ import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/at import { appAtomRegistry } from "../rpc/atomRegistry"; import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles"; import { assetEnvironment } from "../state/assets"; -import { - readEnvironmentScope, - readPreparedConnection, - useEnvironmentScope, -} from "../state/session"; +import { readEnvironmentScope, readPreparedConnection } from "../state/session"; import { useAtomCommand } from "../state/use-atom-command"; import { useOrchestrationCommand } from "../state/use-orchestration-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; From 7c520e82cfc224064bdc069f551a0ce0e43c7915 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:37:47 -0700 Subject: [PATCH 19/26] fix(web): preserve archived thread branch updates --- .../src/components/GitActionsControl.test.ts | 42 ++++++++++++++++++- apps/web/src/components/GitActionsControl.tsx | 8 +++- .../useThreadActions.permissions.test.ts | 20 ++++++++- apps/web/src/hooks/useThreadActions.ts | 17 +++++++- 4 files changed, 81 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.test.ts b/apps/web/src/components/GitActionsControl.test.ts index 5282adaf1378..3cf37f13abe3 100644 --- a/apps/web/src/components/GitActionsControl.test.ts +++ b/apps/web/src/components/GitActionsControl.test.ts @@ -12,6 +12,7 @@ const state = vi.hoisted(() => ({ scopes: new Set(), primaryScopes: new Set(), shell: { branch: "main" } as { branch: string } | null, + detail: null as { branch: string } | null, draft: null as { branch: string; worktreePath: null; envMode: "local" } | null, branch: "main", commits: 0, @@ -34,7 +35,8 @@ vi.mock("react", async (importOriginal) => ({ })); vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); vi.mock("~/state/entities", () => ({ - useThread: () => null, + useThread: (_ref: unknown, options?: { waitForShell?: boolean }) => + options?.waitForShell && state.shell === null ? null : state.detail, useThreadShell: () => state.shell, })); vi.mock("~/state/session", () => ({ @@ -60,6 +62,7 @@ vi.mock("~/state/threads", () => ({ if (!state.scopes.has(AuthOrchestrationOperateScope)) return AsyncResult.failure(Cause.fail(new Error("Task denied"))); if (state.shell) state.shell.branch = input.branch; + if (state.detail) state.detail.branch = input.branch; return AsyncResult.success(undefined); }, }, @@ -178,6 +181,7 @@ describe("Git actions while thread details load", () => { state.scopes = new Set([AuthSourceControlWriteScope]); state.primaryScopes = new Set([AuthSourceControlWriteScope, AuthOrchestrationOperateScope]); state.shell = { branch: "main" }; + state.detail = null; state.draft = null; state.branch = "main"; state.commits = 0; @@ -205,6 +209,42 @@ describe("Git actions while thread details load", () => { expect(state.metadataRequests).toEqual([{ environmentId: "environment", branch: "feature" }]); }); + it("synchronizes archived thread detail after its shell disappears", async () => { + state.scopes.add(AuthOrchestrationOperateScope); + state.detail = { branch: "main" }; + renderActions(); + state.shell = null; + + await renderActions()({ action: "commit", featureBranch: true }); + + expect(state.commits).toBe(1); + expect(state.branch).toBe("feature"); + expect(state.detail.branch).toBe("feature"); + expect(state.metadataRequests).toEqual([{ environmentId: "environment", branch: "feature" }]); + }); + + it("requires task permission before changing an archived thread's branch", async () => { + state.shell = null; + state.detail = { branch: "main" }; + + await renderActions()({ action: "commit", featureBranch: true }); + + expect(state.commits).toBe(0); + expect(state.detail.branch).toBe("main"); + expect(state.metadataRequests).toEqual([]); + }); + + it("uses the current shell branch when cached detail names the new branch", async () => { + state.scopes.add(AuthOrchestrationOperateScope); + state.detail = { branch: "feature" }; + + await renderActions()({ action: "commit", featureBranch: true }); + + expect(state.commits).toBe(1); + expect(state.shell?.branch).toBe("feature"); + expect(state.metadataRequests).toEqual([{ environmentId: "environment", branch: "feature" }]); + }); + it("synchronizes a retained callback after the thread grant is gained", async () => { const run = renderActions(); state.scopes.add(AuthOrchestrationOperateScope); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index dd955e7add29..6a84fc44c7b4 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 { useThreadShell } from "~/state/entities"; +import { useThread, useThreadShell } from "~/state/entities"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { useEnvironmentQuery } from "~/state/query"; import { serverEnvironment } from "~/state/server"; @@ -1024,7 +1024,11 @@ export default function GitActionsControl({ ? store.getDraftThreadByRef(activeThreadRef) : null, ); - const activeServerThread = useThreadShell(activeThreadRef); + const activeServerThreadShell = useThreadShell(activeThreadRef); + const activeServerThreadDetail = useThread(activeThreadRef, { + waitForShell: activeDraftThread !== null, + }); + const activeServerThread = activeServerThreadShell ?? activeServerThreadDetail; const isLocalDraftThread = activeDraftThread !== null && activeServerThread === null; const canChangeThreadBranch = canWriteSourceControl && (isLocalDraftThread || canOperateThread); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index fef5284ddd0c..81661c8046b6 100644 --- a/apps/web/src/hooks/useThreadActions.permissions.test.ts +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -1,6 +1,7 @@ import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope, + EnvironmentAuthorizationError, EnvironmentId, ProjectId, ThreadId, @@ -274,6 +275,18 @@ describe("thread action permissions", () => { }, ); + it("identifies the missing task scope when thread deletion is denied", async () => { + const result = await useThreadActions().deleteThread(target); + + expect(result._tag).toBe("Failure"); + if (result._tag !== "Failure") throw new Error("Expected permission denial"); + const error = Cause.squash(result.cause); + expect(error).toBeInstanceOf(EnvironmentAuthorizationError); + expect(error).toMatchObject({ requiredScope: AuthOrchestrationOperateScope }); + expect(state.requests).toEqual([]); + expect(state.localEffects).toEqual([]); + }); + it("deletes an archived thread only with its own environment's grant", async () => { state.threads = []; const actions = useThreadActions(); @@ -313,7 +326,12 @@ describe("thread action permissions", () => { state.afterRequest = () => state.scopes.get(secondary)!.delete(AuthSourceControlWriteScope); const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); try { - expect((await useThreadActions().deleteThread(target))._tag).toBe("Failure"); + const result = await useThreadActions().deleteThread(target); + expect(result._tag).toBe("Failure"); + if (result._tag !== "Failure") throw new Error("Expected permission denial"); + const error = Cause.squash(result.cause); + expect(error).toBeInstanceOf(EnvironmentAuthorizationError); + expect(error).toMatchObject({ requiredScope: AuthSourceControlWriteScope }); expect(state.requests.map((request) => request.action)).toEqual(["delete"]); expect(state.localEffects).toContain("clear-terminal-ui"); } finally { diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index d3ec0ebb9b27..588f20019660 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -9,6 +9,7 @@ import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-se import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope, + EnvironmentAuthorizationError, EnvironmentId, type ScopedThreadRef, ThreadId, @@ -155,7 +156,14 @@ export async function requestThreadUnpinConfirmation(input: { function threadOperationFailure(target: ScopedThreadRef) { return readEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope) ? null - : AsyncResult.failure(Cause.fail(new Error("This connection cannot change threads."))); + : AsyncResult.failure( + Cause.fail( + new EnvironmentAuthorizationError({ + message: "This connection cannot change threads.", + requiredScope: AuthOrchestrationOperateScope, + }), + ), + ); } export function useThreadActions() { @@ -459,7 +467,12 @@ export function useThreadActions() { }, }) : AsyncResult.failure( - Cause.fail(new Error("This connection can no longer remove worktrees.")), + Cause.fail( + new EnvironmentAuthorizationError({ + message: "This connection can no longer remove worktrees.", + requiredScope: AuthSourceControlWriteScope, + }), + ), ); const refreshResult = removeResult._tag === "Success" From 1e6a0014b7750eecc7d008f3bdbeb009e6e41cae Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:43:31 -0700 Subject: [PATCH 20/26] fix(mobile): describe available branch actions --- apps/mobile/src/features/threads/git/GitOverviewSheet.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 96cf77818bec..c499a6ee6b56 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -53,7 +53,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); - const { canWriteSourceControl } = gitActions; + const { canWriteSourceControl, canChangeThreadBranch } = gitActions; const theme = useUniwindTheme(); const foregroundColor = theme["--color-foreground"]; const sheetColor = theme["--color-sheet"]; @@ -304,7 +304,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { icon="point.topleft.down.curvedto.point.bottomright.up" title="Branches & worktrees" subtitle={ - canWriteSourceControl + canChangeThreadBranch ? "Switch branch, create branch, or move to a worktree" : "View branches and worktrees" } From 1d616fdc2c1cc0e8c702368269936e2b1493c6e6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:59:16 -0700 Subject: [PATCH 21/26] fix(web): recheck checkout permission before opening drafts --- .../src/components/pullRequest/PullRequestDetailPanel.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 2d8e63b1dc75..79e2fd4e712d 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1050,7 +1050,12 @@ export function PullRequestDetailPanel({ }); return; } - if (!prepareThread.isAllowed) return; + if ( + !prepareThread.isAllowed || + !readEnvironmentScope(actingEnvironmentId, AuthSourceControlWriteScope) + ) { + return; + } if ( mode === "worktree" && !readEnvironmentScope(actingEnvironmentId, AuthOrchestrationOperateScope) From a4b1124f8977c68b2df566b4eb5f9f283fc3dad4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:11:47 -0700 Subject: [PATCH 22/26] test(web): handle typed thread action failures --- apps/web/src/hooks/useThreadActions.permissions.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index 81661c8046b6..d331c87965da 100644 --- a/apps/web/src/hooks/useThreadActions.permissions.test.ts +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -280,7 +280,7 @@ describe("thread action permissions", () => { expect(result._tag).toBe("Failure"); if (result._tag !== "Failure") throw new Error("Expected permission denial"); - const error = Cause.squash(result.cause); + const error = Cause.squash(result.cause); expect(error).toBeInstanceOf(EnvironmentAuthorizationError); expect(error).toMatchObject({ requiredScope: AuthOrchestrationOperateScope }); expect(state.requests).toEqual([]); @@ -329,7 +329,7 @@ describe("thread action permissions", () => { const result = await useThreadActions().deleteThread(target); expect(result._tag).toBe("Failure"); if (result._tag !== "Failure") throw new Error("Expected permission denial"); - const error = Cause.squash(result.cause); + const error = Cause.squash(result.cause); expect(error).toBeInstanceOf(EnvironmentAuthorizationError); expect(error).toMatchObject({ requiredScope: AuthSourceControlWriteScope }); expect(state.requests.map((request) => request.action)).toEqual(["delete"]); From 1cba18ff2122ffa952e28030efcd9564c5db1bc6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:37:01 -0700 Subject: [PATCH 23/26] fix(mobile): preserve branch forms after failed Git operations --- .../threads/git/GitBranchesSheet.test.tsx | 171 ++++++++++++++++++ .../features/threads/git/GitBranchesSheet.tsx | 29 +-- .../use-selected-thread-git-actions.test.ts | 65 +++++-- .../state/use-selected-thread-git-actions.ts | 4 +- 4 files changed, 242 insertions(+), 27 deletions(-) create mode 100644 apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx new file mode 100644 index 000000000000..d2768b0deef7 --- /dev/null +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx @@ -0,0 +1,171 @@ +import { isValidElement, type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + fields: [] as string[], + fieldIndex: 0, + canChangeThreadBranch: true, + navigations: 0, + result: Promise.resolve(null) as Promise, +})); + +vi.mock("react", async (importOriginal) => ({ + ...(await importOriginal()), + useState: (initial: string) => { + const index = state.fieldIndex++; + state.fields[index] ??= initial; + return [state.fields[index], (value: string) => (state.fields[index] = value)]; + }, +})); +vi.mock("react-native", () => ({ + Platform: { OS: "ios" }, + Pressable: "Pressable", + ScrollView: "ScrollView", + View: "View", +})); +vi.mock("@react-navigation/native", () => ({ + useNavigation: () => ({ goBack: () => state.navigations++ }), +})); +vi.mock("react-native-safe-area-context", () => ({ + useSafeAreaInsets: () => ({ bottom: 0 }), +})); +vi.mock("../../../components/AndroidScreenHeader", () => ({ + AndroidSheetHeader: "AndroidSheetHeader", +})); +vi.mock("../../../components/AppText", () => ({ AppText: "Text", AppTextInput: "TextInput" })); +vi.mock("./gitSheetComponents", () => ({ SheetActionButton: "SheetActionButton" })); +vi.mock("../../../state/query", () => ({ + useEnvironmentQuery: () => ({ data: { refName: "main" } }), +})); +vi.mock("../../../state/vcs", () => ({ vcsEnvironment: { status: () => null } })); +vi.mock("../../../state/use-thread-selection", () => ({ + useThreadSelection: () => ({ selectedThread: { environmentId: "environment", branch: "main" } }), +})); +vi.mock("../../../state/use-selected-thread-worktree", () => ({ + useSelectedThreadWorktree: () => ({ + selectedThreadCwd: "/repo", + selectedThreadWorktreePath: null, + }), +})); +vi.mock("../../../state/use-selected-thread-git-state", () => ({ + useSelectedThreadGitState: () => ({ + selectedThreadBranches: [{ name: "main", current: true, isDefault: true, worktreePath: null }], + selectedThreadBranchesLoading: false, + gitOperationLabel: null, + }), +})); +vi.mock("../../../state/use-selected-thread-git-actions", () => ({ + useSelectedThreadGitActions: () => ({ + canChangeThreadBranch: state.canChangeThreadBranch, + onCreateSelectedThreadBranch: () => state.result, + onCreateSelectedThreadWorktree: () => state.result, + onCheckoutSelectedThreadBranch: () => state.result, + }), +})); + +import { GitBranchesSheet } from "./GitBranchesSheet"; + +type ControlProps = { + label?: string; + placeholder?: string; + onPress?: () => unknown; + onChangeText?: (value: string) => void; + value?: string; + children?: ReactNode; +}; + +function findControl(node: ReactNode, label: string): ControlProps | null { + if (Array.isArray(node)) { + for (const child of node) { + const control = findControl(child, label); + if (control) return control; + } + return null; + } + if (!isValidElement(node)) return null; + if ( + node.props.label === label || + node.props.placeholder === label || + (label === "checkout" && node.type === "Pressable") + ) { + return node.props; + } + return findControl(node.props.children, label); +} + +function control(label: string) { + state.fieldIndex = 0; + const found = findControl( + GitBranchesSheet({ route: { params: { environmentId: "environment", threadId: "thread" } } }), + label, + ); + if (!found) throw new Error(`Missing sheet control: ${label}`); + return found; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +describe("branch sheet operation completion", () => { + beforeEach(() => { + state.fields = []; + state.fieldIndex = 0; + state.canChangeThreadBranch = true; + state.navigations = 0; + control("feature/mobile-polish").onChangeText?.("feature/branch"); + control("main").onChangeText?.("release"); + control("feature/mobile-thread").onChangeText?.("feature/worktree"); + }); + + it.each(["Create & checkout", "Create worktree", "checkout"])( + "keeps the sheet and its input when %s cannot complete", + async (operation) => { + const result = deferred(); + state.result = result.promise; + const press = control(operation).onPress; + if (!press) throw new Error("Missing operation handler"); + state.canChangeThreadBranch = false; + const completion = press(); + result.resolve(null); + await result.promise; + await completion; + + expect(state.navigations).toBe(0); + expect(control("feature/mobile-polish").value).toBe("feature/branch"); + expect(control("main").value).toBe("release"); + expect(control("feature/mobile-thread").value).toBe("feature/worktree"); + }, + ); + + it.each(["Create & checkout", "Create worktree", "checkout"])( + "dismisses after accepted %s completion even if the source grant changes", + async (operation) => { + const result = deferred(); + state.result = result.promise; + const press = control(operation).onPress; + if (!press) throw new Error("Missing operation handler"); + const completion = press(); + state.canChangeThreadBranch = false; + result.resolve( + operation === "Create worktree" + ? { worktree: { path: "/repo-worktree", refName: "feature/worktree" } } + : { refName: operation === "checkout" ? "main" : "feature/branch" }, + ); + await result.promise; + await completion; + + expect(state.navigations).toBe(1); + expect(control("feature/mobile-polish").value).toBe( + operation === "Create & checkout" ? "" : "feature/branch", + ); + expect(control("feature/mobile-thread").value).toBe( + operation === "Create worktree" ? "" : "feature/worktree", + ); + }, + ); +}); diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index 2d1757cee63c..441ab2d39c04 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -89,14 +89,14 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { label="Create & checkout" tone="primary" disabled={!canChangeThreadBranch || busy || newBranchName.trim().length === 0} - onPress={() => { + onPress={async () => { if (!canChangeThreadBranch) return; const branch = sanitizeFeatureBranchName(newBranchName.trim()); if (branch.length === 0) return; - void gitActions.onCreateSelectedThreadBranch(branch).then(() => { - setNewBranchName(""); - navigation.goBack(); - }); + const result = await gitActions.onCreateSelectedThreadBranch(branch); + if (result === null) return; + setNewBranchName(""); + navigation.goBack(); }} /> @@ -127,15 +127,18 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { worktreeBaseBranch.trim().length === 0 || worktreeBranchName.trim().length === 0 } - onPress={() => { + onPress={async () => { if (!canChangeThreadBranch) return; const baseBranch = worktreeBaseBranch.trim(); const newBranch = worktreeBranchName.trim(); if (baseBranch.length === 0 || newBranch.length === 0) return; - void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => { - setWorktreeBranchName(""); - navigation.goBack(); + const result = await gitActions.onCreateSelectedThreadWorktree({ + baseBranch, + newBranch, }); + if (result === null) return; + setWorktreeBranchName(""); + navigation.goBack(); }} /> @@ -172,11 +175,11 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { branch.current ? "border-subtle-strong" : "border-border", )} disabled={!canChangeThreadBranch || busy || disabled} - onPress={() => { + onPress={async () => { if (!canChangeThreadBranch) return; - void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { - navigation.goBack(); - }); + const result = await gitActions.onCheckoutSelectedThreadBranch(branch.name); + if (result === null) return; + navigation.goBack(); }} > 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 index f74373912c6e..693158a3a7a1 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.test.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.test.ts @@ -134,10 +134,13 @@ describe("thread Git mutation permissions", () => { async (canOperate) => { if (canOperate) state.scopes.add(AuthOrchestrationOperateScope); const actions = useSelectedThreadGitActions(); - await actions.onCreateSelectedThreadWorktree({ + const result = await actions.onCreateSelectedThreadWorktree({ baseBranch: "main", newBranch: "feature/task", }); + expect(result).toEqual( + canOperate ? { worktree: { path: "/repo-worktree", refName: "feature/task" } } : null, + ); expect(state.worktrees).toEqual(canOperate ? ["/repo-worktree"] : []); expect(state.thread.worktreePath).toBe(canOperate ? "/repo-worktree" : null); expect(state.thread.branch).toBe(canOperate ? "feature/task" : "main"); @@ -151,7 +154,8 @@ describe("thread Git mutation permissions", () => { if (operation === "create") { expect(await actions.onCreateSelectedThreadBranch("feature")).toBeNull(); } - if (operation === "checkout") await actions.onCheckoutSelectedThreadBranch("feature"); + if (operation === "checkout") + expect(await actions.onCheckoutSelectedThreadBranch("feature")).toBeNull(); if (operation === "commit on new branch") await actions.onRunSelectedThreadGitAction({ action: "commit", featureBranch: true }); expect(state.branch).toBe("main"); @@ -164,7 +168,12 @@ describe("thread Git mutation permissions", () => { state.scopes.add(AuthOrchestrationOperateScope); const actions = useSelectedThreadGitActions(); state.scopes.delete(AuthOrchestrationOperateScope); - await actions.onCreateSelectedThreadWorktree({ baseBranch: "main", newBranch: "feature/task" }); + expect( + await actions.onCreateSelectedThreadWorktree({ + baseBranch: "main", + newBranch: "feature/task", + }), + ).toBeNull(); expect(state.worktrees).toEqual([]); expect(state.thread.worktreePath).toBeNull(); }); @@ -187,9 +196,15 @@ describe("thread Git mutation permissions", () => { const actions = useSelectedThreadGitActions(); if (operation === "create") expect(await actions.onCreateSelectedThreadBranch("feature")).toBeNull(); - if (operation === "checkout") await actions.onCheckoutSelectedThreadBranch("feature"); + if (operation === "checkout") + expect(await actions.onCheckoutSelectedThreadBranch("feature")).toBeNull(); if (operation === "worktree") - await actions.onCreateSelectedThreadWorktree({ baseBranch: "main", newBranch: "feature" }); + expect( + await actions.onCreateSelectedThreadWorktree({ + baseBranch: "main", + newBranch: "feature", + }), + ).toBeNull(); if (operation === "commit") expect( await actions.onRunSelectedThreadGitAction({ action: "commit", featureBranch: true }), @@ -212,14 +227,40 @@ describe("thread Git mutation permissions", () => { expect(state.metadataRequests).toEqual([{ environmentId: "environment", branch: "feature" }]); }); - it("can finish the thread update when only source-control permission is revoked after Git", async () => { + it.each(["create", "checkout", "worktree"] as const)( + "returns the accepted %s result when only source-control permission is revoked after Git", + async (operation) => { + state.scopes.add(AuthOrchestrationOperateScope); + state.afterGitAction = () => state.scopes.delete(AuthSourceControlWriteScope); + const actions = useSelectedThreadGitActions(); + const result = + operation === "create" + ? await actions.onCreateSelectedThreadBranch("feature/task") + : operation === "checkout" + ? await actions.onCheckoutSelectedThreadBranch("feature/task") + : await actions.onCreateSelectedThreadWorktree({ + baseBranch: "main", + newBranch: "feature/task", + }); + expect(result).toEqual( + operation === "worktree" + ? { worktree: { path: "/repo-worktree", refName: "feature/task" } } + : { refName: "feature/task" }, + ); + expect(state.thread.branch).toBe("feature/task"); + expect(state.metadataRequests).toHaveLength(1); + expect(state.results).toEqual([]); + }, + ); + + it("returns a successful checkout when the requested branch is already selected", async () => { state.scopes.add(AuthOrchestrationOperateScope); - state.afterGitAction = () => state.scopes.delete(AuthSourceControlWriteScope); - expect( - await useSelectedThreadGitActions().onCreateSelectedThreadBranch("feature"), - ).not.toBeNull(); - expect(state.thread.branch).toBe("feature"); - expect(state.metadataRequests).toHaveLength(1); + + expect(await useSelectedThreadGitActions().onCheckoutSelectedThreadBranch("main")).toEqual({ + refName: "main", + }); + expect(state.branch).toBe("main"); + expect(state.thread.branch).toBe("main"); expect(state.results).toEqual([]); }); }); diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.ts b/apps/mobile/src/state/use-selected-thread-git-actions.ts index e196aa90da0a..11b8a61b7953 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -227,7 +227,7 @@ export function useSelectedThreadGitActions() { const onCheckoutSelectedThreadBranch = useCallback( async (branch: string) => { - await runSelectedThreadGitMutation( + return runSelectedThreadGitMutation( "switch_ref", "Switching branch", async ({ thread, cwd }) => { @@ -295,7 +295,7 @@ export function useSelectedThreadGitActions() { const onCreateSelectedThreadWorktree = useCallback( async (nextWorktree: { readonly baseBranch: string; readonly newBranch: string }) => { - await runSelectedThreadGitMutation( + return runSelectedThreadGitMutation( "create_worktree", "Creating worktree", async ({ thread, project }) => { From 14a3e5b9e56dc5d099c42dfacb76708dd7a0b1cb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:37:01 -0700 Subject: [PATCH 24/26] fix(web): preserve selection when no threads can settle --- apps/web/src/components/Sidebar.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 33760f845880..8f5263827940 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3127,7 +3127,7 @@ export default function Sidebar() { { id: "settle", label: `Settle (${count})`, - disabled: !canOperateThreads(settlingThreads), + disabled: settlingThreads.length === 0 || !canOperateThreads(settlingThreads), }, ...(canSnoozeSelection ? [ @@ -3171,8 +3171,14 @@ export default function Sidebar() { : clicked.value === "regenerate-title" ? regeneratableTitleThreads : clicked.value === "settle" - ? settlingThreads + ? settlingThreads.flatMap((thread) => { + const current = threadByKeyRef.current.get( + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + return current && current.settledOverride !== "settled" ? [current] : []; + }) : selectedThreads; + if (clicked.value === "settle" && actionTargets.length === 0) return; if (clicked.value !== "mark-unread" && !checkThreadOperations(actionTargets)) return; if (clicked.value?.startsWith("snooze:")) { const preset = snoozePresets.find( @@ -3271,10 +3277,7 @@ export default function Sidebar() { // valid mixed selection. Pinned rows ARE included: the decider // clears the pin as part of settling, so they park like the rest. const coSettlingKeys = new Set(threadKeys); - for (const thread of settlingThreads) { - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const currentThread = threadByKeyRef.current.get(threadKey); - if (!currentThread || currentThread.settledOverride === "settled") continue; + for (const thread of actionTargets) { attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys }); } clearSelection(); From e1afea1ed792daa3fef0aff3e1fc9eeca7677294 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:55:07 -0700 Subject: [PATCH 25/26] fix(web): show returned pull request checkout errors --- .../PullRequestThreadDialog.test.ts | 201 +++++++++++++++--- .../components/PullRequestThreadDialog.tsx | 16 +- 2 files changed, 182 insertions(+), 35 deletions(-) diff --git a/apps/web/src/components/PullRequestThreadDialog.test.ts b/apps/web/src/components/PullRequestThreadDialog.test.ts index 5736efd61ca5..103985580c81 100644 --- a/apps/web/src/components/PullRequestThreadDialog.test.ts +++ b/apps/web/src/components/PullRequestThreadDialog.test.ts @@ -1,9 +1,25 @@ -import { AuthOrchestrationOperateScope, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, + EnvironmentId, + ThreadId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; import { isValidElement, type ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const state = vi.hoisted(() => ({ canOperate: false, + canWriteSourceControl: true, + hookValues: [] as unknown[], + hookIndex: 0, + tracks: 0, + resets: 0, + manager: { operation: null as string | null, error: null, isRunning: false }, + interrupted: false, + pendingResponse: undefined as Promise | undefined, + openChanges: [] as boolean[], checkouts: [] as string[], setupScripts: 0, prepared: [] as { branch: string; worktreePath: string | null }[], @@ -13,7 +29,11 @@ vi.mock("react", async (importOriginal) => ({ ...(await importOriginal()), useCallback: (callback: unknown) => callback, useMemo: (factory: () => unknown) => factory(), - useState: (initial: unknown) => [initial, () => {}], + useState: (initial: unknown) => { + const index = state.hookIndex++; + if (index === state.hookValues.length) state.hookValues.push(initial); + return [state.hookValues[index], (value: unknown) => (state.hookValues[index] = value)]; + }, useRef: (current: unknown) => ({ current }), useEffect: () => {}, })); @@ -22,32 +42,63 @@ vi.mock("@tanstack/react-pacer", () => ({ })); vi.mock("~/state/session", () => ({ useEnvironmentScope: (_environmentId: unknown, scope: string) => - scope === AuthOrchestrationOperateScope && state.canOperate, + scope === AuthOrchestrationOperateScope + ? state.canOperate + : scope === AuthSourceControlWriteScope && state.canWriteSourceControl, readEnvironmentScope: (_environmentId: unknown, scope: string) => - scope === AuthOrchestrationOperateScope && state.canOperate, + scope === AuthOrchestrationOperateScope + ? state.canOperate + : scope === AuthSourceControlWriteScope && state.canWriteSourceControl, })); -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 }) => { +vi.mock("~/lib/sourceControlActions", async () => { + const { usePreparePullRequestThreadAction } = await vi.importActual< + typeof import("~/state/sourceControlActions") + >("~/state/sourceControlActions"); + return { + readCachedPullRequestResolution: () => null, + usePullRequestResolution: () => ({ + data: { pullRequest: { number: 123, title: "Pull request", state: "open" } }, + }), + usePreparePullRequestThreadAction, + }; +}); +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.manager })); +vi.mock("~/rpc/atomRegistry", () => ({ appAtomRegistry: {} })); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (command: unknown) => command, +})); +vi.mock("~/state/sourceControl", () => ({ sourceControlEnvironment: {} })); +vi.mock("~/state/git", () => ({ + gitEnvironment: { + preparePullRequestThread: async ({ + input: { mode, threadId }, + }: { + input: { mode: string; threadId?: string }; + }) => { state.checkouts.push(mode); if (mode === "worktree" && threadId) state.setupScripts += 1; - return { - _tag: "Success", - value: { branch: "feature/pr", worktreePath: mode === "worktree" ? "/worktree" : null }, - }; + await state.pendingResponse; + if (state.interrupted) return AsyncResult.failure(Cause.interrupt()); + return AsyncResult.success({ + branch: "feature/pr", + worktreePath: mode === "worktree" ? "/worktree" : null, + }); }, - }), + }, })); vi.mock("~/lib/utils", () => ({ cn: () => "" })); vi.mock("~/state/query", () => ({ useEnvironmentQuery: () => ({ data: null }) })); -vi.mock("~/state/vcs", () => ({ vcsEnvironment: { status: () => null } })); +vi.mock("~/state/vcs", () => ({ + vcsEnvironment: { status: () => null }, + vcsActionManager: { + stateAtom: () => "vcs-state", + track: (_registry: unknown, _target: unknown, _input: unknown, execute: () => unknown) => { + state.tracks += 1; + return execute(); + }, + resetError: () => state.resets++, + }, +})); vi.mock("./ui/button", () => ({ Button: "Button" })); vi.mock("./ui/input", () => ({ Input: "Input" })); vi.mock("./ui/spinner", () => ({ Spinner: "Spinner" })); @@ -76,28 +127,53 @@ function findAction(node: ReactNode, label: string): (() => unknown) | null { return findAction(node.props.children, label); } +function renderDialog() { + state.hookIndex = 0; + return PullRequestThreadDialog({ + open: true, + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("thread"), + cwd: "/repo", + initialReference: "123", + onOpenChange: (open) => state.openChanges.push(open), + onPrepared: (input) => { + state.prepared.push(input); + }, + }); +} + function prepareAction(label: "Local" | "Worktree") { - const action = findAction( - PullRequestThreadDialog({ - open: true, - environmentId: EnvironmentId.make("environment"), - threadId: ThreadId.make("thread"), - cwd: "/repo", - initialReference: "123", - onOpenChange: () => {}, - onPrepared: (input) => { - state.prepared.push(input); - }, - }), - label, - ); + const action = findAction(renderDialog(), label); if (!action) throw new Error(`${label} action missing`); return action; } +function visibleText(node: ReactNode): string { + if (Array.isArray(node)) return node.map(visibleText).join(" "); + if (typeof node === "string" || typeof node === "number") return String(node); + return isValidElement<{ children?: ReactNode }>(node) ? visibleText(node.props.children) : ""; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + describe("pull request worktree permissions", () => { beforeEach(() => { state.canOperate = false; + state.canWriteSourceControl = true; + state.hookValues = []; + state.hookIndex = 0; + state.tracks = 0; + state.resets = 0; + state.manager = { operation: null, error: null, isRunning: false }; + state.interrupted = false; + state.pendingResponse = undefined; + state.openChanges = []; state.checkouts = []; state.setupScripts = 0; state.prepared = []; @@ -137,4 +213,61 @@ describe("pull request worktree permissions", () => { expect(state.setupScripts).toBe(0); expect(state.prepared).toEqual([{ branch: "feature/pr", worktreePath: null }]); }); + + it.each(["Local", "Worktree"] as const)( + "shows the returned scope denial for a retained %s callback without starting a checkout", + async (label) => { + state.canOperate = true; + state.manager = { operation: "pull", error: null, isRunning: true }; + const prepare = prepareAction(label); + state.canWriteSourceControl = false; + + await prepare(); + + expect(visibleText(renderDialog())).toContain( + "This connection cannot change source control.", + ); + expect(state.checkouts).toEqual([]); + expect(state.setupScripts).toBe(0); + expect(state.prepared).toEqual([]); + expect(state.openChanges).toEqual([]); + expect(state.tracks).toBe(0); + expect(state.manager).toEqual({ operation: "pull", error: null, isRunning: true }); + }, + ); + + it("clears a returned error when a newly authorized retry starts, then completes normally", async () => { + const prepare = prepareAction("Local"); + state.canWriteSourceControl = false; + await prepare(); + expect(visibleText(renderDialog())).toContain("This connection cannot change source control."); + + state.canWriteSourceControl = true; + const response = deferred(); + state.pendingResponse = response.promise; + const completion = prepareAction("Local")(); + + expect(visibleText(renderDialog())).not.toContain( + "This connection cannot change source control.", + ); + expect(state.checkouts).toEqual(["local"]); + expect(state.prepared).toEqual([]); + response.resolve(undefined); + await completion; + + expect(state.prepared).toEqual([{ branch: "feature/pr", worktreePath: null }]); + expect(state.openChanges).toEqual([false]); + }); + + it("keeps interrupted preparations quiet and preserves error reset behavior", async () => { + state.interrupted = true; + const before = visibleText(renderDialog()); + + await prepareAction("Local")(); + + expect(visibleText(renderDialog())).toBe(before); + expect(state.resets).toBe(1); + expect(state.prepared).toEqual([]); + expect(state.openChanges).toEqual([]); + }); }); diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index c696c32639d5..c08ac67335aa 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -3,7 +3,10 @@ import { type EnvironmentId, type ThreadId, } from "@t3tools/contracts"; -import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -54,6 +57,7 @@ export function PullRequestThreadDialog({ const [reference, setReference] = useState(initialReference ?? ""); const [referenceDirty, setReferenceDirty] = useState(false); const [preparingMode, setPreparingMode] = useState<"local" | "worktree" | null>(null); + const [prepareErrorMessage, setPrepareErrorMessage] = useState(null); const [debouncedReference, referenceDebouncer] = useDebouncedValue( reference, { wait: 450 }, @@ -151,6 +155,7 @@ export function PullRequestThreadDialog({ if (!parsedReference || !resolvedPullRequest || !cwd) { return; } + setPrepareErrorMessage(null); setPreparingMode(mode); const result = await preparePullRequestThreadAction.run({ reference: parsedReference, @@ -161,6 +166,13 @@ export function PullRequestThreadDialog({ if (result._tag === "Failure") { if (isAtomCommandInterrupted(result)) { preparePullRequestThreadAction.resetError(); + } else { + const error = squashAtomCommandFailure(result); + setPrepareErrorMessage( + error instanceof Error + ? error.message + : `Failed to prepare ${terminology.singular} thread.`, + ); } return; } @@ -178,6 +190,7 @@ export function PullRequestThreadDialog({ parsedReference, preparePullRequestThreadAction, resolvedPullRequest, + terminology.singular, threadId, ], ); @@ -191,6 +204,7 @@ export function PullRequestThreadDialog({ : null; const errorMessage = validationMessage ?? + prepareErrorMessage ?? (resolvedPullRequest === null && pullRequestResolution.error ? pullRequestResolution.error : preparePullRequestThreadAction.error instanceof Error From 63cf91d8595eb2645e71857a6dfa466beab1fef6 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:19:33 -0700 Subject: [PATCH 26/26] fix(web): delete worktree threads when the grant lookup fails A failed session lookup only means worktree cleanup cannot be offered; it no longer blocks deleting the thread. Branch actions chosen after the grant is lost now explain the no-op, mobile refreshes the worktree state after a denied metadata update, and the docs state that task worktrees are created with orchestration:operate. Co-Authored-By: Claude Fable 5 --- .../use-selected-thread-git-actions.test.ts | 19 ++++++++++++++++++- .../state/use-selected-thread-git-actions.ts | 16 +++++++++------- .../BranchToolbarBranchSelector.tsx | 11 ++++++++++- .../useThreadActions.permissions.test.ts | 18 +++++++++++++----- apps/web/src/hooks/useThreadActions.ts | 7 ++++--- docs/user/remote-access.md | 6 ++++++ 6 files changed, 60 insertions(+), 17 deletions(-) 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 index 693158a3a7a1..63f8c31a02de 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.test.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.test.ts @@ -19,6 +19,7 @@ const state = vi.hoisted(() => ({ metadataRequests: [] as { environmentId: string; branch: string }[], results: [] as { type: string; description?: string }[], afterGitAction: undefined as (() => void) | undefined, + statusRefreshes: 0, })); vi.mock("react", () => ({ @@ -75,7 +76,10 @@ vi.mock("./threads", () => ({ })); vi.mock("./vcs", () => ({ vcsEnvironment: { - refreshStatus: async () => AsyncResult.success({ refName: state.branch }), + refreshStatus: async () => { + state.statusRefreshes += 1; + return AsyncResult.success({ refName: state.branch }); + }, switchRef: async ({ input }: { input: { refName: string } }) => { state.branch = input.refName; state.afterGitAction?.(); @@ -127,6 +131,7 @@ describe("thread Git mutation permissions", () => { state.metadataRequests = []; state.results = []; state.afterGitAction = undefined; + state.statusRefreshes = 0; }); it.each([false, true])( @@ -218,6 +223,18 @@ describe("thread Git mutation permissions", () => { }, ); + it("refreshes the worktree status when the metadata update is denied after checkout", async () => { + state.scopes.add(AuthOrchestrationOperateScope); + // Git already switched the branch, so the sheet must show it even though the + // thread record could not be updated. + state.afterGitAction = () => state.scopes.delete(AuthOrchestrationOperateScope); + expect( + await useSelectedThreadGitActions().onCheckoutSelectedThreadBranch("feature"), + ).toBeNull(); + expect(state.branch).toBe("feature"); + expect(state.statusRefreshes).toBe(1); + }); + it("uses a newly granted task permission for a retained branch callback", async () => { state.primaryScopes.clear(); const actions = useSelectedThreadGitActions(); 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 11b8a61b7953..451f378029fc 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -212,15 +212,17 @@ export function useSelectedThreadGitActions() { readonly worktreePath?: string | null; }; }): Promise> => { - if (input.nextThreadState) { - const updateResult = await updateThreadGitContext(input.thread, input.nextThreadState); - if (AsyncResult.isFailure(updateResult)) { - return AsyncResult.failure(updateResult.cause); - } - } + // The Git mutation already landed; refresh what the worktree shows even + // when the thread metadata update is denied, so the sheet does not keep + // displaying the previous branch. + const updateResult = input.nextThreadState + ? await updateThreadGitContext(input.thread, input.nextThreadState) + : AsyncResult.success(undefined); branchState.refresh(); await refreshSelectedThreadGitStatus({ quiet: true, cwd: input.cwd }); - return AsyncResult.success(undefined); + return AsyncResult.isFailure(updateResult) + ? AsyncResult.failure(updateResult.cause) + : AsyncResult.success(undefined); }, [branchState, refreshSelectedThreadGitStatus, updateThreadGitContext], ); diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 73552e82f414..8cbc7975ddab 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -406,8 +406,17 @@ export function BranchToolbarBranchSelector({ if ( !readEnvironmentScope(environmentId, AuthSourceControlWriteScope) || (hasServerThread && !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) - ) + ) { + // The menu already closed when the item was chosen; explain the no-op. + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Action unavailable", + description: "This connection cannot change the thread's branch.", + }), + ); return; + } startBranchActionTransition(async () => { await action(); branchRefState.refresh(); diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index d331c87965da..a22b8b7540df 100644 --- a/apps/web/src/hooks/useThreadActions.permissions.test.ts +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -34,6 +34,7 @@ const state = vi.hoisted(() => ({ localEffects: [] as string[], confirm: vi.fn<(message: string) => Promise>(), afterRequest: undefined as ((action: string) => void) | undefined, + sessionLookupFails: false, })); vi.mock("react", () => ({ @@ -65,10 +66,12 @@ vi.mock("../state/use-atom-command", () => ({ })); vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => async (environmentId: string) => - AsyncResult.success({ - authenticated: true, - scopes: [...(state.scopes.get(environmentId) ?? [])], - }), + state.sessionLookupFails + ? AsyncResult.failure(Cause.fail(new Error("Session lookup failed"))) + : AsyncResult.success({ + authenticated: true, + scopes: [...(state.scopes.get(environmentId) ?? [])], + }), })); vi.mock("../state/threads", () => ({ threadEnvironment: Object.fromEntries( @@ -219,6 +222,7 @@ beforeEach(() => { state.localEffects = []; state.confirm.mockReset().mockResolvedValue(true); state.afterRequest = undefined; + state.sessionLookupFails = false; }); describe("thread action permissions", () => { @@ -308,9 +312,13 @@ describe("thread action permissions", () => { expect(state.localEffects).toEqual([]); }); - it("deletes a thread without terminal or source-control permission", async () => { + it.each([ + { reason: "without source-control permission", sessionLookupFails: false }, + { reason: "when the permission lookup fails", sessionLookupFails: true }, + ])("deletes a worktree thread and keeps its worktree $reason", async ({ sessionLookupFails }) => { state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); state.threads[0]!.worktreePath = "/worktrees/thread"; + state.sessionLookupFails = sessionLookupFails; expect((await useThreadActions().deleteThread(target))._tag).toBe("Success"); expect(state.confirm).not.toHaveBeenCalled(); expect(state.requests.map((request) => request.action)).toEqual(["delete"]); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 588f20019660..3067a56048f4 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -351,13 +351,14 @@ export function useThreadActions() { const localApi = readLocalApi(); let canDeleteWorktree = false; if (orphanedWorktreePath !== null && threadProject !== null && localApi) { + // The session lookup only decides whether to offer worktree cleanup. + // A failed lookup is treated like a missing grant: delete the thread + // and leave the worktree behind rather than refusing the delete. const sessionResult = await loadSessionState(threadRef.environmentId); - if (sessionResult._tag === "Failure") { - return sessionResult; - } const permissionFailure = threadOperationFailure(threadRef); if (permissionFailure) return permissionFailure; canDeleteWorktree = + sessionResult._tag === "Success" && sessionResult.value.authenticated && sessionResult.value.scopes?.includes(AuthSourceControlWriteScope) === true; } diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 8de5ca89a139..19386ecadce3 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -158,6 +158,12 @@ Grouping checkouts does not combine their permissions. Shared project settings require `orchestration:operate` on every member environment; actions on one checkout use that checkout's permissions. +`source-control:write` covers direct Git and pull request changes made from the +client: pushing, switching or creating branches, cloning, and removing +worktrees. It does not restrict what a task does. Starting a task in a new +worktree still creates that branch and worktree with `orchestration:operate`, +and the agent it runs can use Git however the environment allows. + Settings changes, provider management, and environment maintenance can be granted separately from access administration. New standard pairings include these permissions. Existing clients keep their original grants after an update; to