diff --git a/src/main/ai-vault/remote-session-scanner-file-stat.ts b/src/main/ai-vault/remote-session-scanner-file-stat.ts new file mode 100644 index 00000000000..f1ab37a29e6 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-file-stat.ts @@ -0,0 +1,34 @@ +import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { FileStat, IFilesystemProvider } from '../providers/types' +import type { FileWithMtime } from './session-scanner-types' +import { errorMessage } from './session-scanner-values' + +export async function statRemoteFile( + provider: IFilesystemProvider, + path: string, + agent: AiVaultAgent, + executionHostId: ExecutionHostId, + issues: AiVaultScanIssue[] +): Promise { + try { + const stat = await provider.stat(path) + const mtimeMs = remoteStatMtimeMs(stat) + return { + path, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString(), + sizeBytes: Number.isFinite(stat.size) ? stat.size : undefined + } + } catch (err) { + issues.push({ executionHostId, agent, path, message: errorMessage(err) }) + return null + } +} + +function remoteStatMtimeMs(stat: FileStat): number { + if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) { + return stat.mtimeMs + } + return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000 +} diff --git a/src/main/ai-vault/remote-session-scanner-parse-cache.test.ts b/src/main/ai-vault/remote-session-scanner-parse-cache.test.ts index fec516d4e3c..849c282c8ba 100644 --- a/src/main/ai-vault/remote-session-scanner-parse-cache.test.ts +++ b/src/main/ai-vault/remote-session-scanner-parse-cache.test.ts @@ -20,12 +20,17 @@ const CLAUDE_ONE = '/home/ada/.claude/projects/repo/one.jsonl' const CLAUDE_TWO = '/home/ada/.claude/projects/repo/two.jsonl' const CODEX_A = '/home/ada/.codex/sessions/codex-a.jsonl' -function scan(provider: MemoryRemoteProvider, executionHostId: ExecutionHostId = 'ssh:dev-box') { +function scan( + provider: MemoryRemoteProvider, + executionHostId: ExecutionHostId = 'ssh:dev-box', + connectionIdentity: string | null = null +) { return scanRemoteAiVaultSessions({ provider, executionHostId, remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') + hostPlatform: getRemoteHostPlatform('linux-x64'), + connectionIdentity }) } @@ -150,25 +155,63 @@ describe('remote session parse cache', () => { expect(provider.readFileCalls).toEqual([CLAUDE_ONE]) }) + it('drops entries when the same target id points at a different machine', async () => { + // Same target id, same path, same (mtime,size) — only the machine behind + // the connection changed (ssh:updateTarget / config import / reprovision). + const content = claudeTranscript({ sessionId: 'one', title: 'Machine A session' }) + const machineA = new MemoryRemoteProvider() + machineA.addFile(CLAUDE_ONE, content, 10) + await scan(machineA, 'ssh:dev-box', 'identity-a') + machineA.clearReadFileCalls() + await scan(machineA, 'ssh:dev-box', 'identity-a') + expect(machineA.readFileCalls).toEqual([]) + + const machineB = new MemoryRemoteProvider() + const contentB = claudeTranscript({ sessionId: 'one', title: 'Machine B session' }) + // Equal size + equal mtime: only the identity gate can force the re-read. + expect(contentB.length).toBe(content.length) + machineB.addFile(CLAUDE_ONE, contentB, 10) + const repointed = await scan(machineB, 'ssh:dev-box', 'identity-b') + expect(machineB.readFileCalls).toEqual([CLAUDE_ONE]) + expect(repointed.sessions[0]?.title).toBe('Machine B session') + + // The new identity now caches normally. + machineB.clearReadFileCalls() + await scan(machineB, 'ssh:dev-box', 'identity-b') + expect(machineB.readFileCalls).toEqual([]) + }) + it('bounds entries per host, evicting least recently used first', () => { - storeRemoteSessionParse('ssh:host-b', fileAt('/other-host'), null) + storeRemoteSessionParse('ssh:host-b', null, fileAt('/other-host'), null) for (let index = 0; index < REMOTE_PARSE_CACHE_MAX_ENTRIES_PER_HOST; index++) { - storeRemoteSessionParse('ssh:host-a', fileAt(`/f${index}`), null) + storeRemoteSessionParse('ssh:host-a', null, fileAt(`/f${index}`), null) } // Reading /f0 refreshes its recency, so the overflow evicts /f1 instead. - expect(readCachedRemoteSessionParse('ssh:host-a', fileAt('/f0'))).not.toBeNull() - storeRemoteSessionParse('ssh:host-a', fileAt('/overflow'), null) + expect(readCachedRemoteSessionParse('ssh:host-a', null, fileAt('/f0'))).not.toBeNull() + storeRemoteSessionParse('ssh:host-a', null, fileAt('/overflow'), null) - expect(readCachedRemoteSessionParse('ssh:host-a', fileAt('/f1'))).toBeNull() - expect(readCachedRemoteSessionParse('ssh:host-a', fileAt('/f0'))).not.toBeNull() - expect(readCachedRemoteSessionParse('ssh:host-a', fileAt('/overflow'))).not.toBeNull() - expect(readCachedRemoteSessionParse('ssh:host-b', fileAt('/other-host'))).not.toBeNull() + expect(readCachedRemoteSessionParse('ssh:host-a', null, fileAt('/f1'))).toBeNull() + expect(readCachedRemoteSessionParse('ssh:host-a', null, fileAt('/f0'))).not.toBeNull() + expect(readCachedRemoteSessionParse('ssh:host-a', null, fileAt('/overflow'))).not.toBeNull() + expect(readCachedRemoteSessionParse('ssh:host-b', null, fileAt('/other-host'))).not.toBeNull() }) it('misses when the stat changed and when the host was never scanned', () => { - storeRemoteSessionParse('ssh:host-a', fileAt('/f', 1), null) - expect(readCachedRemoteSessionParse('ssh:host-a', fileAt('/f', 2))).toBeNull() - expect(readCachedRemoteSessionParse('ssh:host-c', fileAt('/f', 1))).toBeNull() + storeRemoteSessionParse('ssh:host-a', null, fileAt('/f', 1), null) + expect(readCachedRemoteSessionParse('ssh:host-a', null, fileAt('/f', 2))).toBeNull() + expect(readCachedRemoteSessionParse('ssh:host-c', null, fileAt('/f', 1))).toBeNull() + }) + + it('only serves entries whose connection identity matches exactly', () => { + storeRemoteSessionParse('ssh:host-a', 'identity-a', fileAt('/f'), null) + expect(readCachedRemoteSessionParse('ssh:host-a', 'identity-b', fileAt('/f'))).toBeNull() + expect(readCachedRemoteSessionParse('ssh:host-a', null, fileAt('/f'))).toBeNull() + expect(readCachedRemoteSessionParse('ssh:host-a', 'identity-a', fileAt('/f'))).not.toBeNull() + + // Storing under a new identity resets the host, so switching back misses. + storeRemoteSessionParse('ssh:host-a', 'identity-b', fileAt('/g'), null) + expect(readCachedRemoteSessionParse('ssh:host-a', 'identity-a', fileAt('/f'))).toBeNull() + expect(readCachedRemoteSessionParse('ssh:host-a', 'identity-b', fileAt('/g'))).not.toBeNull() }) }) diff --git a/src/main/ai-vault/remote-session-scanner-parse-cache.ts b/src/main/ai-vault/remote-session-scanner-parse-cache.ts index e8e400fdd72..b08d304e72d 100644 --- a/src/main/ai-vault/remote-session-scanner-parse-cache.ts +++ b/src/main/ai-vault/remote-session-scanner-parse-cache.ts @@ -13,27 +13,42 @@ type RemoteSessionParseCacheEntry = { session: AiVaultSession | null } +type RemoteHostParseCache = { + // Opaque connect-time machine identity supplied by the scan. A target id + // can be repointed at a different machine (ssh:updateTarget, ssh-config + // import, runtime reprovision) without being removed, so entries are only + // valid while the scan talks to the machine they were read from. + connectionIdentity: string | null + entries: Map +} + export type RemoteSessionParseCacheHit = { session: AiVaultSession | null } // Keyed by execution host so identical remote paths on different hosts can -// never serve each other's sessions. Reconnects keep entries (paths and -// mtimes stay valid); target removal evicts via evictRemoteSessionParseCache. -const cacheByHost = new Map>() +// never serve each other's sessions. Reconnects to the same machine keep +// entries (paths and mtimes stay valid); an identity change resets the host's +// entries and target removal evicts via evictRemoteSessionParseCache. +const cacheByHost = new Map() /** - * Return the parse result cached for this remote file when its (mtime, size) - * stat is unchanged, refreshing its LRU recency. A hit means the rescan skips - * the SSH body transfer and re-parse entirely; `null` means read + parse. + * Return the parse result cached for this remote file when the host's + * connection identity matches and the (mtime, size) stat is unchanged, + * refreshing its LRU recency. A hit means the rescan skips the SSH body + * transfer and re-parse entirely; `null` means read + parse. */ export function readCachedRemoteSessionParse( executionHostId: ExecutionHostId, + connectionIdentity: string | null, file: FileWithMtime ): RemoteSessionParseCacheHit | null { const hostCache = cacheByHost.get(executionHostId) - const entry = hostCache?.get(file.path) - if (!hostCache || !entry) { + if (!hostCache || hostCache.connectionIdentity !== connectionIdentity) { + return null + } + const entry = hostCache.entries.get(file.path) + if (!entry) { return null } const unchanged = @@ -42,31 +57,34 @@ export function readCachedRemoteSessionParse( if (!unchanged) { return null } - hostCache.delete(file.path) - hostCache.set(file.path, entry) + hostCache.entries.delete(file.path) + hostCache.entries.set(file.path, entry) return { session: entry.session } } export function storeRemoteSessionParse( executionHostId: ExecutionHostId, + connectionIdentity: string | null, file: FileWithMtime, session: AiVaultSession | null ): void { let hostCache = cacheByHost.get(executionHostId) - if (!hostCache) { - hostCache = new Map() + if (!hostCache || hostCache.connectionIdentity !== connectionIdentity) { + // A different machine now answers for this host id: entries read from the + // previous machine must never be served again, so drop them wholesale. + hostCache = { connectionIdentity, entries: new Map() } cacheByHost.set(executionHostId, hostCache) } - hostCache.delete(file.path) - hostCache.set(file.path, { + hostCache.entries.delete(file.path) + hostCache.entries.set(file.path, { mtimeMs: file.mtimeMs, sizeBytes: file.sizeBytes ?? null, session }) - if (hostCache.size > REMOTE_PARSE_CACHE_MAX_ENTRIES_PER_HOST) { - const oldest = hostCache.keys().next() + if (hostCache.entries.size > REMOTE_PARSE_CACHE_MAX_ENTRIES_PER_HOST) { + const oldest = hostCache.entries.keys().next() if (!oldest.done) { - hostCache.delete(oldest.value) + hostCache.entries.delete(oldest.value) } } } diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts index d5a4b2f1acf..51a961a51de 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -7,6 +7,9 @@ import type { FileWithMtime } from './session-scanner-types' export type RemoteScannerContext = { provider: IFilesystemProvider executionHostId: ExecutionHostId + // Connect-time machine identity for the parse cache; entries cached under a + // different identity (a repointed target id) are never served. + connectionIdentity: string | null hostPlatform: RemoteHostPlatform titleCaches: Map>> } diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index da63a62c810..e18e3c6276a 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -1,18 +1,18 @@ import { extname } from 'node:path' import type { - AiVaultAgent, AiVaultListResult, AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import type { ExecutionHostId } from '../../shared/execution-host' -import type { FileStat, IFilesystemProvider } from '../providers/types' +import type { IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import { joinRemotePath } from '../ssh/ssh-remote-platform' import { sessionSortTime } from './session-scanner-accumulator' import type { FileWithMtime } from './session-scanner-types' import { errorMessage } from './session-scanner-values' +import { statRemoteFile } from './remote-session-scanner-file-stat' import { readCachedRemoteSessionParse, storeRemoteSessionParse @@ -33,6 +33,7 @@ export async function scanRemoteAiVaultSessions(args: { executionHostId: ExecutionHostId remoteHome: string hostPlatform: RemoteHostPlatform + connectionIdentity?: string | null limit?: number scopePaths?: readonly string[] }): Promise { @@ -41,6 +42,7 @@ export async function scanRemoteAiVaultSessions(args: { const context: RemoteScannerContext = { provider: args.provider, executionHostId: args.executionHostId, + connectionIdentity: args.connectionIdentity ?? null, hostPlatform: args.hostPlatform, titleCaches: new Map() } @@ -201,18 +203,32 @@ async function parseRemoteSessionCandidate( // A (mtime,size)-unchanged file skips the SSH body transfer and re-parse // entirely; rescans previously re-read every candidate in full. Like the // local cache, an unchanged Codex transcript keeps its cached index title. - const cached = readCachedRemoteSessionParse(context.executionHostId, candidate.file) + const cached = readCachedRemoteSessionParse( + context.executionHostId, + context.connectionIdentity, + candidate.file + ) if (cached) { return cached.session } try { const read = await context.provider.readFile(candidate.file.path) if (read.isBinary) { - storeRemoteSessionParse(context.executionHostId, candidate.file, null) + storeRemoteSessionParse( + context.executionHostId, + context.connectionIdentity, + candidate.file, + null + ) return null } const session = await candidate.source.parse(candidate.file, read.content, context) - storeRemoteSessionParse(context.executionHostId, candidate.file, session) + storeRemoteSessionParse( + context.executionHostId, + context.connectionIdentity, + candidate.file, + session + ) return session } catch (err) { // Failures stay uncached so the issue resurfaces on every scan and a @@ -253,35 +269,6 @@ function normalizeRemoteScopePaths(scopePaths: readonly string[]): string[] { return scopePaths.map((scopePath) => scopePath.trim()).filter(Boolean) } -async function statRemoteFile( - provider: IFilesystemProvider, - path: string, - agent: AiVaultAgent, - executionHostId: ExecutionHostId, - issues: AiVaultScanIssue[] -): Promise { - try { - const stat = await provider.stat(path) - const mtimeMs = remoteStatMtimeMs(stat) - return { - path, - mtimeMs, - modifiedAt: new Date(mtimeMs).toISOString(), - sizeBytes: Number.isFinite(stat.size) ? stat.size : undefined - } - } catch (err) { - issues.push({ executionHostId, agent, path, message: errorMessage(err) }) - return null - } -} - -function remoteStatMtimeMs(stat: FileStat): number { - if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) { - return stat.mtimeMs - } - return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000 -} - function canStopParsingRemoteSessions( sessions: AiVaultSession[], limit: number, diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index 9cc8060eee2..a67450df31f 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -83,6 +83,7 @@ describe('listAiVaultSessions host routing', () => { provider, executionHostId: 'ssh:dev-box', remoteHome: '/home/ada', + connectionIdentity: 'identity-dev-box', scopePaths: ['/home/ada/repo'] }) ) @@ -128,7 +129,8 @@ function hostInfo(targetId: string) { targetId, executionHostId: `ssh:${targetId}` as const, remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') + hostPlatform: getRemoteHostPlatform('linux-x64'), + connectionIdentity: `identity-${targetId}` } } diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 4551379afde..bfe01d6bc31 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -151,6 +151,7 @@ async function scanSshAiVaultSessions( executionHostId: hostInfo.executionHostId, remoteHome: hostInfo.remoteHome, hostPlatform: hostInfo.hostPlatform, + connectionIdentity: hostInfo.connectionIdentity, limit: args?.limit, scopePaths: args?.scopePaths }) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 2462bbd94c3..f7f5cbac816 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -61,7 +61,8 @@ import { type DetectedPort, MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, MIN_SSH_RELAY_GRACE_PERIOD_SECONDS, - SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD + SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, + type SshTarget } from '../../shared/ssh-types' import type { Store } from '../persistence' import type { OrcaRuntimeService } from '../runtime/orca-runtime' @@ -113,6 +114,25 @@ export type SshRelayAiVaultHostInfo = { executionHostId: ExecutionHostId remoteHome: string hostPlatform: RemoteHostPlatform + /** Opaque connect-time machine identity; null while no connection is live. */ + connectionIdentity: string | null +} + +// Identifies the machine an AI-vault scan actually reads from: the target +// snapshot the live connection dialed. Store edits (ssh:updateTarget, config +// import, runtime reprovision) don't take effect until reconnect, so the +// stored target may describe a different machine than the active provider. +// Auth-only fields (identityFile/agent/identitiesOnly) are excluded — they +// change how we authenticate, not whose files we read. +function aiVaultConnectionIdentity(target: SshTarget): string { + return JSON.stringify([ + target.configHost ?? null, + target.host, + target.port, + target.username, + target.proxyCommand ?? null, + target.jumpHost ?? null + ]) } const RECONNECT_REPLAY_DUPLICATE_WINDOW_MS = 1000 @@ -230,11 +250,13 @@ export class SshRelaySession { if (!env) { return null } + const connectionTarget = this.currentConnection?.getTarget() ?? null return { targetId: this.targetId, executionHostId: toSshExecutionHostId(this.targetId), remoteHome: env.remoteHome, - hostPlatform: env.hostPlatform + hostPlatform: env.hostPlatform, + connectionIdentity: connectionTarget ? aiVaultConnectionIdentity(connectionTarget) : null } }