From abdee604aaf7f9c414c26ea3a450a76882d7bbe1 Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Wed, 8 Jul 2026 16:57:14 -0700 Subject: [PATCH] fix(ai-vault): drop remote parse cache when a target id is repointed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ssh:updateTarget, ~/.ssh/config re-import, and runtime reprovision can all point an existing target id at a different machine without removing it, so host-id + (mtime,size) alone could serve the previous machine's sessions. Tag each host's cache with an opaque connect-time connection identity (host/port/username/configHost/proxy/jump from the live connection's target snapshot) and reset the host's entries on mismatch. The identity comes from the connection rather than the store because edits only take effect at reconnect — the provider keeps reading the old machine until then, and evicting at edit time would let interim scans re-poison the cache. Also split statRemoteFile into remote-session-scanner-file-stat.ts to stay under max-lines. Co-authored-by: Orca --- .../remote-session-scanner-file-stat.ts | 34 +++++++++ ...remote-session-scanner-parse-cache.test.ts | 69 +++++++++++++++---- .../remote-session-scanner-parse-cache.ts | 52 +++++++++----- .../ai-vault/remote-session-scanner-types.ts | 3 + src/main/ai-vault/remote-session-scanner.ts | 55 ++++++--------- src/main/ipc/ai-vault.test.ts | 4 +- src/main/ipc/ai-vault.ts | 1 + src/main/ssh/ssh-relay-session.ts | 26 ++++++- 8 files changed, 177 insertions(+), 67 deletions(-) create mode 100644 src/main/ai-vault/remote-session-scanner-file-stat.ts 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 } }