fix project group owner authorization refresh

This commit is contained in:
Brennan Benson
2026-08-10 23:47:12 -07:00
parent 35cbaa1df3
commit 95d721d5e9
5 changed files with 96 additions and 17 deletions
+31
View File
@@ -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 {
+8 -6
View File
@@ -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
@@ -322,6 +322,7 @@ import {
import {
filterFolderWorkspacesForVisibleHosts,
filterProjectGroupsForVisibleHosts,
buildFolderPathStatusRepoMembershipKey,
getFolderPathStatusRouteOptionsForRows,
getVisibleSidebarHostIdSet
} from './worktree-list-host-filtering'
@@ -731,6 +732,7 @@ type VirtualizedWorktreeViewportProps = {
workspaceLineageByChildKey: Record<string, WorkspaceLineage>
// 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}
@@ -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<FolderWorkspace> = {}): FolderWorksp
}
describe('WorktreeList host filtering ownership', () => {
it('tracks each same-id repo owner in the folder path-status refresh key', () => {
type MembershipRepo = Parameters<typeof buildFolderPathStatusRepoMembershipKey>[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(
@@ -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,