fix(mobile): Agent Session History resumes sessions in SSH workspaces that can read them (#29461)

* fix(mobile): resume Agent Session History rows in an SSH workspace that can read them

* fix(mobile): keep transcript-referencing resumes on the transcript's own host
This commit is contained in:
Neil
2026-10-15 23:26:03 -07:00
committed by GitHub
parent 861619fbc2
commit 0c8303c210
6 changed files with 539 additions and 248 deletions
@@ -70,6 +70,91 @@ describe('mobile AI Vault resume target guards', () => {
{ id: 'group-runtime', executionHostId: 'runtime:devbox' as const }
]
const WSL_TRANSCRIPT = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\app\\s.jsonl'
const sshSessionWorktree = worktree({
worktreeId: 'ssh-session-wt',
repoId: 'ssh-repo',
path: '/home/ada/ssh-repo/feature'
})
it('resumes a WSL-stored session in an SSH workspace into that distro (#26818)', () => {
const target = resolveMobileAiVaultSessionResumeTarget({
session: session({ cwd: '/home/ada/ssh-repo/feature/src', filePath: WSL_TRANSCRIPT }),
activeWorktreeId: 'ssh-session-wt',
worktrees: [sshSessionWorktree],
repos
})
expect(target).toEqual({
status: 'ready',
worktreeId: 'ssh-session-wt',
targetStatus: 'ssh',
workspacePath: '/home/ada/ssh-repo/feature',
terminalPlatform: null
})
})
it('resumes a session scanned on the same SSH host in that host workspace', () => {
const target = resolveMobileAiVaultSessionResumeTarget({
session: session({
executionHostId: 'ssh:builder',
cwd: '/home/ada/ssh-repo/feature/src',
filePath: '/home/ada/.claude/projects/app/s.jsonl'
}),
activeWorktreeId: 'ssh-session-wt',
worktrees: [sshSessionWorktree],
repos
})
expect(target.status === 'ready' ? target.targetStatus : target.message).toBe('ssh')
})
it('blocks a session scanned on another SSH host', () => {
const target = resolveMobileAiVaultSessionResumeTarget({
session: session({
executionHostId: 'ssh:other',
cwd: '/home/ada/ssh-repo/feature/src',
filePath: '/home/ada/.claude/projects/app/s.jsonl'
}),
activeWorktreeId: 'ssh-session-wt',
worktrees: [sshSessionWorktree],
repos
})
expect(target.status).toBe('blocked')
})
it('blocks a WSL Antigravity IDE transcript in an SSH workspace', () => {
const target = resolveMobileAiVaultSessionResumeTarget({
session: session({
agent: 'antigravity',
cwd: '/home/ada/ssh-repo/feature/src',
filePath:
'//wsl.localhost/Ubuntu/home/ada/.gemini/antigravity-ide/brain/id/transcript_full.jsonl'
}),
activeWorktreeId: 'ssh-session-wt',
worktrees: [sshSessionWorktree],
repos
})
expect(target.status).toBe('blocked')
})
it('resumes a WSL-stored session in an SSH folder workspace', () => {
const target = resolveMobileAiVaultSessionResumeTarget({
session: session({ cwd: '/home/ada/folder/src', filePath: WSL_TRANSCRIPT }),
activeWorktreeId: 'folder:folder-ssh',
worktrees: [
worktree({
worktreeId: 'folder:folder-ssh',
repoId: 'folder-workspace:group-local',
workspaceKind: 'folder-workspace',
path: '/home/ada/folder'
})
],
repos,
folderWorkspaces,
projectGroups
})
expect(target.status === 'ready' ? target.targetStatus : target.message).toBe('ssh')
})
it('resolves project-scope rows to the matched session worktree before the route worktree', () => {
const target = resolveMobileAiVaultSessionResumeTarget({
session: session({ cwd: '/Users/ada/repo/feature/src' }),
@@ -125,7 +210,7 @@ describe('mobile AI Vault resume target guards', () => {
expect(target.status === 'blocked' ? target.message : '').toContain('runtime-hosted')
})
it('blocks SSH folder workspace targets because transcripts are host-local', () => {
it('blocks SSH folder workspace targets for a host-local transcript', () => {
const target = resolveMobileAiVaultSessionResumeTarget({
session: session({ cwd: '/home/ada/folder/src' }),
activeWorktreeId: 'folder:folder-ssh',
@@ -142,7 +227,7 @@ describe('mobile AI Vault resume target guards', () => {
projectGroups
})
expect(target.status).toBe('blocked')
expect(target.status === 'blocked' ? target.message : '').toContain('SSH workspace')
expect(target.status === 'blocked' ? target.message : '').toContain('different host')
})
it('skips an SSH session-worktree candidate in favor of a local active-worktree fallback', () => {
@@ -182,7 +267,7 @@ describe('mobile AI Vault resume target guards', () => {
repos
})
expect(target.status).toBe('blocked')
expect(target.status === 'blocked' ? target.message : '').toContain('SSH workspace')
expect(target.status === 'blocked' ? target.message : '').toContain('different host')
})
it('blocks folder workspaces whose candidate repos include a runtime owner', () => {
@@ -1,113 +1,62 @@
import {
getRepoExecutionHostId,
normalizeExecutionHostId,
parseExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from '../../../src/shared/execution-host'
import type { ExecutionHostId } from '../../../src/shared/execution-host'
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
import { isPathInsideOrEqual } from '../../../src/shared/cross-platform-path'
import { canResumeAiVaultSessionOnSshHost } from '../../../src/shared/ai-vault-ssh-resume-target'
import type { Worktree } from '../worktree/workspace-list-types'
import {
getMobileAiVaultResumeWorktreeExecutionHostId,
getMobileAiVaultResumeWorktreeTargetStatus,
type MobileAiVaultResumeFolderWorkspace,
type MobileAiVaultResumeProjectGroup,
type MobileAiVaultResumeRepo,
type MobileAiVaultResumeTargetStatus
} from './agent-history-resume-workspace-host'
import {
canResumeInMobileSessionWorktree,
resolveMobileAgentHistorySessionWorktree
} from './agent-history-session-worktree'
export type MobileAiVaultResumeTargetStatus = 'local' | 'ssh' | 'runtime' | 'unknown'
export type MobileAiVaultResumeRepo = {
id: string
path?: string | null
projectGroupId?: string | null
connectionId?: string | null
executionHostId?: ExecutionHostId | null
}
type MobileAiVaultResumeWorktree = Pick<Worktree, 'repoId' | 'worktreeId'> & {
path?: string | null
workspaceKind?: Worktree['workspaceKind']
hostId?: ExecutionHostId | null
}
export type MobileAiVaultResumeFolderWorkspace = {
id: string
projectGroupId: string
folderPath: string
connectionId?: string | null
}
export type MobileAiVaultResumeProjectGroup = {
id: string
parentGroupId?: string | null
connectionId?: string | null
executionHostId?: ExecutionHostId | string | null
export type {
MobileAiVaultResumeFolderWorkspace,
MobileAiVaultResumeProjectGroup,
MobileAiVaultResumeRepo,
MobileAiVaultResumeTargetStatus
}
export type MobileAiVaultSessionResumeTarget =
| {
status: 'ready'
worktreeId: string
targetStatus: 'local'
targetStatus: 'local' | 'ssh'
workspacePath: string | null
terminalPlatform: NodeJS.Platform | null
}
| { status: 'blocked'; message: string }
function getMobileAiVaultResumeRepoTargetStatus(
repo: MobileAiVaultResumeRepo | null | undefined
): MobileAiVaultResumeTargetStatus {
if (!repo) {
return 'unknown'
function canResumeMobileAiVaultSessionOnTarget(args: {
session: AiVaultSession
targetStatus: MobileAiVaultResumeTargetStatus
targetExecutionHostId: ExecutionHostId | null
}): args is typeof args & { targetStatus: 'local' | 'ssh' } {
if (args.targetStatus === 'local') {
return true
}
return getMobileAiVaultResumeExecutionHostTargetStatus(getRepoExecutionHostId(repo))
}
function getMobileAiVaultResumeWorktreeTargetStatus(args: {
worktreeId: string | null
worktrees: readonly MobileAiVaultResumeWorktree[]
repos: readonly MobileAiVaultResumeRepo[]
folderWorkspaces?: readonly MobileAiVaultResumeFolderWorkspace[]
projectGroups?: readonly MobileAiVaultResumeProjectGroup[]
}): MobileAiVaultResumeTargetStatus {
if (!args.worktreeId) {
return 'unknown'
}
const worktree = args.worktrees.find((candidate) => candidate.worktreeId === args.worktreeId)
if (!worktree) {
return 'unknown'
}
if (worktree.workspaceKind === 'folder-workspace') {
return getMobileAiVaultResumeFolderTargetStatus({
worktreeId: args.worktreeId,
worktree,
repos: args.repos,
folderWorkspaces: args.folderWorkspaces ?? [],
projectGroups: args.projectGroups ?? []
// Why: same host rule as desktop: an SSH workspace resumes only transcripts that host can read.
return (
args.targetStatus === 'ssh' &&
canResumeAiVaultSessionOnSshHost({
sessionFilePath: args.session.filePath,
sessionExecutionHostId: args.session.executionHostId,
targetExecutionHostId: args.targetExecutionHostId
})
}
const worktreeHost = getMobileAiVaultResumeExecutionHostTargetStatus(worktree.hostId)
if (worktreeHost !== 'unknown') {
return worktreeHost
}
return getMobileAiVaultResumeRepoTargetStatus(
args.repos.find((candidate) => candidate.id === worktree.repoId)
)
}
function isSupportedMobileAiVaultResumeTargetStatus(
status: MobileAiVaultResumeTargetStatus
): status is 'local' {
// Why: mobile sessions come from the host-local transcript scan, so an SSH
// workspace cannot see the transcript file (mirror of desktop #6270).
return status === 'local'
}
function mobileAiVaultResumeTargetBlockMessage(status: MobileAiVaultResumeTargetStatus): string {
if (status === 'runtime') {
return 'Resume from history is not available in runtime-hosted workspaces.'
}
if (status === 'ssh') {
return 'This session is stored on the host machine, so it cannot be resumed in an SSH workspace. Open a local workspace for this project.'
return 'This session belongs to a different host. Open a workspace on the same host to resume it.'
}
return 'Open a local workspace before resuming a session.'
}
@@ -143,13 +92,27 @@ export function resolveMobileAiVaultSessionResumeTarget(args: {
folderWorkspaces: args.folderWorkspaces,
projectGroups: args.projectGroups
})
if (!isSupportedMobileAiVaultResumeTargetStatus(targetStatus)) {
const candidate = {
session: args.session,
targetStatus,
targetExecutionHostId:
targetStatus === 'ssh'
? getMobileAiVaultResumeWorktreeExecutionHostId({
worktreeId: candidateWorktreeId,
worktrees: args.worktrees,
repos: args.repos,
folderWorkspaces: args.folderWorkspaces ?? [],
projectGroups: args.projectGroups ?? []
})
: null
}
if (!canResumeMobileAiVaultSessionOnTarget(candidate)) {
continue
}
return {
status: 'ready',
worktreeId: candidateWorktreeId,
targetStatus,
targetStatus: candidate.targetStatus,
workspacePath:
args.worktrees.find((worktree) => worktree.worktreeId === candidateWorktreeId)?.path ??
null,
@@ -168,135 +131,3 @@ export function resolveMobileAiVaultSessionResumeTarget(args: {
})
return { status: 'blocked', message: mobileAiVaultResumeTargetBlockMessage(blockedStatus) }
}
function getMobileAiVaultResumeFolderTargetStatus(args: {
worktreeId: string
worktree: MobileAiVaultResumeWorktree
repos: readonly MobileAiVaultResumeRepo[]
folderWorkspaces: readonly MobileAiVaultResumeFolderWorkspace[]
projectGroups: readonly MobileAiVaultResumeProjectGroup[]
}): MobileAiVaultResumeTargetStatus {
const folderWorkspaceId = args.worktreeId.startsWith('folder:')
? args.worktreeId.slice('folder:'.length)
: null
const folderWorkspace = folderWorkspaceId
? args.folderWorkspaces.find((workspace) => workspace.id === folderWorkspaceId)
: null
if (!folderWorkspace) {
return 'unknown'
}
const projectGroupId =
folderWorkspace.projectGroupId ?? parseFolderWorkspaceRepoId(args.worktree.repoId)
const projectGroup = projectGroupId
? args.projectGroups.find((group) => group.id === projectGroupId)
: null
const groupHostId = normalizeExecutionHostId(projectGroup?.executionHostId)
if (groupHostId) {
return getMobileAiVaultResumeExecutionHostTargetStatus(groupHostId)
}
const explicitConnectionId = (
folderWorkspace?.connectionId ??
projectGroup?.connectionId ??
''
).trim()
if (explicitConnectionId) {
return getMobileAiVaultResumeExecutionHostTargetStatus(
toSshExecutionHostId(explicitConnectionId)
)
}
return mergeMobileAiVaultResumeExecutionHostTargetStatuses(
getMobileFolderWorkspaceCandidateRepos({
folderWorkspace,
projectGroupId,
projectGroups: args.projectGroups,
repos: args.repos
}).map(getRepoExecutionHostId)
)
}
function getMobileAiVaultResumeExecutionHostTargetStatus(
hostId: ExecutionHostId | null | undefined
): MobileAiVaultResumeTargetStatus {
const parsed = parseExecutionHostId(hostId)
if (!parsed) {
return 'unknown'
}
return parsed.kind
}
function parseFolderWorkspaceRepoId(repoId: string): string | null {
const prefix = 'folder-workspace:'
return repoId.startsWith(prefix) ? repoId.slice(prefix.length) || null : null
}
function getMobileFolderWorkspaceCandidateRepos(args: {
folderWorkspace: MobileAiVaultResumeFolderWorkspace | null | undefined
projectGroupId: string | null
projectGroups: readonly MobileAiVaultResumeProjectGroup[]
repos: readonly MobileAiVaultResumeRepo[]
}): MobileAiVaultResumeRepo[] {
if (!args.folderWorkspace || !args.projectGroupId) {
return []
}
const folderWorkspace = args.folderWorkspace
const groupIds = getMobileProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId)
const groupRepos = args.repos.filter(
(repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)
)
const pathRepos = args.repos.filter(
(repo) =>
!(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) &&
typeof repo.path === 'string' &&
repo.path.trim().length > 0 &&
isPathInsideOrEqual(folderWorkspace.folderPath, repo.path)
)
if (folderWorkspace.connectionId) {
return [
...groupRepos,
...pathRepos.filter((repo) => (repo.connectionId ?? null) === folderWorkspace.connectionId)
]
}
if (groupRepos.length === 0) {
return pathRepos
}
const groupConnectionIds = new Set(groupRepos.map((repo) => repo.connectionId ?? null))
return [
...groupRepos,
...pathRepos.filter((repo) => groupConnectionIds.has(repo.connectionId ?? null))
]
}
function getMobileProjectGroupSubtreeIds(
projectGroups: readonly MobileAiVaultResumeProjectGroup[],
projectGroupId: string
): Set<string> {
const ids = new Set<string>([projectGroupId])
let changed = true
while (changed) {
changed = false
for (const group of projectGroups) {
if (group.parentGroupId && ids.has(group.parentGroupId) && !ids.has(group.id)) {
ids.add(group.id)
changed = true
}
}
}
return ids
}
function mergeMobileAiVaultResumeExecutionHostTargetStatuses(
hostIds: readonly ExecutionHostId[]
): MobileAiVaultResumeTargetStatus {
if (hostIds.length === 0) {
return 'local'
}
const statuses = hostIds.map(getMobileAiVaultResumeExecutionHostTargetStatus)
const uniqueStatuses = new Set(statuses)
if (uniqueStatuses.has('runtime')) {
return 'runtime'
}
return new Set(hostIds).size === 1 ? (statuses[0] ?? 'unknown') : 'unknown'
}
@@ -0,0 +1,266 @@
import {
getRepoExecutionHostId,
normalizeExecutionHostId,
parseExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from '../../../src/shared/execution-host'
import { isPathInsideOrEqual } from '../../../src/shared/cross-platform-path'
import type { Worktree } from '../worktree/workspace-list-types'
// Which execution host a mobile resume target workspace runs on.
export type MobileAiVaultResumeTargetStatus = 'local' | 'ssh' | 'runtime' | 'unknown'
export type MobileAiVaultResumeRepo = {
id: string
path?: string | null
projectGroupId?: string | null
connectionId?: string | null
executionHostId?: ExecutionHostId | null
}
export type MobileAiVaultResumeWorktree = Pick<Worktree, 'repoId' | 'worktreeId'> & {
path?: string | null
workspaceKind?: Worktree['workspaceKind']
hostId?: ExecutionHostId | null
}
export type MobileAiVaultResumeFolderWorkspace = {
id: string
projectGroupId: string
folderPath: string
connectionId?: string | null
}
export type MobileAiVaultResumeProjectGroup = {
id: string
parentGroupId?: string | null
connectionId?: string | null
executionHostId?: ExecutionHostId | string | null
}
function getMobileAiVaultResumeRepoTargetStatus(
repo: MobileAiVaultResumeRepo | null | undefined
): MobileAiVaultResumeTargetStatus {
if (!repo) {
return 'unknown'
}
return getMobileAiVaultResumeExecutionHostTargetStatus(getRepoExecutionHostId(repo))
}
export function getMobileAiVaultResumeWorktreeTargetStatus(args: {
worktreeId: string | null
worktrees: readonly MobileAiVaultResumeWorktree[]
repos: readonly MobileAiVaultResumeRepo[]
folderWorkspaces?: readonly MobileAiVaultResumeFolderWorkspace[]
projectGroups?: readonly MobileAiVaultResumeProjectGroup[]
}): MobileAiVaultResumeTargetStatus {
if (!args.worktreeId) {
return 'unknown'
}
const worktree = args.worktrees.find((candidate) => candidate.worktreeId === args.worktreeId)
if (!worktree) {
return 'unknown'
}
if (worktree.workspaceKind === 'folder-workspace') {
return getMobileAiVaultResumeFolderTargetStatus({
worktreeId: args.worktreeId,
worktree,
repos: args.repos,
folderWorkspaces: args.folderWorkspaces ?? [],
projectGroups: args.projectGroups ?? []
})
}
const worktreeHost = getMobileAiVaultResumeExecutionHostTargetStatus(worktree.hostId)
if (worktreeHost !== 'unknown') {
return worktreeHost
}
return getMobileAiVaultResumeRepoTargetStatus(
args.repos.find((candidate) => candidate.id === worktree.repoId)
)
}
/** The SSH workspace's own host, so a resume only targets the host holding the transcript. */
export function getMobileAiVaultResumeWorktreeExecutionHostId(args: {
worktreeId: string
worktrees: readonly MobileAiVaultResumeWorktree[]
repos: readonly MobileAiVaultResumeRepo[]
folderWorkspaces: readonly MobileAiVaultResumeFolderWorkspace[]
projectGroups: readonly MobileAiVaultResumeProjectGroup[]
}): ExecutionHostId | null {
const worktree = args.worktrees.find((candidate) => candidate.worktreeId === args.worktreeId)
if (!worktree) {
return null
}
if (worktree.workspaceKind !== 'folder-workspace') {
const repo = args.repos.find((candidate) => candidate.id === worktree.repoId)
return normalizeExecutionHostId(worktree.hostId) ?? (repo ? getRepoExecutionHostId(repo) : null)
}
const folderWorkspace = args.worktreeId.startsWith('folder:')
? args.folderWorkspaces.find(
(workspace) => workspace.id === args.worktreeId.slice('folder:'.length)
)
: undefined
if (!folderWorkspace) {
return null
}
const projectGroupId =
folderWorkspace.projectGroupId ?? parseFolderWorkspaceRepoId(worktree.repoId)
const projectGroup = projectGroupId
? args.projectGroups.find((group) => group.id === projectGroupId)
: null
const groupHostId = normalizeExecutionHostId(projectGroup?.executionHostId)
if (groupHostId) {
return groupHostId
}
const explicitConnectionId = (
folderWorkspace.connectionId ??
projectGroup?.connectionId ??
''
).trim()
if (explicitConnectionId) {
return toSshExecutionHostId(explicitConnectionId)
}
const hostIds = new Set(
getMobileFolderWorkspaceCandidateRepos({
folderWorkspace,
projectGroupId,
projectGroups: args.projectGroups,
repos: args.repos
}).map(getRepoExecutionHostId)
)
return hostIds.size === 1 ? ([...hostIds][0] ?? null) : null
}
function getMobileAiVaultResumeFolderTargetStatus(args: {
worktreeId: string
worktree: MobileAiVaultResumeWorktree
repos: readonly MobileAiVaultResumeRepo[]
folderWorkspaces: readonly MobileAiVaultResumeFolderWorkspace[]
projectGroups: readonly MobileAiVaultResumeProjectGroup[]
}): MobileAiVaultResumeTargetStatus {
const folderWorkspaceId = args.worktreeId.startsWith('folder:')
? args.worktreeId.slice('folder:'.length)
: null
const folderWorkspace = folderWorkspaceId
? args.folderWorkspaces.find((workspace) => workspace.id === folderWorkspaceId)
: null
if (!folderWorkspace) {
return 'unknown'
}
const projectGroupId =
folderWorkspace.projectGroupId ?? parseFolderWorkspaceRepoId(args.worktree.repoId)
const projectGroup = projectGroupId
? args.projectGroups.find((group) => group.id === projectGroupId)
: null
const groupHostId = normalizeExecutionHostId(projectGroup?.executionHostId)
if (groupHostId) {
return getMobileAiVaultResumeExecutionHostTargetStatus(groupHostId)
}
const explicitConnectionId = (
folderWorkspace?.connectionId ??
projectGroup?.connectionId ??
''
).trim()
if (explicitConnectionId) {
return getMobileAiVaultResumeExecutionHostTargetStatus(
toSshExecutionHostId(explicitConnectionId)
)
}
return mergeMobileAiVaultResumeExecutionHostTargetStatuses(
getMobileFolderWorkspaceCandidateRepos({
folderWorkspace,
projectGroupId,
projectGroups: args.projectGroups,
repos: args.repos
}).map(getRepoExecutionHostId)
)
}
function getMobileAiVaultResumeExecutionHostTargetStatus(
hostId: ExecutionHostId | null | undefined
): MobileAiVaultResumeTargetStatus {
const parsed = parseExecutionHostId(hostId)
if (!parsed) {
return 'unknown'
}
return parsed.kind
}
function parseFolderWorkspaceRepoId(repoId: string): string | null {
const prefix = 'folder-workspace:'
return repoId.startsWith(prefix) ? repoId.slice(prefix.length) || null : null
}
function getMobileFolderWorkspaceCandidateRepos(args: {
folderWorkspace: MobileAiVaultResumeFolderWorkspace | null | undefined
projectGroupId: string | null
projectGroups: readonly MobileAiVaultResumeProjectGroup[]
repos: readonly MobileAiVaultResumeRepo[]
}): MobileAiVaultResumeRepo[] {
if (!args.folderWorkspace || !args.projectGroupId) {
return []
}
const folderWorkspace = args.folderWorkspace
const groupIds = getMobileProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId)
const groupRepos = args.repos.filter(
(repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)
)
const pathRepos = args.repos.filter(
(repo) =>
!(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) &&
typeof repo.path === 'string' &&
repo.path.trim().length > 0 &&
isPathInsideOrEqual(folderWorkspace.folderPath, repo.path)
)
if (folderWorkspace.connectionId) {
return [
...groupRepos,
...pathRepos.filter((repo) => (repo.connectionId ?? null) === folderWorkspace.connectionId)
]
}
if (groupRepos.length === 0) {
return pathRepos
}
const groupConnectionIds = new Set(groupRepos.map((repo) => repo.connectionId ?? null))
return [
...groupRepos,
...pathRepos.filter((repo) => groupConnectionIds.has(repo.connectionId ?? null))
]
}
function getMobileProjectGroupSubtreeIds(
projectGroups: readonly MobileAiVaultResumeProjectGroup[],
projectGroupId: string
): Set<string> {
const ids = new Set<string>([projectGroupId])
let changed = true
while (changed) {
changed = false
for (const group of projectGroups) {
if (group.parentGroupId && ids.has(group.parentGroupId) && !ids.has(group.id)) {
ids.add(group.id)
changed = true
}
}
}
return ids
}
function mergeMobileAiVaultResumeExecutionHostTargetStatuses(
hostIds: readonly ExecutionHostId[]
): MobileAiVaultResumeTargetStatus {
if (hostIds.length === 0) {
return 'local'
}
const statuses = hostIds.map(getMobileAiVaultResumeExecutionHostTargetStatus)
const uniqueStatuses = new Set(statuses)
if (uniqueStatuses.has('runtime')) {
return 'runtime'
}
return new Set(hostIds).size === 1 ? (statuses[0] ?? 'unknown') : 'unknown'
}
+12 -29
View File
@@ -11,6 +11,7 @@ import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import { isWslUncPath, parseWslUncPath } from '../../../shared/wsl-paths'
import { antigravitySessionOrigin } from '../../../shared/antigravity-session-origin'
import { canResumeAiVaultSessionOnSshHost } from '../../../shared/ai-vault-ssh-resume-target'
import type { AppState } from '@/store/types'
import { getIndexedWorktreeMap } from '@/store/worktree-repo-index'
import { getFolderWorkspaceCandidateRepos } from './folder-workspace-connection'
@@ -85,38 +86,20 @@ export function canResumeAiVaultSessionOnTarget(args: {
if (!isSupportedAiVaultResumeTargetStatus(args.targetStatus)) {
return false
}
if (
args.targetStatus === 'local' &&
!isAiVaultSessionInWslNamespace(args.sessionFilePath, args.targetWslDistro)
) {
if (args.targetStatus === 'ssh') {
return canResumeAiVaultSessionOnSshHost({
sessionFilePath: args.sessionFilePath,
sessionExecutionHostId,
targetExecutionHostId
})
}
if (!isAiVaultSessionInWslNamespace(args.sessionFilePath, args.targetWslDistro)) {
return false
}
if (sessionExecutionHostId) {
if (targetExecutionHostId) {
if (sessionExecutionHostId === targetExecutionHostId) {
return true
}
// Why: SSH-to-local-WSL setups (#6270) tag the session 'local' but the
// file lives under a WSL UNC path reachable from any SSH shell into this
// machine, so we bypass the exact host-id match for that case.
return (
sessionExecutionHostId === LOCAL_EXECUTION_HOST_ID &&
args.targetStatus === 'ssh' &&
isWslStoredAiVaultSessionFile(args.sessionFilePath)
)
}
if (sessionExecutionHostId !== LOCAL_EXECUTION_HOST_ID) {
return false
}
if (sessionExecutionHostId && targetExecutionHostId) {
return sessionExecutionHostId === targetExecutionHostId
}
// Why: vault sessions are scanned from this machine's disk (host home dirs
// plus local WSL homes). An SSH shell can only reach the WSL-stored ones
// (SSH-to-local-WSL setups, #6270); host-stored session files do not exist
// on a remote filesystem, so queuing a resume there is guaranteed to fail.
if (args.targetStatus === 'ssh') {
return isWslStoredAiVaultSessionFile(args.sessionFilePath)
}
return true
return !sessionExecutionHostId || sessionExecutionHostId === LOCAL_EXECUTION_HOST_ID
}
/**
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import { canResumeAiVaultSessionOnSshHost } from './ai-vault-ssh-resume-target'
const WSL_FILE = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\app\\s.jsonl'
const HOST_FILE = '/Users/ada/.claude/projects/app/s.jsonl'
describe('canResumeAiVaultSessionOnSshHost', () => {
it('accepts a session scanned on the target SSH host', () => {
expect(
canResumeAiVaultSessionOnSshHost({
sessionFilePath: '/home/ada/.claude/projects/app/s.jsonl',
sessionExecutionHostId: 'ssh:builder',
targetExecutionHostId: 'ssh:builder'
})
).toBe(true)
})
it('refuses a session scanned on another SSH host', () => {
expect(
canResumeAiVaultSessionOnSshHost({
sessionFilePath: WSL_FILE,
sessionExecutionHostId: 'ssh:other',
targetExecutionHostId: 'ssh:builder'
})
).toBe(false)
})
it('accepts a local WSL-stored transcript, with or without a stamped host', () => {
for (const sessionExecutionHostId of ['local' as const, null]) {
expect(
canResumeAiVaultSessionOnSshHost({
sessionFilePath: WSL_FILE,
sessionExecutionHostId,
targetExecutionHostId: 'ssh:builder'
})
).toBe(true)
}
})
it('refuses a transcript stored on this machine outside WSL', () => {
expect(
canResumeAiVaultSessionOnSshHost({
sessionFilePath: HOST_FILE,
sessionExecutionHostId: 'local',
targetExecutionHostId: 'ssh:builder'
})
).toBe(false)
})
it('keeps a transcript-referencing Antigravity resume on the transcript host', () => {
const wslReference =
'//wsl.localhost/Ubuntu/home/ada/.gemini/antigravity-ide/brain/id/transcript_full.jsonl'
const sshReference = '/home/ada/.gemini/antigravity-ide/brain/id/transcript_full.jsonl'
expect(
canResumeAiVaultSessionOnSshHost({
sessionFilePath: wslReference,
sessionExecutionHostId: 'local',
targetExecutionHostId: 'ssh:builder'
})
).toBe(false)
expect(
canResumeAiVaultSessionOnSshHost({
sessionFilePath: sshReference,
sessionExecutionHostId: 'ssh:builder',
targetExecutionHostId: 'ssh:builder'
})
).toBe(true)
// The CLI resumes by ID, so it keeps the WSL exception.
expect(
canResumeAiVaultSessionOnSshHost({
sessionFilePath: wslReference.replace('antigravity-ide', 'antigravity-cli'),
sessionExecutionHostId: 'local',
targetExecutionHostId: 'ssh:builder'
})
).toBe(true)
})
it('refuses a runtime-scanned session', () => {
expect(
canResumeAiVaultSessionOnSshHost({
sessionFilePath: WSL_FILE,
sessionExecutionHostId: 'runtime:devbox',
targetExecutionHostId: 'ssh:builder'
})
).toBe(false)
})
})
+39
View File
@@ -0,0 +1,39 @@
import {
LOCAL_EXECUTION_HOST_ID,
normalizeExecutionHostId,
type ExecutionHostId
} from './execution-host'
import { isWslUncPath } from './wsl-paths'
import { antigravitySessionOrigin } from './antigravity-session-origin'
/**
* Whether a history session can resume in an SSH workspace on `targetExecutionHostId`.
* Shared by desktop and mobile so both apply the same host rule.
*/
export function canResumeAiVaultSessionOnSshHost(args: {
sessionFilePath: string | null | undefined
sessionExecutionHostId?: ExecutionHostId | null
targetExecutionHostId?: ExecutionHostId | null
}): boolean {
const sessionHost = normalizeExecutionHostId(args.sessionExecutionHostId)
const targetHost = normalizeExecutionHostId(args.targetExecutionHostId)
const origin = args.sessionFilePath ? antigravitySessionOrigin(args.sessionFilePath) : null
if (origin && origin !== 'antigravity-cli') {
// Why: these resumes reference the transcript file itself, so only its own host can open it;
// the WSL exception below holds only for ID-based resumes.
return (
(sessionHost ?? LOCAL_EXECUTION_HOST_ID) === targetHost &&
!isWslUncPath(args.sessionFilePath ?? '')
)
}
if (sessionHost && targetHost && sessionHost === targetHost) {
return true
}
// Why: SSH-to-local-WSL setups (#6270) scan the WSL home from this machine, so the row is
// stamped local, but its WSL UNC transcript is what the SSH shell into that distro reads.
// Any other host-local transcript does not exist on the remote filesystem.
return (
(sessionHost ?? LOCAL_EXECUTION_HOST_ID) === LOCAL_EXECUTION_HOST_ID &&
Boolean(args.sessionFilePath && isWslUncPath(args.sessionFilePath))
)
}