Restrict AI Vault resume actions to local execution hosts (#6406)

Previously, the AI Vault only blocked resume actions for workspaces with
an active SSH connection (checking `connectionId`). This allowed resume
actions to run on runtime-owned workspaces, which are non-local but do
not use SSH.

To resolve this:
- Introduce `getAiVaultResumeWorkspaceTargetStatus` to classify targets
  based on both `connectionId` and `executionHostId`.
- Restrict AI Vault panel actions, session resume checks, and drop layers
  to local workspaces.
- Preserve the `executionHostId` on project groups during normalization to
  ensure runtime-owned groups retain their execution host status across
  persistence reloads.
This commit is contained in:
Jinjing 2026-06-25 21:58:08 -07:00 committed by GitHub
parent 1f85822b34
commit 32c0b47afe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 372 additions and 13 deletions

View File

@ -6,7 +6,6 @@ import {
useActiveWorktree,
useAllWorktrees,
useProjectHostSetupProjection,
useRepoById,
useRepos
} from '@/store/selectors'
import { filterAiVaultSessions, groupAiVaultSessions } from './ai-vault-session-filters'
@ -27,6 +26,7 @@ import {
import { useAiVaultSessionLaunchActions } from './ai-vault-session-launch-actions'
import { useAiVaultSessionWorktreeMap } from './ai-vault-session-worktree'
import { useAiVaultOriginalPaneActions } from './ai-vault-original-pane-actions'
import { getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target'
import {
AI_VAULT_AGENTS,
type AiVaultAgent,
@ -44,7 +44,6 @@ import { useAiVaultSessionRefresh } from './ai-vault-session-refresh'
export default function AiVaultPanel(): React.JSX.Element {
const activeWorktree = useActiveWorktree()
const activeRepo = useActiveRepo()
const activeWorktreeRepo = useRepoById(activeWorktree?.repoId ?? null)
const repos = useRepos()
const allWorktrees = useAllWorktrees()
const projectHostSetupProjection = useProjectHostSetupProjection()
@ -62,7 +61,10 @@ export default function AiVaultPanel(): React.JSX.Element {
const userChangedScopeRef = useRef(false)
const preferredScopeRef = useRef<AiVaultScope>(DEFAULT_AI_VAULT_SCOPE)
const isRemoteWorktree = Boolean(activeWorktreeRepo?.connectionId)
const isNonLocalWorktree = useAppStore(
(state) =>
getAiVaultResumeWorkspaceTargetStatus(state, activeWorktree?.id ?? null) === 'non-local'
)
const activeWorktreePath = activeWorktree?.path ?? null
// Why: AI Vault ownership is cwd-based, so we must consider live worktrees across all repos.
const activeWorktreePaths = useMemo(
@ -276,11 +278,11 @@ export default function AiVaultPanel(): React.JSX.Element {
onRefresh={() => void refresh({ force: true })}
/>
{isRemoteWorktree ? (
{isNonLocalWorktree ? (
<div className="border-b border-sidebar-border px-3 py-2 text-[11px] leading-4 text-muted-foreground">
{translate(
'auto.components.right.sidebar.AiVaultPanel.remoteBrowseLocalHistory',
'SSH-host workspaces can browse local history. Resume actions run from {{value0}} workspaces.',
'Non-local workspaces can browse local history. Resume actions run from {{value0}} workspaces.',
{ value0: getLocalExecutionHostLabel() }
)}
</div>

View File

@ -8,6 +8,7 @@ import {
import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { useAppStore } from '@/store'
import { isNonLocalAiVaultResumeRepo } from '@/lib/ai-vault-resume-target'
import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types'
import type { Repo, Worktree } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
@ -80,7 +81,7 @@ export function useAiVaultSessionLaunchActions({
}
const worktreeRepo = repos.find((repo) => repo.id === worktree.repoId)
if (worktreeRepo?.connectionId) {
if (isNonLocalAiVaultResumeRepo(worktreeRepo)) {
toast.error(
translate(
'auto.components.right.sidebar.AiVaultPanel.localWorkspacesOnly',

View File

@ -30,6 +30,17 @@ function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
}
}
function makeRepo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo-1',
path: '/repo/orca',
displayName: 'orca',
badgeColor: '#000000',
addedAt: 1,
...overrides
}
}
function makeWorktreeInfo(
status: AiVaultSessionWorktreeInfo['status']
): AiVaultSessionWorktreeInfo {
@ -90,6 +101,26 @@ describe('resolveAiVaultSessionResumeState', () => {
})
})
it('blocks runtime-owned targets even when they have no SSH connection', () => {
expect(
resolveAiVaultSessionResumeState({
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: null,
worktrees: [makeWorktree()],
repos: [
makeRepo({
connectionId: null,
executionHostId: 'runtime:env-1'
})
]
})
).toEqual({
blocked: true,
worktreeId: null,
usesSessionWorktree: false
})
})
it('uses a local session worktree when the active workspace is remote', () => {
expect(
resolveAiVaultSessionResumeState({
@ -111,6 +142,36 @@ describe('resolveAiVaultSessionResumeState', () => {
usesSessionWorktree: true
})
})
it('falls back to the active workspace when the session worktree is runtime-owned', () => {
expect(
resolveAiVaultSessionResumeState({
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: 'repo-2::/repo/other',
worktrees: [
makeWorktree(),
makeWorktree({
id: 'repo-2::/repo/other',
repoId: 'repo-2',
path: '/repo/other'
})
],
repos: [
makeRepo({ connectionId: null, executionHostId: 'runtime:env-1' }),
makeRepo({
id: 'repo-2',
path: '/repo/other',
connectionId: null,
executionHostId: 'local'
})
]
})
).toEqual({
blocked: false,
worktreeId: 'repo-2::/repo/other',
usesSessionWorktree: false
})
})
})
describe('resolveAiVaultSessionResumeActions', () => {
@ -152,6 +213,35 @@ describe('resolveAiVaultSessionResumeActions', () => {
})
})
it('disables runtime-owned targets without disabling local targets', () => {
expect(
resolveAiVaultSessionResumeActions({
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: 'repo-2::/repo/other',
worktrees: [
makeWorktree(),
makeWorktree({
id: 'repo-2::/repo/other',
repoId: 'repo-2',
path: '/repo/other'
})
],
repos: [
makeRepo({ connectionId: null, executionHostId: 'runtime:env-1' }),
makeRepo({
id: 'repo-2',
path: '/repo/other',
connectionId: null,
executionHostId: 'local'
})
]
})
).toEqual({
worktree: { worktreeId: 'repo-1::/repo/orca', disabled: true },
newTab: { worktreeId: 'repo-2::/repo/other', disabled: false }
})
})
it('does not expose the active workspace as a duplicate new-tab target', () => {
expect(
resolveAiVaultSessionResumeActions({

View File

@ -1,4 +1,5 @@
import type { Repo, Worktree } from '../../../../shared/types'
import { isLocalAiVaultResumeRepo } from '@/lib/ai-vault-resume-target'
import { translate } from '@/i18n/i18n'
import {
canJumpToAiVaultSessionWorktree,
@ -45,7 +46,7 @@ export function resolveAiVaultSessionResumeState(args: {
continue
}
const repo = args.repos.find((candidate) => candidate.id === worktree.repoId)
if (repo?.connectionId) {
if (!isLocalAiVaultResumeRepo(repo)) {
continue
}
return {
@ -117,7 +118,7 @@ function resolveLocalResumeWorktreeId(args: {
}
const repo = args.repos.find((candidate) => candidate.id === worktree.repoId)
if (repo?.connectionId) {
if (!isLocalAiVaultResumeRepo(repo)) {
return null
}

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'
import { toast } from 'sonner'
import { getConnectionId } from '@/lib/connection-context'
import { getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target'
import {
AI_VAULT_SESSION_DRAG_END_EVENT,
AI_VAULT_SESSION_DRAG_START_EVENT,
@ -9,6 +9,7 @@ import {
readAiVaultSessionDragData
} from '@/lib/ai-vault-session-drag'
import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session'
import { useAppStore } from '@/store'
import { resolveDropZone } from './tab-drop-zone'
import type { TabDropZone } from './useTabDragSplit'
import { translate } from '@/i18n/i18n'
@ -165,8 +166,8 @@ export default function AiVaultSessionDropLayer({
return true
}
const connectionId = getConnectionId(worktreeId)
if (connectionId) {
const targetStatus = getAiVaultResumeWorkspaceTargetStatus(useAppStore.getState(), worktreeId)
if (targetStatus === 'non-local') {
toast.error(
translate(
'auto.components.tab.group.AiVaultSessionDropLayer.localWorkspacesOnly',
@ -175,7 +176,7 @@ export default function AiVaultSessionDropLayer({
)
return true
}
if (connectionId === undefined) {
if (targetStatus === 'unknown') {
toast.error(
translate(
'auto.components.tab.group.AiVaultSessionDropLayer.openLocalWorkspace',

View File

@ -0,0 +1,134 @@
import { describe, expect, it } from 'vitest'
import type { AppState } from '@/store/types'
import {
getAiVaultResumeRepoTargetStatus,
getAiVaultResumeWorktreeTargetStatus,
getAiVaultResumeWorkspaceTargetStatus,
isLocalAiVaultResumeRepo,
isNonLocalAiVaultResumeRepo
} from './ai-vault-resume-target'
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
type ResumeTargetState = Pick<
AppState,
'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'
>
function makeState(
overrides: Partial<Record<keyof ResumeTargetState, unknown>>
): ResumeTargetState {
return {
folderWorkspaces: [],
projectGroups: [],
repos: [],
worktreesByRepo: {},
...overrides
} as unknown as ResumeTargetState
}
describe('ai vault resume target ownership', () => {
it('classifies local, SSH, runtime, and unknown repo owners', () => {
expect(getAiVaultResumeRepoTargetStatus({ connectionId: null, executionHostId: 'local' })).toBe(
'local'
)
expect(getAiVaultResumeRepoTargetStatus({ connectionId: 'ssh-1', executionHostId: null })).toBe(
'non-local'
)
expect(
getAiVaultResumeRepoTargetStatus({
connectionId: null,
executionHostId: 'runtime:env-1'
})
).toBe('non-local')
expect(getAiVaultResumeRepoTargetStatus(null)).toBe('unknown')
})
it('exposes boolean predicates for resume gates', () => {
expect(isLocalAiVaultResumeRepo({ connectionId: null, executionHostId: 'local' })).toBe(true)
expect(
isNonLocalAiVaultResumeRepo({ connectionId: null, executionHostId: 'runtime:env-1' })
).toBe(true)
})
it('resolves runtime-owned worktree targets through their repo owner', () => {
expect(
getAiVaultResumeWorktreeTargetStatus({
worktreeId: 'repo-1::/repo/orca',
worktrees: [{ id: 'repo-1::/repo/orca', repoId: 'repo-1' }],
repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:env-1' }]
})
).toBe('non-local')
})
it('uses the composite worktree repo id when worktree discovery is incomplete', () => {
expect(
getAiVaultResumeWorkspaceTargetStatus(
makeState({
repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:env-1' }]
}),
'repo-1::/repo/orca'
)
).toBe('non-local')
})
it('resolves explicit workspace keys through the target worktree owner', () => {
expect(
getAiVaultResumeWorkspaceTargetStatus(
makeState({
worktreesByRepo: {
'repo-1': [{ id: 'repo-1::/repo/orca', repoId: 'repo-1' }]
},
repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:env-1' }]
}),
'worktree:repo-1::/repo/orca'
)
).toBe('non-local')
})
it('blocks folder workspaces owned by runtime project groups', () => {
expect(
getAiVaultResumeWorkspaceTargetStatus(
makeState({
folderWorkspaces: [
{
id: 'folder-1',
projectGroupId: 'group-1',
name: 'Platform',
folderPath: '/repo/platform'
}
],
projectGroups: [{ id: 'group-1', executionHostId: 'runtime:env-1' }]
}),
folderWorkspaceKey('folder-1')
)
).toBe('non-local')
})
it('blocks mixed local and runtime folder workspace targets', () => {
expect(
getAiVaultResumeWorkspaceTargetStatus(
makeState({
folderWorkspaces: [
{
id: 'folder-1',
projectGroupId: 'group-1',
name: 'Platform',
folderPath: '/repo/platform'
}
],
projectGroups: [{ id: 'group-1' }],
repos: [
{ id: 'repo-local', path: '/repo/platform/web', connectionId: null },
{
id: 'repo-runtime',
path: '/repo/platform/api',
connectionId: null,
executionHostId: 'runtime:env-1'
}
]
}),
folderWorkspaceKey('folder-1')
)
).toBe('non-local')
})
})

View File

@ -0,0 +1,114 @@
import {
getRepoExecutionHostId,
LOCAL_EXECUTION_HOST_ID,
normalizeExecutionHostId
} from '../../../shared/execution-host'
import type { Repo } from '../../../shared/types'
import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import type { AppState } from '@/store/types'
import { getFolderWorkspaceCandidateRepos } from './folder-workspace-connection'
export type AiVaultResumeTargetStatus = 'local' | 'non-local' | 'unknown'
type AiVaultResumeRepoOwner = Pick<Repo, 'connectionId' | 'executionHostId'>
export function getAiVaultResumeRepoTargetStatus(
repo: AiVaultResumeRepoOwner | null | undefined
): AiVaultResumeTargetStatus {
if (!repo) {
return 'unknown'
}
// Why: runtime-owned repos intentionally keep connectionId null, so resume
// availability must follow the execution owner instead of SSH state alone.
return repo.connectionId || getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID
? 'non-local'
: 'local'
}
export function isLocalAiVaultResumeRepo(repo: AiVaultResumeRepoOwner | null | undefined): boolean {
return getAiVaultResumeRepoTargetStatus(repo) === 'local'
}
export function isNonLocalAiVaultResumeRepo(
repo: AiVaultResumeRepoOwner | null | undefined
): boolean {
return getAiVaultResumeRepoTargetStatus(repo) === 'non-local'
}
export function getAiVaultResumeWorktreeTargetStatus(args: {
worktreeId: string | null
worktrees: readonly { id: string; repoId: string }[]
repos: readonly AiVaultResumeRepoOwnerWithId[]
}): AiVaultResumeTargetStatus {
if (!args.worktreeId) {
return 'unknown'
}
const worktree = args.worktrees.find((candidate) => candidate.id === args.worktreeId)
if (!worktree) {
return 'unknown'
}
return getAiVaultResumeRepoTargetStatus(
args.repos.find((candidate) => candidate.id === worktree.repoId)
)
}
export function getAiVaultResumeWorkspaceTargetStatus(
state: Pick<AppState, 'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'>,
workspaceId: string | null
): AiVaultResumeTargetStatus {
if (!workspaceId) {
return 'unknown'
}
const workspaceKey = parseWorkspaceKey(workspaceId)
if (workspaceKey?.type === 'folder') {
return getAiVaultResumeFolderTargetStatus(state, workspaceKey.folderWorkspaceId)
}
const worktreeId = workspaceKey?.type === 'worktree' ? workspaceKey.worktreeId : workspaceId
const worktree = Object.values(state.worktreesByRepo ?? {})
.flat()
.find((candidate) => candidate.id === worktreeId)
const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId)
return getAiVaultResumeRepoTargetStatus(state.repos.find((repo) => repo.id === repoId))
}
type AiVaultResumeRepoOwnerWithId = AiVaultResumeRepoOwner & { id: string }
function getAiVaultResumeFolderTargetStatus(
state: Pick<AppState, 'folderWorkspaces' | 'projectGroups' | 'repos'>,
folderWorkspaceId: string
): AiVaultResumeTargetStatus {
const workspace = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId)
if (!workspace) {
return 'unknown'
}
const group = state.projectGroups.find((entry) => entry.id === workspace.projectGroupId)
const groupHostId = normalizeExecutionHostId(group?.executionHostId)
if (
workspace.connectionId ||
group?.connectionId ||
(groupHostId && groupHostId !== LOCAL_EXECUTION_HOST_ID)
) {
return 'non-local'
}
return mergeAiVaultResumeTargetStatuses(
getFolderWorkspaceCandidateRepos(state, folderWorkspaceId).map(getAiVaultResumeRepoTargetStatus)
)
}
function mergeAiVaultResumeTargetStatuses(
statuses: readonly AiVaultResumeTargetStatus[]
): AiVaultResumeTargetStatus {
if (statuses.length === 0) {
return 'local'
}
const uniqueStatuses = new Set(statuses)
if (uniqueStatuses.size === 1) {
return statuses[0] ?? 'unknown'
}
return uniqueStatuses.has('unknown') ? 'unknown' : 'non-local'
}

View File

@ -72,6 +72,18 @@ describe('project-groups', () => {
})
})
it('preserves normalized execution ownership for persisted groups', () => {
const groups = normalizeProjectGroups([
{ id: 'runtime', name: 'Runtime', tabOrder: 1, executionHostId: 'runtime:env-1' },
{ id: 'local', name: 'Local', tabOrder: 2, executionHostId: 'local' },
{ id: 'invalid', name: 'Invalid', tabOrder: 3, executionHostId: 'runtime:' }
])
expect(groups.find((group) => group.id === 'runtime')?.executionHostId).toBe('runtime:env-1')
expect(groups.find((group) => group.id === 'local')?.executionHostId).toBe('local')
expect(groups.find((group) => group.id === 'invalid')?.executionHostId).toBeUndefined()
})
it('clears repo memberships whose group no longer exists', () => {
const groups = [createProjectGroup({ name: 'Known', createdFrom: 'manual', tabOrder: 0 })]
const repos = clearMissingProjectGroupMemberships(

View File

@ -1,3 +1,4 @@
import { normalizeExecutionHostId } from './execution-host'
import type { Repo, ProjectGroup, ProjectGroupCreatedFrom } from './types'
export const UNGROUPED_PROJECT_GROUP_KEY = 'project-group:ungrouped'
@ -56,6 +57,7 @@ export function normalizeProjectGroups(value: unknown): ProjectGroup[] {
}
seen.add(raw.id)
const now = Date.now()
const executionHostId = normalizeExecutionHostId(raw.executionHostId)
groups.push({
id: raw.id,
name: normalizeProjectGroupName(typeof raw.name === 'string' ? raw.name : ''),
@ -80,7 +82,9 @@ export function normalizeProjectGroups(value: unknown): ProjectGroup[] {
createdAt:
typeof raw.createdAt === 'number' && Number.isFinite(raw.createdAt) ? raw.createdAt : now,
updatedAt:
typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : now
typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : now,
// Why: runtime-owned groups otherwise look local after persistence reload.
...(executionHostId ? { executionHostId } : {})
})
}
groups.sort(