mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix: block resuming host-stored AI Vault sessions into SSH workspaces (#7130)
* fix: block resuming host-stored AI Vault sessions into SSH workspaces AI Vault scans session history from this machine's disk only (host home dirs plus local WSL homes), but since #6685 resume actions accepted any SSH workspace as a target. Resuming a host-stored session there queues cd '<local cwd>' && <agent> --resume <id> into a remote shell where neither the cwd nor the session file exists, so it always fails. Gate resume targets on where the session file lives: local targets stay unrestricted, SSH targets now require a WSL-stored session (the SSH-to-local-WSL setup #6685 was built for). Applies to the sidebar resume buttons, direct resume, and pane drag-and-drop, with a toast explaining the host mismatch. Catalog sync also picked up two pre-existing drifts (missing failedUnnestWorkspace fallback, two unreferenced SSH-timeout keys). * i18n: translate host-session SSH-resume block message (es/ja/ko/zh) Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -210,6 +210,7 @@ export default function AiVaultPanel(): React.JSX.Element {
|
||||
const getSessionResumeState = useCallback(
|
||||
(session: AiVaultSession) =>
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: session.filePath,
|
||||
worktreeInfo: sessionWorktreeById.get(session.id) ?? null,
|
||||
activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null,
|
||||
worktrees: allWorktrees,
|
||||
@@ -229,6 +230,7 @@ export default function AiVaultPanel(): React.JSX.Element {
|
||||
const getSessionResumeActions = useCallback(
|
||||
(session: AiVaultSession) =>
|
||||
resolveAiVaultSessionResumeActions({
|
||||
sessionFilePath: session.filePath,
|
||||
worktreeInfo: sessionWorktreeById.get(session.id) ?? null,
|
||||
activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null,
|
||||
worktrees: allWorktrees,
|
||||
|
||||
@@ -101,12 +101,13 @@ export function VaultSessionRow({
|
||||
sessionId: session.sessionId,
|
||||
title: session.title,
|
||||
command: resumeStartup.command,
|
||||
sessionFilePath: session.filePath,
|
||||
...(resumeStartup.env ? { env: resumeStartup.env } : {}),
|
||||
...(resumeStartup.launchConfig ? { launchConfig: resumeStartup.launchConfig } : {})
|
||||
})
|
||||
window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_START_EVENT))
|
||||
},
|
||||
[resumeDisabled, session.agent, session.sessionId, session.title, resumeStartup]
|
||||
[resumeDisabled, session, resumeStartup]
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
} from '@/lib/worktree-activation'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
getAiVaultResumeWorkspaceTargetStatus,
|
||||
isSupportedAiVaultResumeTargetStatus
|
||||
canResumeAiVaultSessionOnTarget,
|
||||
getAiVaultResumeWorkspaceTargetStatus
|
||||
} from '@/lib/ai-vault-resume-target'
|
||||
import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
@@ -78,6 +78,7 @@ export function useAiVaultSessionLaunchActions({
|
||||
const handleResume = useCallback(
|
||||
(session: AiVaultSession, targetWorktreeId?: string): void => {
|
||||
const targetId = resolveAiVaultSessionLaunchTarget({
|
||||
sessionFilePath: session.filePath,
|
||||
activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null,
|
||||
targetWorktreeId,
|
||||
targetState
|
||||
@@ -93,17 +94,7 @@ export function useAiVaultSessionLaunchActions({
|
||||
}
|
||||
|
||||
if (targetId.status === 'unsupported') {
|
||||
toast.error(
|
||||
targetId.targetStatus === 'runtime'
|
||||
? translate(
|
||||
'auto.components.right.sidebar.AiVaultPanel.runtimeWorkspacesUnsupported',
|
||||
'Resume from history is not available in runtime-hosted workspaces.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.right.sidebar.AiVaultPanel.openSupportedWorkspace',
|
||||
'Open a local or SSH workspace before resuming a session.'
|
||||
)
|
||||
)
|
||||
toast.error(aiVaultResumeUnsupportedMessage(targetId.targetStatus))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -138,6 +129,7 @@ export type AiVaultSessionLaunchTarget =
|
||||
| { status: 'ready'; worktreeId: string }
|
||||
|
||||
export function resolveAiVaultSessionLaunchTarget(args: {
|
||||
sessionFilePath: string | null
|
||||
activeWorktreeId: string | null
|
||||
targetWorktreeId?: string
|
||||
targetState: AiVaultSessionResumeTargetState
|
||||
@@ -151,13 +143,36 @@ export function resolveAiVaultSessionLaunchTarget(args: {
|
||||
}
|
||||
|
||||
const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, targetWorktreeId)
|
||||
if (!isSupportedAiVaultResumeTargetStatus(targetStatus)) {
|
||||
if (!canResumeAiVaultSessionOnTarget({ sessionFilePath: args.sessionFilePath, targetStatus })) {
|
||||
return { status: 'unsupported', targetStatus }
|
||||
}
|
||||
|
||||
return { status: 'ready', worktreeId: targetWorktreeId }
|
||||
}
|
||||
|
||||
function aiVaultResumeUnsupportedMessage(
|
||||
targetStatus: ReturnType<typeof getAiVaultResumeWorkspaceTargetStatus>
|
||||
): string {
|
||||
if (targetStatus === 'runtime') {
|
||||
return translate(
|
||||
'auto.components.right.sidebar.AiVaultPanel.runtimeWorkspacesUnsupported',
|
||||
'Resume from history is not available in runtime-hosted workspaces.'
|
||||
)
|
||||
}
|
||||
// Why: 'ssh' only reaches the unsupported branch when the session file lives
|
||||
// on this machine, so the message explains the host mismatch, not SSH itself.
|
||||
if (targetStatus === 'ssh') {
|
||||
return translate(
|
||||
'auto.components.right.sidebar.AiVaultPanel.localSessionSshWorkspaceUnsupported',
|
||||
"This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead."
|
||||
)
|
||||
}
|
||||
return translate(
|
||||
'auto.components.right.sidebar.AiVaultPanel.openSupportedWorkspace',
|
||||
'Open a local or SSH workspace before resuming a session.'
|
||||
)
|
||||
}
|
||||
|
||||
function activateAiVaultResumeWorkspace(workspaceId: string): void {
|
||||
const workspaceScope = parseWorkspaceKey(workspaceId)
|
||||
if (workspaceScope?.type === 'folder') {
|
||||
|
||||
@@ -106,10 +106,14 @@ function makeWorktreeInfo(
|
||||
}
|
||||
}
|
||||
|
||||
const HOST_SESSION_FILE = '/Users/ada/.claude/projects/-repo-orca/session-1.jsonl'
|
||||
const WSL_SESSION_FILE = '\\\\wsl$\\Ubuntu\\home\\ada\\.claude\\projects\\-repo-orca\\session-1.jsonl'
|
||||
|
||||
describe('resolveAiVaultSessionResumeState', () => {
|
||||
it('prefers the session worktree over the active workspace', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: 'repo-1::/repo/other',
|
||||
worktrees: [
|
||||
@@ -128,6 +132,7 @@ describe('resolveAiVaultSessionResumeState', () => {
|
||||
it('falls back to the active workspace when the session worktree is unavailable', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('archived'),
|
||||
activeWorktreeId: 'repo-1::/repo/orca',
|
||||
worktrees: [makeWorktree()],
|
||||
@@ -143,6 +148,7 @@ describe('resolveAiVaultSessionResumeState', () => {
|
||||
it('blocks missing worktrees even when the repo is SSH-owned', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: null,
|
||||
worktrees: [],
|
||||
@@ -158,6 +164,7 @@ describe('resolveAiVaultSessionResumeState', () => {
|
||||
it('allows SSH-owned session worktrees', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: WSL_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: null,
|
||||
worktrees: [makeWorktree()],
|
||||
@@ -173,6 +180,7 @@ describe('resolveAiVaultSessionResumeState', () => {
|
||||
it('allows SSH-stamped worktrees even when the repo owner is runtime', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: WSL_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: null,
|
||||
worktrees: [makeWorktree({ hostId: 'ssh:ssh-1' })],
|
||||
@@ -188,6 +196,7 @@ describe('resolveAiVaultSessionResumeState', () => {
|
||||
it('blocks runtime-owned targets even when they have no SSH connection', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: null,
|
||||
worktrees: [makeWorktree()],
|
||||
@@ -208,6 +217,7 @@ describe('resolveAiVaultSessionResumeState', () => {
|
||||
it('blocks runtime-stamped worktrees even when the repo owner is local', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: null,
|
||||
worktrees: [makeWorktree({ hostId: 'runtime:env-1' })],
|
||||
@@ -223,6 +233,7 @@ describe('resolveAiVaultSessionResumeState', () => {
|
||||
it('prefers the session worktree when the active workspace is SSH-owned', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: 'repo-2::/remote/orca',
|
||||
worktrees: [
|
||||
@@ -245,6 +256,7 @@ describe('resolveAiVaultSessionResumeState', () => {
|
||||
it('falls back to the active workspace when the session worktree is runtime-owned', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: 'repo-2::/repo/other',
|
||||
worktrees: [
|
||||
@@ -275,6 +287,7 @@ describe('resolveAiVaultSessionResumeState', () => {
|
||||
it('falls back to an active SSH folder workspace', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: WSL_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('archived'),
|
||||
activeWorktreeId: folderWorkspaceKey('folder-1'),
|
||||
worktrees: [],
|
||||
@@ -288,9 +301,27 @@ describe('resolveAiVaultSessionResumeState', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks host-stored sessions when only an SSH workspace is open', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('archived'),
|
||||
activeWorktreeId: folderWorkspaceKey('folder-1'),
|
||||
worktrees: [],
|
||||
repos: [],
|
||||
targetState: makeFolderTargetState({ id: 'group-1', connectionId: 'ssh-1' })
|
||||
})
|
||||
).toEqual({
|
||||
blocked: true,
|
||||
worktreeId: null,
|
||||
usesSessionWorktree: false
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks an active runtime folder workspace', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeState({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('archived'),
|
||||
activeWorktreeId: folderWorkspaceKey('folder-1'),
|
||||
worktrees: [],
|
||||
@@ -309,6 +340,7 @@ describe('resolveAiVaultSessionResumeActions', () => {
|
||||
it('exposes separate session-worktree and active-workspace targets', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeActions({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: 'repo-1::/repo/other',
|
||||
worktrees: [
|
||||
@@ -326,6 +358,7 @@ describe('resolveAiVaultSessionResumeActions', () => {
|
||||
it('enables an SSH active-workspace action when the session worktree is local', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeActions({
|
||||
sessionFilePath: WSL_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: 'repo-2::/remote/orca',
|
||||
worktrees: [
|
||||
@@ -344,9 +377,32 @@ describe('resolveAiVaultSessionResumeActions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('disables the SSH active-workspace action for host-stored sessions', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeActions({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: 'repo-2::/remote/orca',
|
||||
worktrees: [
|
||||
makeWorktree(),
|
||||
makeWorktree({
|
||||
id: 'repo-2::/remote/orca',
|
||||
repoId: 'repo-2',
|
||||
path: '/remote/orca'
|
||||
})
|
||||
],
|
||||
repos: [{ id: 'repo-1' } as Repo, { id: 'repo-2', connectionId: 'ssh-1' } as Repo]
|
||||
})
|
||||
).toEqual({
|
||||
worktree: { worktreeId: 'repo-1::/repo/orca', disabled: false },
|
||||
newTab: { worktreeId: 'repo-2::/remote/orca', disabled: true }
|
||||
})
|
||||
})
|
||||
|
||||
it('disables runtime-owned targets without disabling local targets', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeActions({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('active'),
|
||||
activeWorktreeId: 'repo-2::/repo/other',
|
||||
worktrees: [
|
||||
@@ -376,6 +432,7 @@ describe('resolveAiVaultSessionResumeActions', () => {
|
||||
it('does not expose the active workspace as a duplicate new-tab target', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeActions({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('current'),
|
||||
activeWorktreeId: 'repo-1::/repo/orca',
|
||||
worktrees: [makeWorktree()],
|
||||
@@ -390,6 +447,7 @@ describe('resolveAiVaultSessionResumeActions', () => {
|
||||
it('enables the active folder workspace action when it is local or SSH-owned', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeActions({
|
||||
sessionFilePath: WSL_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('archived'),
|
||||
activeWorktreeId: folderWorkspaceKey('folder-1'),
|
||||
worktrees: [],
|
||||
@@ -405,6 +463,7 @@ describe('resolveAiVaultSessionResumeActions', () => {
|
||||
it('disables the active folder workspace action when it is runtime-owned', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionResumeActions({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
worktreeInfo: makeWorktreeInfo('archived'),
|
||||
activeWorktreeId: folderWorkspaceKey('folder-1'),
|
||||
worktrees: [],
|
||||
@@ -422,6 +481,7 @@ describe('resolveAiVaultSessionLaunchTarget', () => {
|
||||
it('allows direct resume into an active SSH folder workspace', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionLaunchTarget({
|
||||
sessionFilePath: WSL_SESSION_FILE,
|
||||
activeWorktreeId: folderWorkspaceKey('folder-1'),
|
||||
targetState: makeFolderTargetState({ id: 'group-1', connectionId: 'ssh-1' })
|
||||
})
|
||||
@@ -431,9 +491,23 @@ describe('resolveAiVaultSessionLaunchTarget', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks direct resume of a host-stored session into an SSH folder workspace', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionLaunchTarget({
|
||||
sessionFilePath: HOST_SESSION_FILE,
|
||||
activeWorktreeId: folderWorkspaceKey('folder-1'),
|
||||
targetState: makeFolderTargetState({ id: 'group-1', connectionId: 'ssh-1' })
|
||||
})
|
||||
).toEqual({
|
||||
status: 'unsupported',
|
||||
targetStatus: 'ssh'
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks direct resume into a runtime folder workspace', () => {
|
||||
expect(
|
||||
resolveAiVaultSessionLaunchTarget({
|
||||
sessionFilePath: WSL_SESSION_FILE,
|
||||
activeWorktreeId: folderWorkspaceKey('folder-1'),
|
||||
targetState: makeFolderTargetState({ id: 'group-1', executionHostId: 'runtime:env-1' })
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import {
|
||||
getAiVaultResumeWorkspaceTargetStatus,
|
||||
isSupportedAiVaultResumeTargetStatus
|
||||
canResumeAiVaultSessionOnTarget,
|
||||
getAiVaultResumeWorkspaceTargetStatus
|
||||
} from '@/lib/ai-vault-resume-target'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -33,6 +33,7 @@ export type AiVaultSessionResumeActions = {
|
||||
}
|
||||
|
||||
export function resolveAiVaultSessionResumeState(args: {
|
||||
sessionFilePath: string | null
|
||||
worktreeInfo: AiVaultSessionWorktreeInfo | null
|
||||
activeWorktreeId: string | null
|
||||
worktrees: readonly Worktree[]
|
||||
@@ -54,6 +55,7 @@ export function resolveAiVaultSessionResumeState(args: {
|
||||
|
||||
for (const worktreeId of candidateWorktreeIds) {
|
||||
const targetId = resolveSupportedResumeWorktreeId({
|
||||
sessionFilePath: args.sessionFilePath,
|
||||
worktreeId,
|
||||
targetState
|
||||
})
|
||||
@@ -75,6 +77,7 @@ export function resolveAiVaultSessionResumeState(args: {
|
||||
}
|
||||
|
||||
export function resolveAiVaultSessionResumeActions(args: {
|
||||
sessionFilePath: string | null
|
||||
worktreeInfo: AiVaultSessionWorktreeInfo | null
|
||||
activeWorktreeId: string | null
|
||||
worktrees: readonly Worktree[]
|
||||
@@ -88,10 +91,12 @@ export function resolveAiVaultSessionResumeActions(args: {
|
||||
const targetState = resolveAiVaultResumeTargetState(args)
|
||||
|
||||
const sessionTargetId = resolveSupportedResumeWorktreeId({
|
||||
sessionFilePath: args.sessionFilePath,
|
||||
worktreeId: sessionWorktreeId,
|
||||
targetState
|
||||
})
|
||||
const activeTargetId = resolveSupportedResumeWorktreeId({
|
||||
sessionFilePath: args.sessionFilePath,
|
||||
worktreeId:
|
||||
args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId
|
||||
? args.activeWorktreeId
|
||||
@@ -136,6 +141,7 @@ export function isKnownAiVaultResumeWorkspaceTarget(
|
||||
}
|
||||
|
||||
function resolveSupportedResumeWorktreeId(args: {
|
||||
sessionFilePath: string | null
|
||||
worktreeId: string | null
|
||||
targetState: AiVaultSessionResumeTargetState
|
||||
}): string | null {
|
||||
@@ -148,7 +154,7 @@ function resolveSupportedResumeWorktreeId(args: {
|
||||
}
|
||||
|
||||
const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, args.worktreeId)
|
||||
if (!isSupportedAiVaultResumeTargetStatus(targetStatus)) {
|
||||
if (!canResumeAiVaultSessionOnTarget({ sessionFilePath: args.sessionFilePath, targetStatus })) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target'
|
||||
import {
|
||||
canResumeAiVaultSessionOnTarget,
|
||||
getAiVaultResumeWorkspaceTargetStatus
|
||||
} from '@/lib/ai-vault-resume-target'
|
||||
import {
|
||||
AI_VAULT_SESSION_DRAG_END_EVENT,
|
||||
AI_VAULT_SESSION_DRAG_START_EVENT,
|
||||
@@ -185,6 +188,20 @@ export default function AiVaultSessionDropLayer({
|
||||
)
|
||||
return true
|
||||
}
|
||||
if (
|
||||
!canResumeAiVaultSessionOnTarget({
|
||||
sessionFilePath: payload.sessionFilePath ?? null,
|
||||
targetStatus
|
||||
})
|
||||
) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.tab.group.AiVaultSessionDropLayer.localSessionSshWorkspaceUnsupported',
|
||||
"This session's history is stored on this machine, so it can't resume in an SSH workspace. Drop it onto a local workspace instead."
|
||||
)
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
launchAiVaultSessionInNewTab({
|
||||
agent: payload.agent,
|
||||
|
||||
@@ -2586,7 +2586,8 @@
|
||||
"openLocalWorkspace": "Open a local workspace before resuming a session.",
|
||||
"sessionQueued": "Session queued",
|
||||
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session."
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
|
||||
"localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Drop it onto a local workspace instead."
|
||||
},
|
||||
"TabGroupDropOverlay": {
|
||||
"paneColumnLabel": "New split"
|
||||
@@ -9572,7 +9573,8 @@
|
||||
"worktreeUnavailable": "Worktree is no longer available.",
|
||||
"runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.",
|
||||
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session."
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
|
||||
"localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead."
|
||||
},
|
||||
"AiVaultPanelControls": {
|
||||
"scanningSessions": "Scanning sessions",
|
||||
|
||||
@@ -2586,7 +2586,8 @@
|
||||
"openLocalWorkspace": "Abre un espacio de trabajo local antes de reanudar una sesión.",
|
||||
"sessionQueued": "Sesión en cola",
|
||||
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session."
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
|
||||
"localSessionSshWorkspaceUnsupported": "El historial de esta sesión está almacenado en este equipo, por lo que no se puede reanudar en un espacio de trabajo SSH. En su lugar, suéltala en un espacio de trabajo local."
|
||||
},
|
||||
"TabGroupDropOverlay": {
|
||||
"paneColumnLabel": "Nueva división"
|
||||
@@ -9572,7 +9573,8 @@
|
||||
"worktreeUnavailable": "Worktree is no longer available.",
|
||||
"runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.",
|
||||
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session."
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
|
||||
"localSessionSshWorkspaceUnsupported": "El historial de esta sesión está almacenado en este equipo, por lo que no se puede reanudar en un espacio de trabajo SSH. En su lugar, abre un espacio de trabajo local."
|
||||
},
|
||||
"AiVaultPanelControls": {
|
||||
"scanningSessions": "Scanning sessions",
|
||||
|
||||
@@ -2586,7 +2586,8 @@
|
||||
"openLocalWorkspace": "セッションを再開する前にローカルワークスペースを開いてください。",
|
||||
"sessionQueued": "セッションをキューに追加しました",
|
||||
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session."
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
|
||||
"localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースにドロップしてください。"
|
||||
},
|
||||
"TabGroupDropOverlay": {
|
||||
"paneColumnLabel": "新しい分割"
|
||||
@@ -9572,7 +9573,8 @@
|
||||
"worktreeUnavailable": "ワークツリーは利用できなくなりました。",
|
||||
"runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.",
|
||||
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session."
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
|
||||
"localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースを開いてください。"
|
||||
},
|
||||
"AiVaultPanelControls": {
|
||||
"scanningSessions": "Scanning sessions",
|
||||
|
||||
@@ -2586,7 +2586,8 @@
|
||||
"openLocalWorkspace": "세션을 재개하기 전에 로컬 워크스페이스를 여세요.",
|
||||
"sessionQueued": "세션이 대기열에 추가됨",
|
||||
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session."
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
|
||||
"localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스에 놓으세요."
|
||||
},
|
||||
"TabGroupDropOverlay": {
|
||||
"paneColumnLabel": "새 분할"
|
||||
@@ -9572,7 +9573,8 @@
|
||||
"worktreeUnavailable": "워크트리는 더 이상 사용할 수 없습니다.",
|
||||
"runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.",
|
||||
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session."
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
|
||||
"localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스를 여세요."
|
||||
},
|
||||
"AiVaultPanelControls": {
|
||||
"scanningSessions": "세션 스캔 중",
|
||||
|
||||
@@ -2586,7 +2586,8 @@
|
||||
"openLocalWorkspace": "恢复会话前请先打开一个本地工作区。",
|
||||
"sessionQueued": "会话已排队",
|
||||
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session."
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
|
||||
"localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为将其拖放到本地工作区。"
|
||||
},
|
||||
"TabGroupDropOverlay": {
|
||||
"paneColumnLabel": "新建拆分"
|
||||
@@ -9572,7 +9573,8 @@
|
||||
"worktreeUnavailable": "Worktree is no longer available.",
|
||||
"runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.",
|
||||
"runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.",
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session."
|
||||
"openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.",
|
||||
"localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为打开一个本地工作区。"
|
||||
},
|
||||
"AiVaultPanelControls": {
|
||||
"scanningSessions": "正在扫描会话",
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AppState } from '@/store/types'
|
||||
import {
|
||||
canResumeAiVaultSessionOnTarget,
|
||||
getAiVaultResumeRepoTargetStatus,
|
||||
getAiVaultResumeWorktreeTargetStatus,
|
||||
getAiVaultResumeWorkspaceTargetStatus,
|
||||
isSupportedAiVaultResumeRepo,
|
||||
isUnsupportedAiVaultResumeRepo
|
||||
isUnsupportedAiVaultResumeRepo,
|
||||
isWslStoredAiVaultSessionFile
|
||||
} from './ai-vault-resume-target'
|
||||
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
|
||||
@@ -26,6 +28,56 @@ function makeState(
|
||||
} as unknown as ResumeTargetState
|
||||
}
|
||||
|
||||
describe('ai vault session storage compatibility', () => {
|
||||
const hostSessionFile = '/Users/ada/.claude/projects/-Users-ada-repo/session-1.jsonl'
|
||||
const windowsHostSessionFile = 'C:\\Users\\ada\\.claude\\projects\\C--repo\\session-1.jsonl'
|
||||
const wslSessionFile = '\\\\wsl$\\Ubuntu\\home\\ada\\.claude\\projects\\-home-ada-repo\\s-1.jsonl'
|
||||
const wslLocalhostSessionFile =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\-home-ada-repo\\s-1.jsonl'
|
||||
|
||||
it('detects WSL-stored session files', () => {
|
||||
expect(isWslStoredAiVaultSessionFile(wslSessionFile)).toBe(true)
|
||||
expect(isWslStoredAiVaultSessionFile(wslLocalhostSessionFile)).toBe(true)
|
||||
expect(isWslStoredAiVaultSessionFile(hostSessionFile)).toBe(false)
|
||||
expect(isWslStoredAiVaultSessionFile(windowsHostSessionFile)).toBe(false)
|
||||
expect(isWslStoredAiVaultSessionFile(null)).toBe(false)
|
||||
expect(isWslStoredAiVaultSessionFile(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('allows host-stored sessions on local targets only', () => {
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({ sessionFilePath: hostSessionFile, targetStatus: 'local' })
|
||||
).toBe(true)
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({ sessionFilePath: hostSessionFile, targetStatus: 'ssh' })
|
||||
).toBe(false)
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({
|
||||
sessionFilePath: windowsHostSessionFile,
|
||||
targetStatus: 'ssh'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('allows WSL-stored sessions on local and SSH targets', () => {
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({ sessionFilePath: wslSessionFile, targetStatus: 'local' })
|
||||
).toBe(true)
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({ sessionFilePath: wslSessionFile, targetStatus: 'ssh' })
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('never allows runtime or unknown targets', () => {
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({ sessionFilePath: wslSessionFile, targetStatus: 'runtime' })
|
||||
).toBe(false)
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({ sessionFilePath: hostSessionFile, targetStatus: 'unknown' })
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ai vault resume target ownership', () => {
|
||||
it('classifies local, SSH, runtime, and unknown repo owners', () => {
|
||||
expect(getAiVaultResumeRepoTargetStatus({ connectionId: null, executionHostId: 'local' })).toBe(
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import type { Repo } from '../../../shared/types'
|
||||
import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { isWslUncPath } from '../../../shared/wsl-paths'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { getFolderWorkspaceCandidateRepos } from './folder-workspace-connection'
|
||||
|
||||
@@ -36,6 +37,29 @@ export function isSupportedAiVaultResumeTargetStatus(status: AiVaultResumeTarget
|
||||
return status === 'local' || status === 'ssh'
|
||||
}
|
||||
|
||||
export function isWslStoredAiVaultSessionFile(
|
||||
sessionFilePath: string | null | undefined
|
||||
): boolean {
|
||||
return Boolean(sessionFilePath && isWslUncPath(sessionFilePath))
|
||||
}
|
||||
|
||||
export function canResumeAiVaultSessionOnTarget(args: {
|
||||
sessionFilePath: string | null | undefined
|
||||
targetStatus: AiVaultResumeTargetStatus
|
||||
}): boolean {
|
||||
if (!isSupportedAiVaultResumeTargetStatus(args.targetStatus)) {
|
||||
return false
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
export function isUnsupportedAiVaultResumeRepo(
|
||||
repo: AiVaultResumeRepoOwner | null | undefined
|
||||
): boolean {
|
||||
|
||||
@@ -48,6 +48,7 @@ describe('Session History session drag data', () => {
|
||||
sessionId: 'session-1',
|
||||
title: 'Fix terminal split',
|
||||
command: "cd '/repo' && claude --resume session-1",
|
||||
sessionFilePath: '/Users/ada/.claude/projects/-repo/session-1.jsonl',
|
||||
env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' },
|
||||
launchConfig: {
|
||||
agentCommand: 'claude --dangerously-skip-permissions',
|
||||
@@ -63,6 +64,24 @@ describe('Session History session drag data', () => {
|
||||
expect(readAiVaultSessionDragData(transfer)).toEqual(payload)
|
||||
})
|
||||
|
||||
it('rejects blank session file paths', () => {
|
||||
const transfer = createTransfer()
|
||||
transfer.setData(
|
||||
AI_VAULT_SESSION_DRAG_TYPE,
|
||||
JSON.stringify({
|
||||
kind: 'ai-vault-session',
|
||||
version: 1,
|
||||
agent: 'claude',
|
||||
sessionId: 'session-1',
|
||||
title: 'Blank session file path',
|
||||
command: 'claude --resume session-1',
|
||||
sessionFilePath: ' '
|
||||
})
|
||||
)
|
||||
|
||||
expect(readAiVaultSessionDragData(transfer)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects malformed payloads', () => {
|
||||
const transfer = createTransfer()
|
||||
transfer.setData(
|
||||
|
||||
@@ -12,6 +12,9 @@ export type AiVaultSessionDragPayload = {
|
||||
sessionId: string
|
||||
title: string
|
||||
command: string
|
||||
// Why: drop targets must know where the session file lives (host vs local
|
||||
// WSL) to reject SSH panes that cannot reach it.
|
||||
sessionFilePath?: string
|
||||
// Why: drag/drop resume must preserve planned env/default args, not just the shell command.
|
||||
env?: Record<string, string>
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
@@ -63,6 +66,7 @@ function isSerializedPayload(value: unknown): value is SerializedAiVaultSessionD
|
||||
isNonEmptyString(payload.sessionId) &&
|
||||
isNonEmptyString(payload.title) &&
|
||||
isNonEmptyString(payload.command) &&
|
||||
(payload.sessionFilePath === undefined || isNonEmptyString(payload.sessionFilePath)) &&
|
||||
(payload.env === undefined || isStringRecord(payload.env)) &&
|
||||
(payload.launchConfig === undefined || isLaunchConfig(payload.launchConfig))
|
||||
)
|
||||
@@ -110,12 +114,13 @@ export function readAiVaultSessionDragData(
|
||||
if (!isSerializedPayload(parsed)) {
|
||||
return null
|
||||
}
|
||||
const { agent, sessionId, title, command, env, launchConfig } = parsed
|
||||
const { agent, sessionId, title, command, sessionFilePath, env, launchConfig } = parsed
|
||||
return {
|
||||
agent,
|
||||
sessionId,
|
||||
title,
|
||||
command,
|
||||
...(sessionFilePath ? { sessionFilePath } : {}),
|
||||
...(env ? { env } : {}),
|
||||
...(launchConfig ? { launchConfig } : {})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user