From 95d721d5e98cb3f3942473d7009a397fa20877e4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:47:12 -0700 Subject: [PATCH] fix project group owner authorization refresh --- src/main/ipc/filesystem-auth.test.ts | 31 +++++++++++++++++++ src/main/ipc/filesystem-auth.ts | 14 +++++---- .../src/components/sidebar/WorktreeList.tsx | 18 +++++------ .../worktree-list-host-filtering.test.ts | 30 ++++++++++++++++++ .../sidebar/worktree-list-host-filtering.ts | 20 +++++++++++- 5 files changed, 96 insertions(+), 17 deletions(-) diff --git a/src/main/ipc/filesystem-auth.test.ts b/src/main/ipc/filesystem-auth.test.ts index 7633ff9e353..256afe04bb4 100644 --- a/src/main/ipc/filesystem-auth.test.ts +++ b/src/main/ipc/filesystem-auth.test.ts @@ -298,6 +298,37 @@ describe('filesystem-auth path containment', () => { } }) + it('keeps connection-free local folders authorized beside legacy SSH folders', () => { + const localPath = resolve('/explicit-local-mixed-folder/local') + const sshPath = resolve('/explicit-local-mixed-folder/ssh') + const projectGroup = makeProjectGroup({ + parentPath: localPath, + connectionId: undefined, + executionHostId: 'local' + }) + const store = makeStore([], { + projectGroups: [projectGroup], + folderWorkspaces: [ + makeFolderWorkspace({ + id: 'local-folder', + folderPath: localPath, + projectGroupId: projectGroup.id, + connectionId: undefined, + executionHostId: undefined + }), + makeFolderWorkspace({ + id: 'ssh-folder', + folderPath: sshPath, + projectGroupId: projectGroup.id, + connectionId: 'ssh-1' + }) + ] + }) + + expect(isPathAllowed(localPath, store)).toBe(true) + expect(isPathAllowed(sshPath, store)).toBe(false) + }) + it('does not authorize SSH-only folder-backed project group roots as local paths', async () => { const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-remote-project-group-')) try { diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index 730facc04ad..21df58b5d44 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -171,16 +171,18 @@ function buildFolderAuthIndex( } } for (const workspace of folderWorkspaces) { - if ( - workspace.connectionId === undefined && - !normalizeExecutionHostId(workspace.executionHostId) - ) { - continue - } const group = resolveFolderWorkspaceProjectGroupWithLegacySsh(projectGroupIndex, workspace) if (!group) { continue } + if ( + workspace.connectionId === undefined && + !normalizeExecutionHostId(workspace.executionHostId) && + group.connectionId === undefined && + !normalizeExecutionHostId(group.executionHostId) + ) { + continue + } const ownerHostId = resolveFolderWorkspaceCatalogOwnerHostIdFromIndex( workspace, projectGroupIndex diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index a9670660c5e..153323ab687 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -322,6 +322,7 @@ import { import { filterFolderWorkspacesForVisibleHosts, filterProjectGroupsForVisibleHosts, + buildFolderPathStatusRepoMembershipKey, getFolderPathStatusRouteOptionsForRows, getVisibleSidebarHostIdSet } from './worktree-list-host-filtering' @@ -731,6 +732,7 @@ type VirtualizedWorktreeViewportProps = { workspaceLineageByChildKey: Record // Full canonical repo-id order; must include hidden repos or a reorder silently drops them. allRepoIds: string[] + folderPathStatusRepoMembershipKey: string onReorderHostSections: (orderedHostIds: ExecutionHostId[]) => void onHostDragActiveChange: (active: boolean) => void prCache: AppState['prCache'] | null @@ -1464,6 +1466,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp worktreeLineageById, workspaceLineageByChildKey, allRepoIds, + folderPathStatusRepoMembershipKey, onReorderHostSections, onHostDragActiveChange, prCache, @@ -2023,16 +2026,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp activeRuntimeEnvironmentId: s.settings?.activeRuntimeEnvironmentId ?? null })) ) - const folderPathStatusRepoMembershipKey = useMemo( - () => - allRepoIds - .map((repoId) => { - const repo = repoMap.get(repoId) - return `${repoId}:${repo?.path ?? ''}:${repo?.projectGroupId ?? ''}:${repo?.connectionId ?? ''}` - }) - .join('\0'), - [allRepoIds, repoMap] - ) const folderPathStatusSshConnectionKey = useMemo( () => [...sshConnectionStates.entries()] @@ -5936,6 +5929,10 @@ const WorktreeList = React.memo(function WorktreeList({ }) }, [filterRepoIds, groupBy, visibleReposForRows, visibleWorktrees, worktreesByRepo]) const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos]) + const folderPathStatusRepoMembershipKey = useMemo( + () => buildFolderPathStatusRepoMembershipKey(repos), + [repos] + ) // Why: subscribe on a flat key array (useShallow) so progress ticks don't rebuild the whole row model. // Split on first space — creationId is a UUID (no space) so a space-containing repoId stays intact. @@ -7132,6 +7129,7 @@ const WorktreeList = React.memo(function WorktreeList({ worktreeLineageById={worktreeLineageById} workspaceLineageByChildKey={workspaceLineageByChildKey} allRepoIds={allRepoIds} + folderPathStatusRepoMembershipKey={folderPathStatusRepoMembershipKey} onReorderHostSections={handleReorderHostSections} onHostDragActiveChange={setHostDragActive} prCache={prCache} diff --git a/src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts b/src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts index 069649539e7..d070d7ec438 100644 --- a/src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts @@ -5,6 +5,7 @@ import type { FolderWorkspace, ProjectGroup, Repo, Worktree } from '../../../../ import { addHostSectionRows } from './host-section-rows' import { buildRows, type Row } from './worktree-list-groups' import { + buildFolderPathStatusRepoMembershipKey, filterFolderWorkspacesForVisibleHosts, filterProjectGroupsForVisibleHosts, getFolderPathStatusRouteOptionsForRows, @@ -140,6 +141,35 @@ function folderWorkspace(overrides: Partial = {}): FolderWorksp } describe('WorktreeList host filtering ownership', () => { + it('tracks each same-id repo owner in the folder path-status refresh key', () => { + type MembershipRepo = Parameters[0][number] + const local: MembershipRepo = { + id: 'same:repo', + path: '/local:repo', + projectGroupId: 'group:local', + connectionId: null, + executionHostId: 'local' + } + const runtime: MembershipRepo = { + id: 'same:repo', + path: '/runtime:repo', + projectGroupId: 'group:runtime', + connectionId: null, + executionHostId: 'runtime:env:1' + } + const before = buildFolderPathStatusRepoMembershipKey([local, runtime]) + const after = buildFolderPathStatusRepoMembershipKey([ + { ...local, projectGroupId: 'group:moved' }, + runtime + ]) + + expect(after).not.toBe(before) + expect(JSON.parse(before)).toEqual([ + ['local', 'same:repo', '/local:repo', 'group:local', ''], + ['runtime:env:1', 'same:repo', '/runtime:repo', 'group:runtime', ''] + ]) + }) + it('uses runtime execution host stamps before SSH/default fallbacks for project groups', () => { expect( getProjectGroupExecutionHostIdForRows( diff --git a/src/renderer/src/components/sidebar/worktree-list-host-filtering.ts b/src/renderer/src/components/sidebar/worktree-list-host-filtering.ts index b348e9ab110..f1b79796bd2 100644 --- a/src/renderer/src/components/sidebar/worktree-list-host-filtering.ts +++ b/src/renderer/src/components/sidebar/worktree-list-host-filtering.ts @@ -1,5 +1,6 @@ import { ALL_EXECUTION_HOSTS_SCOPE, + getRepoExecutionHostId, normalizeExecutionHostId, parseExecutionHostId, toSshExecutionHostId, @@ -7,7 +8,7 @@ import { type ExecutionHostScope } from '../../../../shared/execution-host' import type { FolderWorkspacePathStatusRequest } from '../../../../shared/folder-workspace-path-status' -import type { FolderWorkspace, ProjectGroup } from '../../../../shared/types' +import type { FolderWorkspace, ProjectGroup, Repo } from '../../../../shared/types' import { buildProjectGroupSidebarIndex, findProjectGroupForFolderWorkspace, @@ -19,6 +20,23 @@ import { export { getFolderWorkspaceExecutionHostIdForRows, getProjectGroupExecutionHostIdForRows } +export function buildFolderPathStatusRepoMembershipKey( + repos: readonly Pick< + Repo, + 'id' | 'path' | 'projectGroupId' | 'connectionId' | 'executionHostId' + >[] +): string { + return JSON.stringify( + repos.map((repo) => [ + getRepoExecutionHostId(repo), + repo.id, + repo.path, + repo.projectGroupId ?? '', + repo.connectionId ?? '' + ]) + ) +} + /** null means "no host filter" — every host is visible. */ export function getVisibleSidebarHostIdSet( visibleWorkspaceHostIds: readonly ExecutionHostId[] | null | undefined,