From f0693a3232c761a48a33b31f815021bf2068ef98 Mon Sep 17 00:00:00 2001 From: yoniebans Date: Fri, 19 Jun 2026 12:02:33 +0200 Subject: [PATCH] fix(desktop): connectionCacheKey identity includes remote host (fs cache collision) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: (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. --- apps/desktop/src/lib/desktop-fs.test.ts | 22 ++++++++++++++++++++++ apps/desktop/src/lib/desktop-fs.ts | 9 ++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/desktop-fs.test.ts b/apps/desktop/src/lib/desktop-fs.test.ts index c45ffb6745a..664937cb806 100644 --- a/apps/desktop/src/lib/desktop-fs.test.ts +++ b/apps/desktop/src/lib/desktop-fs.test.ts @@ -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:') + }) }) diff --git a/apps/desktop/src/lib/desktop-fs.ts b/apps/desktop/src/lib/desktop-fs.ts index b57701013e6..0ed21855716 100644 --- a/apps/desktop/src/lib/desktop-fs.ts +++ b/apps/desktop/src/lib/desktop-fs.ts @@ -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: 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() {