fix(desktop): connectionCacheKey identity includes remote host (fs cache collision)

connectionCacheKey() keyed the desktop-fs cache on mode:profile:baseUrl only.
Local forwarded ports are reusable across different remotes, so two remotes
that map to the same 127.0.0.1:<localPort> (e.g. two SSH hosts whose tunnels
land on the same local port across reconnects) collide — one host's cached
directory listings and file reads get served for the other.

Fold the remote host into the identity: remoteHost (user@host for SSH, the real
backend host for token/oauth), with a baseUrl fallback. This is a latent bug on
main for ANY two remotes sharing a forwarded port, not only SSH — but SSH mode
makes it reachable in normal use.

Adds vitest coverage: two SSH hosts on the same local port get distinct keys;
the no-remoteHost fallback and local key are preserved.

vitest deferred (no node_modules in the worktree on this host); the regression
guard ships with the fix.
This commit is contained in:
yoniebans 2026-06-19 12:02:33 +02:00
parent 7c21034330
commit f0693a3232
2 changed files with 30 additions and 1 deletions

View file

@ -5,6 +5,7 @@ import { $connection } from '@/store/session'
import {
desktopDefaultCwd,
desktopGitRoot,
desktopFsCacheKey,
readDesktopDir,
readDesktopFileDataUrl,
readDesktopFileText,
@ -113,4 +114,25 @@ describe('desktop filesystem facade', () => {
expect(remoteSelect).not.toHaveBeenCalled()
expect(selectPaths).not.toHaveBeenCalled()
})
it('cache key distinguishes two SSH hosts that share the same local forwarded port', () => {
// Both remotes resolve to the same loopback tunnel baseUrl (the local
// forwarded port is reusable across remotes). Without the remoteHost in the
// identity these collide and one host's cached fs reads serve the other.
$connection.set({ mode: 'remote', baseUrl: 'http://127.0.0.1:50001', remoteHost: 'jonny@mac-mini' } as never)
const keyA = desktopFsCacheKey()
$connection.set({ mode: 'remote', baseUrl: 'http://127.0.0.1:50001', remoteHost: 'jonny@ubuntu-box' } as never)
const keyB = desktopFsCacheKey()
expect(keyA).not.toBe(keyB)
expect(keyA).toContain('mac-mini')
expect(keyB).toContain('ubuntu-box')
})
it('cache key falls back to baseUrl when no remoteHost is present', () => {
$connection.set({ mode: 'remote', baseUrl: 'https://box.tail1234.ts.net' } as never)
expect(desktopFsCacheKey()).toContain('box.tail1234.ts.net')
$connection.set(null)
expect(desktopFsCacheKey()).toBe('local:')
})
})

View file

@ -21,7 +21,14 @@ function connectionCacheKey(connection: HermesConnection | null) {
if (!connection) {
return 'local:'
}
return `${connection.mode || 'local'}:${connection.profile || ''}:${connection.baseUrl || ''}`
// The remote host is part of the cache identity, NOT just the baseUrl. Local
// forwarded ports are reusable across different remotes, so two SSH hosts
// that happen to map to the same 127.0.0.1:<localPort> would otherwise
// collide — serving one host's cached fs reads for the other. remoteHost is
// the user@host (SSH) or the real backend host (token/oauth); fall back to
// baseUrl for safety.
const host = connection.remoteHost || connection.baseUrl || ''
return `${connection.mode || 'local'}:${connection.profile || ''}:${host}:${connection.baseUrl || ''}`
}
export function desktopFsCacheKey() {