Desktop remote backend file/path integration issues
NousResearch/hermes-agent · cluster 38369 · apps/desktop
Data as of
2 open items. Fix merged in #44326. Check it covers your case.
The short answer
- Known?
- Yes. 19 related items grouped here, 7 issues already closed.
- Fix event
- A fix was merged to main in PR #44326. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #44326, #73926. That is separate from the fix event. - Still open
- 2 issues and 0 PRs.
Next step: read #44326, then check whether your report is one of the 2 still open.
Read #44326Pick up a Lab taskThese issues and PRs address problems and feature work around the Desktop app's file browser, plugin resolution, and terminal when connected to remote or WSL backends — including ENOENT errors from mismatched local/remote paths, folder picker bridging, and remote workspace support.
Open issues2
Open PRs0
Fixes on record
-
#44326 feat(desktop): browse remote backend files (salvage #43434)
files:
apps/desktop/src/app/chat/right-rail/preview-file.tsx,apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx,apps/desktop/src/app/chat/right-rail/preview-pane.tsx,apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts,apps/desktop/src/app/right-sidebar/files/ipc.ts,apps/desktop/src/app/right-sidebar/files/remote-picker.tsx,apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts,apps/desktop/src/app/right-sidebar/files/use-project-tree.ts,apps/desktop/src/app/right-sidebar/index.tsx,apps/desktop/src/i18n/en.ts,apps/desktop/src/i18n/ja.ts,apps/desktop/src/i18n/types.ts,apps/desktop/src/i18n/zh-hant.ts,apps/desktop/src/i18n/zh.ts,apps/desktop/src/lib/desktop-fs.test.ts,apps/desktop/src/lib/desktop-fs.ts,apps/desktop/src/lib/local-preview.ts,apps/desktop/src/store/session.test.ts,apps/desktop/src/store/session.ts,hermes_cli/web_server.py(+1 more)Show the diff
-
apps/desktop/src/app/chat/right-rail/preview-file.tsx@@ -13,6 +13,7 @@ import { Streamdown } from 'streamdown' import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions' import { PageLoader } from '@/components/page-loader' import { translateNow, useI18n } from '@/i18n' +import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs' import { cn } from '@/lib/utils' import type { PreviewTarget } from '@/store/preview' @@ -180,15 +181,13 @@ function looksBinaryBytes(bytes: Uint8Array) { } async function readTextPreview(filePath: string) { - if (window.hermesDesktop.readFileText) { - try { - return await window.hermesDesktop.readFileText(filePath) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - - if (!message.includes("No handler registered for 'hermes:readFileText'")) { - throw error - } + try { + return await readDesktopFileText(filePath) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + + if (!message.includes("No handler registered for 'hermes:readFileText'")) { + throw error } } @@ -448,7 +447,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar if (isImage) { // Prefer bytes the caller already handed us (a pasted/dropped // screenshot) over re-reading a path that may be transient/unreadable. - const dataUrl = target.dataUrl || (await window.hermesDesktop.readFileDataUrl(filePath)) + const dataUrl = target.dataUrl || (await readDesktopFileDataUrl(filePath)) if (active) { setState({ dataUrl, loading: false }) -
apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx@@ -1,11 +1,50 @@ import { act, cleanup, render } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $connection } from '@/store/session' import { PreviewPane } from './preview-pane' describe('PreviewPane console state', () => { + beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0)) + vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id)) + }) + afterEach(() => { cleanup() + $connection.set(null) + vi.unstubAllGlobals() + }) + + it('does not watch backend-only remote filesystem previews locally', () => { + const watchPreviewFile = vi.fn(async () => ({ id: 'watch-1', path: '/remote/file.txt' })) + const onPreviewFileChanged = vi.fn(() => vi.fn()) + $connection.set({ mode: 'remote' } as never) + vi.stubGlobal('window', { + ...window, + hermesDesktop: { + onPreviewFileChanged, + watchPreviewFile + } + }) + + render( + <PreviewPane + setTitlebarToolGroup={vi.fn()} + target={{ + kind: 'file', + label: 'file.txt', + path: '/remote/file.txt', + previewKind: 'text', + source: '/remote/file.txt', + url: 'file:///remote/file.txt' + }} + /> + ) + + expect(watchPreviewFile).not.toHaveBeenCalled() + expect(onPreviewFileChanged).not.toHaveBeenCalled() }) it('does not rebuild the pane titlebar group for streamed console logs', () => { -
apps/desktop/src/app/chat/right-rail/preview-pane.tsx@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls' import { Tip } from '@/components/ui/tooltip' import { type Translations, useI18n } from '@/i18n' +import { isDesktopFsRemoteMode } from '@/lib/desktop-fs' import { Bug } from '@/lib/icons' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' @@ -406,6 +407,7 @@ export function PreviewPane({ useEffect(() => { if ( target.kind !== 'file' || + isDesktopFsRemoteMode() || !window.hermesDesktop?.watchPreviewFile || !window.hermesDesktop?.onPreviewFileChanged ) { -
apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts@@ -3,6 +3,7 @@ import { useEffect, useRef } from 'react' import type { HermesConnection } from '@/global' import { HermesGateway } from '@/hermes' import { translateNow } from '@/i18n' +import { desktopDefaultCwd } from '@/lib/desktop-fs' import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url' import { $desktopBoot, @@ -25,12 +26,16 @@ import { import { notify, notifyError } from '@/store/notifications' import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile' import { + $activeSessionId, $attentionSessionIds, $connection, + $currentCwd, $sessions, $workingSessionIds, ensureDefaultWorkspaceCwd, setConnection, + setCurrentBranch, + setCurrentCwd, setSessionsLoading } from '@/store/session' import type { RpcEvent } from '@/types/hermes' @@ -353,6 +358,11 @@ export function useGatewayBoot({ progress: 97 }) await ensureDefaultWorkspaceCwd() + const remoteDefault = await desktopDefaultCwd().catch(() => null) + if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) { + setCurrentCwd(remoteDefault.cwd) + setCurrentBranch(remoteDefault.branch || '') + } await callbacksRef.current.refreshHermesConfig() if (cancelled) { -
apps/desktop/src/app/right-sidebar/files/ipc.ts@@ -1,5 +1,6 @@ import ignore from 'ignore' +import { desktopFsCacheKey, desktopGitRoot, readDesktopDir, readDesktopFileDataUrl } from '@/lib/desktop-fs' import type { HermesReadDirEntry, HermesReadDirResult } from '@/global' export type ProjectTreeEntry = HermesReadDirEntry @@ -63,15 +64,11 @@ function ancestorDirs(root: string, dir: string) { } async function gitRootFor(start: string) { - if (!window.hermesDesktop?.gitRoot) { - return null - } - - const key = clean(start) + const key = `${desktopFsCacheKey()}:${clean(start)}` let cached = gitRootCache.get(key) if (!cached) { - cached = window.hermesDesktop.gitRoot(key) + cached = desktopGitRoot(start) gitRootCache.set(key, cached) } @@ -80,18 +77,14 @@ async function gitRootFor(start: string) { /** Read .gitignore at `dir` if it actually exists — never probe missing files. */ async function readGitignore(dir: string): Promise<GitignoreRule | null> { - if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) { - return null - } - try { - const listing = await window.hermesDesktop.readDir(dir) + const listing = await readDesktopDir(dir) if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) { return null } - const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`)) + const text = decodeDataUrl(await readDesktopFileDataUrl(`${dir}/.gitignore`)) return { base: dir, ig: ignore().add(text) } } catch { @@ -100,11 +93,11 @@ async function readGitignore(dir: string): Promise<GitignoreRule | null> { } async function gitignoreFor(dir: string) { - const key = clean(dir) + const key = `${desktopFsCacheKey()}:${clean(dir)}` let cached = gitignoreCache.get(key) if (!cached) { - cached = readGitignore(key) + cached = readGitignore(clean(dir)) gitignoreCache.set(key, cached) } @@ -142,9 +135,10 @@ export async function readProjectDir(dirPath: string, rootPath = dirPath): Promi return { entries: [], error: 'no-bridge' } } - const result = await window.hermesDesktop.readDir(dirPath) + const result = await readDesktopDir(dirPath) + const entries = result?.entries ?? [] - return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) } + return { ...result, entries: await filterIgnored(entries, rootPath, dirPath) } } export function clearProjectDirCache(rootPath?: string) { @@ -155,7 +149,7 @@ export function clearProjectDirCache(rootPath?: string) { return } - const key = clean(rootPath) + const key = `${desktopFsCacheKey()}:${clean(rootPath)}` gitRootCache.delete(key) gitignoreCache.delete(key) } -
apps/desktop/src/app/right-sidebar/files/remote-picker.tsx@@ -0,0 +1,177 @@ +import { useEffect, useMemo, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' +import { useI18n } from '@/i18n' +import { readDesktopDir, setDesktopFsRemotePicker } from '@/lib/desktop-fs' +import { cn } from '@/lib/utils' + +function clean(path: string) { + return path.replace(/\/+$/, '') || '/' +} + +function parentDir(path: string) { + const value = clean(path) + if (value === '/') { + return '/' + } + const parent = value.slice(0, value.lastIndexOf('/')) + return parent || '/' +} + +function pathName(path: string) { + return path.split('/').filter(Boolean).pop() || path +} + +interface PendingSelection { + defaultPath: string + resolve: (paths: string[]) => void + title: string +} + +export function RemoteFolderPicker() { + const { t } = useI18n() + const r = t.rightSidebar + const [pending, setPending] = useState<PendingSelection | null>(null) + const [currentPath, setCurrentPath] = useState('/') + const [entries, setEntries] = useState<Array<{ name: string; path: string }>>([]) + const [error, setError] = useState<string | null>(null) + const [loading, setLoading] = useState(false) + + useEffect(() => { + setDesktopFsRemotePicker({ + selectPaths: options => + new Promise(resolve => { + const defaultPath = clean(options?.defaultPath || '/') + setCurrentPath(defaultPath) + setPending({ defaultPath, resolve, title: options?.title || r.remotePickerTitle }) + }) + }) + return () => setDesktopFsRemotePicker(null) + }, [r.remotePickerTitle]) + + useEffect(() => { + if (!pending) { + return + } + + let active = true + setLoading(true) + setError(null) + + void readDesktopDir(currentPath) + .then(result => { + if (!active) { + return + } + if (result.error) { + setError(result.error) + setEntries([]) + return + } + setEntries(result.entries.filter(entry => entry.isDirectory).map(entry => ({ name: entry.name, path: entry.path }))) + }) + .catch(err => { + if (active) { + setError(err instanceof Error ? err.message : String(err)) + setEntries([]) + } + }) + .finally(() => { + if (active) { + setLoading(false) + } + }) + + return () => { + active = false + } + }, [currentPath, pending]) + + const crumbs = useMemo(() => { + const parts = clean(currentPath).split('/').filter(Boolean) + const out = [{ label: '/', path: '/' }] + let acc = '' + for (const part of parts) { + acc += `/${part}` + out.push({ label: part, path: acc }) + } + return out + }, [currentPath]) + + const close = (paths: string[] = []) => { + pending?.resolve(paths) + setPending(null) + setEntries([]) + setError(null) + } + + return ( + <Dialog onOpenChange={open => !open && close()} open={Boolean(pending)}> + <DialogContent className="max-w-lg gap-0 overflow-hidden p-0"> + <div className="border-b border-border/70 px-4 py-3"> + <DialogTitle className="text-sm">{pending?.title || r.remotePickerTitle}</DialogTitle> + <DialogDescription className="mt-1 text-xs">{r.remotePickerDescription}</DialogDescription> + </div> + + <div className="flex min-h-[22rem] flex-col"> + <div className="flex flex-wrap items-center gap-1 border-b border-border/50 px-3 py-2 text-xs text-muted-foreground"> + {crumbs.map((crumb, index) => ( + <button + className={cn('rounded px-1.5 py-0.5 hover:bg-muted hover:text-foreground', index === crumbs.length - 1 && 'text-foreground')} + key={crumb.path} + onClick={() => setCurrentPath(crumb.path)} + type="button" + > + {crumb.label} + </button> + ))} + </div> + + <div className="min-h-0 flex-1 overflow-y-auto p-2"> + <FolderRow disabled={currentPath === '/'} name=".." onClick={() => setCurrentPath(parentDir(currentPath))} /> + {loading ? ( + <div className="flex items-center gap-2 px-2 py-3 text-xs text-muted-foreground"> + <Codicon name="loading" size="0.8rem" spinning /> + {r.loadingFiles} + </div> + ) : error ? ( + <div className="px-2 py-3 text-xs text-destructive">{r.unreadableBody(error)}</div> + ) : entries.length === 0 ? ( + <div className="px-2 py-3 text-xs text-muted-foreground">{r.emptyBody}</div> + ) : ( + entries.map(entry => <FolderRow key={entry.path} name={pathName(entry.path)} onClick={() => setCurrentPath(entry.path)} />) + )} + </div> + </div> + + <div className="flex items-center justify-between gap-2 border-t border-border/70 px-4 py-3"> + <div className="min-w-0 truncate text-xs text-muted-foreground">{currentPath}</div> + <div className="flex shrink-0 items-center gap-2"> + <Button onClick={() => close()} size="sm" variant="ghost"> + {t.common.cancel} + </Button> + <Button onClick={() => close([currentPath])} size="sm"> + {r.remotePickerSelect} + </Button> + </div> + </div> + </DialogContent> + </Dialog> + ) +} + +function FolderRow({ disabled = false, name, onClick }: { disabled?: boolean; name: string; onClick: () => void }) { + return ( + <button + className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground disabled:pointer-events-none disabled:opacity-40" + disabled={disabled} + onClick={onClick} + type="button" + > + <Codicon name="folder" size="0.875rem" /> + <span className="min-w-0 truncate">{name}</span> + </button> + ) +} -
apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts@@ -1,19 +1,24 @@ -import { act, renderHook, waitFor } from '@testing-library/react' +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { $connection } from '@/store/session' import type { HermesReadDirResult } from '@/global' +import { clearProjectDirCache, readProjectDir } from './ipc' import { resetProjectTreeState, useProjectTree } from './use-project-tree' const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>() beforeEach(() => { + $connection.set(null) resetProjectTreeState() readDir.mockReset() ;(window as unknown as { hermesDesktop: { readDir: typeof readDir } }).hermesDesktop = { readDir } }) afterEach(() => { + cleanup() + $connection.set(null) resetProjectTreeState() delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop }) @@ -106,6 +111,36 @@ describe('useProjectTree', () => { expect(readDir).toHaveBeenCalledTimes(1) }) + it('reads gitignore from the real path while caching per connection', async () => { + const readFileDataUrl = vi.fn(async () => `data:text/plain;base64,${btoa('ignored.log\n')}`) + const gitRoot = vi.fn(async () => '/repo') + readDir.mockImplementation(async path => { + if (path === '/repo') return ok([{ name: '.gitignore', path: '/repo/.gitignore', isDirectory: false }]) + if (path === '/repo/src') { + return ok([ + { name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }, + { name: 'ignored.log', path: '/repo/src/ignored.log', isDirectory: false } + ]) + } + throw new Error(`unexpected path ${path}`) + }) + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { gitRoot, readDir, readFileDataUrl } + + $connection.set({ baseUrl: 'local-a', mode: 'local' } as never) + await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({ + entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }] + }) + expect(readDir).toHaveBeenCalledWith('/repo') + expect(readDir).not.toHaveBeenCalledWith(expect.stringContaining('local-a')) + + $connection.set({ baseUrl: 'local-b', mode: 'local' } as never) + clearProjectDirCache() + await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({ + entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }] + }) + expect(readDir.mock.calls.filter(([path]) => path === '/repo')).toHaveLength(2) + }) + it('captures per-folder error code and shows an error placeholder child', async () => { readDir.mockResolvedValueOnce(ok([{ name: 'priv', path: '/p/priv', isDirectory: true }])) readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' }) -
apps/desktop/src/app/right-sidebar/files/use-project-tree.ts@@ -2,6 +2,8 @@ import { useStore } from '@nanostores/react' import { atom } from 'nanostores' import { useCallback, useEffect, useMemo } from 'react' +import { $connection } from '@/store/session' + import { clearProjectDirCache, readProjectDir } from './ipc' export interface TreeNode { @@ -96,6 +98,7 @@ const initialState: ProjectTreeState = { const inflight = new Set<string>() const $projectTree = atom<ProjectTreeState>(initialState) let nextRootRequestId = 0 +let lastConnectionKey = '' function setProjectTree(updater: (current: ProjectTreeState) => ProjectTreeState) { $projectTree.set(updater($projectTree.get())) @@ -157,6 +160,7 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {} } export function resetProjectTreeState() { + lastConnectionKey = '' clearProjectTree() clearProjectDirCache() } @@ -170,6 +174,8 @@ export function resetProjectTreeState() { */ export function useProjectTree(cwd: string): UseProjectTreeResult { const state = useStore($projectTree) + const connection = useStore($connection) + const connectionKey = `${connection?.mode || 'local'}:${connection?.profile || ''}:${connection?.baseUrl || ''}` const refreshRoot = useCallback(() => loadRoot(cwd, { force: true }), [cwd]) @@ -248,8 +254,15 @@ export function useProjectTree(cwd: string): UseProjectTreeResult { ) useEffect(() => { + const connectionChanged = lastConnectionKey !== '' && lastConnectionKey !== connectionKey + lastConnectionKey = connectionKey + if (connectionChanged) { + clearProjectDirCache() + void loadRoot(cwd, { force: true }) + return + } void loadRoot(cwd) - }, [cwd]) + }, [connectionKey, cwd]) return useMemo( () => ({ -
apps/desktop/src/app/right-sidebar/index.tsx@@ -7,6 +7,7 @@ import { Codicon } from '@/components/ui/codicon' import { Loader } from '@/components/ui/loader' import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' +import { selectDesktopPaths } from '@/lib/desktop-fs' import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' import { cn } from '@/lib/utils' import { $panesFlipped } from '@/store/layout' @@ -16,6 +17,7 @@ import { $currentCwd } from '@/store/session' import { SidebarPanelLabel } from '../shell/sidebar-label' +import { RemoteFolderPicker } from './files/remote-picker' import { ProjectTree } from './files/tree' import { useProjectTree } from './files/use-project-tree' @@ -54,7 +56,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd const canCollapse = Object.values(openState).some(Boolean) const chooseFolder = async () => { - const selected = await window.hermesDesktop?.selectPaths({ + const selected = await selectDesktopPaths({ defaultPath: hasCwd ? currentCwd : undefined, directories: true, multiple: false, @@ -90,6 +92,8 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd : 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]' )} > + <RemoteFolderPicker /> + <FilesystemTab canCollapse={canCollapse} collapseNonce={collapseNonce} -
apps/desktop/src/i18n/en.ts@@ -1532,6 +1532,9 @@ export const en: Translations = { terminal: 'Terminal', noFolderSelected: 'No folder selected', changeCwdTitle: 'Change working directory', + remotePickerTitle: 'Choose remote folder', + remotePickerDescription: 'Browse folders on the connected backend.', + remotePickerSelect: 'Select folder', folderTip: cwd => `${cwd} — click to change folder`, openFolder: 'Open folder', refreshTree: 'Refresh tree', -
apps/desktop/src/i18n/ja.ts@@ -1665,6 +1665,9 @@ export const ja = defineLocale({ terminal: 'ターミナル', noFolderSelected: 'フォルダーが選択されていません', changeCwdTitle: '作業ディレクトリを変更', + remotePickerTitle: 'リモートフォルダーを選択', + remotePickerDescription: '接続中のバックエンド上のフォルダーを参照します。', + remotePickerSelect: 'フォルダーを選択', folderTip: cwd => `${cwd} — クリックしてフォルダーを変更`, openFolder: 'フォルダーを開く', refreshTree: 'ツリーを更新', -
apps/desktop/src/i18n/types.ts@@ -1194,6 +1194,9 @@ export interface Translations { terminal: string noFolderSelected: string changeCwdTitle: string + remotePickerTitle: string + remotePickerDescription: string + remotePickerSelect: string folderTip: (cwd: string) => string openFolder: string refreshTree: string -
apps/desktop/src/i18n/zh-hant.ts@@ -1626,6 +1626,9 @@ export const zhHant = defineLocale({ terminal: '終端機', noFolderSelected: '未選擇資料夾', changeCwdTitle: '變更工作目錄', + remotePickerTitle: '選擇遠端資料夾', + remotePickerDescription: '瀏覽已連線後端上的資料夾。', + remotePickerSelect: '選擇資料夾', folderTip: cwd => `${cwd} — 點擊以變更資料夾`, openFolder: '開啟資料夾', refreshTree: '重新整理檔案樹', -
apps/desktop/src/i18n/zh.ts@@ -1712,6 +1712,9 @@ export const zh: Translations = { terminal: '终端', noFolderSelected: '未选择文件夹', changeCwdTitle: '更改工作目录', + remotePickerTitle: '选择远程文件夹', + remotePickerDescription: '浏览已连接后端上的文件夹。', + remotePickerSelect: '选择文件夹', folderTip: cwd => `${cwd} — 点击更改文件夹`, openFolder: '打开文件夹', refreshTree: '刷新文件树', -
apps/desktop/src/lib/desktop-fs.test.ts@@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $connection } from '@/store/session' + +import { + desktopDefaultCwd, + desktopGitRoot, + readDesktopDir, + readDesktopFileDataUrl, + readDesktopFileText, + selectDesktopPaths, + setDesktopFsRemotePicker +} from './desktop-fs' + +const readDir = vi.fn(async () => ({ entries: [{ name: 'local', path: '/local', isDirectory: true }] })) +const readFileText = vi.fn(async () => ({ path: '/local/file.txt', text: 'local', byteSize: 5 })) +const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,bG9jYWw=') +const gitRoot = vi.fn(async () => '/local') +const selectPaths = vi.fn(async () => ['/local']) +const api = vi.fn(async ({ path }: { path: string }) => { + if (path.startsWith('/api/fs/list?')) return { entries: [{ name: 'remote', path: '/remote', isDirectory: true }] } + if (path.startsWith('/api/fs/read-text?')) return { path: '/remote/file.txt', text: 'remote', byteSize: 6 } + if (path.startsWith('/api/fs/read-data-url?')) return { dataUrl: 'data:text/plain;base64,cmVtb3Rl' } + if (path.startsWith('/api/fs/git-root?')) return { root: '/remote' } + if (path === '/api/fs/default-cwd') return { cwd: '/backend/project', branch: 'main' } + throw new Error(`unexpected path ${path}`) +}) + +function stubBridge() { + vi.stubGlobal('window', { + hermesDesktop: { + api, + gitRoot, + readDir, + readFileDataUrl, + readFileText, + selectPaths + } + }) +} + +describe('desktop filesystem facade', () => { + beforeEach(() => { + stubBridge() + $connection.set(null) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + $connection.set(null) + setDesktopFsRemotePicker(null) + }) + + it('uses local Electron filesystem methods in local mode', async () => { + $connection.set({ mode: 'local' } as never) + + await expect(readDesktopDir('/work')).resolves.toEqual({ entries: [{ name: 'local', path: '/local', isDirectory: true }] }) + await expect(readDesktopFileText('/work/file.txt')).resolves.toMatchObject({ text: 'local' }) + await expect(readDesktopFileDataUrl('/work/file.txt')).resolves.toBe('data:text/plain;base64,bG9jYWw=') + await expect(desktopGitRoot('/work')).resolves.toBe('/local') + await expect(selectDesktopPaths({ directories: true })).resolves.toEqual(['/local']) + + expect(readDir).toHaveBeenCalledWith('/work') + expect(readFileText).toHaveBeenCalledWith('/work/file.txt') + expect(readFileDataUrl).toHaveBeenCalledWith('/work/file.txt') + expect(gitRoot).toHaveBeenCalledWith('/work') + expect(selectPaths).toHaveBeenCalledWith({ directories: true }) + expect(api).not.toHaveBeenCalled() + }) + + it('routes filesystem reads through authenticated backend REST in remote mode', async () => { + $connection.set({ mode: 'remote' } as never) + + await expect(readDesktopDir('/home/user/project')).resolves.toMatchObject({ entries: [{ name: 'remote' }] }) + await expect(readDesktopFileText('/home/user/project/a b.txt')).resolves.toMatchObject({ text: 'remote' }) + await expect(readDesktopFileDataUrl('/home/user/project/a b.txt')).resolves.toBe('data:text/plain;base64,cmVtb3Rl') + await expect(desktopGitRoot('/home/user/project')).resolves.toBe('/remote') + await expect(desktopDefaultCwd()).resolves.toEqual({ cwd: '/backend/project', branch: 'main' }) + + expect(api).toHaveBeenCalledWith({ path: '/api/fs/list?path=%2Fhome%2Fuser%2Fproject' }) + expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-text?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' }) + expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-data-url?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' }) + expect(api).toHaveBeenCalledWith({ path: '/api/fs/git-root?path=%2Fhome%2Fuser%2Fproject' }) + expect(api).toHaveBeenCalledWith({ path: '/api/fs/default-cwd' }) + expect(readDir).not.toHaveBeenCalled() + expect(readFileText).not.toHaveBeenCalled() + expect(readFileDataUrl).not.toHaveBeenCalled() + expect(gitRoot).not.toHaveBeenCalled() + }) + + it('uses the registered in-app directory picker in remote mode', async () => { + const remoteSelect = vi.fn(async () => ['/remote/project']) + $connection.set({ mode: 'remote' } as never) + setDesktopFsRemotePicker({ selectPaths: remoteSelect }) + + await expect(selectDesktopPaths({ defaultPath: '/remote', directories: true, multiple: false })).resolves.toEqual([ + '/remote/project' + ]) + + expect(remoteSelect).toHaveBeenCalledWith({ defaultPath: '/remote', directories: true, multiple: false }) + expect(selectPaths).not.toHaveBeenCalled() + }) + + it('does not treat the remote directory picker as a general file picker', async () => { + const remoteSelect = vi.fn(async () => ['/remote/project']) + $connection.set({ mode: 'remote' } as never) + setDesktopFsRemotePicker({ selectPaths: remoteSelect }) + + await expect(selectDesktopPaths({ directories: false, multiple: false })).resolves.toEqual([]) + await expect(selectDesktopPaths({ directories: true, multiple: true })).resolves.toEqual([]) + + expect(remoteSelect).not.toHaveBeenCalled() + expect(selectPaths).not.toHaveBeenCalled() + }) +}) -
apps/desktop/src/lib/desktop-fs.ts@@ -0,0 +1,95 @@ +import { $connection } from '@/store/session' + +import type { HermesConnection, HermesReadDirResult, HermesReadFileTextResult, HermesSelectPathsOptions } from '@/global' + +export interface DesktopFsRemotePicker { + selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]> +} + +let remotePicker: DesktopFsRemotePicker | null = null + +export function setDesktopFsRemotePicker(next: DesktopFsRemotePicker | null) { + remotePicker = next +} + +function connectionCacheKey(connection: HermesConnection | null) { + if (!connection) { + return 'local:' + } + return `${connection.mode || 'local'}:${connection.profile || ''}:${connection.baseUrl || ''}` +} + +export function desktopFsCacheKey() { + return connectionCacheKey($connection.get()) +} + +export function isDesktopFsRemoteMode() { + return $connection.get()?.mode === 'remote' +} + +function fsPath(endpoint: string, filePath: string) { + return `/api/fs/${endpoint}?path=${encodeURIComponent(filePath)}` +} + +function bridge() { + const desktop = window.hermesDesktop + if (!desktop) { + throw new Error('Hermes Desktop bridge is unavailable') + } + return desktop +} + +export async function readDesktopDir(path: string): Promise<HermesReadDirResult> { + const desktop = bridge() + if (!isDesktopFsRemoteMode()) { + return desktop.readDir(path) + } + return desktop.api<HermesReadDirResult>({ path: fsPath('list', path) }) +} + +export async function readDesktopFileText(path: string): Promise<HermesReadFileTextResult> { + const desktop = bridge() + if (!isDesktopFsRemoteMode()) { + return desktop.readFileText(path) + } + return desktop.api<HermesReadFileTextResult>({ path: fsPath('read-text', path) }) +} + +export async function readDesktopFileDataUrl(path: string): Promise<string> { + const desktop = bridge() + if (!isDesktopFsRemoteMode()) { + return desktop.readFileDataUrl(path) + } + + const result = await desktop.api<string | { dataUrl?: string }>({ path: fsPath('read-data-url', path) }) + return typeof result === 'string' ? result : result.dataUrl || '' +} + +export async function desktopGitRoot(path: string): Promise<string | null> { + const desktop = bridge() + if (!isDesktopFsRemoteMode()) { + return desktop.gitRoot ? desktop.gitRoot(path) : null + } + + const result = await desktop.api<{ root: string | null }>({ path: fsPath('git-root', path) }) + return result.root +} + +export async function desktopDefaultCwd(): Promise<{ branch: string; cwd: string } | null> { + if (!isDesktopFsRemoteMode()) { + return null + } + + return bridge().api<{ branch: string; cwd: string }>({ path: '/api/fs/default-cwd' }) +} + +export async function selectDesktopPaths(options?: HermesSelectPathsOptions): Promise<string[]> { + const desktop = bridge() + if (!isDesktopFsRemoteMode()) { + return desktop.selectPaths(options) + } + if (!options?.directories || options.multiple !== false) { + return [] + } + return remotePicker ? remotePicker.selectPaths(options) : [] +} -
apps/desktop/src/lib/local-preview.ts@@ -1,3 +1,4 @@ +import { isDesktopFsRemoteMode, readDesktopFileText } from '@/lib/desktop-fs' import type { PreviewTarget } from '@/store/preview' const HTML_EXTENSIONS = new Set(['.htm', '.html']) @@ -107,6 +108,26 @@ export function localPreviewTarget(rawTarget: string, cwd?: string | null): Prev } } +async function enrichPreviewTarget(target: PreviewTarget | null): Promise<PreviewTarget | null> { + if (!isDesktopFsRemoteMode() || !target || target.kind !== 'file' || target.previewKind === 'image') { + return target + } + + try { + const result = await readDesktopFileText(target.path || target.source) + return { + ...target, + binary: result.binary, + byteSize: result.byteSize, + language: result.language || target.language, + large: (result.byteSize ?? 0) > 512 * 1024, + mimeType: result.mimeType + } + } catch { + return target + } +} + export async function normalizeOrLocalPreviewTarget( rawTarget: string, cwd?: string | null @@ -115,12 +136,12 @@ export async function normalizeOrLocalPreviewTarget( const normalized = await window.hermesDesktop?.normalizePreviewTarget?.(rawTarget, cwd || undefined) if (normalized) { - return normalized + return enrichPreviewTarget(normalized) } } catch { // Running Electron may still have the old HTML-only preview IPC. Fall // through to renderer-side local classification so text/images still open. } - return localPreviewTarget(rawTarget, cwd) + return enrichPreviewTarget(localPreviewTarget(rawTarget, cwd)) } -
apps/desktop/src/store/session.test.ts@@ -5,12 +5,14 @@ import type { SessionInfo } from '@/types/hermes' import { $activeSessionId, $attentionSessionIds, + $connection, $currentCwd, $workingSessionIds, applyConfiguredDefaultProjectDir, getRecentlySettledSessionIds, mergeSessionPage, sessionPinId, + setCurrentCwd, setSessionAttention, setSessionWorking, workspaceCwdForNewSession @@ -184,9 +186,12 @@ describe('mergeSessionPage', () => { describe('workspaceCwdForNewSession', () => { afterEach(() => { applyConfiguredDefaultProjectDir(null) + $connection.set(null) $currentCwd.set('') $activeSessionId.set(null) window.localStorage.removeItem('hermes.desktop.workspace-cwd') + window.localStorage.removeItem('hermes.desktop.workspace-cwd.remote.http%3A%2F%2Fbackend-a.default') + window.localStorage.removeItem('hermes.desktop.workspace-cwd.remote.http%3A%2F%2Fbackend-b.default') }) it('prefers the configured default over the sticky remembered workspace', () => { @@ -216,6 +221,26 @@ describe('workspaceCwdForNewSession', () => { expect($currentCwd.get()).toBe('/live/session/path') expect(workspaceCwdForNewSession()).toBe('/home/user/configured') }) + + it('keeps remote workspace memory separate from local and other remotes', () => { + window.localStorage.setItem('hermes.desktop.workspace-cwd', '/local/project') + $currentCwd.set('/live/session/path') + $connection.set({ baseUrl: 'http://backend-a', mode: 'remote' } as never) + + expect(workspaceCwdForNewSession()).toBe('') + + setCurrentCwd('/backend/project-a') + expect(workspaceCwdForNewSession()).toBe('/backend/project-a') + + $connection.set({ baseUrl: 'http://backend-b', mode: 'remote' } as never) + expect(workspaceCwdForNewSession()).toBe('') + + setCurrentCwd('/backend/project-b') + expect(workspaceCwdForNewSession()).toBe('/backend/project-b') + + $connection.set(null) + expect(workspaceCwdForNewSession()).toBe('/local/project') + }) }) describe('getRecentlySettledSessionIds', () => { -
apps/desktop/src/store/session.ts@@ -10,13 +10,19 @@ type Updater<T> = T | ((current: T) => T) const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd' -// Cached copy of Settings → Sessions → Default project directory. The main -// process persists this in project-dir.json, but the renderer must also honor it -// when seeding $currentCwd — otherwise PR #37586's sticky localStorage home dir -// wins and new sessions ignore the user's explicit picker choice. let configuredDefaultProjectDir = '' -export const getRememberedWorkspaceCwd = (): string => storedString(WORKSPACE_CWD_KEY)?.trim() || '' +function workspaceCwdKey(connection: HermesConnection | null = $connection.get()): string { + if (connection?.mode !== 'remote') { + return WORKSPACE_CWD_KEY + } + + const base = encodeURIComponent(connection.baseUrl || 'remote') + const profile = encodeURIComponent(connection.profile || 'default') + return `${WORKSPACE_CWD_KEY}.remote.${base}.${profile}` +} + +export const getRememberedWorkspaceCwd = (): string => storedString(workspaceCwdKey())?.trim() || '' export const getConfiguredDefaultProjectDir = (): string => configuredDefaultProjectDir @@ -54,6 +60,13 @@ export async function ensureDefaultWorkspaceCwd(): Promise<void> { } } + const remembered = getRememberedWorkspaceCwd() + + if ($connection.get()?.mode === 'remote') { + seedLiveCwd(remembered) + return + } + if (configured) { const { cwd } = await sanitize(configured) seedLiveCwd(cwd) @@ -61,8 +74,10 @@ export async function ensureDefaultWorkspaceCwd(): Promise<void> { return } - const { cwd } = await sanitize(getRememberedWorkspaceCwd()) - seedLiveCwd(cwd) + if (remembered) { + const { cwd } = await sanitize(remembered) + seedLiveCwd(cwd) + } } export function applyConfiguredDefaultProjectDir(dir: null | string | undefined): void { @@ -238,15 +253,16 @@ export const setYoloActive = (next: Updater<boolean>) => updateAtom($yoloActive, export const setCurrentCwd = (next: Updater<string>) => { updateAtom($currentCwd, next) - // Keep localStorage in sync with the atom: a real folder is remembered, an - // empty cwd clears the key (|| null → removeItem). - persistString(WORKSPACE_CWD_KEY, $currentCwd.get().trim() || null) + persistString(workspaceCwdKey(), $currentCwd.get().trim() || null) } -/** Workspace for a brand-new chat. Explicit Settings override wins; otherwise - * fall back to the sticky last-used folder, then whatever is already live. */ -export const workspaceCwdForNewSession = (): string => - getConfiguredDefaultProjectDir() || getRememberedWorkspaceCwd() || $currentCwd.get().trim() +export const workspaceCwdForNewSession = (): string => { + if ($connection.get()?.mode === 'remote') { + return getRememberedWorkspaceCwd() + } + + return getConfiguredDefaultProjectDir() || getRememberedWorkspaceCwd() || $currentCwd.get().trim() +} export const setCurrentBranch = (next: Updater<string>) => updateAtom($currentBranch, next) export const setCurrentUsage = (next: Updater<UsageStats>) => updateAtom($currentUsage, next) -
hermes_cli/web_server.py@@ -920,6 +920,178 @@ class ManagedFilesPolicy: can_change_path: bool +_FS_READDIR_HIDDEN = { + ".git", + ".hg", + ".svn", + ".cache", + ".next", + ".turbo", + ".venv", + "__pycache__", + "build", + "dist", + "node_modules", + "target", + "venv", +} +_FS_DATA_URL_MAX_BYTES = 16 * 1024 * 1024 +_FS_TEXT_SOURCE_MAX_BYTES = 64 * 1024 * 1024 +_FS_TEXT_PREVIEW_MAX_BYTES = 512 * 1024 +_FS_PREVIEW_LANGUAGE_BY_EXT = { + ".c": "c", + ".conf": "ini", + ".cpp": "cpp", + ".css": "css", + ".csv": "csv", + ".go": "go", + ".graphql": "graphql", + ".h": "c", + ".hpp": "cpp", + ".html": "html", + ".java": "java", + ".js": "javascript", + ".json": "json", + ".jsx": "jsx", + ".kt": "kotlin", + ".lua": "lua", + ".md": "markdown", + ".mjs": "javascript", + ".py": "python", + ".rb": "ruby", + ".rs": "rust", + ".sh": "shell", + ".sql": "sql", + ".svg": "xml", + ".toml": "toml", + ".ts": "typescript", + ".tsx": "tsx", + ".txt": "text", + ".xml": "xml", + ".yaml": "yaml", + ".yml": "yaml", + ".zsh": "shell", +} +_FS_MIME_TYPES = { + ".avi": "video/x-msvideo", + ".bmp": "image/bmp", + ".flac": "audio/flac", + ".gif": "image/gif", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".m4a": "audio/mp4", + ".mkv": "video/x-matroska", + ".mov": "video/quicktime", + ".mp3": "audio/mpeg", + ".mp4": "video/mp4", + ".ogg": "audio/ogg", + ".opus": "audio/ogg; codecs=opus", + ".png": "image/png", + ".svg": "image/svg+xml", + ".wav": "audio/wav", + ".webm": "video/webm", + ".webp": "image/webp", +} + + +def _fs_path(raw_path: str) -> Path: + raw = str(raw_path or "").strip() + if not raw: + raise HTTPException(status_code=400, detail="Path is required") + if "\0" in raw: + raise HTTPException(status_code=400, detail="Invalid path") + try: + if raw.lower().startswith("file:"): + parsed = urllib.parse.urlparse(raw) + if parsed.netloc and parsed.netloc not in {"", "localhost"}: + raise ValueError + raw = urllib.request.url2pathname(parsed.path) + candidate = Path(raw).expanduser() + if not candidate.is_absolute(): + candidate = Path.cwd() / candidate + return candidate.resolve(strict=False) + except (OSError, RuntimeError, ValueError): + raise HTTPException(status_code=400, detail="Invalid path") + + +def _fs_mime_type(path: Path) -> str: + suffix = path.suffix.lower() + if suffix in _FS_MIME_TYPES: + return _FS_MIME_TYPES[suffix] + guessed, _ = mimetypes.guess_type(str(path)) + return guessed or "application/octet-stream" + + +def _fs_looks_binary(data: bytes) -> bool: + if not data: + return False + if b"\0" in data: + return True + suspicious = sum(1 for byte in data if byte < 32 and byte not in {9, 10, 13}) + return suspicious / len(data) > 0.12 + + +def _fs_regular_file(path: Path) -> tuple[Path, os.stat_result]: + target = _fs_path(str(path)) + try: + st = target.stat() + except FileNotFoundError: + raise HTTPException(status_code=404, detail="File not found") + except NotADirectoryError: + raise HTTPException(status_code=404, detail="File not found") + except PermissionError: + raise HTTPException(status_code=403, detail="File is not readable") + except OSError as exc: + raise HTTPException(status_code=400, detail=str(exc) or "Invalid path") + if stat.S_ISDIR(st.st_mode): + raise HTTPException(status_code=400, detail="Path points to a directory") + if not stat.S_ISREG(st.st_mode): + raise HTTPException(status_code=400, detail="Only regular files can be read") + return target, st + + +def _fs_find_git_root(start: Path) -> str | None: + directory = start + for _ in range(50): + try: + if (directory / ".git").exists(): + return str(directory) + except OSError: + return None + parent = directory.parent + if parent == directory: + return None + directory = parent + return None + + +def _fs_default_cwd() -> str: + cfg_terminal = load_config().get("terminal") or {} + raw = str(cfg_terminal.get("cwd") or os.environ.get("TERMINAL_CWD") or "").strip() + if raw and raw not in {".", "auto", "cwd"}: + try: + candidate = Path(raw).expanduser().resolve(strict=False) + if candidate.is_dir(): + return str(candidate) + except (OSError, RuntimeError): + pass + return str(Path.cwd()) + + +def _fs_git_branch(cwd: str) -> str: + try: + result = subprocess.run( + ["git", "-C", cwd, "branch", "--show-current"], + capture_output=True, + text=True, + timeout=2, + check=False, + ) + return result.stdout.strip() if result.returncode == 0 else "" + except Exception: + return "" + + def _media_serve_roots() -> list[Path]: """Directories ``GET /api/media`` is allowed to read from. @@ -1264,6 +1436,87 @@ async def delete_managed_file(payload: ManagedFileDelete, request: Request): return {"ok": True, "path": display_path, **_managed_response_meta(policy)} [redacted:email]("/api/fs/list") +async def fs_list(path: str): + target = _fs_path(path) + try: + entries = [] + with os.scandir(target) as scan: + for entry in scan: + if entry.name in _FS_READDIR_HIDDEN: + continue + entries.append({ + "name": entry.name, + "path": str(target / entry.name), + "isDirectory": entry.is_dir(follow_symlinks=False), + }) + entries.sort(key=lambda item: (not item["isDirectory"], item["name"].lower(), item["name"])) + return {"entries": entries} + except FileNotFoundError: + return {"entries": [], "error": "ENOENT"} + except NotADirectoryError: + return {"entries": [], "error": "ENOTDIR"} + except PermissionError: + return {"entries": [], "error": "EACCES"} + except OSError as exc: + return {"entries": [], "error": getattr(exc, "strerror", None) or "read-error"} + + [redacted:email]("/api/fs/read-text") +async def fs_read_text(path: str): + target, st = _fs_regular_file(_fs_path(path)) + if st.st_size > _FS_TEXT_SOURCE_MAX_BYTES: + raise HTTPException(status_code=413, detail="File too large") + bytes_to_read = min(st.st_size, _FS_TEXT_PREVIEW_MAX_BYTES) + try: + with target.open("rb") as handle: + data = handle.read(bytes_to_read) + except PermissionError: + raise HTTPException(status_code=403, detail="File is not readable") + except OSError as exc: + raise HTTPException(status_code=400, detail=str(exc) or "File read failed") + return { + "binary": _fs_looks_binary(data[:4096]), + "byteSize": st.st_size, + "language": _FS_PREVIEW_LANGUAGE_BY_EXT.get(target.suffix.lower(), "text"), + "mimeType": _fs_mime_type(target), + "path": str(target), + "text": data.decode("utf-8", errors="replace"), + "truncated": st.st_size > _FS_TEXT_PREVIEW_MAX_BYTES, + } + + [redacted:email]("/api/fs/read-data-url") +async def fs_read_data_url(path: str): + target, st = _fs_regular_file(_fs_path(path)) + if st.st_size > _FS_DATA_URL_MAX_BYTES: + raise HTTPException(status_code=413, detail="File too large") + try: + encoded = base64.b64encode(target.read_bytes()).decode("ascii") + except PermissionError: + raise HTTPException(status_code=403, detail="File is not readable") + except OSError as exc: + raise HTTPException(status_code=400, detail=str(exc) or "File read failed") + return {"dataUrl": f"data:{_fs_mime_type(target)};base64,{encoded}"} + + [redacted:email]("/api/fs/git-root") +async def fs_git_root(path: str): + target = _fs_path(path) + try: + st = target.stat() + start = target if stat.S_ISDIR(st.st_mode) else target.parent + except OSError: + start = target + return {"root": _fs_find_git_root(start)} + + [redacted:email]("/api/fs/default-cwd") +async def fs_default_cwd(): + cwd = _fs_default_cwd() + return {"cwd": cwd, "branch": _fs_git_branch(cwd)} + + @app.get("/api/status") async def get_status(): current_ver, latest_ver = check_config_version() -
tests/hermes_cli/test_web_server_fs.py@@ -0,0 +1,188 @@ +import base64 +from pathlib import Path + +import pytest + +from hermes_cli import web_server + +pytest.importorskip("starlette.testclient") +from starlette.testclient import TestClient + + [redacted:email] +def client(monkeypatch): + previous_auth_required = getattr(web_server.app.state, "auth_required", None) + web_server.app.state.auth_required = False + test_client = TestClient(web_server.app) + test_client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN + try: + yield test_client + finally: + if previous_auth_required is None: + try: + delattr(web_server.app.state, "auth_required") + except AttributeError: + pass + else: + web_server.app.state.auth_required = previous_auth_required + + +def test_fs_list_sorts_and_hides_noise(client, tmp_path): + root = tmp_path / "project" + root.mkdir() + (root / "b.txt").write_text("b") + (root / "a_dir").mkdir() + (root / "a.txt").write_text("a") + (root / "node_modules").mkdir() + (root / ".git").mkdir() + + response = client.get("/api/fs/list", params={"path": str(root)}) + + assert response.status_code == 200 + entries = response.json()["entries"] + assert [entry["name"] for entry in entries] == ["a_dir", "a.txt", "b.txt"] + assert entries[0] == {"name": "a_dir", "path": str(root / "a_dir"), "isDirectory": True} + assert all(entry["name"] not in {".git", "node_modules"} for entry in entries) + + +def test_fs_list_accepts_relative_paths(client, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "rel").mkdir() + (tmp_path / "rel" / "file.txt").write_text("ok") + + response = client.get("/api/fs/list", params={"path": "rel"}) + + assert response.status_code == 200 + assert response.json()["entries"] == [ + {"name": "file.txt", "path": str(tmp_path / "rel" / "file.txt"), "isDirectory": False} + ] + + +def test_fs_list_missing_path_returns_structured_error(client, tmp_path): + response = client.get("/api/fs/list", params={"path": str(tmp_path / "missing")}) + + assert response.status_code == 200 + assert response.json() == {"entries": [], "error": "ENOENT"} + + +def test_fs_read_text_matches_preview_shape_and_truncates(client, tmp_path, monkeypatch): + monkeypatch.setattr(web_server, "_FS_TEXT_SOURCE_MAX_BYTES", 32) + monkeypatch.setattr(web_server, "_FS_TEXT_PREVIEW_MAX_BYTES", 5) + target = tmp_path / "sample.py" + target.write_text("print('hello')") + + response = client.get("/api/fs/read-text", params={"path": str(target)}) + + assert response.status_code == 200 + assert response.json() == { + "binary": False, + "byteSize": 14, + "language": "python", + "mimeType": "text/x-python", + "path": str(target), + "text": "print", + "truncated": True, + } + + +def test_fs_read_text_rejects_source_over_cap(client, tmp_path, monkeypatch): + monkeypatch.setattr(web_server, "_FS_TEXT_SOURCE_MAX_BYTES", 4) + target = tmp_path / "large.txt" + target.write_text("12345") + + response = client.get("/api/fs/read-text", params={"path": str(target)}) + + assert response.status_code == 413 + + +def test_fs_read_text_flags_binary(client, tmp_path): + target = tmp_path / "blob.bin" + target.write_bytes(b"hello\x00world") + + response = client.get("/api/fs/read-text", params={"path": str(target)}) + + assert response.status_code == 200 + body = response.json() + assert body["binary"] is True + assert body["text"].startswith("hello") + + +def test_fs_read_data_url_returns_capped_data_url(client, tmp_path, monkeypatch): + monkeypatch.setattr(web_server, "_FS_DATA_URL_MAX_BYTES", 16) + target = tmp_path / "image.png" + target.write_bytes(b"pngbytes") + + response = client.get("/api/fs/read-data-url", params={"path": str(target)}) + + assert response.status_code == 200 + assert response.json() == {"dataUrl": "data:image/png;base64," + base64.b64encode(b"pngbytes").decode("ascii")} + + +def test_fs_read_data_url_rejects_over_cap(client, tmp_path, monkeypatch): + monkeypatch.setattr(web_server, "_FS_DATA_URL_MAX_BYTES", 3) + target = tmp_path / "image.png" + target.write_bytes(b"1234") + + response = client.get("/api/fs/read-data-url", params={"path": str(target)}) + + assert response.status_code == 413 + + +def test_fs_git_root_for_nested_file(client, tmp_path): + (tmp_path / ".git").mkdir() + nested = tmp_path / "pkg" / "mod" + nested.mkdir(parents=True) + target = nested / "file.py" + target.write_text("x") + + response = client.get("/api/fs/git-root", params={"path": str(target)}) + + assert response.status_code == 200 + assert response.json() == {"root": str(tmp_path)} + + +def test_fs_git_root_returns_null_outside_repo(client, tmp_path): + response = client.get("/api/fs/git-root", params={"path": str(tmp_path)}) + + assert response.status_code == 200 + assert response.json() == {"root": None} + + +def test_fs_default_cwd_prefers_existing_terminal_cwd(client, tmp_path, monkeypatch): + monkeypatch.setattr(web_server, "load_config", lambda: {"terminal": {"cwd": str(tmp_path)}}) + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path / "env")) + monkeypatch.setattr(web_server.Path, "cwd", lambda: tmp_path / "process") + monkeypatch.setattr(web_server, "_fs_git_branch", lambda cwd: "main") + + response = client.get("/api/fs/default-cwd") + + assert response.status_code == 200 + assert response.json() == {"cwd": str(tmp_path), "branch": "main"} + + +def test_fs_default_cwd_falls_back_when_terminal_cwd_is_invalid(client, tmp_path, monkeypatch): + fallback = tmp_path / "backend" + fallback.mkdir() + monkeypatch.setattr(web_server, "load_config", lambda: {"terminal": {"cwd": "/client/missing"}}) + monkeypatch.setenv("TERMINAL_CWD", "/client/missing") + monkeypatch.setattr(web_server.Path, "cwd", lambda: fallback) + monkeypatch.setattr(web_server, "_fs_git_branch", lambda cwd: "") + + response = client.get("/api/fs/default-cwd") + + assert response.status_code == 200 + assert response.json() == {"cwd": str(fallback), "branch": ""} + + +def test_fs_endpoints_require_auth(tmp_path): + client = TestClient(web_server.app) + target = tmp_path / "secret.txt" + target.write_text("secret") + + list_response = client.get("/api/fs/list", params={"path": str(tmp_path)}) + read_response = client.get("/api/fs/read-text", params={"path": str(target)}) + default_response = client.get("/api/fs/default-cwd") + + assert list_response.status_code == 401 + assert read_response.status_code == 401 + assert default_response.status_code == 401
-
-
#68130 feat(desktop): SSH remote-backend connection mode
files:
apps/desktop/electron/connection-apply.test.ts,apps/desktop/electron/connection-apply.ts,apps/desktop/electron/connection-config.test.ts,apps/desktop/electron/connection-config.ts,apps/desktop/electron/desktop-installation.test.ts,apps/desktop/electron/desktop-installation.ts,apps/desktop/electron/main.ts,apps/desktop/electron/preload.ts,apps/desktop/electron/remote-lifecycle.test.ts,apps/desktop/electron/remote-lifecycle.ts,apps/desktop/electron/ssh-bootstrap-coordinator.test.ts,apps/desktop/electron/ssh-bootstrap-coordinator.ts,apps/desktop/electron/ssh-config.test.ts,apps/desktop/electron/ssh-config.ts,apps/desktop/electron/ssh-connection.test.ts,apps/desktop/electron/ssh-connection.ts,apps/desktop/electron/windows-remote-lifecycle.test.ts,apps/desktop/electron/windows-remote-lifecycle.ts,apps/desktop/electron/windows-remote-live.test.ts,apps/desktop/src/app/contrib/wiring.tsx(+28 more)Show the diff
-
apps/desktop/electron/connection-apply.test.ts@@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from 'vitest' + +import { applyConnectionChange, commitConnectionFailure, resolveTerminalConnection } from './connection-apply' + +function deferred() { + let resolve!: () => void + + const promise = new Promise<void>(done => { + resolve = done + }) + + return { promise, resolve } +} + +describe('applyConnectionChange', () => { + it.each([['SSH A to SSH B'], ['SSH to Cloud'], ['Cloud to SSH']])( + 'serializes %s behind bootstrap rollback before teardown and apply', + async () => { + const gate = deferred() + const events: string[] = [] + + const run = applyConnectionChange({ + cancelAndWait: async () => { + events.push('cancel') + await gate.promise + events.push('drained') + }, + isPrimary: true, + scope: '', + sendApplied: () => events.push('applied'), + stopPool: vi.fn(), + teardownPrimary: async () => { + events.push('primary') + }, + teardownSsh: async () => { + events.push('ssh') + } + }) + + await Promise.resolve() + expect(events).toEqual(['cancel']) + gate.resolve() + await run + expect(events).toEqual(['cancel', 'drained', 'ssh', 'primary', 'applied']) + } + ) + + it('tears down only a non-primary scope without applying the primary connection', async () => { + const events: string[] = [] + await applyConnectionChange({ + cancelAndWait: async scope => { + events.push(`cancel:${scope}`) + }, + isPrimary: false, + scope: 'worker', + sendApplied: () => events.push('applied'), + stopPool: scope => events.push(`pool:${scope}`), + teardownPrimary: async () => { + events.push('primary') + }, + teardownSsh: async scope => { + events.push(`ssh:${scope}`) + } + }) + expect(events).toEqual(['cancel:worker', 'ssh:worker', 'pool:worker']) + }) +}) + +describe('resolveTerminalConnection', () => { + it('joins an in-flight backend before resolving the SSH terminal target', async () => { + const target = { ssh: {}, scope: '' } + const getTarget = vi.fn().mockReturnValueOnce('pending').mockReturnValueOnce(target) + const ensureBackend = vi.fn(async () => undefined) + + await expect(resolveTerminalConnection(getTarget, ensureBackend)).resolves.toBe(target) + expect(ensureBackend).toHaveBeenCalledOnce() + }) + + it('does not start a local terminal while configured SSH remains unavailable', async () => { + await expect( + resolveTerminalConnection( + () => 'pending', + async () => undefined + ) + ).rejects.toThrow('not ready') + }) +}) + +describe('commitConnectionFailure', () => { + it('prevents a stale bootstrap from publishing failure state', () => { + const stale = Promise.resolve('stale') + const current = Promise.resolve('current') + const commit = vi.fn() + + expect(commitConnectionFailure(current, stale, commit)).toBe(false) + expect(commit).not.toHaveBeenCalled() + expect(commitConnectionFailure(current, current, commit)).toBe(true) + expect(commit).toHaveBeenCalledOnce() + }) +}) -
apps/desktop/electron/connection-apply.ts@@ -0,0 +1,50 @@ +async function applyConnectionChange({ + cancelAndWait, + isPrimary, + scope, + sendApplied, + stopPool, + teardownPrimary, + teardownSsh +}) { + await cancelAndWait(scope) + await teardownSsh(scope) + + if (!isPrimary) { + stopPool(scope) + + return + } + + await teardownPrimary() + sendApplied() +} + +function commitConnectionFailure(current, starting, commit) { + if (current !== starting) { + return false + } + + commit() + + return true +} + +async function resolveTerminalConnection(getTarget, ensureBackend) { + let target = getTarget() + + if (target !== 'pending') { + return target + } + + await ensureBackend() + target = getTarget() + + if (target === 'pending') { + throw new Error('Remote connection is not ready yet. Try again in a moment.') + } + + return target +} + +export { applyConnectionChange, commitConnectionFailure, resolveTerminalConnection } -
apps/desktop/electron/connection-config.test.ts@@ -26,14 +26,19 @@ import { gatewayTicketFailure, gatewayWsUrlIpcResult, isGatewayAuthRejection, + localProfileEntry, modeIsRemoteLike, normalizeRemoteBaseUrl, + normalizeSshConfig, normAuthMode, pathWithGlobalRemoteProfile, + profileHasRemoteConnection, profileRemoteOverride, + profileSshOverride, resolveAuthMode, resolveTestWsUrl, RT_COOKIE_VARIANTS, + savedProfileSsh, tokenPreview } from './connection-config' @@ -127,6 +132,61 @@ test('profileRemoteOverride tolerates a missing/!object profiles map', () => { assert.equal(profileRemoteOverride(null, 'coder'), null) }) +test('SSH remains separate from URL-shaped remote modes', () => { + assert.equal(modeIsRemoteLike('ssh'), false) + const config = { profiles: { coder: { mode: 'ssh', host: 'alice@box:2222', keyPath: '/key' } } } + assert.equal(profileRemoteOverride(config, 'coder'), null) + assert.deepEqual(profileSshOverride(config, 'coder'), { + mode: 'ssh', + host: 'box', + user: 'alice', + port: 2222, + keyPath: '/key' + }) +}) + +test('normalizeSshConfig handles IPv6 and strict port bounds', () => { + assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: '::1', port: 22 }), { + mode: 'ssh', + host: '::1' + }) + assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: '[::1]:2222' }), { + mode: 'ssh', + host: '::1', + port: 2222 + }) + assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box', port: '2222junk' }), { + mode: 'ssh', + host: 'box' + }) + assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box', port: 65536 }), { + mode: 'ssh', + host: 'box' + }) +}) + +test('localProfileEntry preserves inactive SSH drafts but drops Cloud state', () => { + const ssh = { mode: 'ssh', host: 'box', user: 'alice', remoteHermesPath: '/hermes' } + assert.deepEqual(localProfileEntry(ssh), { mode: 'local', savedSsh: ssh }) + assert.deepEqual(localProfileEntry({ mode: 'local', savedSsh: ssh }), { + mode: 'local', + savedSsh: ssh + }) + assert.equal(localProfileEntry({ mode: 'cloud', url: 'https://agent' }), null) +}) + +test('saved SSH drafts are inactive and explicit overrides take precedence', () => { + const saved = { mode: 'ssh', host: 'saved' } + const config: any = { profiles: { coder: { mode: 'local', savedSsh: saved } } } + assert.deepEqual(savedProfileSsh(config, 'coder'), saved) + assert.equal(profileSshOverride(config, 'coder'), null) + assert.equal(profileHasRemoteConnection(config, 'coder'), false) + + config.profiles.coder = { mode: 'ssh', host: 'active' } + assert.deepEqual(profileSshOverride(config, 'coder'), { mode: 'ssh', host: 'active' }) + assert.equal(profileHasRemoteConnection(config, 'coder'), true) +}) + // --- pathWithGlobalRemoteProfile --- test('pathWithGlobalRemoteProfile appends profile in global remote mode', () => { -
apps/desktop/electron/connection-config.ts@@ -203,6 +203,127 @@ function modeIsRemoteLike(mode) { return mode === 'remote' || mode === 'cloud' } +function normalizeSshConfig(entry) { + if (!entry || typeof entry !== 'object' || entry.mode !== 'ssh') { + return null + } + + let host = String(entry.host || '').trim() + + if (!host) { + return null + } + + let parsedUser + let parsedPort + const at = host.indexOf('@') + + if (at > 0) { + parsedUser = host.slice(0, at) + host = host.slice(at + 1) + } + + const bracketed = /^\[([^\]]+)](?::(\d+))?$/.exec(host) + + if (bracketed) { + host = bracketed[1] + + if (bracketed[2]) { + parsedPort = Number(bracketed[2]) + } + } else if ((host.match(/:/g) || []).length === 1) { + const [name, rawPort] = host.split(':') + + if (/^\d+$/.test(rawPort)) { + host = name + parsedPort = Number(rawPort) + } + } + + if (!host) { + return null + } + + const out: any = { mode: 'ssh', host } + const user = String(entry.user || '').trim() || parsedUser || '' + + if (user) { + out.user = user + } + + const rawExplicitPort = String(entry.port ?? '').trim() + const explicitPort = /^\d+$/.test(rawExplicitPort) ? Number(rawExplicitPort) : null + const port = explicitPort ?? parsedPort + + if (Number.isInteger(port) && port > 0 && port <= 65535 && port !== 22) { + out.port = port + } + + const keyPath = String(entry.keyPath || '').trim() + + if (keyPath) { + out.keyPath = keyPath + } + + const remoteHermesPath = String(entry.remoteHermesPath || '').trim() + + if (remoteHermesPath) { + out.remoteHermesPath = remoteHermesPath + } + + return out +} + +function profileSshOverride(config, profile) { + const key = connectionScopeKey(profile) + const entry = key ? config?.profiles?.[key] : null + + return normalizeSshConfig(entry) +} + +function savedProfileSsh(config, profile) { + const key = connectionScopeKey(profile) + const entry = key ? config?.profiles?.[key] : null + + if (!entry || entry.mode !== 'local') { + return null + } + + return normalizeSshConfig(entry.savedSsh) +} + +function profileHasRemoteConnection(config, profile) { + return Boolean(profileRemoteOverride(config, profile) || profileSshOverride(config, profile)) +} + +function localProfileEntry(existing) { + const ssh = normalizeSshConfig(existing) || normalizeSshConfig(existing?.savedSsh) + + return ssh ? { mode: 'local', savedSsh: ssh } : null +} + +function hostLabelFromBaseUrl(baseUrl) { + const raw = String(baseUrl || '').trim() + + if (!raw) { + return null + } + + try { + const parsed = new URL(raw) + + if (!parsed.hostname) { + return null + } + + return parsed.port && parsed.port !== '80' && parsed.port !== '443' + ? `${parsed.hostname}:${parsed.port}` + : parsed.hostname + } catch { + return null + } +} + /** * Select a profile's explicit remote override from a connection config, or null * when it has none (so the caller falls back to env → global remote → local). @@ -372,15 +493,21 @@ export { cookiesHaveSession, gatewayTicketFailure, gatewayWsUrlIpcResult, + hostLabelFromBaseUrl, isGatewayAuthRejection, + localProfileEntry, modeIsRemoteLike, normalizeRemoteBaseUrl, + normalizeSshConfig, normAuthMode, pathWithGlobalRemoteProfile, PRIVY_SESSION_COOKIE_VARIANTS, + profileHasRemoteConnection, profileRemoteOverride, + profileSshOverride, resolveAuthMode, resolveTestWsUrl, RT_COOKIE_VARIANTS, + savedProfileSsh, tokenPreview } -
apps/desktop/electron/desktop-installation.test.ts@@ -0,0 +1,105 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { test } from 'vitest' + +import { loadOrCreateInstallationId, parseInstallationId, sshOwnershipId } from './desktop-installation' + +const ID_A = '11111111-1111-4111-8111-111111111111' +const ID_B = '22222222-2222-4222-8222-222222222222' + +function withTempDir(run) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-installation-')) + + try { + return run(directory) + } finally { + fs.rmSync(directory, { recursive: true, force: true }) + } +} + +test('parseInstallationId accepts only a version-4 UUID record', () => { + assert.equal(parseInstallationId(JSON.stringify({ installationId: ID_A.toUpperCase() })), ID_A) + assert.equal(parseInstallationId(JSON.stringify({ installationId: 'not-an-id' })), '') + assert.equal(parseInstallationId('{}'), '') + assert.equal(parseInstallationId('{'), '') +}) + +test('loadOrCreateInstallationId persists and reuses one installation ID', () => + withTempDir(directory => { + const filePath = path.join(directory, 'desktop-installation.json') + assert.equal( + loadOrCreateInstallationId(filePath, () => ID_A), + ID_A + ) + assert.equal( + loadOrCreateInstallationId(filePath, () => ID_B), + ID_A + ) + assert.equal(fs.statSync(filePath).mode & 0o777, 0o600) + })) + +test('loadOrCreateInstallationId tightens an existing identity file', () => + withTempDir(directory => { + const filePath = path.join(directory, 'desktop-installation.json') + fs.writeFileSync(filePath, JSON.stringify({ installationId: ID_A }), { mode: 0o644 }) + assert.equal( + loadOrCreateInstallationId(filePath, () => ID_B), + ID_A + ) + + if (process.platform !== 'win32') { + assert.equal(fs.statSync(filePath).mode & 0o777, 0o600) + } + })) + +test('loadOrCreateInstallationId replaces a malformed existing record', () => + withTempDir(directory => { + const filePath = path.join(directory, 'desktop-installation.json') + fs.writeFileSync(filePath, '{', { mode: 0o600 }) + assert.equal( + loadOrCreateInstallationId(filePath, () => ID_A), + ID_A + ) + assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).installationId, ID_A) + })) + +test('loadOrCreateInstallationId replaces an existing symlink', () => + withTempDir(directory => { + if (process.platform === 'win32') { + return + } + + const target = path.join(directory, 'target.json') + const filePath = path.join(directory, 'desktop-installation.json') + fs.writeFileSync(target, JSON.stringify({ installationId: ID_B }), { mode: 0o600 }) + fs.symlinkSync(target, filePath) + assert.equal( + loadOrCreateInstallationId(filePath, () => ID_A), + ID_A + ) + assert.equal(fs.lstatSync(filePath).isSymbolicLink(), false) + assert.equal(JSON.parse(fs.readFileSync(target, 'utf8')).installationId, ID_B) + })) + +test('loadOrCreateInstallationId replaces a malformed destination without a repair lock', () => + withTempDir(directory => { + const filePath = path.join(directory, 'desktop-installation.json') + fs.writeFileSync(filePath, '{', { mode: 0o600 }) + assert.equal( + loadOrCreateInstallationId(filePath, () => ID_A), + ID_A + ) + assert.equal(fs.existsSync(`${filePath}.lock`), false) + })) + +test('sshOwnershipId is stable, scoped, and does not disclose the UUID', () => { + const global = sshOwnershipId(ID_A, '') + assert.match(global, /^[0-9a-f]{32}$/) + assert.equal(global, sshOwnershipId(ID_A, '')) + assert.notEqual(global, sshOwnershipId(ID_A, 'worker')) + assert.ok(!global.includes(ID_A.slice(0, 8))) + assert.throws(() => sshOwnershipId('bad', '')) +}) -
apps/desktop/electron/desktop-installation.ts@@ -0,0 +1,137 @@ +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' + +const INSTALLATION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +function parseInstallationId(raw) { + try { + const value = JSON.parse(String(raw || ''))?.installationId + + return INSTALLATION_ID_RE.test(value) ? value.toLowerCase() : '' + } catch { + return '' + } +} + +function readInstallationId(filePath) { + try { + const stat = fs.lstatSync(filePath) + + if (!stat.isFile() || stat.isSymbolicLink()) { + return '' + } + + if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) { + return '' + } + + if (process.platform !== 'win32' && (stat.mode & 0o777) !== 0o600) { + fs.chmodSync(filePath, 0o600) + } + + return parseInstallationId(fs.readFileSync(filePath, 'utf8')) + } catch { + return '' + } +} + +function waitForRepair() { + const buffer = new SharedArrayBuffer(4) + Atomics.wait(new Int32Array(buffer), 0, 0, 25) +} + +function loadOrCreateInstallationId(filePath, randomUUID = crypto.randomUUID) { + const existing = readInstallationId(filePath) + + if (existing) { + return existing + } + + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + const installationId = randomUUID().toLowerCase() + + if (!INSTALLATION_ID_RE.test(installationId)) { + throw new Error('Could not generate a valid desktop installation ID.') + } + + const repairPath = `${filePath}.repair.lock` + + for (let attempt = 0; attempt < 40; attempt++) { + let repairFd + + try { + repairFd = fs.openSync(repairPath, 'wx', 0o600) + } catch (error: any) { + if (error?.code !== 'EEXIST') { + throw error + } + + const winner = readInstallationId(filePath) + + if (winner) { + return winner + } + + waitForRepair() + + continue + } + + try { + const winner = readInstallationId(filePath) + + if (winner) { + return winner + } + + try { + const stat = fs.lstatSync(filePath) + + if (!stat.isFile() && !stat.isSymbolicLink()) { + throw new Error('Desktop installation ID path is not a regular file.') + } + + if (!stat.isSymbolicLink() && typeof process.getuid === 'function' && stat.uid !== process.getuid()) { + throw new Error('Desktop installation ID is owned by another user.') + } + + fs.unlinkSync(filePath) + } catch (error: any) { + if (error?.code !== 'ENOENT') { + throw error + } + } + + fs.writeFileSync(filePath, JSON.stringify({ installationId }), { encoding: 'utf8', flag: 'wx', mode: 0o600 }) + + return installationId + } finally { + if (repairFd !== undefined) { + fs.closeSync(repairFd) + } + + try { + fs.unlinkSync(repairPath) + } catch { + void 0 + } + } + } + + throw new Error('Could not repair the desktop installation ID.') +} + +function sshOwnershipId(installationId, scope) { + if (!INSTALLATION_ID_RE.test(String(installationId || ''))) { + throw new Error('Desktop installation ID is invalid.') + } + + return crypto + .createHash('sha256') + .update(`${installationId}\0${String(scope || '')}`) + .digest('hex') + .slice(0, 32) +} + +export { INSTALLATION_ID_RE, loadOrCreateInstallationId, parseInstallationId, readInstallationId, sshOwnershipId } -
apps/desktop/electron/main.ts@@ -39,6 +39,7 @@ import { waitForDashboardPortAnnouncement } from './backend-ready' import { shouldLatchBackendStartFailure } from './backend-start-failure' import { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } from './bootstrap-platform' import { runBootstrap } from './bootstrap-runner' +import { applyConnectionChange, resolveTerminalConnection } from './connection-apply' import { authModeFromStatus, buildGatewayWsUrl, @@ -49,16 +50,23 @@ import { cookiesHaveSession, gatewayTicketFailure, gatewayWsUrlIpcResult, + hostLabelFromBaseUrl, + localProfileEntry, modeIsRemoteLike, normalizeRemoteBaseUrl, + normalizeSshConfig, normAuthMode, pathWithGlobalRemoteProfile, + profileHasRemoteConnection, profileRemoteOverride, + profileSshOverride, resolveAuthMode, resolveTestWsUrl, + savedProfileSsh, tokenPreview } from './connection-config' import { adoptServedDashboardToken } from './dashboard-token' +import { loadOrCreateInstallationId, sshOwnershipId } from './desktop-installation' import { buildPosixCleanupScript, buildWindowsCleanupScript, @@ -111,6 +119,7 @@ import { ensureMainWindow } from './main-window-lifecycle' import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' import { createKeepAwake } from './power-save' import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' +import * as remoteLifecycle from './remote-lifecycle' import { RemoteLivenessTracker, RemoteRevalidationCoordinator, revalidateRemoteConnection } from './remote-liveness' import { buildSessionWindowUrl, @@ -121,6 +130,15 @@ import { SESSION_WINDOW_MIN_WIDTH } from './session-windows' import { ensureSpawnHelperExecutable } from './spawn-helper-perms' +import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator' +import { collectSshConfigHosts, parseSshGOutput } from './ssh-config' +import { + buildInteractiveSshArgs, + createSshProbeConnection, + pickLocalPort, + redactSecrets, + SshConnection +} from './ssh-connection' import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width' import { resolveBehindCount, shouldCountCommits } from './update-count' import { readLiveUpdateMarker, writeUpdateMarker } from './update-marker' @@ -151,6 +169,12 @@ import { getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path' +import { + buildWindowsInteractiveCommand, + connectWindowsRemote, + detectRemotePlatform, + helper +} from './windows-remote-lifecycle' import { alreadyHasNoSandbox, buildNoSandboxRelaunchArgs, @@ -507,6 +531,7 @@ const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstr const BOOTSTRAP_MARKER_SCHEMA_VERSION = 1 const DESKTOP_CONNECTION_CONFIG_PATH = path.join(app.getPath('userData'), 'connection.json') +const DESKTOP_INSTALLATION_PATH = path.join(app.getPath('userData'), 'desktop-installation.json') const DESKTOP_UPDATE_CONFIG_PATH = path.join(app.getPath('userData'), 'updates.json') const DESKTOP_WINDOW_STATE_PATH = path.join(app.getPath('userData'), 'window-state.json') // active-profile.json records which Hermes profile the desktop launches its @@ -4590,18 +4615,36 @@ function closePreviewWatchers() { } } -async function waitForHermes(baseUrl, token) { +async function waitForHermes(baseUrl, token, signal?) { const deadline = Date.now() + 45_000 let lastError = null while (Date.now() < deadline) { + if (signal?.aborted) { + const error: any = new Error('SSH bootstrap was superseded by newer connection settings.') + error.kind = 'superseded' + throw error + } + try { await fetchJson(`${baseUrl}/api/status`, token) return } catch (error) { lastError = error - await new Promise(resolve => setTimeout(resolve, 500)) + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, 500) + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timer) + const aborted: any = new Error('SSH bootstrap was superseded by newer connection settings.') + aborted.kind = 'superseded' + reject(aborted) + }, + { once: true } + ) + }) } } @@ -4936,7 +4979,9 @@ function setAndPersistZoomLevel(window, zoomLevel) { // downgrade or JSON read failure still finds a sane value). window.webContents .executeJavaScript( - `try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}` + `try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch { + void 0 + }` ) .catch(error => rememberLog(`[zoom] persist failed: ${error?.message || error}`)) } @@ -6042,16 +6087,39 @@ function sanitizeConnectionProfiles(raw: Record<string, any>) { continue } + if (entry.mode === 'ssh') { + const ssh = normalizeSshConfig(entry) + + if (ssh) { + if (entry.token && typeof entry.token === 'object') { + ssh.token = [redacted:secret-assignment] + } + + out[name] = ssh + } + + continue + } + const cleaned: { mode: 'remote' | 'local' | 'cloud' url?: string authMode?: string token?: object org?: string + savedSsh?: object } = { mode: modeIsRemoteLike(entry.mode) ? entry.mode : 'local' } + if (cleaned.mode === 'local') { + const savedSsh = normalizeSshConfig(entry.savedSsh) + + if (savedSsh) { + cleaned.savedSsh = savedSsh + } + } + const url = String(entry.url || '').trim() if (url) { @@ -6109,7 +6177,7 @@ function readDesktopConnectionConfig() { // backward compatibility with configs written before OAuth support. remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token' config = { - mode: modeIsRemoteLike(parsed.mode) ? parsed.mode : 'local', + mode: parsed.mode === 'ssh' ? 'ssh' : modeIsRemoteLike(parsed.mode) ? parsed.mode : 'local', remote, // Per-profile remote overrides: each profile may point at its own // backend (local spawn or its own remote URL). Preserved verbatim so @@ -6176,15 +6244,15 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon const block = key ? scoped || {} : config.remote || {} const envOverride = key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL) + const savedMode = key ? scoped?.mode : config.mode + const ssh = savedMode === 'ssh' ? normalizeSshConfig(block) : null + + const savedSsh = savedMode === 'local' ? (key ? savedProfileSsh(config, key) : normalizeSshConfig(block)) : null const remoteToken = [redacted:secret-assignment] const authMode = normAuthMode(block.authMode) const remoteUrl = envOverride ? String(process.env.HERMES_DESKTOP_REMOTE_URL || '') : String(block.url || '') - // The env override forces a plain remote connection. Otherwise reflect the - // saved mode, preserving 'cloud' (a Hermes Cloud connection — Q6) so the UI - // reopens into the cloud picker; any non-remote-like value collapses to local. - const savedMode = key ? scoped?.mode : config.mode - const mode = envOverride ? 'remote' : modeIsRemoteLike(savedMode) ? savedMode : 'local' + const mode = envOverride ? 'remote' : savedMode === 'ssh' ? 'ssh' : modeIsRemoteLike(savedMode) ? savedMode : 'local' let remoteOauthConnected = false @@ -6212,6 +6280,11 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon cloudOrg: mode === 'cloud' ? String(block.org || '') : '', remoteTokenPreview: [redacted:secret-assignment] remoteTokenSet: [redacted:secret-assignment] + sshHost: (ssh || savedSsh)?.host || '', + sshUser: (ssh || savedSsh)?.user || '', + sshPort: (ssh || savedSsh)?.port || null, + sshKeyPath: (ssh || savedSsh)?.keyPath || '', + sshRemoteHermesPath: (ssh || savedSsh)?.remoteHermesPath || '', // The env override only forces the global/primary connection; a per-profile // scope is never overridden by HERMES_DESKTOP_REMOTE_URL. envOverride @@ -6250,7 +6323,7 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo // 'cloud' and 'remote' both persist a remote-shaped block; 'cloud' is // remembered as its own provenance (Q6) and resolves to remote downstream. // Anything else collapses to local. - const mode = modeIsRemoteLike(input.mode) ? input.mode : 'local' + const mode = input.mode === 'ssh' ? 'ssh' : modeIsRemoteLike(input.mode) ? input.mode : 'local' const remoteLike = modeIsRemoteLike(mode) // The block being edited: a per-profile entry or the global remote block. @@ -6263,7 +6336,8 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo // block. (remote↔local toggles still preserve a real remote URL as before.) const existingMode = key ? existing.profiles?.[key]?.mode : existing.mode const leavingCloud = existingMode === 'cloud' && mode !== 'cloud' - const existingBlock = leavingCloud ? {} : rawExistingBlock + const leavingSsh = rawExistingBlock.mode === 'ssh' && mode !== 'ssh' && mode !== 'local' + const existingBlock = leavingCloud || leavingSsh ? {} : rawExistingBlock const remoteUrl = String(input.remoteUrl ?? existingBlock.url ?? '').trim() // authMode: explicit input wins; otherwise inherit the saved value, default 'token'. const authMode = resolveAuthMode(input.remoteAuthMode, existingBlock.authMode) @@ -6279,6 +6353,22 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo : { encoding: 'plain', value: incomingToken } : existingBlock.token + if (mode === 'ssh') { + const sshBlock = buildSshBlock(input, savedProfileSsh(existing, key) || rawExistingBlock) + + if (key) { + const profiles = { ...(existing.profiles || {}), [key]: sshBlock } + + return { + mode: existing.mode === 'ssh' || modeIsRemoteLike(existing.mode) ? existing.mode : 'local', + remote: existing.remote || {}, + profiles + } + } + + return { mode: 'ssh', remote: sshBlock, profiles: existing.profiles || {} } + } + if (key) { // Per-profile scope: a remote/cloud entry pins this profile to its own // backend; a local entry clears the override so the profile inherits the @@ -6288,11 +6378,17 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo if (remoteLike) { profiles[key] = { mode, ...buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg) } } else { - delete profiles[key] + const localEntry = localProfileEntry(rawExistingBlock) + + if (localEntry) { + profiles[key] = localEntry + } else { + delete profiles[key] + } } return { - mode: modeIsRemoteLike(existing.mode) ? existing.mode : 'local', + mode: existing.mode === 'ssh' || modeIsRemoteLike(existing.mode) ? existing.mode : 'local', remote: existing.remote || {}, profiles } @@ -6300,19 +6396,62 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo const nextRemote = remoteLike ? buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg) - : { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken } + : existingMode === 'ssh' + ? rawExistingBlock + : { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken } // Preserve per-profile overrides when saving the global connection. return { mode, remote: nextRemote, profiles: existing.profiles || {} } } +// Build an SSH connection block from a save payload, preserving an +// already-adopted dashboard token from the existing block (the token is minted +// + reconciled at bootstrap, never user-entered). `mode: 'ssh'` is stamped so +// normalizeSshConfig/profileSshOverride recognize it. +function buildSshBlock(input: any, existingBlock: any = {}) { + // `??` (not `||`) so an explicit '' (user CLEARED the field) wins over the + // saved value; only a truly absent (undefined) field inherits. + const merged = normalizeSshConfig({ + mode: 'ssh', + host: input.sshHost ?? existingBlock.host, + user: input.sshUser ?? existingBlock.user, + port: input.sshPort ?? existingBlock.port, + keyPath: input.sshKeyPath ?? existingBlock.keyPath, + remoteHermesPath: input.sshRemoteHermesPath ?? existingBlock.remoteHermesPath + }) + + if (!merged) { + throw new Error('SSH host is required.') + } + + // Carry forward an already-adopted dashboard token unless the host changed + // (a different host invalidates the old dashboard's token). + if (existingBlock.token && existingBlock.host === merged.host) { + merged.token = [redacted:secret-assignment] + } + + return merged +} + // Build a remote backend connection descriptor from an already-resolved remote // config. Handles both auth models (OAuth ws-ticket vs static session token) // and is shared by the per-profile, env, and global resolution paths. `token` // is the DECRYPTED static token (or null in OAuth mode). `source` is a label // for diagnostics ('profile' | 'env' | 'settings'). -async function buildRemoteConnection(rawUrl, authMode, token, source) { +async function buildRemoteConnection( + rawUrl, + authMode, + token, + source, + remoteHost?, + remoteKind = 'url', + remoteIdentity? +) { const baseUrl = normalizeRemoteBaseUrl(rawUrl) + // For token/oauth remotes the meaningful host is the real backend URL; for + // SSH remotes the caller passes the entered/resolved host explicitly (the + // baseUrl is a 127.0.0.1 tunnel and would be useless in the pill). + const host = remoteHost || hostLabelFromBaseUrl(baseUrl) if (authMode === 'oauth') { // OAuth gateway: auth comes from the session cookies in the OAuth @@ -6350,6 +6489,9 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) { mode: 'remote', source, authMode: 'oauth', + remoteHost: host || undefined, + remoteIdentity, + remoteKind, // No static token in OAuth mode; REST is cookie-authed via the partition. token: null, wsUrl: buildGatewayWsUrlWithTicket(baseUrl, ticket) @@ -6368,11 +6510,288 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) { mode: 'remote', source, authMode: 'token', + remoteHost: host || undefined, + remoteIdentity, + remoteKind, token, wsUrl: buildGatewayWsUrl(baseUrl, token) } } +const sshConnections = new Map<string, any>() +const desktopInstallationId = loadOrCreateInstallationId(DESKTOP_INSTALLATION_PATH) + +const sshBootstrapCoordinator = createBootstrapCoordinator() + +let sshQuitTeardownDone = false + +function sshScopeKey(profile) { + return connectionScopeKey(profile) || '' +} + +function sshOwnershipKey(profile) { + return sshOwnershipId(desktopInstallationId, sshScopeKey(profile)) +} + +function sshRememberLog(chunk) { + rememberLog(redactSecrets(String(chunk == null ? '' : chunk))) +} + +async function sshProbeReuseProof(baseUrl, token, spawnNonce) { + try { + const proof: any = await fetchJson(`${baseUrl}/api/ssh/ownership`, token) + + return proof?.ok === true && proof.sshOwnerNonce === spawnNonce && proof.protocolVersion === 1 + ? 'authenticated-ok' + : 'authenticated-stale' + } catch (error: any) { + if (/^(401|403|404):/.test(String(error?.message || ''))) { + return 'authenticated-stale' + } + + throw error + } +} + +async function teardownSshConnection(profile) { + const scope = sshScopeKey(profile) + const state = sshConnections.get(scope) + + if (!state) { + return + } + + sshConnections.delete(scope) + + for (const [id, info] of [...terminalSessions.entries()]) { + if (info.sshScope === scope) { + disposeTerminalSession(id) + } + } + + try { + if (state.localPort && state.remotePort) { + await state.ssh.cancelForward(state.localPort, state.remotePort) + } + } catch { + // best effort + } + + try { + await state.ssh.close() + } catch { + // best effort + } +} + +// CRITICAL: this must mirror resolveRemoteBackend's precedence, not just return +// any cached SSH state. A per-profile token/OAuth override wins over a global +// SSH connection — so if the active profile resolves to a NON-SSH backend, the +// terminal must NOT fall through to a global SSH host. +function activeSshTerminalTarget() { + const profile = primaryProfileKey() + const config = readDesktopConnectionConfig() + + if (profileSshOverride(config, profile)) { + const scope = sshScopeKey(profile) + const state = sshConnections.get(scope) + + return state && state.ssh ? { ssh: state.ssh, scope } : 'pending' + } + + if (profileRemoteOverride(config, profile)) { + return null + } + + if (process.env.HERMES_DESKTOP_REMOTE_URL) { + return null + } + + if (config.mode === 'ssh') { + const state = sshConnections.get('') + + return state && state.ssh ? { ssh: state.ssh, scope: '' } : 'pending' + } + + return null +} + +function effectiveSshConfigFingerprint(sshConfig) { + const ssh = + process.platform === 'win32' + ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe') + : 'ssh' + + const args = ['-G'] + + if (sshConfig.port) { + args.push('-p', String(sshConfig.port)) + } + + if (sshConfig.keyPath) { + args.push('-i', sshConfig.keyPath) + } + + args.push('--', sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host) + const output = execFileSync(ssh, args, { encoding: 'utf8', timeout: 10_000, windowsHide: true }) + + return crypto.createHash('sha256').update(output).digest('hex') +} + +async function bootstrapSshConnection(profile, sshConfig, reuseToken, source) { + const scope = sshScopeKey(profile) + const effectiveConfigFingerprint = effectiveSshConfigFingerprint(sshConfig) + const resolvedConfig = { ...sshConfig, effectiveConfigFingerprint } + const fingerprint = sshConfigFingerprint(scope, resolvedConfig) + + return sshBootstrapCoordinator.start(scope, fingerprint, lease => + bootstrapSshConnectionInner(profile, resolvedConfig, reuseToken, source, fingerprint, lease) + ) +} + +async function bootstrapSshConnectionInner(profile, sshConfig, reuseToken, source, fingerprint, lease) { + const scope = sshScopeKey(profile) + const hostLabel = sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host + const existing = sshConnections.get(scope) + + if (existing && existing.fingerprint !== fingerprint) { + await teardownSshConnection(profile) + } + + let ssh = sshConnections.get(scope)?.ssh + + if (ssh && !(await ssh.isAlive())) { + try { + await ssh.close() + } catch { + void 0 + } + + ssh = null + sshConnections.delete(scope) + } + + const created = !ssh + + let removeForceCleanup = () => {} + + if (created) { + ssh = new SshConnection( + { host: sshConfig.host, user: sshConfig.user, port: sshConfig.port, keyPath: sshConfig.keyPath }, + { + rememberLog: sshRememberLog, + ownershipId: sshOwnershipKey(profile), + scope, + effectiveConfigFingerprint: sshConfig.effectiveConfigFingerprint + } + ) + removeForceCleanup = lease.onForceCleanup(() => ssh.close()) + await ssh.open() + } + + let result + + try { + const platform = await detectRemotePlatform(ssh, sshConfig.remoteHermesPath || '') + const lifecycle = platform.os === 'Windows' ? connectWindowsRemote : remoteLifecycle.connect + result = await lifecycle({ + ssh, + profile: connectionScopeKey(profile) || '', + remoteHermesPath: sshConfig.remoteHermesPath || '', + ownershipId: sshOwnershipKey(profile), + reuseToken: reuseToken || '', + forward: (localPort, remotePort) => ssh.forward(localPort, remotePort), + cancelForward: (localPort, remotePort) => ssh.cancelForward(localPort, remotePort), + pickLocalPort, + waitForHermes: (baseUrl, token) => waitForHermes(baseUrl, token, lease.signal), + probeReuseProof: sshProbeReuseProof, + adoptServedToken: [redacted:secret-assignment] + rememberLog: sshRememberLog, + signal: lease.signal + }) + } catch (error: any) { + if (created) { + try { + await ssh.close() + } catch { + void 0 + } + } + + const err = new Error(error.message) as any + err.sshError = error.kind || 'unknown' + err.isSshBootstrap = true + throw err + } + + try { + lease.assertCurrent() + } catch (error) { + try { + await ssh.cancelForward(result.localPort, result.remotePort) + await ssh.close() + } catch { + void 0 + } + + throw error + } + + persistSshConnectionToken(profile, source, result.token) + + removeForceCleanup() + sshConnections.set(scope, { + ssh, + fingerprint, + localPort: result.localPort, + remotePort: result.remotePort, + pid: result.pid, + host: sshConfig.host, + hostLabel, + hermesVersion: result.hermesVersion || '', + remotePlatform: result.platform?.os || '', + reused: result.reused + }) + + sshRememberLog( + `[ssh] connection ${result.reused ? 'REUSED' : 'spawned'} dashboard: ` + + `${result.hermesVersion || 'hermes (version unknown)'} at ${result.hermesPath || '?'}` + ) + + const connection = await buildRemoteConnection( + result.baseUrl, + 'token', + result.token, + source, + hostLabel, + 'ssh', + result.ownershipId + ) + + return { ...connection, remoteHermesVersion: result.hermesVersion || '' } +} + +function persistSshConnectionToken(profile, source, token) { + try { + const config = readDesktopConnectionConfig() + const encrypted = encryptDesktopSecret(token) + + if (source === 'profile') { + const key = connectionScopeKey(profile) + + if (key && config.profiles?.[key]?.mode === 'ssh') { + config.profiles[key].token = encrypted + writeDesktopConnectionConfig(config) + } + } else if (config.mode === 'ssh' && config.remote) { + config.remote.token = encrypted + writeDesktopConnectionConfig(config) + } + } catch (error: any) { + sshRememberLog(`[ssh] could not persist served token: [redacted:secret-assignment] + } +} + // Resolve the remote backend for a given profile, or null when that profile // should run a LOCAL backend. Precedence: // 1. explicit per-profile remote override (connection.json `profiles[name]`) @@ -6386,12 +6805,27 @@ async function resolveRemoteBackend(profile) { // 1. Per-profile override — "a profile with its own remote host". Wins even // over the env override so an explicitly-configured profile always // reaches its intended backend. + const sshOverride = profileSshOverride(config, profile) + + if (sshOverride) { + const reuseToken = [redacted:secret-assignment] + + return bootstrapSshConnection(profile, sshOverride, reuseToken, 'profile') + } + const override = profileRemoteOverride(config, profile) if (override) { const token = [redacted:secret-assignment] === 'oauth' ? null : decryptDesktopSecret(override.token) - return buildRemoteConnection(override.url, override.authMode, token, 'profile') + return buildRemoteConnection( + override.url, + override.authMode, + token, + 'profile', + undefined, + config.profiles?.[connectionScopeKey(profile)]?.mode === 'cloud' ? 'cloud' : 'url' + ) } // 2. Env override (global, token-auth only). @@ -6409,7 +6843,20 @@ async function resolveRemoteBackend(profile) { return buildRemoteConnection(rawEnvUrl, 'token', rawEnvToken, 'env') } - // 3. Global remote (or cloud — cloud resolves to a remote backend, Q6). + // 3. Global remote. + if (config.mode === 'ssh') { + const ssh = normalizeSshConfig({ mode: 'ssh', ...(config.remote || {}) }) + + if (!ssh) { + throw new Error('SSH remote mode is selected but no host is configured.') + } + + const reuseToken = [redacted:secret-assignment] + + return bootstrapSshConnection(null, ssh, reuseToken, 'settings') + } + + // Cloud resolves through the existing URL/OAuth path. if (!modeIsRemoteLike(config.mode)) { return null } @@ -6417,7 +6864,14 @@ async function resolveRemoteBackend(profile) { const authMode = normAuthMode(config.remote?.authMode) const token = authMode === 'oauth' ? null : decryptDesktopSecret(config.remote?.token) - return buildRemoteConnection(config.remote?.url, authMode, token, 'settings') + return buildRemoteConnection( + config.remote?.url, + authMode, + token, + 'settings', + undefined, + config.mode === 'cloud' ? 'cloud' : 'url' + ) } // A remote profile's sessions live on its remote host's state.db, not on a local @@ -6425,13 +6879,13 @@ async function resolveRemoteBackend(profile) { // not the local-disk fast path. These three helpers drive that (see // interceptSessionReadForRemote). function profileHasRemoteOverride(profile) { - return Boolean(profileRemoteOverride(readDesktopConnectionConfig(), profile)) + return profileHasRemoteConnection(readDesktopConnectionConfig(), profile) } function configuredRemoteProfileNames() { const config = readDesktopConnectionConfig() - return Object.keys(config.profiles || {}).filter(name => profileRemoteOverride(config, name)) + return Object.keys(config.profiles || {}).filter(name => profileHasRemoteConnection(config, name)) } // True when the app is in app-global remote mode (Settings → "All profiles" → @@ -6443,7 +6897,9 @@ function globalRemoteActive() { return true } - return modeIsRemoteLike(readDesktopConnectionConfig().mode) + const mode = readDesktopConnectionConfig().mode + + return modeIsRemoteLike(mode) || mode === 'ssh' } // True when the PRIMARY profile's backend resolves to a remote/cloud host — @@ -6538,6 +6994,90 @@ async function probeRemoteAuthMode(rawUrl) { } async function testDesktopConnectionConfig(input: any = {}) { + if (input.mode === 'ssh') { + const sshConfig = normalizeSshConfig({ + mode: 'ssh', + host: input.sshHost, + user: input.sshUser, + port: input.sshPort, + keyPath: input.sshKeyPath, + remoteHermesPath: input.sshRemoteHermesPath + }) + + if (!sshConfig) { + return { reachable: false, sshError: 'unreachable', error: 'SSH host is required.' } + } + + const ssh = createSshProbeConnection( + { host: sshConfig.host, user: sshConfig.user, port: sshConfig.port, keyPath: sshConfig.keyPath }, + { rememberLog: sshRememberLog } + ) + + try { + // One bounded retry on TIMEOUT only: a cold Windows backend's first + // PowerShell exec can exceed the budget (observed live), and a timeout is + // indeterminate — unlike auth/host-key/unreachable, which are verdicts. + let attempt = 0 + + for (;;) { + try { + await ssh.open() + const platform: any = await detectRemotePlatform(ssh, sshConfig.remoteHermesPath || '') + let hermesPath + let hermesVersion + let supported + + if (platform.os === 'Windows') { + const runtime = platform + hermesPath = runtime.hermesPath + const inspection = await helper(ssh, runtime, 'inspect', [runtime.hermesPath]) + hermesVersion = inspection.version + supported = inspection.supported + } else { + hermesPath = await remoteLifecycle.locateHermes(ssh, sshConfig.remoteHermesPath || '') + hermesVersion = await remoteLifecycle.probeHermesVersion(ssh, hermesPath) + supported = await remoteLifecycle.remoteSupportsSshOwnership(ssh, hermesPath) + } + + if (!supported) { + return { + reachable: false, + sshError: 'update-required', + error: 'Update Hermes on the remote host before connecting with Desktop SSH.' + } + } + + return { + reachable: true, + sshError: null, + error: null, + remotePlatform: `${platform.os}/${platform.arch}`, + remoteHermesPath: hermesPath, + remoteHermesVersion: hermesVersion, + host: sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host + } + } catch (error: any) { + if (error?.kind === 'timeout' && attempt === 0) { + attempt += 1 + sshRememberLog('[ssh] test probe timed out once; retrying') + + continue + } + + throw error + } + } + } catch (error: any) { + return { reachable: false, sshError: error.kind || 'unknown', error: error.message } + } finally { + try { + await ssh.close() + } catch { + void 0 + } + } + } + const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false }) const key = connectionScopeKey(input.profile) // The block under test: a per-profile entry or the global remote. Coerce has @@ -7063,6 +7603,9 @@ async function startHermes() { mode: 'remote', source: remote.source, authMode: remote.authMode || 'token', + remoteHost: remote.remoteHost, + remoteKind: remote.remoteKind, + remoteHermesVersion: remote.remoteHermesVersion, token: [redacted:secret-assignment] wsUrl: remote.wsUrl, logs: hermesLog.slice(-80), @@ -7864,8 +8407,8 @@ ipcMain.handle('hermes:connection:revalidate', async () => { // Main and every session pop-out have their own renderer reconnect loop but // share this primary connection. Coalesce simultaneous requests so one outage // produces one failure observation rather than exhausting the whole streak. - return remoteRevalidation.run(connectionPromise, () => - revalidateRemoteConnection({ + return remoteRevalidation.run(connectionPromise, async () => { + const result = await revalidateRemoteConnection({ connectionPromise, currentConnectionPromise: () => backendConnectionState.getPromise(), log: rememberLog, @@ -7873,7 +8416,22 @@ ipcMain.handle('hermes:connection:revalidate', async () => { resetConnection: resetHermesConnection, tracker: remoteLiveness }) - ) + + // A rebuilt SSH connection must also tear down its tunnel/master before the + // renderer re-dials (which only happens after this handler resolves), so the + // fresh bootstrap can't reattach to a dying transport. + if (result.rebuilt) { + const conn = await connectionPromise.catch(() => null) + + if (conn?.remoteKind === 'ssh') { + const profile = primaryProfileKey() + await sshBootstrapCoordinator.cancelAndWait(sshScopeKey(profile)) + await teardownSshConnection(profile) + } + } + + return result + }) }) ipcMain.handle('hermes:backend:touch', async (_event, profile) => { touchPoolBackend(profile) @@ -8103,6 +8661,50 @@ ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState()) ipcMain.handle('hermes:connection-config:get', async (_event, profile) => sanitizeDesktopConnectionConfig(readDesktopConnectionConfig(), profile) ) +ipcMain.handle('hermes:ssh-config:hosts', async () => ({ hosts: collectSshConfigHosts() })) +ipcMain.handle('hermes:ssh-config:resolve', async (_event, host) => { + const value = String(host || '').trim() + + if (!value) { + throw new Error('SSH host is required.') + } + + const ssh = + process.platform === 'win32' + ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe') + : 'ssh' + + return new Promise((resolve, reject) => { + const child = spawn(ssh, ['-G', '--', value], hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'pipe'] })) + let stdout = '' + let stderr = '' + + const timer = setTimeout(() => { + child.kill() + reject(new Error('SSH config resolution timed out.')) + }, 10_000) + + child.stdout.on('data', chunk => { + stdout += String(chunk) + }) + child.stderr.on('data', chunk => { + stderr += String(chunk) + }) + child.once('error', error => { + clearTimeout(timer) + reject(error) + }) + child.once('close', code => { + clearTimeout(timer) + + if (code !== 0) { + reject(new Error(stderr.trim() || 'Could not resolve SSH host.')) + } else { + resolve(parseSshGOutput(stdout)) + } + }) + }) +}) ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload)) ipcMain.handle('hermes:connection-config:probe', async (_event, rawUrl) => probeRemoteAuthMode(rawUrl)) ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => { @@ -8164,19 +8766,17 @@ ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => { writeDesktopConnectionConfig(config) const key = connectionScopeKey(payload?.profile) - - if (key && key !== primaryProfileKey()) { - // Editing a NON-primary profile's connection: don't disturb the window's - // primary backend. Drop the profile's pooled backend so the next switch - // re-resolves against the new remote/local target. - stopPoolBackend(key) - } else { - // Global / primary connection: soft re-home. Tear down the window backend - // without resetting boot UI or reloading — the shell stays, the renderer - // wipes session lists (skeletons) and re-dials on hermes:connection:applied. - await teardownPrimaryBackendAndWait({ soft: true }) - sendConnectionApplied() - } + const scope = key || '' + + await applyConnectionChange({ + cancelAndWait: value => sshBootstrapCoordinator.cancelAndWait(value), + isPrimary: !key || key === primaryProfileKey(), + scope, + sendApplied: sendConnectionApplied, + stopPool: stopPoolBackend, + teardownPrimary: () => teardownPrimaryBackendAndWait({ soft: true }), + teardownSsh: value => teardownSshConnection(value || null) + }) return sanitizeDesktopConnectionConfig(config, payload?.profile) }) @@ -9254,15 +9854,30 @@ ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80) const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24) - const ptyProcess = nodePty.spawn(command, args, { - cols, - cwd, - env: terminalShellEnv(), - name: 'xterm-256color', - rows - }) + const sshTarget = await resolveTerminalConnection(activeSshTerminalTarget, () => ensureBackend(primaryProfileKey())) + const remote = Boolean(sshTarget) + const remoteState = remote ? sshConnections.get(sshTarget.scope) : null + + const remoteCommand = + remoteState?.remotePlatform === 'Windows' + ? buildWindowsInteractiveCommand(String(payload?.cwd || '').trim()) + : undefined + + const ptyProcess = remote + ? nodePty.spawn( + process.platform === 'win32' + ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe') + : 'ssh', + buildInteractiveSshArgs(sshTarget.ssh, String(payload?.cwd || '').trim(), undefined, remoteCommand), + { cols, cwd: app.getPath('home'), env: terminalShellEnv(), name: 'xterm-256color', rows } + ) + : nodePty.spawn(command, args, { cols, cwd, env: terminalShellEnv(), name: 'xterm-256color', rows }) - terminalSessions.set(id, { pty: ptyProcess, webContentsId: event.sender.id }) + terminalSessions.set(id, { + pty: ptyProcess, + webContentsId: event.sender.id, + ...(remote ? { sshScope: sshTarget.scope, remoteCwd: String(payload?.cwd || '') } : {}) + }) const send = (suffix, payload) => { if (event.sender.isDestroyed()) { @@ -9279,7 +9894,7 @@ ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { }) event.sender.once('destroyed', () => disposeTerminalSession(id)) - return { cwd, id, shell: name } + return { cwd: remote ? null : cwd, id, shell: remote ? 'ssh' : name } }) ipcMain.handle('hermes:terminal:write', (_event, id, data) => { @@ -9315,7 +9930,7 @@ ipcMain.handle('hermes:terminal:cwd', async (_event, id) => { return null } - return readProcessCwd(sessionInfo.pty.pid) + return sessionInfo.sshScope !== undefined ? null : readProcessCwd(sessionInfo.pty.pid) }) ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || ''))) @@ -9808,7 +10423,24 @@ function configureSpellChecker() { } } -app.on('before-quit', () => { +app.on('before-quit', event => { + if ((sshConnections.size > 0 || sshBootstrapCoordinator.promises().length > 0) && !sshQuitTeardownDone) { + event.preventDefault() + sshBootstrapCoordinator.cancelAll() + const scopes = [...sshConnections.keys()] + + const pending = Promise.allSettled([ + ...scopes.map(scope => teardownSshConnection(scope || null)), + ...sshBootstrapCoordinator.promises() + ]) + + void Promise.race([pending, new Promise(resolve => setTimeout(resolve, 4_000))]).then(async () => { + await sshBootstrapCoordinator.forceCleanupAll() + sshQuitTeardownDone = true + app.quit() + }) + } + // Clean quit mid-boot should not trip next-launch --no-sandbox (#38216). // FATAL GPU aborts skip before-quit, leaving the `booting` marker in place. // Keyed on sticky (not active): a manual --no-sandbox run still records a -
apps/desktop/electron/preload.ts@@ -41,6 +41,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', { saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload), applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload), testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload), + sshConfigHosts: () => ipcRenderer.invoke('hermes:ssh-config:hosts'), + sshResolveHost: host => ipcRenderer.invoke('hermes:ssh-config:resolve', host), probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl), oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl), oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl), -
apps/desktop/electron/remote-lifecycle.test.ts@@ -0,0 +1,1017 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { + buildSpawnCommand, + cleanupStale, + connect, + expandRemotePath, + fingerprintToken, + isForwardBindCollision, + locateHermes, + LOCKFILE_SCHEMA_VERSION, + lockfilePath, + openForward, + ownershipDirectory, + pidIsOurDashboard, + probeRemotePlatform, + PROTOCOL_VERSION, + readLockfile, + READY_RE, + remotePidAlive, + remoteSupportsSshOwnership, + scrapeReadyPort, + spawnLogPath, + spawnRemoteDashboard, + validateRemotePath, + writeLockfile +} from './remote-lifecycle' + +const OWNERSHIP_ID = '0123456789abcdef0123456789abcdef' +const SPAWN_NONCE = '0123456789abcdef' + +function ownedLock(over: any = {}) { + return { + schemaVersion: LOCKFILE_SCHEMA_VERSION, + protocolVersion: PROTOCOL_VERSION, + ownershipId: OWNERSHIP_ID, + spawnNonce: SPAWN_NONCE, + pid: 333, + port: 40000, + profile: '', + hermesPath: '~/.local/bin/hermes', + hermesHome: '~/.hermes', + logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), + tokenFingerprint: [redacted:secret-assignment]'stored-token'), + startedAt: '2026-07-14T00:00:00.000Z', + ...over + } +} + +// A fake SshConnection whose exec() is matched against an ordered list of +// [regex|fn, response|fn] rules. First match wins; unmatched commands return ''. +function fakeSsh(rules: any[] = []) { + const calls: string[] = [] + + return { + calls, + async exec(cmd) { + calls.push(cmd) + + for (const [matcher, resp] of rules) { + const hit = typeof matcher === 'function' ? matcher(cmd) : matcher.test(cmd) + + if (hit) { + const out = typeof resp === 'function' ? resp(cmd) : resp + + if (out instanceof Error) { + throw out + } + + return out + } + } + + return '' + } + } +} + +test('locateHermes prefers the explicit profile path when executable', async () => { + const ssh = fakeSsh([[/\[ -x .*\/opt\/hermes/, 'OK']]) + assert.equal(await locateHermes(ssh, '/opt/hermes'), '/opt/hermes') +}) + +test('locateHermes throws (no silent fallback) when an EXPLICIT path is not executable', async () => { + // command -v WOULD find a different install, but an explicit path must not + // silently fall back to it — that is the "connected to the wrong hermes" bug. + const ssh = fakeSsh([ + [/command -v hermes/, '/home/u/.local/bin/hermes\n'], + [/\[ -x .*\.local\/bin\/hermes/, 'OK'] + ]) + + await assert.rejects( + () => locateHermes(ssh, '/bad/path/hermes'), + (err: any) => { + assert.equal(err.kind, 'hermes-not-found') + assert.match(err.message, /\/bad\/path\/hermes/) + + return true + } + ) +}) + +test('locateHermes falls back to the login-shell command -v probe', async () => { + const ssh = fakeSsh([ + [/command -v hermes/, '/home/u/.local/bin/hermes\n'], + [/\[ -x .*\.local\/bin\/hermes/, 'OK'] + ]) + + assert.equal(await locateHermes(ssh, ''), '/home/u/.local/bin/hermes') +}) + +test('locateHermes canonicalizes an installer wrapper to its executable target', async () => { + const ssh = fakeSsh([ + [/command -v hermes/, '/home/u/.local/bin/hermes\n'], + [/\[ -x .*\.local\/bin\/hermes/, 'OK'], + [/python3 -c/, '/home/u/.hermes/hermes-agent/venv/bin/hermes\n'] + ]) + + assert.equal(await locateHermes(ssh, ''), '/home/u/.hermes/hermes-agent/venv/bin/hermes') +}) + +test('locateHermes falls back to ~/.local/bin/hermes when the login-shell probe misses', async () => { + // ~/.local/bin is the non-root installer's command location (scripts/install.sh). + const ssh = fakeSsh([ + [/command -v hermes/, ''], + [/\[ -x .*\.local\/bin\/hermes/, 'OK'] + ]) + + assert.equal(await locateHermes(ssh, ''), '~/.local/bin/hermes') +}) + +test('locateHermes tries the conventional venv path last', async () => { + const ssh = fakeSsh([[/\[ -x .*venv\/bin\/hermes/, 'OK']]) + assert.equal(await locateHermes(ssh, ''), '~/.hermes/hermes-agent/venv/bin/hermes') +}) + +test('locateHermes throws a hermes-not-found error with an install hint', async () => { + const ssh = fakeSsh([]) // nothing is executable + await assert.rejects( + () => locateHermes(ssh, ''), + (err: any) => { + assert.equal(err.kind, 'hermes-not-found') + assert.match(err.message, /install/i) + + return true + } + ) +}) + +test('locateHermes uses a login shell for the command -v probe', async () => { + const ssh = fakeSsh([ + [/command -v hermes/, '/x/hermes'], + [/\[ -x/, 'OK'] + ]) + + await locateHermes(ssh, '') + assert.ok( + ssh.calls.some(c => /bash -lc/.test(c)), + 'must probe in a login shell (PATH pitfall)' + ) +}) + +test('probeRemotePlatform accepts Linux and macOS', async () => { + assert.deepEqual(await probeRemotePlatform(fakeSsh([[/uname/, 'Linux\nx86_64']])), { + os: 'Linux', + arch: 'x86_64' + }) + assert.deepEqual(await probeRemotePlatform(fakeSsh([[/uname/, 'Darwin\narm64']])), { + os: 'Darwin', + arch: 'arm64' + }) +}) + +test('probeRemotePlatform rejects unsupported remote platforms', async () => { + await assert.rejects( + () => probeRemotePlatform(fakeSsh([[/uname/, 'MINGW64_NT\nx86_64']])), + (err: any) => { + assert.equal(err.kind, 'unsupported-platform') + + return true + } + ) +}) + +test('ownership paths are isolated by ownership ID and spawn nonce', () => { + assert.equal(ownershipDirectory(OWNERSHIP_ID), `~/.hermes/desktop-ssh/${OWNERSHIP_ID}`) + assert.equal(lockfilePath(OWNERSHIP_ID), `~/.hermes/desktop-ssh/${OWNERSHIP_ID}/backend.lock.json`) + assert.equal(spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), `~/.hermes/desktop-ssh/${OWNERSHIP_ID}/${SPAWN_NONCE}.log`) +}) + +test('readLockfile returns null for missing, empty, malformed, or wrong-schema', async () => { + assert.equal(await readLockfile(fakeSsh([[/cat/, '']]), OWNERSHIP_ID), null) + assert.equal(await readLockfile(fakeSsh([[/cat/, 'not json']]), OWNERSHIP_ID), null) + assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify({ schemaVersion: 999 })]]), OWNERSHIP_ID), null) + const good = ownedLock({ pid: 1, port: 2 }) + assert.deepEqual(await readLockfile(fakeSsh([[/cat/, JSON.stringify(good)]]), OWNERSHIP_ID), good) +}) + +test('writeLockfile mkdir -ps and stamps the schema version', async () => { + const ssh = fakeSsh([]) + await writeLockfile(ssh, OWNERSHIP_ID, ownedLock({ pid: 7, port: 9 })) + const cmd = ssh.calls.join('\n') + assert.match(cmd, /mkdir -p/) + assert.match(cmd, new RegExp(`"schemaVersion":${LOCKFILE_SCHEMA_VERSION}`)) +}) + +test('remotePidAlive maps kill -0 ALIVE/DEAD', async () => { + assert.equal(await remotePidAlive(fakeSsh([[/kill -0/, 'ALIVE']]), 123), true) + assert.equal(await remotePidAlive(fakeSsh([[/kill -0/, 'DEAD']]), 123), false) + assert.equal(await remotePidAlive(fakeSsh([]), null), false) +}) + +test('metadata and process proof transport failures remain indeterminate', async () => { + const failure = new Error('connection reset') + await assert.rejects( + () => readLockfile(fakeSsh([[/cat/, failure]]), OWNERSHIP_ID), + (error: any) => error.kind === 'transient-transport-error' + ) + await assert.rejects( + () => remotePidAlive(fakeSsh([[/kill -0/, failure]]), 123), + (error: any) => error.kind === 'transient-transport-error' + ) + await assert.rejects( + () => pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, failure]]), 5, SPAWN_NONCE, '/x/hermes'), + (error: any) => error.kind === 'transient-transport-error' + ) +}) + +test('pidIsOurDashboard requires the exact serve ownership nonce', async () => { + const ours = `/x/hermes serve --isolated --ssh-owner-nonce ${SPAWN_NONCE}` + assert.equal(await pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, 'OWNED\n']]), 5, SPAWN_NONCE, '/x/hermes'), true) + assert.equal( + await pidIsOurDashboard( + fakeSsh([[/print\("OWNED"/, command => (command.includes('fedcba9876543210') ? 'FOREIGN\n' : 'OWNED\n')]]), + 5, + 'fedcba9876543210', + '/x/hermes' + ), + false + ) + assert.equal(await pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, 'FOREIGN\n']]), 5, SPAWN_NONCE, '/x/hermes'), false) +}) + +test('cleanupStale kills ONLY a provably-ours pid, always drops the lockfile', async () => { + const notOurs = fakeSsh([[/print\("OWNED"/, 'FOREIGN\n']]) + await cleanupStale(notOurs, OWNERSHIP_ID, { + pid: 5, + spawnNonce: SPAWN_NONCE, + hermesPath: '/x/hermes', + logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) + }) + assert.ok(!notOurs.calls.some(c => /kill 5\b/.test(c)), 'must not kill a pid that is not our dashboard') + assert.ok(notOurs.calls.some(c => /rm -f/.test(c))) + + const ours = fakeSsh([[/print\("OWNED"/, 'OWNED\n']]) + await cleanupStale(ours, OWNERSHIP_ID, { + pid: 9, + spawnNonce: SPAWN_NONCE, + hermesPath: '/x/hermes', + logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) + }) + assert.ok(ours.calls.some(c => /kill 9\b/.test(c))) + assert.ok(ours.calls.some(c => /rm -f/.test(c))) +}) + +test('buildSpawnCommand is headless serve, detached, token not in argv', () => { + const cmd = buildSpawnCommand('/x/hermes', 'work', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) }) + assert.match(cmd, /serve --isolated/) + assert.match(cmd, /--host 127\.0\.0\.1 --port 0/) + assert.doesNotMatch(cmd, /--skip-build|--no-open/) + assert.doesNotMatch(cmd, /\bdashboard\b/) + assert.match(cmd, /--profile/) + assert.match(cmd, /work/) + assert.match(cmd, /setsid/) + assert.match(cmd, /<\/dev\/null/) + assert.match(cmd, /echo \$!/) + assert.ok(!cmd.includes('tok_secret_value'), 'token must not appear in spawn command') + assert.ok(!cmd.includes('HERMES_DASHBOARD_SESSION_TOKEN'), 'token env var must not appear') +}) + +test('buildSpawnCommand always uses serve (legacy dashboard path removed)', () => { + const cmd = buildSpawnCommand('/x/hermes', 'work', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) }) + assert.match(cmd, /serve --isolated/) + assert.match(cmd, /--host 127\.0\.0\.1 --port 0/) + assert.doesNotMatch(cmd, /dashboard/) + assert.doesNotMatch(cmd, /--skip-build/) + assert.match(cmd, /setsid/) +}) + +test('spawnRemoteDashboard returns exact ownership artifacts', async () => { + const ssh = fakeSsh([ + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/printf '%s\\n'/, ''], + [/setsid|nohup/, '4242\n'] + ]) + + const { pid, spawnNonce, logPath } = await spawnRemoteDashboard(ssh, { + hermesPath: '/x/hermes', + profile: '', + token: 'tk', + ownershipId: OWNERSHIP_ID + }) + + assert.equal(pid, 4242) + assert.match(spawnNonce, /^[0-9a-f]{16}$/) + assert.equal(logPath, spawnLogPath(OWNERSHIP_ID, spawnNonce)) +}) + +test('spawnRemoteDashboard always spawns serve (legacy dashboard path removed)', async () => { + const ssh = fakeSsh([ + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/printf '%s\\n'/, ''], + [/setsid|nohup/, '4242\n'] + ]) + + await spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tk', ownershipId: OWNERSHIP_ID }) + const spawn = ssh.calls.find(c => /setsid|nohup/.test(c)) + assert.match(spawn, /serve --isolated/) + assert.doesNotMatch(spawn, /\bdashboard\b/) +}) + +test('READY_RE accepts both serve and dashboard sentinels', () => { + assert.equal(READY_RE.exec('HERMES_BACKEND_READY port=4321')?.[1], '4321') + assert.equal(READY_RE.exec('HERMES_DASHBOARD_READY port=8765')?.[1], '8765') +}) + +test('spawnRemoteDashboard rejects when no pid is returned', async () => { + const ssh = fakeSsh([ + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/printf '%s\\n'/, ''], + [/setsid|nohup/, 'not-a-pid'] + ]) + + await assert.rejects( + () => spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 't', ownershipId: OWNERSHIP_ID }), + (err: any) => { + assert.equal(err.kind, 'spawn-failed') + + return true + } + ) +}) + +test('scrapeReadyPort reads only the named spawn log', async () => { + const logPath = spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) + const ssh = fakeSsh([[/cat/, 'some noise\nHERMES_DASHBOARD_READY port=51234\n']]) + const port = await scrapeReadyPort(ssh, logPath, { timeoutMs: 1000 }) + assert.equal(port, 51234) + assert.ok(ssh.calls.every(call => !call.includes('desktop-ssh.log'))) +}) + +test('scrapeReadyPort times out and reports a dead spawn', async () => { + // never emits a READY line + const ssh = fakeSsh([[/cat .*\.log/, 'still starting...']]) + await assert.rejects( + () => scrapeReadyPort(ssh, spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), { timeoutMs: 60 }), + (err: any) => { + assert.equal(err.kind, 'ready-timeout') + + return true + } + ) + // dead process before announcement → spawn-failed + await assert.rejects( + () => + scrapeReadyPort(fakeSsh([[/cat/, '']]), spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), { + timeoutMs: 1000, + isAlive: async () => false + }), + (err: any) => { + assert.equal(err.kind, 'spawn-failed') + + return true + } + ) +}) + +function connectDeps(ssh, over: any = {}) { + return { + ssh, + ownershipId: OWNERSHIP_ID, + profile: '', + forward: async () => {}, + cancelForward: async () => {}, + pickLocalPort: async () => 50001, + waitForHermes: async () => {}, + probeReuseProof: async () => 'authenticated-ok', + adoptServedToken: async (_baseUrl, spawn) => spawn || 'served-token', + rememberLog: () => {}, + readyTimeoutMs: 2000, + ...over + } +} + +test('connect() spawns fresh when there is no lockfile, adopts the served token', async () => { + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, ''], // no lockfile + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], // token file write + [/printf '%s\\n'/, ''], + [/setsid/, '777\n'], + [/kill -0 777/, 'ALIVE'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=51999\n'] + ]) + + const result = await connect(connectDeps(ssh, { adoptServedToken: async () => 'the-served-token' })) + assert.equal(result.reused, false) + assert.equal(result.remotePort, 51999) + assert.equal(result.localPort, 50001) + assert.equal(result.pid, 777) + assert.equal(result.token, 'the-served-token') + assert.equal(result.baseUrl, 'http://127.0.0.1:50001') + assert.equal(result.tokenFingerprint, fingerprintToken('the-served-token')) +}) + +test('connect() reuses a healthy dashboard when fingerprint + probe pass', async () => { + const reuseToken = '[redacted:secret-assignment]' + const lock = ownedLock({ tokenFingerprint: [redacted:secret-assignment] }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0/, 'ALIVE'], + [/print\("OWNED"/, 'OWNED\n'] + ]) + + const result = await connect(connectDeps(ssh, { reuseToken, adoptServedToken: async (_b, t) => t })) + assert.equal(result.reused, true) + assert.equal(result.pid, 333) + assert.equal(result.remotePort, 40000) + // never spawned + assert.ok(!ssh.calls.some(c => /setsid/.test(c)), 'reuse path must not spawn a new dashboard') +}) + +test('connect() respawns when the lockfile hermesPath differs from the resolved path', async () => { + const reuseToken = '[redacted:secret-assignment]' + const lock = ownedLock({ hermesPath: '/old/stale/hermes', tokenFingerprint: [redacted:secret-assignment] }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0/, 'ALIVE'], + [/print\("OWNED"/, 'FOREIGN\n'], + [/--version/, 'Hermes Agent v0.18.2\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '890\n'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=52050\n'] + ]) + + const result = await connect( + connectDeps(ssh, { reuseToken, remoteHermesPath: '/new/hermes', adoptServedToken: async () => 'fresh' }) + ) + + assert.equal(result.reused, false, 'must respawn, not reuse the old-path dashboard') + assert.ok( + ssh.calls.some(c => /setsid/.test(c)), + 'a fresh dashboard must be spawned' + ) +}) + +test('connect() respawns when the lockfile protocolVersion is incompatible', async () => { + const reuseToken = '[redacted:secret-assignment]' + + const lock = { + schemaVersion: LOCKFILE_SCHEMA_VERSION, + protocolVersion: PROTOCOL_VERSION + 99, + pid: 333, + port: 40000, + tokenFingerprint: [redacted:secret-assignment] + } + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0 333/, 'ALIVE'], + [/print\("OWNED"/, 'FOREIGN\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '901\n'], + [/kill -0 901/, 'ALIVE'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=44100\n'] + ]) + + const result = await connect(connectDeps(ssh, { reuseToken, adoptServedToken: async () => 'fresh' })) + assert.equal(result.reused, false, 'incompatible protocol must force a fresh spawn, not a reattach') + assert.equal(result.pid, 901) +}) + +test('connect() fresh spawn writes hermesHome + protocolVersion into the lockfile', async () => { + const writes: string[] = [] + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, ''], // no lockfile + [/HERMES_HOME/, '/home/alice/.hermes\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/printf '%s\\n'/, ''], + [/setsid/, '700\n'], + [/kill -0 700/, 'ALIVE'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=45500\n'], + [ + /printf '%s' '/, + c => { + writes.push(c) + + return '' + } + ] + ]) + + await connect(connectDeps(ssh, { adoptServedToken: async () => 'fresh' })) + const lockWrite = writes.find(c => c.includes('schemaVersion')) || '' + assert.match(lockWrite, new RegExp(`"protocolVersion":${PROTOCOL_VERSION}`)) + assert.match(lockWrite, /"hermesHome":"\/home\/alice\/\.hermes"/) +}) + +test('connect() respawns when the lockfile pid is dead (killed dashboard)', async () => { + const lock = ownedLock({ tokenFingerprint: [redacted:secret-assignment]'t') }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0 333/, 'DEAD'], + [/print\("OWNED"/, 'FOREIGN\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '888\n'], + [/kill -0 888/, 'ALIVE'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=42000\n'] + ]) + + const result = await connect(connectDeps(ssh, { reuseToken: 't', adoptServedToken: async () => 'fresh' })) + assert.equal(result.reused, false) + assert.equal(result.pid, 888) + assert.equal(result.remotePort, 42000) + assert.ok( + !ssh.calls.some(command => command.includes('pid=333') && command.includes('print("OWNED"')), + 'a dead pid has no process identity to verify' + ) +}) + +test('connect() respawns when the dashboard is wedged (alive pid, probe fails)', async () => { + const reuseToken = '[redacted:secret-assignment]' + + const lock = { + schemaVersion: LOCKFILE_SCHEMA_VERSION, + protocolVersion: PROTOCOL_VERSION, + pid: 333, + port: 40000, + tokenFingerprint: [redacted:secret-assignment] + } + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0/, 'ALIVE'], + [/print\("OWNED"/, 'FOREIGN\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '999\n'], + [/kill -0 999/, 'ALIVE'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=43000\n'] + ]) + + const result = await connect( + connectDeps(ssh, { + reuseToken, + probeReuseProof: async () => 'authenticated-stale', + adoptServedToken: async () => 'fresh' + }) + ) + + assert.equal(result.reused, false) + assert.equal(result.pid, 999) + assert.equal(result.remotePort, 43000) +}) + +test('connect() aborts on an unsupported remote platform before doing anything else', async () => { + const ssh = fakeSsh([[/uname/, 'SunOS\nsun4v']]) + await assert.rejects( + () => connect(connectDeps(ssh)), + (err: any) => { + assert.equal(err.kind, 'unsupported-platform') + + return true + } + ) + assert.ok(!ssh.calls.some(c => /setsid/.test(c))) +}) + +test('openForward retries bind collisions only', async () => { + const ports = [41001, 41002] + const calls: number[] = [] + + const localPort = await openForward( + { + pickLocalPort: async () => ports.shift(), + forward: async port => { + calls.push(port) + + if (calls.length === 1) { + throw new Error('bind: Address already in use') + } + } + }, + 9119 + ) + + assert.equal(localPort, 41002) + assert.deepEqual(calls, [41001, 41002]) + assert.equal(isForwardBindCollision(new Error('Permission denied')), false) +}) + +test('connect() preserves an owned backend when a reuse transport throws', async () => { + const reuseToken = '[redacted:secret-assignment]' + const lock = ownedLock({ tokenFingerprint: [redacted:secret-assignment] }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0/, 'ALIVE'], + [/print\("OWNED"/, 'OWNED\n'] + ]) + + await assert.rejects( + () => + connect( + connectDeps(ssh, { + reuseToken, + forward: async () => { + throw new Error('network reset') + } + }) + ), + /network reset/ + ) + assert.ok(!ssh.calls.some(cmd => /kill 333\b/.test(cmd))) +}) + +test('validateRemotePath accepts absolute POSIX paths', () => { + assert.doesNotThrow(() => validateRemotePath('/usr/bin/hermes')) + assert.doesNotThrow(() => validateRemotePath('/home/user/.hermes/hermes-agent/venv/bin/hermes')) +}) + +test('validateRemotePath accepts ~/ prefix paths', () => { + assert.doesNotThrow(() => validateRemotePath('~/bin/hermes')) + assert.doesNotThrow(() => validateRemotePath('~/.hermes/logs/desktop-ssh.log')) + assert.doesNotThrow(() => validateRemotePath('~')) +}) + +test('validateRemotePath accepts paths with spaces and quotes', () => { + assert.doesNotThrow(() => validateRemotePath('/home/user/my project/hermes')) + assert.doesNotThrow(() => validateRemotePath("~/path with 'quotes'/file")) + assert.doesNotThrow(() => validateRemotePath('/path with "double quotes"/file')) +}) + +test('validateRemotePath rejects relative paths', () => { + assert.throws(() => validateRemotePath('hermes'), /absolute|relative/i) + assert.throws(() => validateRemotePath('./bin/hermes'), /absolute|relative/i) + assert.throws(() => validateRemotePath('../etc/passwd'), /absolute|relative/i) +}) + +test('validateRemotePath rejects NUL and newline', () => { + assert.throws(() => validateRemotePath('/usr/bin/hermes\x00'), /unsafe/i) + assert.throws(() => validateRemotePath('/usr/bin/hermes\n'), /unsafe/i) + assert.throws(() => validateRemotePath('/usr/bin/hermes\r'), /unsafe/i) +}) + +test('validateRemotePath preserves shell metacharacters as path data', () => { + for (const p of ['/usr/$(whoami)/hermes', '/usr/`id`/hermes', '/usr/a;b|c&d<e>f']) { + assert.doesNotThrow(() => validateRemotePath(p)) + assert.match(expandRemotePath(p), /^'/) + } +}) + +test('expandRemotePath expands ~/ to "$HOME"/', () => { + const result = expandRemotePath('~/.hermes/logs/desktop-ssh.log') + assert.match(result, /\$HOME/) + assert.ok(!result.includes('eval'), 'must not use eval') + assert.ok(!result.includes('echo'), 'must not use echo for expansion') +}) + +test('expandRemotePath returns quoted absolute paths unchanged', () => { + const result = expandRemotePath('/usr/local/bin/hermes') + assert.ok(result.includes('/usr/local/bin/hermes')) + assert.ok(!result.includes('eval')) +}) + +test('expandRemotePath preserves spaces as data', () => { + const result = expandRemotePath('/home/user/my project/hermes') + assert.ok(result.includes('my project'), 'spaces must be preserved, not split') +}) + +test('buildSpawnCommand does not embed the token in the command string', () => { + const cmd = buildSpawnCommand('/x/hermes', 'work', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) }) + assert.ok(!cmd.includes('super_secret_token_value'), 'token must not appear in the spawn command') + assert.ok(!cmd.includes('HERMES_DASHBOARD_SESSION_TOKEN'), 'env var name must not appear') +}) + +test('buildSpawnCommand includes --ssh-session-token-file when tokenFilePath is provided', () => { + const cmd = buildSpawnCommand('/x/hermes', 'work', { + tokenFilePath: [redacted:secret-assignment] + logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), + spawnNonce: SPAWN_NONCE + }) + + assert.match(cmd, /--ssh-session-token-file/) + assert.match(cmd, /\.hermes\/desktop-ssh\//) +}) + +test('buildSpawnCommand always uses serve, never dashboard', () => { + const cmd = buildSpawnCommand('/x/hermes', '', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) }) + assert.match(cmd, /serve --isolated/) + assert.doesNotMatch(cmd, /\bdashboard\b/) + assert.doesNotMatch(cmd, /--skip-build/) + assert.doesNotMatch(cmd, /--no-open/) +}) + +test('spawnRemoteDashboard removes a token file when upload reporting fails', async () => { + const failure = new Error('channel closed') + + const ssh = fakeSsh([ + [/grep -q ssh-session-token-file/, 'YES\n'], + [command => /python3 -c/.test(command) && !/rm -f/.test(command), failure], + [/rm -f/, ''] + ]) + + await assert.rejects( + () => spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tok', ownershipId: OWNERSHIP_ID }), + /channel closed/ + ) + assert.ok(ssh.calls.some(command => /rm -f .*\.token/.test(command))) +}) + +test('spawnRemoteDashboard streams the token over stdin, not argv/env', async () => { + const stdinCalls: string[] = [] + const calls: string[] = [] + + const ssh = { + calls, + async exec(cmd, opts?) { + calls.push(cmd) + + if (opts?.stdinData) { + stdinCalls.push(opts.stdinData) + } + + if (/grep -q ssh-session-token-file/.test(cmd)) { + return 'YES\n' + } + + if (/python3 -c/.test(cmd)) { + return '' + } + + if (/setsid|nohup/.test(cmd)) { + return '4242\n' + } + + if (/printf '%s\\n'/.test(cmd)) { + return '' + } + + return '' + } + } + + const { pid } = await spawnRemoteDashboard(ssh as any, { + hermesPath: '/x/hermes', + profile: '', + token: '[redacted:secret-assignment]', + ownershipId: OWNERSHIP_ID + }) + + assert.equal(pid, 4242) + assert.ok(stdinCalls.length > 0, 'token must be sent via stdin') + assert.ok( + stdinCalls.some(d => d === 'secret_token_val'), + 'stdin must contain the token' + ) + + for (const cmd of calls) { + assert.ok(!cmd.includes('secret_token_val'), `token leaked into command: ${cmd}`) + } +}) + +test('spawnRemoteDashboard upload uses exclusive-create and O_NOFOLLOW', async () => { + const calls: string[] = [] + + const ssh = { + calls, + async exec(cmd, opts?) { + calls.push(cmd) + + if (/grep -q ssh-session-token-file/.test(cmd)) { + return 'YES\n' + } + + if (/python3 -c/.test(cmd)) { + return '' + } + + if (/setsid|nohup/.test(cmd)) { + return '4242\n' + } + + if (/printf '%s\\n'/.test(cmd)) { + return '' + } + + return '' + } + } + + await spawnRemoteDashboard(ssh as any, { + hermesPath: '/x/hermes', + profile: '', + token: 'tk', + ownershipId: OWNERSHIP_ID + }) + const uploadCmd = calls.find(c => /python3 -c/.test(c)) + assert.ok(uploadCmd, 'must use python3 -c for token upload') + assert.match(uploadCmd, /O_EXCL/, 'upload must use O_EXCL to reject existing files') + assert.match(uploadCmd, /O_NOFOLLOW/, 'upload must use O_NOFOLLOW to reject symlinks') + assert.match(uploadCmd, /O_WRONLY/, 'upload must open write-only') + assert.match(uploadCmd, /dir_fd=dd/, 'upload must create relative to the opened parent directory') + assert.match(uploadCmd, /os\.fstat\(dd\)/, 'upload must validate the opened parent directory') + assert.ok(!uploadCmd.includes('tk'), 'token must not appear in the upload command') +}) + +test('readLockfile rejects lock with non-integer pid', async () => { + const lock = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 'not-a-number', port: 8080 } + assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock)]]), OWNERSHIP_ID), null) +}) + +test('readLockfile rejects lock with pid <= 0', async () => { + const lock = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: -1, port: 8080 } + assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock)]]), OWNERSHIP_ID), null) +}) + +test('readLockfile rejects lock with port out of range', async () => { + const lock = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 100, port: 99999 } + assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock)]]), OWNERSHIP_ID), null) + const lock2 = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 100, port: 0 } + assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock2)]]), OWNERSHIP_ID), null) +}) + +test('readLockfile accepts a complete owned lock', async () => { + const lock = ownedLock({ pid: 42, port: 51234 }) + const result = await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock)]]), OWNERSHIP_ID) + assert.deepEqual(result, lock) +}) + +test('connect() reuse path does not write a token file', async () => { + const reuseToken = '[redacted:secret-assignment]' + const lock = ownedLock({ tokenFingerprint: [redacted:secret-assignment] }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0/, 'ALIVE'], + [/print\("OWNED"/, 'OWNED\n'] + ]) + + const result = await connect(connectDeps(ssh, { reuseToken, adoptServedToken: async (_b, t) => t })) + assert.equal(result.reused, true) + assert.ok(!ssh.calls.some(c => /sys\.stdin\.buffer\.read/.test(c)), 'reuse must not upload a token file') +}) + +test('spawnRemoteDashboard fails with update-required when remote lacks --ssh-session-token-file', async () => { + const ssh = fakeSsh([[/--ssh-session-token-file/, 'NO\n']]) + + await assert.rejects( + () => spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tk', ownershipId: OWNERSHIP_ID }), + (err: any) => { + assert.match(err.message, /update|upgrade/i) + assert.equal(err.kind, 'update-required') + + return true + } + ) +}) + +test('readLockfile rejects a log path outside the exact ownership and spawn path', async () => { + const lock = ownedLock({ logPath: '~/.hermes/desktop-ssh/other.log' }) + const ssh = fakeSsh([[/cat .*lock\.json/, JSON.stringify(lock)]]) + assert.equal(await readLockfile(ssh, OWNERSHIP_ID), null) +}) + +test('cleanupStale never deletes a lock-supplied unexpected log path', async () => { + const ssh = fakeSsh([[/print\("OWNED"/, 'OWNED\n']]) + await cleanupStale(ssh, OWNERSHIP_ID, ownedLock({ logPath: '~/.hermes/unrelated.log' })) + assert.ok(!ssh.calls.some(command => command.includes('unrelated.log'))) +}) + +test('pidIsOurDashboard requires an exact nonce option value', async () => { + const prefix = `/x/hermes serve --isolated --ssh-owner-nonce ${SPAWN_NONCE}ff` + const suffix = `/x/hermes serve --isolated --ssh-owner-nonce xx${SPAWN_NONCE}` + assert.equal(await pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, 'FOREIGN\n']]), 5, SPAWN_NONCE, '/x/hermes'), false) + assert.equal(await pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, 'FOREIGN\n']]), 5, SPAWN_NONCE, '/x/hermes'), false) +}) + +test('connect removes the token file when a fresh backend fails after returning a pid', async () => { + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, ''], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '999\n'], + [/kill -0 999/, 'DEAD'] + ]) + + await assert.rejects(() => connect(connectDeps(ssh)), /exited before announcing/i) + assert.ok(ssh.calls.some(command => /rm -f .*\.token/.test(command))) +}) + +test('connect preserves an exact-owned backend when reuse proof transport fails', async () => { + const reuseToken = '[redacted:secret-assignment]' + const lock = ownedLock({ tokenFingerprint: [redacted:secret-assignment] }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0/, 'ALIVE'], + [/print\("OWNED"/, 'OWNED\n'] + ]) + + await assert.rejects( + () => + connect( + connectDeps(ssh, { + reuseToken, + probeReuseProof: async () => { + throw new Error('connection reset') + } + }) + ), + (error: any) => error.kind === 'transient-transport-error' + ) + assert.ok(!ssh.calls.some(command => /kill 333\b/.test(command))) + assert.ok(!ssh.calls.some(command => /rm -f .*backend\.lock\.json/.test(command))) +}) + +test('connect replaces an exact-owned backend only after authenticated stale proof', async () => { + const reuseToken = '[redacted:secret-assignment]' + const lock = ownedLock({ tokenFingerprint: [redacted:secret-assignment] }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0 333/, 'ALIVE'], + [/print\("OWNED"/, 'OWNED\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '999\n'], + [/kill -0 999/, 'ALIVE'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=43000\n'] + ]) + + const result = await connect( + connectDeps(ssh, { + reuseToken, + probeReuseProof: async (_baseUrl, token, nonce) => { + assert.equal(token, reuseToken) + assert.equal(nonce, SPAWN_NONCE) + + return 'authenticated-stale' + }, + adoptServedToken: async () => 'fresh' + }) + ) + + assert.equal(result.reused, false) + assert.ok(ssh.calls.some(command => /kill 333\b/.test(command))) +}) + +test('remote SSH ownership capability requires both secure bootstrap flags', async () => { + let helpProbe = '' + + const supported = fakeSsh([ + [ + /serve --help/, + command => { + helpProbe = command + + return 'YES\n' + } + ] + ]) + + assert.equal(await remoteSupportsSshOwnership(supported, '/x/hermes'), true) + assert.match(helpProbe, /ssh-session-token-file/) + assert.match(helpProbe, /ssh-owner-nonce/) + + const unsupported = fakeSsh([[/serve --help/, 'NO\n']]) + assert.equal(await remoteSupportsSshOwnership(unsupported, '/x/hermes'), false) +}) -
apps/desktop/electron/remote-lifecycle.ts@@ -0,0 +1,906 @@ +/** + * remote-lifecycle.ts + * + * Pure, electron-free remote Hermes dashboard lifecycle over SSH for Desktop + * SSH remote mode. Composes an SshConnection (injected) with HTTP probes + * through the established tunnel (injected fetch) and the served-token adoption + * step (injected). Knows how to: + * + * - locate the Hermes install on the remote (login-shell probe), + * - gate the remote platform to Linux/macOS via `uname`, + * - reuse an existing desktop-dedicated dashboard via a lockfile + an + * AUTHENTICATED /api/status probe (pid liveness alone is insufficient), + * - spawn a fresh detached `--isolated --port 0` dashboard and scrape its + * `HERMES_DASHBOARD_READY port=<n>` readiness line, + * - adopt the token the dashboard actually serves (served-token adoption), + * - clean up a stale dashboard only when it is provably ours. + * + * No `import 'electron'` so it's unit-testable with `node --test`. main.ts wires + * the real SshConnection, fetch, adoptServedDashboardToken, and waitForHermes in. + * + * The minted HERMES_DASHBOARD_SESSION_TOKEN is the SPAWN credential. After + * readiness the caller runs served-token adoption against the tunneled baseUrl + * and the SERVED token's fingerprint is what lands in the lockfile — so the + * reuse probe checks the credential that actually authenticates /api/ws, not + * the minted one (which the dashboard may regen). + */ + +import crypto from 'node:crypto' + +const LOCKFILE_SCHEMA_VERSION = 2 +// Bumped when the desktop<->dashboard reuse contract changes in a way that makes +// an old running dashboard unsafe to reattach to (token handling, readiness/spawn +// args, served-token reconciliation). A mismatch forces a clean respawn. +const PROTOCOL_VERSION = 1 +const READY_RE = /^HERMES_(?:BACKEND|DASHBOARD)_READY port=(\d+)/m +const REMOTE_LOCK_DIR = '~/.hermes/desktop-ssh' +const SUPPORTED_REMOTE_OS = new Set(['Linux', 'Darwin']) +const DEFAULT_READY_TIMEOUT_MS = 45_000 +const READY_POLL_INTERVAL_MS = 750 + +function mintToken() { + return crypto.randomBytes(32).toString('hex') +} + +// Fingerprint a token for the lockfile — never store the raw secret on the +// remote. SHA256, truncated. +function fingerprintToken(token) { + return crypto + .createHash('sha256') + .update(String(token || '')) + .digest('hex') + .slice(0, 32) +} + +function validateOwnershipId(ownershipId) { + const value = String(ownershipId || '') + + if (!/^[0-9a-f]{32}$/.test(value)) { + throw new Error('SSH ownership ID is invalid.') + } + + return value +} + +function validateSpawnNonce(spawnNonce) { + const value = String(spawnNonce || '') + + if (!/^[0-9a-f]{16}$/.test(value)) { + throw new Error('SSH spawn nonce is invalid.') + } + + return value +} + +function ownershipDirectory(ownershipId) { + return `${REMOTE_LOCK_DIR}/${validateOwnershipId(ownershipId)}` +} + +function lockfilePath(ownershipId) { + return `${ownershipDirectory(ownershipId)}/backend.lock.json` +} + +function spawnLogPath(ownershipId, spawnNonce) { + return `${ownershipDirectory(ownershipId)}/${validateSpawnNonce(spawnNonce)}.log` +} + +// shell-single-quote a value for safe interpolation into a remote command. +function shq(value) { + return `'${String(value).replace(/'/g, `'\\''`)}'` +} + +function validateRemotePath(p) { + const s = String(p || '') + + if (!s) { + throw new Error('Remote path must not be empty.') + } + + // eslint-disable-next-line no-control-regex -- deliberately reject NUL in remote paths + if (/[\x00\n\r]/.test(s)) { + throw new Error('Unsafe remote path: contains NUL or newline.') + } + + if (s === '~' || s.startsWith('~/') || s.startsWith('/')) { + return + } + + throw new Error(`Remote path must be absolute or start with ~/: "${s}"`) +} + +function expandRemotePath(p) { + validateRemotePath(p) + + if (p === '~') { + return '"$HOME"' + } + + if (p.startsWith('~/')) { + return '"$HOME"' + shq(p.slice(1)) + } + + return shq(p) +} + +// Resolve the remote hermes executable. An EXPLICIT path is honored strictly +// (throws a path-naming error if not executable — never silently falls back to a +// different install). A BLANK path auto-detects: login-shell `command -v` (a +// non-login `ssh host cmd` PATH misses user installs), then known install paths. +async function locateHermes(ssh, remoteHermesPath) { + const resolveLauncher = async (candidate: string) => { + const script = + 'import os,shlex,sys\n' + + `p=os.path.expanduser(${shq(candidate)})\n` + + 'out=p\n' + + 'try:\n' + + ' data=open(p,"r",encoding="utf-8",errors="ignore").read(4096)\n' + + ' for line in data.splitlines():\n' + + ' words=shlex.split(line)\n' + + ' if len(words)>1 and words[0]=="exec":\n' + + ' target=os.path.expanduser(words[1])\n' + + ' if os.path.isabs(target) and os.access(target,os.X_OK):out=target\n' + + ' break\n' + + 'except (OSError,ValueError):pass\n' + + 'print(out)' + + const resolved = (await ssh.exec(`python3 -c ${shq(script)}`)).trim() + + return resolved || candidate + } + + const isExecutable = async (candidate: string) => { + try { + validateRemotePath(candidate) + const ok = (await ssh.exec(`[ -x ${expandRemotePath(candidate)} ] && echo OK || true`)).trim() + + return ok === 'OK' + } catch { + return false + } + } + + if (remoteHermesPath) { + if (await isExecutable(remoteHermesPath)) { + return resolveLauncher(remoteHermesPath) + } + + const err: any = new Error( + `The Hermes path you set is not an executable on the remote host: "${remoteHermesPath}". ` + + 'Check the path (it must be the full path to the `hermes` binary on the remote, e.g. ' + + '~/hermes-agent/.venv/bin/hermes), or clear it to auto-detect.' + ) + + err.kind = 'hermes-not-found' + throw err + } + + const candidates: string[] = [] + + try { + const found = (await ssh.exec(`bash -lc ${shq('command -v hermes')}`)).trim() + + if (found) { + candidates.push(found.split('\n').pop().trim()) + } + } catch { + // ignore + } + + // Fallback candidates when the login-shell probe misses: the installer's + // command locations (scripts/install.sh) — per-user, root/FHS, legacy venv. + candidates.push('~/.local/bin/hermes') + candidates.push('/usr/local/bin/hermes') + candidates.push('~/.hermes/hermes-agent/venv/bin/hermes') + + for (const candidate of candidates) { + if (!candidate) { + continue + } + + if (await isExecutable(candidate)) { + return resolveLauncher(candidate) + } + } + + const err: any = new Error( + 'Hermes is not installed on the remote host (could not find a `hermes` executable). ' + + 'Install it on the remote with: curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh ' + + '— or set the Hermes path explicitly in the SSH connection settings.' + ) + + err.kind = 'hermes-not-found' + throw err +} + +// Probe the resolved binary's version string (first line of `<hermes> --version`, +// e.g. "Hermes Agent v0.18.2 ..."), or '' on failure. Surfaces WHICH hermes a +// connection uses, so a stale/unexpected install is visible. +async function probeHermesVersion(ssh, hermesPath) { + try { + const out = (await ssh.exec(`${expandRemotePath(hermesPath)} --version 2>&1`)).trim() + + return (out.split('\n')[0] || '').trim() + } catch { + return '' + } +} + +async function probeRemotePlatform(ssh) { + const out = (await ssh.exec('uname -s; uname -m')).trim().split('\n') + const osName = (out[0] || '').trim() + const arch = (out[1] || '').trim() + + if (!SUPPORTED_REMOTE_OS.has(osName)) { + const err: any = new Error( + `Unsupported remote platform "${osName || 'unknown'}". Hermes Desktop SSH mode supports Linux, macOS, and Windows remote hosts.` + ) + + err.kind = 'unsupported-platform' + throw err + } + + return { os: osName, arch } +} + +// The HERMES_HOME the remote dashboard will use (explicit env wins, else +// ~/.hermes). Recorded in the lockfile so a future reuse can tell it's the same +// state store; best-effort. +async function probeRemoteHermesHome(ssh) { + try { + const out = (await ssh.exec('echo "${HERMES_HOME:-$HOME/.hermes}"')).trim().split('\n').pop() + + return out || '~/.hermes' + } catch (cause) { + const error: any = new Error('Could not resolve the remote Hermes home.') + error.kind = 'transient-transport-error' + error.cause = cause + throw error + } +} + +async function readLockfile(ssh, ownershipId) { + const lpath = lockfilePath(ownershipId) + let raw + + try { + raw = await ssh.exec(`if [ ! -e ${expandRemotePath(lpath)} ]; then exit 0; fi; cat ${expandRemotePath(lpath)}`) + } catch (cause) { + const error: any = new Error('Could not read the SSH backend ownership record.') + error.kind = 'transient-transport-error' + error.cause = cause + throw error + } + + const text = String(raw || '').trim() + + if (!text) { + return null + } + + let parsed + + try { + parsed = JSON.parse(text) + } catch { + return null + } + + if (!parsed || parsed.schemaVersion !== LOCKFILE_SCHEMA_VERSION) { + return null + } + + const pid = parsed.pid + const port = parsed.port + + if (!Number.isInteger(pid) || pid <= 0 || pid > 4194304) { + return null + } + + // port 0 = spawn-in-progress record (written before readiness); valid + // ownership proof for cleanup, but never reusable. + if (!Number.isInteger(port) || port < 0 || port > 65535) { + return null + } + + if (parsed.ownershipId !== ownershipId || !/^[0-9a-f]{16}$/.test(parsed.spawnNonce || '')) { + return null + } + + if (!/^[0-9a-f]{32}$/.test(parsed.tokenFingerprint || '')) { + return null + } + + if (parsed.protocolVersion !== PROTOCOL_VERSION) { + return null + } + + if (parsed.logPath !== spawnLogPath(ownershipId, parsed.spawnNonce)) { + return null + } + + for (const field of ['profile', 'hermesPath', 'hermesHome', 'logPath', 'startedAt']) { + if (typeof parsed[field] !== 'string' || parsed[field].length > 1024) { + return null + } + } + + return parsed +} + +async function writeLockfile(ssh, ownershipId, lock) { + const directory = ownershipDirectory(ownershipId) + const lpath = lockfilePath(ownershipId) + const temporaryPath = `${directory}/.${crypto.randomBytes(8).toString('hex')}.lock.tmp` + const json = JSON.stringify({ ...lock, schemaVersion: LOCKFILE_SCHEMA_VERSION }) + await ssh.exec( + `umask 077 && mkdir -p ${expandRemotePath(directory)} && ` + + `printf '%s' ${shq(json)} > ${expandRemotePath(temporaryPath)} && ` + + `mv -f ${expandRemotePath(temporaryPath)} ${expandRemotePath(lpath)}` + ) +} + +async function removeLockfile(ssh, ownershipId) { + const lpath = lockfilePath(ownershipId) + + try { + await ssh.exec(`rm -f ${expandRemotePath(lpath)}`) + } catch { + // best effort + } +} + +async function remotePidAlive(ssh, pid) { + if (!pid || !Number.isInteger(Number(pid))) { + return false + } + + try { + const out = (await ssh.exec(`kill -0 ${Number(pid)} 2>/dev/null && echo ALIVE || echo DEAD`)).trim() + + return out === 'ALIVE' + } catch (cause) { + const error: any = new Error('Could not verify the SSH backend process.') + error.kind = 'transient-transport-error' + error.cause = cause + throw error + } +} + +// A pid is "provably ours" only if its remote cmdline carries our dashboard +// args — never kill a pid we can't positively identify as our dashboard. +async function pidIsOurDashboard(ssh, pid, spawnNonce, hermesPath = '') { + if (!pid || !/^[0-9a-f]{16}$/.test(String(spawnNonce || '')) || !hermesPath) { + return false + } + + try { + const script = + 'import os,shlex,subprocess,sys\n' + + `pid=${Number(pid)}\n` + + `expected=os.path.expanduser(${shq(hermesPath)})\n` + + `nonce=${shq(spawnNonce)}\n` + + 'try:\n' + + ' raw=open(f"/proc/{pid}/cmdline","rb").read()\n' + + ' args=[x.decode("utf-8","surrogateescape") for x in raw.split(b"\\0") if x]\n' + + 'except OSError:\n' + + ' line=subprocess.check_output(["ps","-o","command=","-p",str(pid)],text=True).strip()\n' + + ' args=shlex.split(line)\n' + + 'ok=False\n' + + 'try:\n' + + ' serve=args.index("serve")\n' + + ' owner=args.index("--ssh-owner-nonce",serve+1)\n' + + ' direct=args[0]==expected\n' + + ' python_entry=len(args)>1 and args[1]==expected and os.path.basename(args[0]).startswith("python")\n' + + ' ok=(direct or python_entry) and "--isolated" in args[serve+1:] and args[owner+1]==nonce\n' + + 'except (ValueError,IndexError):pass\n' + + 'print("OWNED" if ok else "FOREIGN")' + + const out = await ssh.exec(`python3 -c ${shq(script)}`) + + return String(out || '').trim() === 'OWNED' + } catch (cause) { + const error: any = new Error('Could not verify SSH backend process ownership.') + error.kind = 'transient-transport-error' + error.cause = cause + throw error + } +} + +// Kill the stale dashboard ONLY if provably ours, then drop the lockfile. +async function cleanupStale(ssh, ownershipId, lock, pidAlive = true) { + if (pidAlive && lock && (await pidIsOurDashboard(ssh, lock.pid, lock.spawnNonce, lock.hermesPath))) { + try { + const result = ( + await ssh.exec( + `kill ${Number(lock.pid)} && ` + + `i=0; while kill -0 ${Number(lock.pid)} 2>/dev/null; do ` + + `i=$((i+1)); [ "$i" -ge 50 ] && exit 1; sleep 0.1; done` + ) + ).trim() + + void result + } catch (cause) { + const error: any = new Error('Could not terminate the stale SSH backend.') + error.kind = 'transient-transport-error' + error.cause = cause + throw error + } + } + + const expectedLogPath = lock?.spawnNonce ? spawnLogPath(ownershipId, lock.spawnNonce) : '' + + if (lock?.logPath === expectedLogPath) { + try { + await ssh.exec(`rm -f ${expandRemotePath(lock.logPath)}`) + } catch { + void 0 + } + } + + await removeLockfile(ssh, ownershipId) +} + +// Detach so the backend survives the SSH channel closing: setsid (Linux) +// starts a new session; macOS has no setsid, so fall back to nohup (HUP-immune; +// fd-detachment is already handled by </dev/null + redirect + &). +function buildSpawnCommand(hermesPath, profile, opts: any = {}) { + const hermes = expandRemotePath(hermesPath) + const profileArgs = profile ? `--profile ${shq(profile)} ` : '' + const logPath = expandRemotePath(opts.logPath) + const tokenFilePath = [redacted:secret-assignment] + const tokenArg = tokenFilePath ? ` --ssh-session-token-file ${expandRemotePath(tokenFilePath)}` : '' + const ownerArg = opts.spawnNonce ? ` --ssh-owner-nonce ${validateSpawnNonce(opts.spawnNonce)}` : '' + const subCmd = `serve --isolated --host 127.0.0.1 --port 0${tokenArg}${ownerArg}` + const dashCmd = `env HERMES_DESKTOP=1 ${hermes} ${profileArgs}${subCmd}` + + return ( + `mkdir -p "$(dirname ${logPath})" && ` + + `"$(command -v setsid || echo nohup)" sh -c ${shq(`${dashCmd} </dev/null >> ${logPath} 2>&1 & echo $!`)}` + ) +} + +async function remoteSupportsSshOwnership(ssh, hermesPath) { + const hermes = expandRemotePath(hermesPath) + + const out = await ssh.exec( + `help="$(${hermes} serve --help 2>&1)"; ` + + `printf '%s' "$help" | grep -q ssh-session-token-file && ` + + `printf '%s' "$help" | grep -q ssh-owner-nonce && echo YES || echo NO` + ) + + return String(out || '') + .trim() + .endsWith('YES') +} + +async function scrapeReadyPort(ssh, logPath, { timeoutMs = DEFAULT_READY_TIMEOUT_MS, isAlive, signal }: any = {}) { + const deadline = Date.now() + timeoutMs + const remoteLog = expandRemotePath(logPath) + + while (Date.now() < deadline) { + assertNotAborted(signal) + + if (isAlive && !(await isAlive())) { + const err: any = new Error('Remote dashboard process exited before announcing its port.') + err.kind = 'spawn-failed' + throw err + } + + let tail + + try { + tail = await ssh.exec(`cat ${remoteLog} 2>/dev/null || true`) + } catch { + tail = '' + } + + const m = READY_RE.exec(String(tail || '')) + + if (m) { + return parseInt(m[1], 10) + } + + await new Promise(r => setTimeout(r, READY_POLL_INTERVAL_MS)) + } + + const err: any = new Error(`Timed out waiting for the remote dashboard to announce its port (${timeoutMs}ms).`) + err.kind = 'ready-timeout' + throw err +} + +async function spawnRemoteDashboard(ssh, { hermesPath, profile, token, ownershipId }) { + if (!(await remoteSupportsSshOwnership(ssh, hermesPath))) { + const err: any = new Error( + 'The remote Hermes install does not support --ssh-session-token-file and --ssh-owner-nonce. ' + + 'Update Hermes on the remote host to continue using Desktop SSH mode.' + ) + + err.kind = 'update-required' + throw err + } + + const spawnNonce = crypto.randomBytes(8).toString('hex') + const tokenDir = [redacted:secret-assignment] + const tokenFilePath = [redacted:secret-assignment] + const logPath = spawnLogPath(ownershipId, spawnNonce) + + const tokenUploadPy = + 'import os,sys,stat\n' + + `p=os.path.expanduser(${shq(tokenFilePath)})\n` + + 'd=os.path.dirname(p)\n' + + 'n=os.path.basename(p)\n' + + 'os.makedirs(d,mode=0o700,exist_ok=True)\n' + + 'df=os.O_RDONLY|getattr(os,"O_DIRECTORY",0)|getattr(os,"O_NOFOLLOW",0)\n' + + 'dd=os.open(d,df)\n' + + 'try:\n' + + ' s=os.fstat(dd)\n' + + ' if not stat.S_ISDIR(s.st_mode):raise SystemExit("unsafe token directory")\n' + + ' if hasattr(os,"getuid") and s.st_uid!=os.getuid():raise SystemExit("token directory owner mismatch")\n' + + ' if (s.st_mode&0o777)!=0o700:os.fchmod(dd,0o700)\n' + + ' fl=os.O_WRONLY|os.O_CREAT|os.O_EXCL|getattr(os,"O_NOFOLLOW",0)\n' + + ' now=__import__("time").time()\n' + + ' for stale in os.listdir(dd):\n' + + ' if stale.endswith(".token") and len(stale)==22:\n' + + ' try:\n' + + ' ss=os.stat(stale,dir_fd=dd,follow_symlinks=False)\n' + + ' if stat.S_ISREG(ss.st_mode) and now-ss.st_mtime>3600:os.unlink(stale,dir_fd=dd)\n' + + ' except OSError:pass\n' + + ' fd=os.open(n,fl,0o600,dir_fd=dd)\n' + + ' try:os.write(fd,sys.stdin.buffer.read())\n' + + ' except BaseException:\n' + + ' try:os.unlink(n,dir_fd=dd)\n' + + ' except OSError:pass\n' + + ' raise\n' + + ' finally:os.close(fd)\n' + + 'finally:os.close(dd)' + + try { + await ssh.exec(`python3 -c ${shq(tokenUploadPy)}`, { stdinData: token }) + } catch (error) { + try { + await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`) + } catch { + void 0 + } + + throw error + } + + let out + + try { + out = await ssh.exec(buildSpawnCommand(hermesPath, profile, { spawnNonce, tokenFilePath, logPath })) + } catch (error) { + try { + await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`) + } catch { + void 0 + } + + throw error + } + + const pid = parseInt( + String(out || '') + .trim() + .split('\n') + .pop(), + 10 + ) + + if (!Number.isInteger(pid) || pid <= 0) { + try { + await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`) + } catch { + void 0 + } + + const err: any = new Error('Failed to launch the remote dashboard (no pid returned).') + err.kind = 'spawn-failed' + throw err + } + + return { pid, spawnNonce, logPath, tokenFilePath } +} + +// Best-effort forward teardown when a reuse attempt fails mid-flight, so we +// don't leak a forward before respawning. `deps.cancelForward` is optional. +async function cancelForwardSafe(deps, localPort, remotePort) { + if (typeof deps.cancelForward !== 'function') { + return + } + + try { + await deps.cancelForward(localPort, remotePort) + } catch { + // best effort + } +} + +function assertNotAborted(signal) { + if (signal?.aborted) { + const error: any = new Error('SSH bootstrap was cancelled.') + error.kind = 'superseded' + throw error + } +} + +function isForwardBindCollision(error) { + return /address already in use|cannot listen to port|bind.*failed/i.test(String(error?.message || error || '')) +} + +async function openForward(deps, remotePort, attempts = 3) { + let lastError + + for (let attempt = 0; attempt < attempts; attempt++) { + const localPort = await deps.pickLocalPort() + + try { + await deps.forward(localPort, remotePort) + + return localPort + } catch (error) { + lastError = error + + if (!isForwardBindCollision(error) || attempt === attempts - 1) { + throw error + } + } + } + + throw lastError +} + +/** + * Establish (or reuse) a remote dashboard and a tunnel to it. `deps` injects the + * opened SshConnection, forward/pickLocalPort/waitForHermes, a token-gated + * probeReuseProof, and adoptServedToken. Returns the connection descriptor + * { baseUrl, token, tokenFingerprint, remotePort, localPort, pid, reused, platform }. + */ +async function adoptOwnedServedToken(adoptServedToken, baseUrl, expectedToken, ssh, pid, label) { + const token = await adoptServedToken(baseUrl, expectedToken, { + childAlive: () => true, + label + }) + + if (!(await remotePidAlive(ssh, pid))) { + const error: any = new Error(`${label} exited while its served token was being resolved.`) + error.kind = token === expectedToken ? 'spawn-failed' : 'foreign-backend' + throw error + } + + return token +} + +async function connect(deps) { + const { + ssh, + profile = '', + remoteHermesPath = '', + ownershipId, + forward, + pickLocalPort, + waitForHermes, + probeReuseProof, + adoptServedToken, + rememberLog = () => {}, + readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS, + signal + } = deps + + const log = msg => rememberLog(`[ssh-lifecycle] ${msg}`) + + assertNotAborted(signal) + const platform = await probeRemotePlatform(ssh) + log(`remote platform ${platform.os}/${platform.arch}`) + const hermesPath = await locateHermes(ssh, remoteHermesPath) + log(`located hermes at ${hermesPath}`) + const hermesVersion = await probeHermesVersion(ssh, hermesPath) + + if (hermesVersion) { + log(`remote hermes version: ${hermesVersion}`) + } + + const reuseToken = [redacted:secret-assignment] || '' + const hermesHome = await probeRemoteHermesHome(ssh) + const lock = await readLockfile(ssh, ownershipId) + + if (lock) { + const pidAlive = await remotePidAlive(ssh, lock.pid) + const owned = pidAlive && (await pidIsOurDashboard(ssh, lock.pid, lock.spawnNonce, lock.hermesPath)) + + const reusable = + pidAlive && + owned && + lock.port > 0 && + Boolean(reuseToken) && + lock.tokenFingerprint === fingerprintToken(reuseToken) && + lock.hermesPath === hermesPath && + lock.hermesHome === hermesHome + + if (reusable) { + assertNotAborted(signal) + const localPort = await openForward(deps, lock.port) + + try { + const baseUrl = `http://127.0.0.1:${localPort}` + let reuseClassification + + try { + reuseClassification = await probeReuseProof(baseUrl, reuseToken, lock.spawnNonce) + } catch (cause) { + const error: any = new Error('Could not verify the existing SSH backend.') + error.kind = 'transient-transport-error' + error.cause = cause + throw error + } + + if (reuseClassification === 'authenticated-stale') { + assertNotAborted(signal) + await cancelForwardSafe(deps, localPort, lock.port) + await cleanupStale(ssh, ownershipId, lock) + } else if (reuseClassification === 'authenticated-ok') { + const token = await adoptOwnedServedToken( + adoptServedToken, + baseUrl, + reuseToken, + ssh, + lock.pid, + 'reused remote dashboard' + ) + + assertNotAborted(signal) + log(`reusing remote dashboard pid=${lock.pid} port=${lock.port}`) + + return { + baseUrl, + token, + tokenFingerprint: [redacted:secret-assignment] + remotePort: lock.port, + localPort, + pid: lock.pid, + reused: true, + platform, + hermesPath, + hermesVersion, + ownershipId, + spawnNonce: lock.spawnNonce, + logPath: lock.logPath + } + } else { + const error: any = new Error('SSH reuse proof returned an invalid classification.') + error.kind = 'transient-transport-error' + throw error + } + } catch (error) { + await cancelForwardSafe(deps, localPort, lock.port) + throw error + } + } else { + assertNotAborted(signal) + await cleanupStale(ssh, ownershipId, lock, pidAlive) + } + } + + assertNotAborted(signal) + const spawnToken = [redacted:secret-assignment] + + const { pid, spawnNonce, logPath, tokenFilePath } = await spawnRemoteDashboard(ssh, { + hermesPath, + profile, + token: [redacted:secret-assignment] + ownershipId + }) + + log(`spawned remote dashboard pid=${pid}`) + + const ownedSpawn = { + ownershipId, + spawnNonce, + pid, + port: 0, + profile, + hermesPath, + hermesHome, + logPath, + tokenFingerprint: [redacted:secret-assignment] + protocolVersion: PROTOCOL_VERSION, + startedAt: new Date().toISOString() + } + + let localPort = 0 + let remotePort = 0 + + try { + // Write the ownership record IMMEDIATELY (port=0): a supersede between + // spawn and readiness whose cleanup cannot reach the box must not leave a + // lockless orphan — the next connect reaps it by exact ownership via this + // record. Inside the try: if this write itself fails, the catch still + // kills the just-spawned process via the in-memory record. + await writeLockfile(ssh, ownershipId, ownedSpawn) + remotePort = await scrapeReadyPort(ssh, logPath, { + timeoutMs: readyTimeoutMs, + isAlive: () => remotePidAlive(ssh, pid), + signal + }) + assertNotAborted(signal) + log(`remote dashboard bound port ${remotePort}`) + + localPort = await openForward(deps, remotePort) + assertNotAborted(signal) + const baseUrl = `http://127.0.0.1:${localPort}` + await waitForHermes(baseUrl, spawnToken) + assertNotAborted(signal) + + const token = await adoptOwnedServedToken(adoptServedToken, baseUrl, spawnToken, ssh, pid, 'remote dashboard') + + assertNotAborted(signal) + const tokenFingerprint = [redacted:secret-assignment] + await writeLockfile(ssh, ownershipId, { ...ownedSpawn, port: remotePort, tokenFingerprint }) + assertNotAborted(signal) + + return { + baseUrl, + token, + tokenFingerprint, + remotePort, + localPort, + pid, + reused: false, + platform, + hermesPath, + hermesVersion, + ownershipId, + spawnNonce, + logPath + } + } catch (error) { + if (localPort && remotePort) { + await cancelForwardSafe(deps, localPort, remotePort) + } + + try { + await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`) + } catch { + void 0 + } + + await cleanupStale(ssh, ownershipId, ownedSpawn) + throw error + } +} + +export { + adoptOwnedServedToken, + buildSpawnCommand, + cleanupStale, + connect, + DEFAULT_READY_TIMEOUT_MS, + expandRemotePath, + fingerprintToken, + isForwardBindCollision, + locateHermes, + LOCKFILE_SCHEMA_VERSION, + lockfilePath, + mintToken, + openForward, + ownershipDirectory, + pidIsOurDashboard, + probeHermesVersion, + probeRemoteHermesHome, + probeRemotePlatform, + PROTOCOL_VERSION, + readLockfile, + READY_RE, + REMOTE_LOCK_DIR, + remotePidAlive, + remoteSupportsSshOwnership, + removeLockfile, + scrapeReadyPort, + shq, + spawnLogPath, + spawnRemoteDashboard, + SUPPORTED_REMOTE_OS, + validateRemotePath, + writeLockfile +} -
apps/desktop/electron/ssh-bootstrap-coordinator.test.ts@@ -0,0 +1,192 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator' + +function deferred() { + let resolve + let reject + + const promise = new Promise((ok, fail) => { + resolve = ok + reject = fail + }) + + return { promise, reject, resolve } +} + +const config = { host: 'box', user: 'alice', port: 22, keyPath: '/key', remoteHermesPath: '/hermes' } + +test('sshConfigFingerprint covers scope and every connection field', () => { + const base = sshConfigFingerprint('', config) + assert.equal(base, sshConfigFingerprint('', { ...config })) + + for (const [field, value] of Object.entries({ + host: 'other', + user: 'bob', + port: 2222, + keyPath: '/other', + remoteHermesPath: '/other-hermes', + effectiveConfigFingerprint: 'changed-config' + })) { + assert.notEqual(base, sshConfigFingerprint('', { ...config, [field]: value })) + } + + assert.notEqual(base, sshConfigFingerprint('profile', config)) +}) + +test('same scope and fingerprint share one bootstrap', async () => { + const coordinator = createBootstrapCoordinator() + const gate = deferred() + let runs = 0 + + const first = coordinator.start('', 'same', async () => { + runs++ + + return gate.promise + }) + + const second = coordinator.start('', 'same', async () => { + runs++ + + return 'wrong' + }) + + assert.equal(first, second) + gate.resolve('done') + assert.equal(await second, 'done') + assert.equal(runs, 1) +}) + +test('changed fingerprint waits for old rollback before starting', async () => { + const coordinator = createBootstrapCoordinator() + const gate = deferred() + const events: string[] = [] + let oldLease + + const oldPromise = coordinator.start('', 'old', async lease => { + oldLease = lease + events.push('old-start') + await gate.promise + events.push('old-rollback') + lease.assertCurrent() + }) + + await Promise.resolve() + + const newPromise = coordinator.start('', 'new', async lease => { + events.push('new-start') + lease.assertCurrent() + + return 'new' + }) + + assert.equal(oldLease.signal.aborted, true) + await Promise.resolve() + assert.deepEqual(events, ['old-start']) + gate.resolve() + await assert.rejects(oldPromise, (error: any) => error.kind === 'superseded') + assert.equal(await newPromise, 'new') + assert.deepEqual(events, ['old-start', 'old-rollback', 'new-start']) +}) + +test('forceCleanupAll runs registered pending resource cleanup', async () => { + const coordinator = createBootstrapCoordinator() + const gate = deferred() + let cleaned = 0 + + const promise = coordinator.start('', 'x', async lease => { + lease.onForceCleanup(async () => { + cleaned++ + }) + await gate.promise + }) + + await Promise.resolve() + await coordinator.forceCleanupAll() + assert.equal(cleaned, 1) + gate.resolve() + await promise +}) + +test('cancelAll invalidates every pending scope and exposes promises for quit', async () => { + const coordinator = createBootstrapCoordinator() + const gates = [deferred(), deferred()] + + const promises = gates.map((gate, index) => + coordinator.start(String(index), 'x', async lease => { + await gate.promise + lease.assertCurrent() + }) + ) + + assert.equal(coordinator.promises().length, 2) + coordinator.cancelAll() + gates.forEach(gate => gate.resolve()) + const results = await Promise.allSettled(promises) + assert.ok(results.every(result => result.status === 'rejected' && (result.reason as any).kind === 'superseded')) +}) + +test('cancelAndWait drains only the requested scope', async () => { + const coordinator = createBootstrapCoordinator() + const firstGate = deferred() + const secondGate = deferred() + + const first = coordinator.start('first', 'x', async lease => { + await firstGate.promise + lease.assertCurrent() + }) + + const second = coordinator.start('second', 'x', async lease => { + await secondGate.promise + lease.assertCurrent() + + return 'second' + }) + + await Promise.resolve() + let drained = false + + const drain = coordinator.cancelAndWait('first').then(() => { + drained = true + }) + + await Promise.resolve() + assert.equal(drained, false) + firstGate.resolve() + await drain + await assert.rejects(first, (error: any) => error.kind === 'superseded') + assert.equal(coordinator.pending.has('second'), true) + secondGate.resolve() + assert.equal(await second, 'second') +}) + +test('a generation started during cancelAndWait cannot run before the drain completes', async () => { + const coordinator = createBootstrapCoordinator() + const oldGate = deferred() + const events: string[] = [] + + const old = coordinator.start('scope', 'old', async lease => { + events.push('old-start') + await oldGate.promise + lease.assertCurrent() + }) + + await Promise.resolve() + const drain = coordinator.cancelAndWait('scope') + + const next = coordinator.start('scope', 'new', async () => { + events.push('new-start') + + return 'new' + }) + + await Promise.resolve() + assert.deepEqual(events, ['old-start']) + oldGate.resolve() + await drain + await assert.rejects(old, (error: any) => error.kind === 'superseded') + assert.equal(await next, 'new') + assert.deepEqual(events, ['old-start', 'new-start']) +}) -
apps/desktop/electron/ssh-bootstrap-coordinator.ts@@ -0,0 +1,130 @@ +import crypto from 'node:crypto' + +function sshConfigFingerprint(scope, config) { + const parts = [ + scope, + config.host, + config.user, + config.port, + config.keyPath, + config.remoteHermesPath, + config.effectiveConfigFingerprint + ] + + return crypto + .createHash('sha256') + .update(JSON.stringify(parts.map(value => value ?? ''))) + .digest('hex') +} + +function createBootstrapCoordinator() { + const active = new Set<any>() + const pending = new Map<string, any>() + const generations = new Map<string, number>() + const drains = new Map<string, Promise<void>>() + + function start(scope, fingerprint, run) { + const current = pending.get(scope) + + if (current?.fingerprint === fingerprint) { + return current.promise + } + + current?.controller.abort() + + const generation = (generations.get(scope) || 0) + 1 + generations.set(scope, generation) + const controller = new AbortController() + const forceCleanups = new Set<() => any>() + + const lease = { + signal: controller.signal, + onForceCleanup(cleanup) { + forceCleanups.add(cleanup) + + return () => forceCleanups.delete(cleanup) + }, + isCurrent: () => !controller.signal.aborted && generations.get(scope) === generation, + assertCurrent() { + if (!this.isCurrent()) { + const error: any = new Error('SSH bootstrap was superseded by newer connection settings.') + error.kind = 'superseded' + throw error + } + } + } + + const drain = drains.get(scope) || Promise.resolve() + const predecessor = current ? Promise.allSettled([current.promise, drain]) : drain + const entry: any = { controller, fingerprint, forceCleanups, generation, promise: null, scope } + + const promise = predecessor + .then(() => { + lease.assertCurrent() + + return run(lease) + }) + .finally(() => { + forceCleanups.clear() + active.delete(entry) + + if (pending.get(scope)?.generation === generation) { + pending.delete(scope) + } + }) + + entry.promise = promise + active.add(entry) + pending.set(scope, entry) + + return promise + } + + function cancel(scope) { + pending.get(scope)?.controller.abort() + } + + async function cancelAndWait(scope) { + let release + + const barrier = new Promise<void>(resolve => { + release = resolve + }) + + drains.set(scope, barrier) + const entries = [...active].filter(entry => entry.scope === scope) + + for (const entry of entries) { + entry.controller.abort() + } + + try { + await Promise.allSettled(entries.map(entry => entry.promise)) + } finally { + if (drains.get(scope) === barrier) { + drains.delete(scope) + } + + release() + } + } + + function cancelAll() { + for (const entry of active) { + entry.controller.abort() + } + } + + async function forceCleanupAll() { + const cleanups = [...active].flatMap(entry => [...entry.forceCleanups]) + await Promise.allSettled(cleanups.map(cleanup => cleanup())) + } + + function promises() { + return [...active].map(entry => entry.promise) + } + + return { active, cancel, cancelAll, cancelAndWait, forceCleanupAll, pending, promises, start } +} + +export { createBootstrapCoordinator, sshConfigFingerprint } -
apps/desktop/electron/ssh-config.test.ts@@ -0,0 +1,104 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { collectSshConfigHosts, parseSshConfigHosts, parseSshConfigIncludes, parseSshGOutput } from './ssh-config' + +test('parseSshConfigHosts keeps literal aliases and drops wildcard/negated patterns', () => { + const cfg = [ + 'Host devbox', + ' HostName 10.0.0.5', + 'Host *.internal prod !staging glob*', + 'Host alpha beta', + '# Host commented-out', + 'host lower-case' + ].join('\n') + + assert.deepEqual(parseSshConfigHosts(cfg), ['devbox', 'prod', 'alpha', 'beta', 'lower-case']) +}) + +test('parseSshConfigHosts de-duplicates', () => { + assert.deepEqual(parseSshConfigHosts('Host box\nHost box\nHost box other'), ['box', 'other']) +}) + +test('parseSshConfigIncludes extracts include tokens', () => { + const cfg = 'Include ~/.ssh/config.d/*\nInclude work_hosts personal_hosts\n# Include ignored' + assert.deepEqual(parseSshConfigIncludes(cfg), ['~/.ssh/config.d/*', 'work_hosts', 'personal_hosts']) +}) + +test('collectSshConfigHosts follows Include directives (read-only)', () => { + const files = { + '/home/u/.ssh/config': 'Host main\nInclude work\nInclude ~/abs_inc', + '/home/u/.ssh/work': 'Host work-box\nInclude nested', + '/home/u/.ssh/nested': 'Host deep', + '/home/u/abs_inc': 'Host home-abs' + } + + const hosts = collectSshConfigHosts('/home/u/.ssh/config', { + homeDir: '/home/u', + readFile: p => files[p] ?? null + }) + + assert.deepEqual(hosts.sort(), ['deep', 'home-abs', 'main', 'work-box'].sort()) +}) + +test('collectSshConfigHosts tolerates a missing config file', () => { + assert.deepEqual(collectSshConfigHosts('/nope/config', { homeDir: '/home/u', readFile: () => null }), []) +}) + +test('collectSshConfigHosts does not loop on a self-include cycle', () => { + const files = { + '/home/u/.ssh/config': 'Host a\nInclude loop', + '/home/u/.ssh/loop': 'Host b\nInclude config' // points back at config + } + + const hosts = collectSshConfigHosts('/home/u/.ssh/config', { + homeDir: '/home/u', + readFile: p => files[p] ?? null + }) + + assert.deepEqual(hosts.sort(), ['a', 'b']) +}) + +test('collectSshConfigHosts expands globbed includes via injected globSync', () => { + const files = { + '/home/u/.ssh/config': 'Host root\nInclude config.d/*', + '/home/u/.ssh/config.d/10-work': 'Host work', + '/home/u/.ssh/config.d/20-home': 'Host home' + } + + const hosts = collectSshConfigHosts('/home/u/.ssh/config', { + homeDir: '/home/u', + readFile: p => files[p] ?? null, + globSync: pattern => + pattern.endsWith('config.d/*') ? ['/home/u/.ssh/config.d/10-work', '/home/u/.ssh/config.d/20-home'] : [pattern] + }) + + assert.deepEqual(hosts.sort(), ['home', 'root', 'work'].sort()) +}) + +test('parseSshGOutput pulls hostname/user/port/identityfile', () => { + const out = [ + 'host devbox', + 'hostname 10.0.0.5', + 'user alice', + 'port 2222', + 'identityfile ~/.ssh/id_ed25519', + 'forwardagent no' + ].join('\n') + + assert.deepEqual(parseSshGOutput(out), { + hostname: '10.0.0.5', + user: 'alice', + port: 2222, + identityFile: '~/.ssh/id_ed25519' + }) +}) + +test('parseSshGOutput takes the FIRST identityfile and tolerates missing keys', () => { + const out = 'hostname box\nidentityfile ~/.ssh/a\nidentityfile ~/.ssh/b' + const parsed = parseSshGOutput(out) + assert.equal(parsed.identityFile, '~/.ssh/a') + assert.equal(parsed.user, null) + assert.equal(parsed.port, null) +}) -
apps/desktop/electron/ssh-config.ts@@ -0,0 +1,175 @@ +/** + * ssh-config.ts + * + * Pure, electron-free helpers for reading the user's OpenSSH client config: + * `Host` aliases for the settings UI's suggestions, `Include` traversal + * (read-only), and `ssh -G` output parsing. No `import 'electron'` so it's + * unit-testable without Electron; main.ts wires the fs + `ssh -G` exec in. + */ + +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +function parseSshConfigHosts(text) { + const hosts: string[] = [] + const seen = new Set() + + for (const rawLine of String(text || '').split('\n')) { + const line = rawLine.trim() + + if (!line || line.startsWith('#')) { + continue + } + + const m = /^host\s+(.+)$/i.exec(line) + + if (!m) { + continue + } + + for (const pattern of m[1].split(/\s+/)) { + if (!pattern || pattern.includes('*') || pattern.includes('?') || pattern.startsWith('!')) { + continue + } + + if (!seen.has(pattern)) { + seen.add(pattern) + hosts.push(pattern) + } + } + } + + return hosts +} + +function parseSshConfigIncludes(text) { + const includes: string[] = [] + + for (const rawLine of String(text || '').split('\n')) { + const line = rawLine.trim() + + if (!line || line.startsWith('#')) { + continue + } + + const m = /^include\s+(.+)$/i.exec(line) + + if (!m) { + continue + } + + for (const token of m[1].split(/\s+/)) { + if (token) { + includes.push(token) + } + } + } + + return includes +} + +function collectSshConfigHosts(rootPath = '', deps: any = {}) { + const readFile = + deps.readFile || + (p => { + try { + return fs.readFileSync(p, 'utf8') + } catch { + return null + } + }) + + const homeDir = deps.homeDir || os.homedir() + const root = rootPath || path.join(homeDir, '.ssh', 'config') + const sshDir = path.join(homeDir, '.ssh') + + const out: string[] = [] + const seen = new Set() + const visited = new Set() + + const resolveIncludePath = token => { + if (token.startsWith('~/')) { + return path.join(homeDir, token.slice(2)) + } + + if (path.isAbsolute(token)) { + return token + } + + return path.join(sshDir, token) + } + + const walk = (filePath, depth) => { + if (depth > 8 || visited.has(filePath)) { + return + } + + visited.add(filePath) + const text = readFile(filePath) + + if (text == null) { + return + } + + for (const host of parseSshConfigHosts(text)) { + if (!seen.has(host)) { + seen.add(host) + out.push(host) + } + } + + for (const token of parseSshConfigIncludes(text)) { + const target = resolveIncludePath(token) + const expanded = deps.globSync ? deps.globSync(target) : [target] + + for (const p of expanded) { + walk(p, depth + 1) + } + } + } + + walk(root, 0) + + return out +} + +function parseSshGOutput(text) { + const out: { hostname: string | null; user: string | null; port: number | null; identityFile: string | null } = { + hostname: null, + user: null, + port: null, + identityFile: null + } + + for (const rawLine of String(text || '').split('\n')) { + const line = rawLine.trim() + + if (!line) { + continue + } + + const sp = line.indexOf(' ') + + if (sp === -1) { + continue + } + + const key = line.slice(0, sp).toLowerCase() + const value = line.slice(sp + 1).trim() + + if (key === 'hostname' && !out.hostname) { + out.hostname = value + } else if (key === 'user' && !out.user) { + out.user = value + } else if (key === 'port' && !out.port) { + out.port = Number.parseInt(value, 10) || null + } else if (key === 'identityfile' && !out.identityFile) { + out.identityFile = value + } + } + + return out +} + +export { collectSshConfigHosts, parseSshConfigHosts, parseSshConfigIncludes, parseSshGOutput } -
apps/desktop/electron/ssh-connection.test.ts@@ -0,0 +1,866 @@ +import assert from 'node:assert/strict' +import { EventEmitter } from 'node:events' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { test } from 'vitest' + +import { + baseSshOptions, + buildControlArgs, + buildExecArgs, + buildInteractiveSshArgs, + buildMasterArgs, + classifySshError, + controlSocketPath, + createSshProbeConnection, + forwardSpec, + hostArgs, + redactSecrets, + runSsh, + SSH_ERROR, + SshConnection, + sshErrorMessage, + stopTunnelChild, + target, + validateSshTarget +} from './ssh-connection' + +test('redactSecrets scrubs the spawn-time session token env var', () => { + const line = 'setsid env HERMES_DASHBOARD_SESSION_TOKEN=[redacted:secret-assignment] HERMES_DESKTOP=1 hermes dashboard' + const out = redactSecrets(line) + assert.ok(!out.includes('abc123deadbeef')) + assert.match(out, /HERMES_DASHBOARD_SESSION_TOKEN=[redacted:secret-assignment] + // non-secret env vars are preserved + assert.match(out, /HERMES_DESKTOP=1/) +}) + +test('redactSecrets scrubs ?token= and ?ticket= URL params', () => { + assert.match(redactSecrets('ws://127.0.0.1:5000/api/ws?token=supersecret'), /\?token=[redacted:secret-assignment] + assert.match(redactSecrets('ws://127.0.0.1:5000/api/ws?ticket=onetimeticket'), /\?ticket=<redacted>/) + assert.match(redactSecrets('GET /x?a=1&token=zzz HTTP'), /&token=[redacted:secret-assignment] + assert.ok(!redactSecrets('?token=supersecret').includes('supersecret')) +}) + +test('redactSecrets scrubs Authorization and X-Hermes-Session-Token headers', () => { + assert.match(redactSecrets('Authorization: Bearer tok_9999'), /Authorization: Bearer <redacted>/) + assert.ok(!redactSecrets('Authorization: Bearer tok_9999').includes('tok_9999')) + assert.match(redactSecrets('X-Hermes-Session-Token: [redacted:secret-assignment]'), /X-Hermes-Session-Token: [redacted:secret-assignment] + assert.ok(!redactSecrets('X-Hermes-Session-Token: [redacted:secret-assignment]').includes('hdr_888')) +}) + +test('redactSecrets handles null/undefined and non-secret text untouched', () => { + assert.equal(redactSecrets(null), '') + assert.equal(redactSecrets(undefined), '') + assert.equal(redactSecrets('uname -s -m'), 'uname -s -m') +}) + +test('controlSocketPath is stable, short, and host-distinct', () => { + const a = controlSocketPath('me', 'box1', 22, '/tmp/d') + const a2 = controlSocketPath('me', 'box1', 22, '/tmp/d') + const b = controlSocketPath('me', 'box2', 22, '/tmp/d') + assert.equal(a, a2, 'same triple → same socket (ControlMaster reuse)') + assert.notEqual(a, b, 'different host → different socket') + // 16 hex chars + .sock keeps the basename short for sun_path 104-byte limit + assert.match(a, /\/[0-9a-f]{16}\.sock$/) +}) + +test('controlSocketPath default base stays under sun_path even with the temp-listener suffix', () => { + // OpenSSH binds a temporary listener at `<ControlPath>.<16 random chars>` (a + // 17-byte suffix) while opening the master. The macOS regression was the + // default base under os.tmpdir() (/var/folders/.../T/) pushing it over 104. + const p = controlSocketPath('hermes', 'remote-build-server', 22) // no baseDir → default + const worstCase = `${p}.0123456789abcdef` // mimic the .<16-char> temp suffix + assert.ok( + worstCase.length <= 104, + `default control socket + temp suffix must fit sun_path (got ${worstCase.length}: ${worstCase})` + ) + // And it must NOT live under the deeply-nested macOS per-user temp dir. + assert.ok(!p.includes('/var/folders/'), 'default base must not be os.tmpdir() on macOS') +}) + +test('baseSshOptions carries the house ControlMaster/BatchMode/accept-new policy', () => { + const opts = baseSshOptions('/tmp/x.sock', 15000) + const joined = opts.join(' ') + assert.match(joined, /ControlPath=\/tmp\/x\.sock/) + assert.match(joined, /ControlMaster=auto/) + assert.match(joined, /ControlPersist=\d+/) + assert.match(joined, /BatchMode=yes/) + assert.match(joined, /StrictHostKeyChecking=accept-new/) + assert.match(joined, /ExitOnForwardFailure=yes/) + assert.match(joined, /ConnectTimeout=15/) + assert.ok(!joined.includes('StrictHostKeyChecking=no'), 'never disables host-key checking') +}) + +test('hostArgs adds -p only for non-default port and -i only with a key', () => { + assert.deepEqual(hostArgs({ port: 22 }), []) + assert.deepEqual(hostArgs({ port: 2222 }), ['-p', '2222']) + assert.deepEqual(hostArgs({ port: 22, keyPath: '/k' }), ['-i', '/k']) + assert.deepEqual(hostArgs({ port: 2200, keyPath: '/k' }), ['-p', '2200', '-i', '/k']) +}) + +test('target builds user@host or bare host', () => { + assert.equal(target('me', 'box'), 'me@box') + assert.equal(target('', 'box'), 'box') +}) + +test('buildExecArgs ends with host then the remote command', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildExecArgs(conn, 'command -v hermes', 15000) + assert.equal(args[args.length - 1], 'command -v hermes') + assert.equal(args[args.length - 2], 'me@box') + assert.ok(args.includes('BatchMode=yes')) +}) + +test('buildControlArgs places -O <op> first and never appends a remote command', () => { + const conn = { user: 'me', host: 'box', port: 2222, keyPath: '/k', controlPath: '/tmp/x.sock' } + const args = buildControlArgs(conn, 'forward', ['-L', forwardSpec(5000, 6000)], 15000) + assert.equal(args[0], '-O') + assert.equal(args[1], 'forward') + assert.ok(args.includes('-L')) + assert.ok(args.includes('127.0.0.1:5000:127.0.0.1:6000')) + assert.equal(args[args.length - 1], 'me@box') +}) + +test('buildMasterArgs requests a backgrounded master (-M -N -f)', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildMasterArgs(conn, 15000) + assert.ok(args.includes('-M')) + assert.ok(args.includes('-N')) + assert.ok(args.includes('-f')) +}) + +test('forwardSpec binds the local end to 127.0.0.1 only', () => { + assert.equal(forwardSpec(5000, 6000), '127.0.0.1:5000:127.0.0.1:6000') + assert.ok(forwardSpec(5000, 6000).startsWith('127.0.0.1:')) + assert.ok(!forwardSpec(5000, 6000).startsWith('0.0.0.0')) +}) + +test('buildInteractiveSshArgs requests a PTY, reuses the control master, execs a login shell', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildInteractiveSshArgs(conn, '', 15000) + assert.equal(args[0], '-tt', 'forces a PTY so the remote sees a real terminal') + assert.ok(args.join(' ').includes('ControlPath=/tmp/x.sock'), 'reuses the existing master (no new auth)') + assert.equal(args[args.length - 2], 'me@box') + assert.equal(args[args.length - 1], 'exec "$SHELL" -l') +}) + +test('buildInteractiveSshArgs cds into the remote cwd (best-effort) before the shell', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildInteractiveSshArgs(conn, '/home/me/project', 15000) + const remoteCmd = args[args.length - 1] + assert.match(remoteCmd, /^cd '\/home\/me\/project' 2>\/dev\/null; exec "\$SHELL" -l$/) +}) + +test('buildInteractiveSshArgs single-quotes a cwd with quotes safely', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildInteractiveSshArgs(conn, "/tmp/a'b", 15000) + // the embedded quote must be escaped, not break out of the quoting + assert.ok(args[args.length - 1].startsWith("cd '/tmp/a'")) + assert.ok(args[args.length - 1].includes('exec "$SHELL" -l')) +}) + +test('classifySshError detects a changed host key (fail-closed)', () => { + assert.equal( + classifySshError('@@@@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @@@@'), + SSH_ERROR.HOST_KEY_CHANGED + ) + assert.equal(classifySshError('Host key verification failed.'), SSH_ERROR.HOST_KEY_CHANGED) + assert.equal(classifySshError('Offending ECDSA key in /home/u/.ssh/known_hosts:5'), SSH_ERROR.HOST_KEY_CHANGED) +}) + +test('classifySshError detects auth failure', () => { + assert.equal(classifySshError('Permission denied (publickey).'), SSH_ERROR.AUTH_FAILED) + assert.equal(classifySshError('Too many authentication failures'), SSH_ERROR.AUTH_FAILED) +}) + +test('classifySshError detects unreachable', () => { + assert.equal(classifySshError('ssh: Could not resolve hostname nope'), SSH_ERROR.UNREACHABLE) + assert.equal(classifySshError('connect to host x port 22: Connection refused'), SSH_ERROR.UNREACHABLE) +}) + +test('sshErrorMessage gives actionable guidance for auth and host-key-change', () => { + const conn = { user: 'me', host: 'box', port: 22 } + assert.match(sshErrorMessage(SSH_ERROR.AUTH_FAILED, conn, 'Permission denied'), /ssh-agent|ssh-add|IdentityFile/) + assert.match(sshErrorMessage(SSH_ERROR.HOST_KEY_CHANGED, conn, 'CHANGED'), /ssh-keygen -R box/) +}) + +// A fake child process that emits a scripted result on next tick. +function fakeChild({ code = 0, stdout = '', stderr = '', errorEvent = null, hang = false }: any = {}) { + const child: any = new EventEmitter() + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + + child.kill = () => { + child._killed = true + } + + if (hang) { + return child // never emits close → drives the timeout path + } + + process.nextTick(() => { + if (errorEvent) { + child.emit('error', errorEvent) + + return + } + + if (stdout) { + child.stdout.emit('data', Buffer.from(stdout)) + } + + if (stderr) { + child.stderr.emit('data', Buffer.from(stderr)) + } + + child.emit('close', code) + }) + + return child +} + +// Build a spawnFn that returns scripted children per ssh invocation, recording +// the args it was called with. +function scriptedSpawn(scripts) { + const calls: any[] = [] + let i = 0 + + const fn: any = (_cmd, args) => { + calls.push(args) + const script = typeof scripts === 'function' ? scripts(args, i) : scripts[Math.min(i, scripts.length - 1)] + i += 1 + + return fakeChild(script || {}) + } + + fn.calls = calls + + return fn +} + +test('open() establishes the master when not already alive', async () => { + // `-O check` fails first (not alive) → master opens (code 0). Track which + // ssh ops ran rather than re-probing with the same always-failing check. + const ops: string[] = [] + + const spawnFn = scriptedSpawn(args => { + ops.push(args.includes('check') ? 'check' : args.includes('-M') ? 'master' : 'other') + + if (args.includes('check')) { + return { code: 255, stderr: 'no control path' } + } + + return { code: 0 } + }) + + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' }) + await conn.open() + assert.deepEqual(ops, ['check', 'master'], 'probes liveness first, then opens the master') +}) + +test('open() is a no-op when the master is already alive and execs verify', async () => { + const ops: string[] = [] + + const spawnFn = scriptedSpawn(args => { + ops.push(args.includes('check') ? 'check' : args.includes('exit 0') ? 'verify' : 'master') + + return { code: 0 } // check succeeds → alive; verify exec succeeds → trusted + }) + + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' }) + await conn.open() + assert.deepEqual(ops, ['check', 'verify'], 'alive master is exec-verified, then trusted without reopening') +}) + +test('open() evicts a wedged master (check passes, exec hangs) and dials fresh', async () => { + // The macOS mode-switch wedge: ControlPersist master answers -O check but + // every exec through it hangs. open() must verify, evict (-O exit), and + // establish a fresh master instead of trusting the corpse. + const ops: string[] = [] + + const spawnFn = scriptedSpawn(args => { + if (args.includes('check')) { + ops.push('check') + + return { code: 0 } + } + + if (args.includes('exit 0')) { + ops.push('verify') + + return { hang: true } + } + + if (args.includes('-O')) { + ops.push('evict') + + return { code: 0 } + } + + ops.push('master') + + return { code: 0 } + }) + + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d', connectTimeoutMs: 50 }) + + await conn.open() + assert.deepEqual( + ops, + ['check', 'verify', 'evict', 'master'], + 'wedged master: verified, evicted, then a fresh master is dialed' + ) +}) + +test('close() removes the control socket when -O exit fails', async () => { + const dir = path.join(os.tmpdir(), `hermes-ssh-close-${process.pid}-${Date.now()}`) + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) + + const spawnFn = scriptedSpawn(args => { + if (args.includes('check')) { + return { code: 255 } + } // not alive → open dials master + + if (args.includes('-M')) { + return { code: 0 } + } + + return { code: 255, stderr: 'mux: master gone' } // -O exit fails + }) + + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir }) + await conn.open() + fs.writeFileSync(conn.controlPath, '') // simulate the lingering socket file + await conn.close() + assert.ok(!fs.existsSync(conn.controlPath), 'failed -O exit drops the socket so the next open dials fresh') + fs.rmSync(dir, { recursive: true, force: true }) +}) + +test('open() creates the control-socket directory if it does not exist', async () => { + const dir = path.join(os.tmpdir(), `hermes-ssh-test-${process.pid}-${Date.now()}`) + assert.ok(!fs.existsSync(dir), 'precondition: control dir absent') + const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 })) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir }) + + try { + await conn.open() + assert.ok(fs.existsSync(dir), 'open() created the control-socket directory before spawning ssh') + } finally { + try { + fs.rmSync(dir, { recursive: true, force: true }) + } catch { + /* ignore */ + } + } +}) + +test('open() surfaces a classified auth error', async () => { + const spawnFn = scriptedSpawn(args => { + if (args.includes('check')) { + return { code: 255 } + } + + return { code: 255, stderr: 'Permission denied (publickey).' } + }) + + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' }) + await assert.rejects( + () => conn.open(), + (err: any) => { + assert.equal(err.kind, SSH_ERROR.AUTH_FAILED) + assert.match(err.message, /ssh-agent|ssh-add/) + + return true + } + ) +}) + +test('exec() returns stdout on success and rejects (classified) on failure', async () => { + const okSpawn = scriptedSpawn([{ code: 0, stdout: 'Linux\n' }]) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn: okSpawn, controlDir: '/tmp/d' }) + assert.equal((await conn.exec('uname -s')).trim(), 'Linux') + + const failSpawn = scriptedSpawn([{ code: 1, stderr: 'ssh: Could not resolve hostname box' }]) + const conn2 = new SshConnection({ host: 'box', user: 'me' }, { spawnFn: failSpawn, controlDir: '/tmp/d' }) + await assert.rejects( + () => conn2.exec('uname -s'), + (err: any) => { + assert.equal(err.kind, SSH_ERROR.UNREACHABLE) + + return true + } + ) +}) + +test('exec() treats a hung ssh as a timeout (half-open connection)', async () => { + const spawnFn = scriptedSpawn([{ hang: true }]) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' }) + await assert.rejects( + () => conn.exec('uname -s', { timeoutMs: 30 }), + (err: any) => { + assert.equal(err.kind, SSH_ERROR.TIMEOUT) + + return true + } + ) +}) + +test('forward() issues -O forward with a loopback-bound -L spec', async () => { + const spawnFn = scriptedSpawn([{ code: 0 }]) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' }) + await conn.forward(5000, 6000) + const args = spawnFn.calls[0] + assert.equal(args[0], '-O') + assert.equal(args[1], 'forward') + assert.ok(args.includes('127.0.0.1:5000:127.0.0.1:6000')) +}) + +test('lifecycle logging passes through redaction', async () => { + const logs: string[] = [] + const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 })) + + const conn = new SshConnection( + { host: 'box', user: 'me' }, + { spawnFn, controlDir: '/tmp/d', rememberLog: l => logs.push(l) } + ) + + await conn.open() + + // none of the emitted log lines may carry a raw token-shaped secret + for (const line of logs) { + assert.ok(!/token=[redacted:secret-assignment] + } + + assert.ok(logs.some(l => l.includes('[ssh]'))) +}) + +test('no-mux: ssh args carry no ControlMaster/ControlPath options', async () => { + const spawnFn = scriptedSpawn({ code: 0 }) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false }) + await conn.open() + + for (const args of spawnFn.calls) { + assert.ok(!args.some(a => /ControlMaster|ControlPath|ControlPersist/.test(a)), `mux option leaked: ${args}`) + } +}) + +test('no-mux: open() verifies auth with a one-shot exec, no -M master', async () => { + const spawnFn = scriptedSpawn({ code: 0 }) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false }) + await conn.open() + assert.ok(!spawnFn.calls.some(args => args.includes('-M')), 'no master should be spawned') + assert.ok( + spawnFn.calls.some(args => args[args.length - 1] === 'exit 0'), + 'liveness/openness via one-shot exec' + ) +}) + +test('SSH probe never creates or closes a ControlMaster', async () => { + const spawnFn = scriptedSpawn({ code: 0 }) + const conn = createSshProbeConnection({ host: 'box', user: 'me' }, { spawnFn }) + await conn.open() + await conn.close() + const args = spawnFn.calls.flat() + assert.ok(!args.includes('-M')) + assert.ok(!args.includes('-O')) + assert.ok(!args.some(value => /Control(?:Master|Path|Persist)/.test(value))) +}) + +test('no-mux: open() classifies auth failure', async () => { + const spawnFn = scriptedSpawn([{ code: 255, stderr: 'me@box: Permission denied (publickey).' }]) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false }) + await assert.rejects(conn.open(), (err: any) => err.kind === 'auth-failed') +}) + +test('no-mux: forward spawns a persistent -N -L child; cancel + close kill it', async () => { + // Real listener stands in for the tunnel's local end so waitForLocalPort sees it. + const net = await import('node:net') + const srv = net.createServer() + await new Promise<void>(r => srv.listen(0, '127.0.0.1', () => r())) + const localPort = (srv.address() as any).port + const tunnels: any[] = [] + + const spawnFn: any = (_cmd, args) => { + const child: any = new EventEmitter() + child.stderr = new EventEmitter() + child.exitCode = null + + child.kill = () => { + child._killed = true + child.exitCode = 0 + process.nextTick(() => child.emit('exit', 0)) + + return true + } + + if (args.includes('-N')) { + tunnels.push({ args, child }) + process.nextTick(() => + child.stderr.emit('data', Buffer.from(`Local forwarding listening on 127.0.0.1 port ${localPort}.`)) + ) + } else { + process.nextTick(() => child.emit('close', 0)) + } + + if (!args.includes('-N')) { + child.stdout = new EventEmitter() + process.nextTick(() => child.emit('close', 0)) + } + + return child + } + + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false }) + await conn.forward(localPort, 9119) + assert.equal(tunnels.length, 1, 'one persistent tunnel child') + assert.ok(tunnels[0].args.includes('-L'), 'tunnel child carries -L spec') + assert.ok(!tunnels[0].args.some(a => /ControlPath/.test(a))) + + await conn.cancelForward(localPort, 9119) + assert.ok(tunnels[0].child._killed, 'cancelForward kills the tunnel child') + + conn._opened = true + await conn.close() // no-mux close never runs ssh -O exit; must not throw + srv.close() +}) + +test('no-mux: forward fails fast when the tunnel child dies (bad spec/auth)', async () => { + const spawnFn: any = (_cmd, args) => { + const child: any = new EventEmitter() + child.stderr = new EventEmitter() + child.exitCode = null + + child.kill = () => {} + + if (args.includes('-N')) { + process.nextTick(() => { + child.stderr.emit('data', Buffer.from('Permission denied (publickey).')) + child.exitCode = 255 + }) + } + + return child + } + + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false, forwardTimeoutMs: 2000 }) + await assert.rejects(conn.forward(1, 9119), (err: any) => err.kind === 'auth-failed') +}) + +test('no-mux: an unrelated listener cannot mask a delayed bind failure', async () => { + const net = await import('node:net') + const srv = net.createServer() + await new Promise<void>(resolve => srv.listen(0, '127.0.0.1', resolve)) + const localPort = (srv.address() as any).port + + const spawnFn: any = (_cmd, args) => { + const child: any = new EventEmitter() + child.stderr = new EventEmitter() + child.exitCode = null + + child.kill = () => {} + + if (args.includes('-N')) { + setTimeout(() => { + child.stderr.emit('data', Buffer.from(`bind [127.0.0.1]:${localPort}: Address already in use`)) + child.exitCode = 255 + child.emit('exit', 255) + }, 20) + } + + return child + } + + const conn = new SshConnection({ host: 'box' }, { spawnFn, mux: false, forwardTimeoutMs: 1000 }) + await assert.rejects(conn.forward(localPort, 9119), /address already in use/i) + srv.close() +}) + +test('no-mux: tunnel death after readiness makes the connection unhealthy', async () => { + const net = await import('node:net') + const srv = net.createServer() + await new Promise<void>(resolve => srv.listen(0, '127.0.0.1', resolve)) + const localPort = (srv.address() as any).port + let tunnel + + const spawnFn: any = (_cmd, args) => { + const child: any = new EventEmitter() + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + child.exitCode = null + + child.kill = () => {} + + if (args.includes('-N')) { + tunnel = child + process.nextTick(() => + child.stderr.emit('data', Buffer.from(`Local forwarding listening on 127.0.0.1 port ${localPort}.`)) + ) + } else { + process.nextTick(() => child.emit('close', 0)) + } + + return child + } + + const conn = new SshConnection({ host: 'box' }, { spawnFn, mux: false }) + await conn.open() + await conn.forward(localPort, 9119) + tunnel.emit('exit', 255) + assert.equal(await conn.isAlive(), false) + srv.close() +}) + +test('validateSshTarget rejects a host starting with a dash (option injection)', () => { + assert.throws(() => validateSshTarget('-oProxyCommand=evil', '', 22), /unsafe/i) + assert.throws(() => validateSshTarget('--version', '', 22), /unsafe/i) +}) + +test('validateSshTarget rejects control characters in host', () => { + assert.throws(() => validateSshTarget('host\x00evil', '', 22), /unsafe/i) + assert.throws(() => validateSshTarget('host\nnewline', '', 22), /unsafe/i) + assert.throws(() => validateSshTarget('host\ttab', '', 22), /unsafe/i) +}) + +test('validateSshTarget rejects control characters in user', () => { + assert.throws(() => validateSshTarget('box', 'me\x00root', 22), /unsafe/i) + assert.throws(() => validateSshTarget('box', '-oForward=yes', 22), /unsafe/i) +}) + +test('validateSshTarget rejects ports outside 1-65535', () => { + assert.throws(() => validateSshTarget('box', '', 0), /port/i) + assert.throws(() => validateSshTarget('box', '', 65536), /port/i) + assert.throws(() => validateSshTarget('box', '', -1), /port/i) + assert.throws(() => validateSshTarget('box', '', NaN), /port/i) +}) + +test('validateSshTarget accepts valid targets', () => { + assert.doesNotThrow(() => validateSshTarget('my-host.example.com', 'alice', 22)) + assert.doesNotThrow(() => validateSshTarget('192.168.1.1', '', 2222)) + assert.doesNotThrow(() => validateSshTarget('::1', 'root', 22)) +}) + +test('SshConnection constructor rejects hostile host/user/port', () => { + assert.throws(() => new SshConnection({ host: '-oProxyCommand=evil' }), /unsafe/i) + assert.throws(() => new SshConnection({ host: 'box', user: '-oForward' }), /unsafe/i) + assert.throws(() => new SshConnection({ host: 'box', port: 99999 }), /port/i) +}) + +test('buildExecArgs inserts -- before the destination', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildExecArgs(conn, 'uname -s', 15000) + const ddIdx = args.indexOf('--') + assert.ok(ddIdx >= 0, 'must contain --') + assert.equal(args[ddIdx + 1], 'me@box', '-- immediately precedes the destination') + assert.equal(args[ddIdx + 2], 'uname -s', 'remote command follows destination') +}) + +test('buildMasterArgs inserts -- before the destination', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildMasterArgs(conn, 15000) + const ddIdx = args.indexOf('--') + assert.ok(ddIdx >= 0, 'must contain --') + assert.equal(args[ddIdx + 1], 'me@box') +}) + +test('buildControlArgs inserts -- before the destination', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildControlArgs(conn, 'check', [], 15000) + const ddIdx = args.indexOf('--') + assert.ok(ddIdx >= 0, 'must contain --') + assert.equal(args[ddIdx + 1], 'me@box') +}) + +test('buildInteractiveSshArgs inserts -- before the destination', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildInteractiveSshArgs(conn, '', 15000) + const ddIdx = args.indexOf('--') + assert.ok(ddIdx >= 0, 'must contain --') + assert.equal(args[ddIdx + 1], 'me@box') +}) + +test('hostArgs rejects a keyPath with control characters', () => { + assert.throws(() => hostArgs({ keyPath: '/tmp/key\x00inject' }), /unsafe/i) +}) + +test('hostArgs rejects a keyPath starting with a dash', () => { + assert.throws(() => hostArgs({ keyPath: '-oProxyCommand=evil' }), /unsafe/i) +}) + +test('hostArgs accepts valid key paths', () => { + assert.deepEqual(hostArgs({ keyPath: '/home/user/.ssh/id_ed25519' }), ['-i', '/home/user/.ssh/id_ed25519']) + assert.deepEqual(hostArgs({ keyPath: '~/.ssh/id_rsa' }), ['-i', '~/.ssh/id_rsa']) +}) + +test('runSsh delivers stdinData to the child and does not log it', async () => { + let stdinWritten = '' + + const spawnFn: any = (_cmd, _args, opts) => { + const child: any = new EventEmitter() + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + + child.kill = () => {} + child.stdin = { + end(data) { + stdinWritten = String(data) + } + } + assert.equal(opts.stdio[0], 'pipe', 'stdin must be pipe when stdinData is provided') + process.nextTick(() => child.emit('close', 0)) + + return child + } + + await runSsh(['host', 'cat'], { timeoutMs: 5000, spawnFn, stdinData: 'secret-token-value' }) + assert.equal(stdinWritten, 'secret-token-value', 'stdinData must be written to child.stdin') +}) + +test('open() rejects a control-dir that is a symlink', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-test-')) + const real = path.join(tmp, 'real') + const link = path.join(tmp, 'link') + fs.mkdirSync(real, { mode: 0o700 }) + fs.symlinkSync(real, link) + const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 })) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: link }) + await assert.rejects(conn.open(), /symlink|unsafe/i) + fs.rmSync(tmp, { recursive: true, force: true }) +}) + +test('open() enforces 0700 on an existing control dir with lax permissions', async () => { + if (process.platform === 'win32') { + return + } + + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-test-')) + const dir = path.join(tmp, 'ctrl') + fs.mkdirSync(dir, { mode: 0o755 }) + const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 })) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir }) + await conn.open() + const stat = fs.statSync(dir) + assert.equal(stat.mode & 0o777, 0o700, 'control dir must be tightened to 0700') + fs.rmSync(tmp, { recursive: true, force: true }) +}) + +test('control socket identity separates installation scope and key identity', () => { + const base = controlSocketPath('me', 'box', 22, '/tmp/d', { + ownershipId: 'installation-a', + scope: 'primary', + keyPath: '/keys/id' + }) + + assert.equal( + base, + controlSocketPath('me', 'box', 22, '/tmp/d', { + ownershipId: 'installation-a', + scope: 'primary', + keyPath: '/keys/./id' + }) + ) + assert.notEqual( + base, + controlSocketPath('me', 'box', 22, '/tmp/d', { + ownershipId: 'installation-a', + scope: 'worker', + keyPath: '/keys/id' + }) + ) + assert.notEqual( + base, + controlSocketPath('me', 'box', 22, '/tmp/d', { + ownershipId: 'installation-b', + scope: 'primary', + keyPath: '/keys/id' + }) + ) + assert.notEqual( + base, + controlSocketPath('me', 'box', 22, '/tmp/d', { + ownershipId: 'installation-a', + scope: 'primary', + keyPath: '/keys/other' + }) + ) + assert.notEqual( + base, + controlSocketPath('me', 'box', 22, '/tmp/d', { + ownershipId: 'installation-a', + scope: 'primary', + keyPath: '/keys/id', + effectiveConfigFingerprint: 'changed-config' + }) + ) +}) + +test('closing one scope addresses only that scope control master', async () => { + const firstSpawn = scriptedSpawn({ code: 0 }) + const secondSpawn = scriptedSpawn({ code: 0 }) + + const first = new SshConnection( + { host: 'box', user: 'me' }, + { + spawnFn: firstSpawn, + controlDir: '/tmp/d', + ownershipId: 'installation', + scope: 'first' + } + ) + + const second = new SshConnection( + { host: 'box', user: 'me' }, + { + spawnFn: secondSpawn, + controlDir: '/tmp/d', + ownershipId: 'installation', + scope: 'second' + } + ) + + first._opened = true + second._opened = true + await first.close() + assert.notEqual(first.controlPath, second.controlPath) + assert.ok(firstSpawn.calls[0].includes(`ControlPath=${first.controlPath}`)) + assert.ok(!firstSpawn.calls[0].includes(`ControlPath=${second.controlPath}`)) + assert.equal(second._opened, true) +}) + +test('failed ControlMaster close disowns the master instead of retrying it', async () => { + // Old contract kept _opened=true for a retry — which left wedged ControlPersist + // masters trusted and reattachable (the macOS mode-switch livelock). New + // contract: a master that refuses -O exit is disowned — socket dropped, + // connection marked closed — so the next open dials fresh. + const spawnFn = scriptedSpawn([{ code: 255, stderr: 'master refused exit' }]) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' }) + conn._opened = true + await conn.close() + assert.equal(conn._opened, false) + assert.equal(spawnFn.calls.length, 1) +}) + +test('stopTunnelChild waits for process exit', async () => { + const child: any = new EventEmitter() + child.exitCode = null + + child.kill = () => { + process.nextTick(() => { + child.exitCode = 0 + child.emit('exit', 0) + }) + + return true + } + + let stopped = false + + const stopping = stopTunnelChild(child).then(() => { + stopped = true + }) + + assert.equal(stopped, false) + await stopping + assert.equal(stopped, true) +}) -
apps/desktop/electron/ssh-connection.ts@@ -0,0 +1,921 @@ +/** + * ssh-connection.ts + * + * Pure, electron-free OpenSSH ControlMaster connection manager for Desktop SSH + * remote mode. Uses the system `ssh` client (not a JS SSH library) so it + * inherits ~/.ssh/config, the agent, jump hosts (ProxyJump), and hardware keys + * for free — the same rationale as tools/environments/ssh.py. + * + * No `import 'electron'` so it is unit-testable without Electron. main.ts + * wires it into the electron-coupled lifecycle. + * + * Conventions mirrored from tools/environments/ssh.py: + * - ControlMaster=auto + ControlPersist so one TCP/auth handshake is reused + * across exec/forward operations. + * - Hashed control-socket filename under a short tmpdir to stay under the + * 104-byte sun_path limit macOS enforces on Unix domain sockets. + * - BatchMode=yes for every programmatic invocation — a spawned ssh must + * never hang on an interactive prompt (passphrase / 2FA). If auth needs + * interactivity we fail fast and tell the user to load the key into their + * agent. + * + * Host-key policy: StrictHostKeyChecking=accept-new (trust-on-first-use, log + * the fingerprint), never `no`. A host-key *change* fails closed with the + * verbatim OpenSSH error surfaced to the UI. + * + * Every operation is raced against a hard timeout. A half-open TCP connection + * after laptop sleep can leave ssh hanging indefinitely rather than erroring; + * timeout is treated as connection-dead so the caller does a full reconnect + * rather than retrying in place. + */ + +import { spawn } from 'node:child_process' +import crypto from 'node:crypto' +import fs from 'node:fs' +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' + +const DEFAULT_CONNECT_TIMEOUT_MS = 15_000 +const DEFAULT_EXEC_TIMEOUT_MS = 20_000 +const DEFAULT_FORWARD_TIMEOUT_MS = 15_000 +const CONTROL_PERSIST_SECONDS = 300 + +// eslint-disable-next-line no-control-regex -- deliberately reject control chars in ssh targets +const _CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/ + +function validateSshTarget(host, user, port) { + if (!host || typeof host !== 'string') { + throw new Error('Unsafe SSH target: host is required.') + } + + if (host.startsWith('-')) { + throw new Error(`Unsafe SSH target: host must not start with a dash ("${host}").`) + } + + if (_CONTROL_CHAR_RE.test(host)) { + throw new Error('Unsafe SSH target: host contains control characters.') + } + + if (user && _CONTROL_CHAR_RE.test(user)) { + throw new Error('Unsafe SSH target: user contains control characters.') + } + + if (user && user.startsWith('-')) { + throw new Error(`Unsafe SSH target: user must not start with a dash ("${user}").`) + } + + const p = Number(port) + + if (!Number.isInteger(p) || p < 1 || p > 65535) { + throw new Error(`Unsafe SSH port: ${port} (must be 1-65535).`) + } +} + +function validateKeyPath(keyPath) { + if (!keyPath) { + return + } + + if (_CONTROL_CHAR_RE.test(keyPath)) { + throw new Error('Unsafe SSH key path: contains control characters.') + } + + if (keyPath.startsWith('-')) { + throw new Error(`Unsafe SSH key path: must not start with a dash ("${keyPath}").`) + } +} + +// Token / secret redaction + +const _REDACTIONS: Array<[RegExp, string]> = [ + [/(HERMES_DASHBOARD_SESSION_TOKEN=[redacted:secret-assignment] '$1<redacted>'], + [/(X-Hermes-Session-Token["']?\s*[:=]\s*["']?)([^\s"'&]+)/gi, '$1<redacted>'], + [/(Authorization["']?\s*:\s*Bearer\s+)(\S+)/gi, '$1<redacted>'], + [/([?&](?:token|ticket)=)([^\s&"']+)/gi, '$1<redacted>'] +] + +function redactSecrets(text) { + let out = String(text == null ? '' : text) + + for (const [re, repl] of _REDACTIONS) { + out = out.replace(re, repl) + } + + return out +} + +// Control-socket path + +// Hash user@host:port to a short, stable, filesystem-safe socket id — stable +// across reconnects so ControlMaster reuse works, short so the full path stays +// under sun_path's 104-byte limit. +// +// CRITICAL (macOS): the base dir must be SHORT. os.tmpdir() on macOS is the +// per-user `/var/folders/xx/yyyy…/T/` (~49 bytes), and OpenSSH binds a +// TEMPORARY listener at `<ControlPath>.<16 random chars>` while establishing +// the master — so a path that itself fits 104 still overflows at bind time. We +// root under a short per-user base (`~/.hermes/desktop-ssh`) so even worst case +// (~72 bytes on macOS) stays clear. Windows has no AF_UNIX sun_path limit. +function controlSocketPath(user, host, port, baseDir?, identity: any = {}) { + const dir = baseDir || defaultControlDir() + const keyPathIdentity = path.normalize(String(identity.keyPath || '')) + + const parts = [ + identity.ownershipId || '', + identity.scope || '', + user || '', + host, + Number(port), + keyPathIdentity, + identity.effectiveConfigFingerprint || '' + ] + + const id = crypto.createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16) + + return path.join(dir, `${id}.sock`) +} + +function defaultControlDir() { + // POSIX: a SHORT, PER-USER base stays under the socket limit AND avoids a + // world-shared /tmp dir (no symlink-hijack surface). Created 0700 in open(). + if (process.platform === 'win32') { + return path.join(os.tmpdir(), 'hermes-desktop-ssh') + } + + return path.join(os.homedir(), '.hermes', 'desktop-ssh') +} + +// Command construction (pure — the unit tests exercise these directly) + +// Mux (POSIX): ControlMaster options so exec/forward share one authenticated +// connection. No-mux (Windows OpenSSH never implemented mux sockets): plain +// per-invocation options — each ssh call authenticates on its own. +function baseSshOptions(controlPath, connectTimeoutMs?) { + const connectSecs = Math.max(1, Math.round((connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS) / 1000)) + + const mux = controlPath + ? [ + '-o', + `ControlPath=${controlPath}`, + '-o', + 'ControlMaster=auto', + '-o', + `ControlPersist=${CONTROL_PERSIST_SECONDS}` + ] + : [] + + return [ + ...mux, + '-o', + 'BatchMode=yes', + '-o', + 'StrictHostKeyChecking=accept-new', + '-o', + 'ExitOnForwardFailure=yes', + '-o', + `ConnectTimeout=${connectSecs}` + ] +} + +// Non-default port and explicit identity file, shared by exec/master/forward. +function hostArgs({ port, keyPath }: { port?: number | string; keyPath?: string } = {}) { + const args: string[] = [] + + if (port && Number(port) !== 22) { + args.push('-p', String(port)) + } + + if (keyPath) { + validateKeyPath(keyPath) + args.push('-i', keyPath) + } + + return args +} + +function target(user, host) { + return user ? `${user}@${host}` : host +} + +function buildExecArgs(conn, remoteCommand, connectTimeoutMs?) { + return [ + ...baseSshOptions(conn.controlPath, connectTimeoutMs), + ...hostArgs(conn), + '--', + target(conn.user, conn.host), + remoteCommand + ] +} + +function buildControlArgs(conn, op, extra: string[] = [], connectTimeoutMs?) { + return [ + '-O', + op, + ...extra, + ...baseSshOptions(conn.controlPath, connectTimeoutMs), + ...hostArgs(conn), + '--', + target(conn.user, conn.host) + ] +} + +// Open the master explicitly: `-M -N -f` backgrounds ssh once the master is up, +// so the spawn resolves when the connection is established (or fails fast under +// BatchMode if auth is non-interactive-only). +function buildMasterArgs(conn, connectTimeoutMs?) { + return [ + '-M', + '-N', + '-f', + ...baseSshOptions(conn.controlPath, connectTimeoutMs), + ...hostArgs(conn), + '--', + target(conn.user, conn.host) + ] +} + +// Interactive `ssh -tt` for the INTERIM remote terminal (SSH mode only). Reuses +// the existing ControlMaster socket so NO new auth handshake happens — the +// master is already open, so this attaches instantly and never prompts. +// +// NOTE(remote-terminal): interim until the dashboard /api/terminal WebSocket +// lands (specs/desktop-remote-terminal.md); delete this path then. +function buildInteractiveSshArgs(conn, remoteCwd, connectTimeoutMs?, remoteCommand?) { + const args = [ + '-tt', + ...baseSshOptions(conn.controlPath, connectTimeoutMs), + ...hostArgs(conn), + '--', + target(conn.user, conn.host) + ] + + if (remoteCommand) { + args.push(remoteCommand) + + return args + } + + const cwd = String(remoteCwd || '').trim() + + if (cwd) { + const q = `'${cwd.replace(/'/g, `'\\''`)}'` + args.push(`cd ${q} 2>/dev/null; exec "$SHELL" -l`) + } else { + args.push('exec "$SHELL" -l') + } + + return args +} + +// Bind the local end to 127.0.0.1 ONLY — never 0.0.0.0 — so the tunnel does not +// re-expose the remote dashboard to the client's LAN. +function forwardSpec(localPort, remotePort, remoteHost = '127.0.0.1') { + return `127.0.0.1:${localPort}:${remoteHost}:${remotePort}` +} + +// Error classification — distinct, actionable messages for the UI + +const SSH_ERROR = { + UNREACHABLE: 'unreachable', + AUTH_FAILED: 'auth-failed', + HOST_KEY_CHANGED: 'host-key-changed', + TIMEOUT: 'timeout', + UNKNOWN: 'unknown' +} + +// Order matters: the host-key-change banner also contains "WARNING"/"Offending", +// so check it before generic auth. +function classifySshError(stderr) { + const text = String(stderr || '') + + if ( + /REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed|Offending (?:key|ECDSA|RSA|ED25519)/i.test( + text + ) + ) { + return SSH_ERROR.HOST_KEY_CHANGED + } + + if ( + /Permission denied|Too many authentication failures|no matching host key|publickey|password|keyboard-interactive/i.test( + text + ) + ) { + return SSH_ERROR.AUTH_FAILED + } + + if ( + /Could not resolve hostname|Connection refused|Connection timed out|No route to host|Network is unreachable|Operation timed out|port \d+: Connection/i.test( + text + ) + ) { + return SSH_ERROR.UNREACHABLE + } + + return SSH_ERROR.UNKNOWN +} + +function sshErrorMessage(kind, conn, stderr?) { + const host = target(conn.user, conn.host) + + switch (kind) { + case SSH_ERROR.HOST_KEY_CHANGED: + return ( + `The host key for ${host} has CHANGED since you last connected. ` + + `This could be a man-in-the-middle attack, or the server was reinstalled. ` + + `SSH refused to connect. Verify the change is expected, then remove the old key ` + + `with \`ssh-keygen -R ${conn.host}\` and reconnect.\n\n${String(stderr || '').trim()}` + ) + + case SSH_ERROR.AUTH_FAILED: + return ( + `SSH authentication to ${host} failed. Desktop runs ssh non-interactively ` + + `(BatchMode), so a key requiring a passphrase or 2FA must be loaded into your ` + + `ssh-agent first (e.g. \`ssh-add ~/.ssh/id_ed25519\`), or set an IdentityFile in ` + + `~/.ssh/config. Original error: ${String(stderr || '').trim()}` + ) + + case SSH_ERROR.UNREACHABLE: + return `Could not reach ${host} over SSH. Check the host, port, and your network. Original error: ${String(stderr || '').trim()}` + + case SSH_ERROR.TIMEOUT: + return `SSH operation to ${host} timed out. The connection may be half-open (e.g. after sleep); reconnecting.` + + default: + return `SSH error connecting to ${host}: ${String(stderr || '').trim() || 'unknown failure'}` + } +} + +// Spawn helper — runs an ssh invocation, races it against a hard timeout + +// Resolves { code, stdout, stderr }. On timeout the child is SIGKILLed and the +// promise rejects with err.kind = TIMEOUT. `spawnFn` is injectable for tests. +function runSsh(args, { timeoutMs, spawnFn = spawn, stdin = 'ignore', stdinData }: any = {}) { + return new Promise((resolve, reject) => { + const useStdinPipe = stdinData != null || stdin !== 'ignore' + let child + + try { + child = spawnFn('ssh', args, { stdio: [useStdinPipe ? 'pipe' : 'ignore', 'pipe', 'pipe'] }) + } catch (error) { + reject(error) + + return + } + + if (stdinData != null && child.stdin) { + child.stdin.end(stdinData) + } + + let stdout = '' + let stderr = '' + let settled = false + + const timer = setTimeout(() => { + if (settled) { + return + } + + settled = true + + try { + child.kill('SIGKILL') + } catch { + // already gone + } + + const err: any = new Error(`ssh timed out after ${timeoutMs}ms`) + err.kind = SSH_ERROR.TIMEOUT + reject(err) + }, timeoutMs) + + child.stdout?.on('data', d => { + stdout += d.toString() + }) + child.stderr?.on('data', d => { + stderr += d.toString() + }) + child.on('error', error => { + if (settled) { + return + } + + settled = true + clearTimeout(timer) + reject(error) + }) + child.on('close', code => { + if (settled) { + return + } + + settled = true + clearTimeout(timer) + resolve({ code, stdout, stderr }) + }) + }) +} + +function stopTunnelChild(child, timeoutMs = 5_000) { + if (!child || child.exitCode != null || child.signalCode != null) { + return Promise.resolve() + } + + return new Promise<void>((resolve, reject) => { + let settled = false + + const finish = (error?: unknown) => { + if (settled) { + return + } + + settled = true + clearTimeout(timer) + child.off?.('exit', onExit) + child.off?.('error', onError) + error ? reject(error) : resolve() + } + + const onExit = () => finish() + const onError = error => finish(error) + const timer = setTimeout(() => finish(new Error('SSH tunnel did not exit after termination.')), timeoutMs) + child.once('exit', onExit) + child.once('error', onError) + + try { + if (!child.kill()) { + finish(new Error('SSH tunnel termination was refused.')) + } + } catch (error) { + finish(error) + } + }) +} + +// SshConnection — the public manager + +class SshConnection { + host: string + user: string + port: number + keyPath: string + controlPath: string + _spawnFn: any + _log: (msg: string) => void + _connectTimeoutMs: number + _execTimeoutMs: number + _forwardTimeoutMs: number + _opened: boolean + _mux: boolean + _tunnels: Map<string, any> + + constructor(cfg, opts: any = {}) { + if (!cfg || !cfg.host) { + throw new Error('SshConnection requires a host.') + } + + const port = cfg.port ? Number(cfg.port) : 22 + validateSshTarget(cfg.host, cfg.user || '', port) + + if (cfg.keyPath) { + validateKeyPath(cfg.keyPath) + } + + this.host = cfg.host + this.user = cfg.user || '' + this.port = port + this.keyPath = cfg.keyPath || '' + // Windows OpenSSH has no ControlMaster (mux sockets were never implemented + // on Win32) — fall back to one ssh invocation per operation and a + // persistent `ssh -N -L` child per tunnel. Empty controlPath routes the + // pure builders onto their no-mux form. + this._mux = opts.mux ?? process.platform !== 'win32' + this.controlPath = this._mux + ? controlSocketPath(this.user, this.host, this.port, opts.controlDir, { + keyPath: this.keyPath, + ownershipId: opts.ownershipId, + scope: opts.scope, + effectiveConfigFingerprint: opts.effectiveConfigFingerprint + }) + : '' + this._tunnels = new Map() + + this._spawnFn = opts.spawnFn || spawn + + this._log = typeof opts.rememberLog === 'function' ? opts.rememberLog : () => {} + this._connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS + this._execTimeoutMs = opts.execTimeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS + this._forwardTimeoutMs = opts.forwardTimeoutMs ?? DEFAULT_FORWARD_TIMEOUT_MS + this._opened = false + } + + // Lifecycle logging — ALWAYS through redaction. + _logLine(msg) { + this._log(redactSecrets(`[ssh] ${msg}`)) + } + + _fail(stderrOrErr, fallbackKind = SSH_ERROR.UNKNOWN) { + if (stderrOrErr && stderrOrErr.kind === SSH_ERROR.TIMEOUT) { + const err: any = new Error(sshErrorMessage(SSH_ERROR.TIMEOUT, this)) + err.kind = SSH_ERROR.TIMEOUT + + return err + } + + const stderr = typeof stderrOrErr === 'string' ? stderrOrErr : stderrOrErr?.message || '' + const kind = stderr ? classifySshError(stderr) : fallbackKind + const err: any = new Error(sshErrorMessage(kind, this, stderr)) + err.kind = kind + + return err + } + + // Open the connection. Mux: start the persistent ControlMaster (idempotent — + // a live master is a no-op). No-mux: there is no master; validate auth + + // reachability with a one-shot `ssh true` so failures classify identically. + async open() { + if (await this.isAlive()) { + // -O check passing is not proof the master works: a ControlPersist master + // can survive a failed teardown with wedged channels (observed on macOS + // after a mode switch — check succeeds, every exec times out). Verify with + // a real exec before trusting it; on failure, evict and dial fresh. + if (!this._mux || (await this._verifyMuxChannel())) { + this._opened = true + + return + } + + this._logLine('existing control master failed exec verification; evicting stale master') + await this._evictStaleMaster() + } + + if (!this._mux) { + this._logLine(`connecting (no-mux) to ${target(this.user, this.host)}:${this.port}`) + let result + + try { + result = await runSsh(buildExecArgs(this, 'exit 0', this._connectTimeoutMs), { + timeoutMs: this._connectTimeoutMs, + spawnFn: this._spawnFn + }) + } catch (error) { + throw this._fail(error, SSH_ERROR.UNREACHABLE) + } + + if (result.code !== 0) { + throw this._fail(result.stderr, SSH_ERROR.UNREACHABLE) + } + + this._opened = true + this._logLine('connection verified (no-mux; per-operation ssh)') + + return + } + + const controlDir = path.dirname(this.controlPath) + + try { + fs.mkdirSync(controlDir, { recursive: true, mode: 0o700 }) + } catch { + void 0 + } + + if (process.platform !== 'win32') { + const st = fs.lstatSync(controlDir) + + if (st.isSymbolicLink()) { + throw new Error(`Unsafe SSH control dir: ${controlDir} is a symlink.`) + } + + if (!st.isDirectory()) { + throw new Error(`Unsafe SSH control dir: ${controlDir} is not a directory.`) + } + + if (st.uid !== process.getuid!()) { + throw new Error(`Unsafe SSH control dir: ${controlDir} is owned by uid ${st.uid}, not ${process.getuid!()}.`) + } + + if ((st.mode & 0o777) !== 0o700) { + fs.chmodSync(controlDir, 0o700) + } + } + + const args = buildMasterArgs(this, this._connectTimeoutMs) + this._logLine(`opening control master to ${target(this.user, this.host)}:${this.port}`) + let result + + try { + result = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn }) + } catch (error) { + throw this._fail(error, SSH_ERROR.UNREACHABLE) + } + + if (result.code !== 0) { + throw this._fail(result.stderr, SSH_ERROR.UNREACHABLE) + } + + this._opened = true + this._logLine('control master established') + } + + // Liveness. Mux: `-O check` against the master socket. No-mux: a cheap + // one-shot exec — "alive" means "we can still authenticate and run". + async isAlive() { + if ([...this._tunnels.values()].some(tunnel => tunnel.alive === false)) { + return false + } + + const args = this._mux + ? buildControlArgs(this, 'check', [], this._connectTimeoutMs) + : buildExecArgs(this, 'exit 0', this._connectTimeoutMs) + + try { + const result: any = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn }) + + return result.code === 0 + } catch { + return false + } + } + + // A real exec through the master (`exit 0` works under POSIX shells and + // cmd.exe); a wedged mux hangs to the timeout. + async _verifyMuxChannel() { + try { + const result: any = await runSsh(buildExecArgs(this, 'exit 0', this._connectTimeoutMs), { + timeoutMs: this._connectTimeoutMs, + spawnFn: this._spawnFn + }) + + return result.code === 0 + } catch { + return false + } + } + + // -O exit (best-effort) then drop the socket so ControlMaster=auto cannot + // re-attach to the corpse. (The orphaned master process is left to + // ControlPersist; a wedged channel can pin it, but without its socket it is + // inert.) + async _evictStaleMaster() { + try { + await runSsh(buildControlArgs(this, 'exit', [], this._connectTimeoutMs), { + timeoutMs: this._connectTimeoutMs, + spawnFn: this._spawnFn + }) + } catch { + void 0 + } + + try { + fs.unlinkSync(this.controlPath) + } catch (error: any) { + if (error?.code !== 'ENOENT') { + this._logLine(`could not remove stale control socket (${error.code}); a fresh master may not dial`) + } + } + } + + // One-shot remote command over the control connection. Resolves stdout; + // rejects with a classified error on non-zero exit or timeout. + async exec(remoteCommand, { timeoutMs, stdinData }: any = {}) { + const args = buildExecArgs(this, remoteCommand, this._connectTimeoutMs) + let result + + try { + result = await runSsh(args, { + timeoutMs: timeoutMs ?? this._execTimeoutMs, + spawnFn: this._spawnFn, + ...(stdinData != null ? { stdinData } : {}) + }) + } catch (error) { + throw this._fail(error) + } + + if (result.code !== 0) { + throw this._fail(result.stderr) + } + + return result.stdout + } + + // Establish a local→remote forward. Mux: `-O forward` against the master. + // No-mux: spawn a persistent `ssh -N -L` child that IS the tunnel; ready when + // the local port accepts. The child dying = tunnel down (isAlive of the + // backend catches it upstream). + async forward(localPort, remotePort, remoteHost = '127.0.0.1') { + const spec = forwardSpec(localPort, remotePort, remoteHost) + this._logLine(`forwarding 127.0.0.1:${localPort} -> ${remoteHost}:${remotePort}`) + + if (!this._mux) { + const args = [ + ...baseSshOptions('', this._connectTimeoutMs), + ...hostArgs(this), + '-v', + '-N', + '-L', + spec, + '--', + target(this.user, this.host) + ] + + const child = this._spawnFn('ssh', args, { stdio: ['ignore', 'ignore', 'pipe'] }) + const tunnel = { child, alive: true } + this._tunnels.set(spec, tunnel) + let stderr = '' + let readyConfirmed = false + let readyResolve + let readyReject + + const ready = new Promise<void>((resolve, reject) => { + readyResolve = resolve + readyReject = reject + }) + + const readyPattern = new RegExp(`Local forwarding listening on .* port ${localPort}\\b`) + child.stderr?.on('data', d => { + if (readyConfirmed) { + return + } + + stderr = `${stderr}${String(d)}`.slice(-16_384) + + if (readyPattern.test(stderr)) { + readyConfirmed = true + readyResolve() + } + }) + child.on('error', error => { + tunnel.alive = false + readyReject(error) + }) + child.on('exit', code => { + tunnel.alive = false + readyReject(new Error(`tunnel process exited with code ${code}`)) + }) + child.on('close', code => { + tunnel.alive = false + readyReject(new Error(`tunnel process closed with code ${code}`)) + }) + let readyTimeout + + try { + await Promise.race([ + ready, + new Promise((_, reject) => { + readyTimeout = setTimeout( + () => reject(new Error('tunnel did not confirm local forwarding')), + this._forwardTimeoutMs + ) + }) + ]) + } catch (error: any) { + try { + await stopTunnelChild(child) + this._tunnels.delete(spec) + } catch (stopError) { + throw this._fail(stopError, SSH_ERROR.UNKNOWN) + } + + throw this._fail(stderr || error, SSH_ERROR.UNKNOWN) + } finally { + clearTimeout(readyTimeout) + } + + return + } + + const args = buildControlArgs(this, 'forward', ['-L', spec], this._connectTimeoutMs) + let result + + try { + result = await runSsh(args, { timeoutMs: this._forwardTimeoutMs, spawnFn: this._spawnFn }) + } catch (error) { + throw this._fail(error) + } + + if (result.code !== 0) { + throw this._fail(result.stderr) + } + } + + // Cancel a previously-established forward. Best-effort: a failure here is + // logged but not thrown (close tears everything down anyway). + async cancelForward(localPort, remotePort, remoteHost = '127.0.0.1') { + const spec = forwardSpec(localPort, remotePort, remoteHost) + + if (!this._mux) { + const tunnel = this._tunnels.get(spec) + + if (tunnel) { + await stopTunnelChild(tunnel.child) + this._tunnels.delete(spec) + this._logLine(`cancelled forward 127.0.0.1:${localPort}`) + } + + return + } + + const args = buildControlArgs(this, 'cancel', ['-L', spec], this._connectTimeoutMs) + + try { + await runSsh(args, { timeoutMs: this._forwardTimeoutMs, spawnFn: this._spawnFn }) + this._logLine(`cancelled forward 127.0.0.1:${localPort}`) + } catch (error: any) { + this._logLine(`cancelForward failed (ignored): ${error.message}`) + } + } + + // Tear down. Mux: exit the master (drops every forward with it). No-mux: + // kill the tunnel children. Best-effort; never throws. + async close() { + if (!this._opened) { + return + } + + if (!this._mux) { + for (const [spec, tunnel] of this._tunnels) { + await stopTunnelChild(tunnel.child) + this._tunnels.delete(spec) + } + + this._opened = false + this._logLine('connection closed (no-mux tunnels killed)') + + return + } + + const args = buildControlArgs(this, 'exit', [], this._connectTimeoutMs) + + try { + const result: any = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn }) + + if (result.code !== 0) { + throw this._fail(result.stderr) + } + + this._logLine('control master closed') + } catch (error: any) { + // A master that refuses -O exit is the wedge that poisons re-attach; + // disown it. (Without its socket the orphan is inert; ControlPersist may + // not reap it if a wedged channel never idles.) + this._logLine(`close failed; removing control socket: ${error.message}`) + + try { + fs.unlinkSync(this.controlPath) + } catch { + void 0 + } + } + + this._opened = false + } +} + +// Free local port for the tunnel's local end. Bind 127.0.0.1:0, read the +// kernel-assigned port, release. The benign TOCTOU window (release → forward +// grabs it) is caught upstream and retried with a fresh port. + +function pickLocalPort() { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.unref() + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() as net.AddressInfo + server.close(() => resolve(port)) + }) + }) +} + +function createSshProbeConnection(config, options: any = {}) { + return new SshConnection(config, { ...options, mux: false }) +} + +export { + baseSshOptions, + buildControlArgs, + buildExecArgs, + buildInteractiveSshArgs, + buildMasterArgs, + classifySshError, + CONTROL_PERSIST_SECONDS, + controlSocketPath, + createSshProbeConnection, + DEFAULT_CONNECT_TIMEOUT_MS, + DEFAULT_EXEC_TIMEOUT_MS, + DEFAULT_FORWARD_TIMEOUT_MS, + forwardSpec, + hostArgs, + pickLocalPort, + redactSecrets, + runSsh, + SSH_ERROR, + SshConnection, + sshErrorMessage, + stopTunnelChild, + target, + validateKeyPath, + validateSshTarget +} -
apps/desktop/electron/windows-remote-lifecycle.test.ts@@ -0,0 +1,122 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { + buildWindowsInteractiveCommand, + detectRemotePlatform, + encodedPowerShell, + helperCommand, + powerShellCommand, + psLiteral, + validLock +} from './windows-remote-lifecycle' + +const ownershipId = '0123456789abcdef0123456789abcdef' + +function sshWith(exec) { + return { exec } +} + +test('PowerShell transport uses UTF-16LE encoded commands and literal escaping', () => { + assert.equal(Buffer.from(encodedPowerShell("'ok'"), 'base64').toString('utf16le'), "'ok'") + assert.equal(psLiteral("a'b"), "'a''b'") + assert.match(powerShellCommand('Write-Output ok'), /^powershell\.exe -NoProfile -NonInteractive .* -EncodedCommand /) +}) + +test('platform detection preserves POSIX and falls back to Windows PowerShell', async () => { + assert.deepEqual(await detectRemotePlatform(sshWith(async () => 'Linux\nx86_64\n')), { os: 'Linux', arch: 'x86_64' }) + const calls: string[] = [] + + const result = await detectRemotePlatform( + sshWith(async command => { + calls.push(command) + + if (command.startsWith('uname ')) { + throw new Error('PowerShell does not recognize uname') + } + + return JSON.stringify({ + os: 'Windows', + arch: 'ARM64', + hermesHome: 'C:\\h', + hermesPath: 'C:\\h\\hermes.exe', + python: 'C:\\h\\python.exe' + }) + }) + ) + + assert.equal(result.os, 'Windows') + assert.match(calls[1], /EncodedCommand/) +}) + +test('platform detection surfaces transport failures as themselves, not unsupported-platform', async () => { + // A dead/unauthorized host is a connectivity verdict; only a host that answers + // neither probe is an unsupported platform. + const transportErr: any = new Error('SSH connection timed out') + transportErr.kind = 'timeout' + await assert.rejects( + detectRemotePlatform( + sshWith(async () => { + throw transportErr + }) + ), + (err: any) => err.kind === 'timeout' + ) + // Probe genuinely failing on a reachable host still classifies unsupported, + // and carries the probe detail for diagnosis. + await assert.rejects( + detectRemotePlatform( + sshWith(async command => { + if (command.startsWith('uname ')) { + throw new Error('not recognized') + } + + throw new Error('Hermes is not installed on the remote Windows host.') + }) + ), + (err: any) => err.kind === 'unsupported-platform' && /Hermes is not installed/.test(err.message) + ) +}) + +test('helper command uses the fixed remote Python entry point and quotes path data', () => { + const command = helperCommand({ python: "C:\\Program Files\\Hermes's\\python.exe" }, 'inspect', [ + 'C:\\x y\\hermes.exe' + ]) + + const encoded = command.split(' ').pop()! + const script = Buffer.from(encoded, 'base64').toString('utf16le') + assert.match(script, /-m' 'hermes_cli\.windows_ssh_runtime' 'inspect'/) + assert.match(script, /Hermes''s/) + assert.match(script, /C:\\x y\\hermes\.exe/) +}) + +test('Windows lock validation is scoped and exact', () => { + const lock = { + schemaVersion: 2, + protocolVersion: 1, + ownershipId, + spawnNonce: '0123456789abcdef', + pid: 10, + creationTimeNs: '1784219690452757504', + port: 1234, + tokenFingerprint: 'a'.repeat(32), + hermesPath: 'C:\\h\\hermes.exe', + hermesHome: 'C:\\h' + } + + assert.equal(validLock(lock, ownershipId), true) + assert.equal(validLock({ ...lock, ownershipId: 'b'.repeat(32) }, ownershipId), false) + assert.equal(validLock({ ...lock, creationTimeNs: '0' }, ownershipId), false) + // port 0 = spawn-in-progress record: valid ownership proof (cleanup can act + // on it) but the reuse gate must reject it separately. + assert.equal(validLock({ ...lock, port: 0 }, ownershipId), true) + assert.equal(validLock({ ...lock, port: -1 }, ownershipId), false) +}) + +test('Windows integrated terminal uses encoded PowerShell and preserves cwd as literal data', () => { + const command = buildWindowsInteractiveCommand("C:\\Users\\O'Brien\\repo") + const script = Buffer.from(command.split(' ').pop()!, 'base64').toString('utf16le') + assert.match(script, /Set-Location -LiteralPath 'C:\\Users\\O''Brien\\repo'/) + assert.match(script, /powershell\.exe -NoLogo/) +}) -
apps/desktop/electron/windows-remote-lifecycle.ts@@ -0,0 +1,455 @@ +import crypto from 'node:crypto' + +import { redactSecrets, SSH_ERROR } from './ssh-connection' + +const LOCKFILE_SCHEMA_VERSION = 2 +const PROTOCOL_VERSION = 1 +const READY_RE = /^HERMES_(?:BACKEND|DASHBOARD)_READY port=(\d+)/gm +const READY_POLL_INTERVAL_MS = 750 + +function psLiteral(value) { + return `'${String(value).replace(/'/g, "''")}'` +} + +function encodedPowerShell(script) { + return Buffer.from(script, 'utf16le').toString('base64') +} + +function powerShellCommand(script) { + return `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodedPowerShell(script)}` +} + +async function probeWindowsRemote(ssh, explicitHermesPath = '') { + const explicit = psLiteral(explicitHermesPath) + + const script = [ + '$ErrorActionPreference="Stop"', + `$explicit=${explicit}`, + '$hermesHome=$env:HERMES_HOME', + 'if(-not $hermesHome){$hermesHome=Join-Path $env:LOCALAPPDATA "hermes"}', + '$candidates=@()', + 'if($explicit){$candidates+=$explicit}', + '$cmd=Get-Command hermes.exe -ErrorAction SilentlyContinue', + 'if($cmd){$candidates+=$cmd.Source}', + '$candidates+=(Join-Path $hermesHome "hermes-agent\\venv\\Scripts\\hermes.exe")', + '$candidates+=(Join-Path $HOME "hermes-agent\\.venv\\Scripts\\hermes.exe")', + '$hermes=$candidates|Where-Object{Test-Path -LiteralPath $_ -PathType Leaf}|Select-Object -First 1', + 'if(-not $hermes){throw "Hermes is not installed on the remote Windows host."}', + 'if($explicit -and $hermes -ne $explicit){throw "The configured Hermes path is not an executable file."}', + '$python=Join-Path (Split-Path $hermes) "python.exe"', + 'if(-not (Test-Path -LiteralPath $python -PathType Leaf)){throw "The remote Hermes Python runtime was not found."}', + '[ordered]@{os="Windows";arch=$env:PROCESSOR_ARCHITECTURE;hermesHome=$hermesHome;hermesPath=$hermes;python=$python}|ConvertTo-Json -Compress' + ].join(';') + + return JSON.parse((await ssh.exec(powerShellCommand(script))).trim()) +} + +const TRANSPORT_KINDS = new Set([ + SSH_ERROR.AUTH_FAILED, + SSH_ERROR.HOST_KEY_CHANGED, + SSH_ERROR.TIMEOUT, + SSH_ERROR.UNREACHABLE +]) + +async function detectRemotePlatform(ssh, explicitHermesPath = '') { + try { + const output = (await ssh.exec('uname -s; uname -m')).trim().split('\n') + + if (output[0] === 'Linux' || output[0] === 'Darwin') { + return { os: output[0], arch: output[1] || '' } + } + } catch (error: any) { + // uname failing is the expected Windows fall-through; a TRANSPORT failure + // (auth/host-key/timeout/unreachable) is not a platform verdict — surface it + // as itself instead of letting the probe chain end in 'unsupported-platform'. + if (TRANSPORT_KINDS.has(error?.kind)) { + throw error + } + } + + try { + return await probeWindowsRemote(ssh, explicitHermesPath) + } catch (cause: any) { + if (TRANSPORT_KINDS.has(cause?.kind)) { + throw cause + } + + // detail is remote-controlled output headed for the UI: redact + strip control chars. + const detail = redactSecrets(String(cause?.message || cause || '')) + // eslint-disable-next-line no-control-regex -- deliberately strip control chars from remote output + .replace(/[\x00-\x1f\x7f]/g, ' ') + .trim() + + const error: any = new Error( + `The remote operating system is not supported by Desktop SSH.${detail ? ` (probe: ${detail.slice(0, 300)})` : ''}` + ) + + error.kind = 'unsupported-platform' + error.cause = cause + throw error + } +} + +function helperCommand(runtime, operation, args = []) { + const argv = [runtime.python, '-m', 'hermes_cli.windows_ssh_runtime', operation, ...args] + + const script = [ + '$ErrorActionPreference="Stop"', + `& ${argv.map(psLiteral).join(' ')}`, + 'if($LASTEXITCODE -ne 0){exit $LASTEXITCODE}' + ].join(';') + + return powerShellCommand(script) +} + +async function helper(ssh, runtime, operation, args = [], stdinData?) { + const output = await ssh.exec(helperCommand(runtime, operation, args), stdinData == null ? {} : { stdinData }) + + const lines = String(output || '') + .replace(/^\uFEFF/, '') + .trim() + .split(/\r?\n/) + .filter(Boolean) + + const parsed = JSON.parse(lines[lines.length - 1] || 'null') + + if (parsed?.error) { + throw new Error(parsed.error) + } + + return parsed +} + +function fingerprintToken(token) { + return crypto + .createHash('sha256') + .update(String(token || '')) + .digest('hex') + .slice(0, 32) +} + +function validLock(lock, ownershipId) { + // port 0 = spawn-in-progress record (written before readiness); a valid + // ownership proof for cleanup, but never reusable. + return Boolean( + lock && + lock.schemaVersion === LOCKFILE_SCHEMA_VERSION && + lock.protocolVersion === PROTOCOL_VERSION && + lock.ownershipId === ownershipId && + /^[0-9a-f]{16}$/.test(lock.spawnNonce || '') && + Number.isInteger(lock.pid) && + lock.pid > 0 && + /^[0-9]{10,20}$/.test(lock.creationTimeNs || '') && + Number.isInteger(lock.port) && + lock.port >= 0 && + lock.port <= 65535 && + /^[0-9a-f]{32}$/.test(lock.tokenFingerprint || '') && + typeof lock.hermesPath === 'string' && + typeof lock.hermesHome === 'string' + ) +} + +function assertCurrent(signal) { + if (signal?.aborted) { + const error: any = new Error('SSH bootstrap was cancelled.') + error.kind = 'superseded' + throw error + } +} + +async function processState(ssh, runtime, lock) { + return helper(ssh, runtime, 'process-state', [ + String(lock.pid), + String(lock.creationTimeNs), + lock.hermesPath, + lock.spawnNonce + ]) +} + +async function cleanupOwned(ssh, runtime, ownershipId, lock) { + const attempt = async fn => { + try { + await fn() + } catch { + void 0 + } + } + + if (lock) { + const state = await processState(ssh, runtime, lock) + + if (state.alive && state.owned) { + // Deliberately not attempt()-wrapped: a thrown terminate must abort before + // remove-lock, or a live backend is orphaned with no lock to reclaim it. + await helper(ssh, runtime, 'terminate', [ + String(lock.pid), + String(lock.creationTimeNs), + lock.hermesPath, + lock.spawnNonce + ]) + } + + if (lock.spawnNonce) { + await attempt(() => helper(ssh, runtime, 'remove-token', [ownershipId, lock.spawnNonce])) + await attempt(() => helper(ssh, runtime, 'remove-log', [ownershipId, lock.spawnNonce])) + } + } + + await attempt(() => helper(ssh, runtime, 'remove-lock', [ownershipId])) +} + +async function waitReady(ssh, runtime, ownershipId, lock, timeoutMs, signal) { + const deadline = Date.now() + timeoutMs + + while (Date.now() < deadline) { + assertCurrent(signal) + let state + + try { + state = await processState(ssh, runtime, lock) + } catch { + await new Promise(resolve => setTimeout(resolve, READY_POLL_INTERVAL_MS)) + + continue + } + + if (!state.indeterminate && (!state.alive || !state.owned)) { + let detail = '' + + try { + detail = (await helper(ssh, runtime, 'read-log', [ownershipId, lock.spawnNonce]))?.content || '' + } catch { + void 0 + } + + const error: any = new Error( + `Remote Windows backend exited before announcing its port. state=${JSON.stringify(state)} ${detail.slice(-2000)}` + ) + + error.kind = 'spawn-failed' + throw error + } + + let content = '' + + try { + content = (await helper(ssh, runtime, 'read-log', [ownershipId, lock.spawnNonce]))?.content || '' + } catch { + void 0 + } + + let port + + for (const match of content.matchAll(READY_RE)) { + port = Number(match[1]) + } + + if (port) { + return port + } + + await new Promise(resolve => setTimeout(resolve, READY_POLL_INTERVAL_MS)) + } + + const error: any = new Error(`Timed out waiting for the remote Windows backend (${timeoutMs}ms).`) + error.kind = 'ready-timeout' + throw error +} + +async function connectWindowsRemote(deps) { + const { + ssh, + ownershipId, + profile = '', + remoteHermesPath = '', + reuseToken = '', + signal, + pickLocalPort, + forward, + cancelForward, + waitForHermes, + probeReuseProof, + rememberLog = () => {}, + readyTimeoutMs = 45_000 + } = deps + + assertCurrent(signal) + const runtime = await probeWindowsRemote(ssh, remoteHermesPath) + const inspection = await helper(ssh, runtime, 'inspect', [runtime.hermesPath]) + + if (!inspection.supported) { + const error: any = new Error('Update Hermes on the remote Windows host before connecting with Desktop SSH.') + error.kind = 'update-required' + throw error + } + + runtime.hermesPath = inspection.path + const hermesVersion = inspection.version || '' + rememberLog(`[ssh-lifecycle] remote platform Windows/${runtime.arch}`) + rememberLog(`[ssh-lifecycle] located hermes at ${runtime.hermesPath}`) + + const lock = await helper(ssh, runtime, 'read-lock', [ownershipId]) + + if (validLock(lock, ownershipId)) { + const state = await processState(ssh, runtime, lock) + + if (state.indeterminate) { + const error: any = new Error('Could not determine the state of the existing remote backend.') + error.kind = 'transient-transport-error' + throw error + } + + const reusable = + state.alive && + state.owned && + lock.port > 0 && + Boolean(reuseToken) && + lock.tokenFingerprint === fingerprintToken(reuseToken) && + lock.hermesPath === runtime.hermesPath && + lock.hermesHome === runtime.hermesHome + + if (reusable) { + const localPort = await pickLocalPort() + await forward(localPort, lock.port) + + try { + const baseUrl = `http://127.0.0.1:${localPort}` + const classification = await probeReuseProof(baseUrl, reuseToken, lock.spawnNonce) + + if (classification === 'authenticated-ok') { + return { + baseUrl, + token: [redacted:secret-assignment] + remotePort: lock.port, + localPort, + pid: lock.pid, + reused: true, + platform: { os: 'Windows', arch: runtime.arch }, + hermesPath: runtime.hermesPath, + hermesVersion, + ownershipId, + spawnNonce: lock.spawnNonce, + creationTimeNs: lock.creationTimeNs + } + } + + if (classification !== 'authenticated-stale') { + throw new Error('Invalid SSH reuse classification.') + } + + await cancelForward(localPort, lock.port) + await cleanupOwned(ssh, runtime, ownershipId, lock) + } catch (error) { + await cancelForward(localPort, lock.port) + throw error + } + } else { + await cleanupOwned(ssh, runtime, ownershipId, lock) + } + } else if (lock) { + await helper(ssh, runtime, 'remove-lock', [ownershipId]) + } + + assertCurrent(signal) + const token = [redacted:secret-assignment]'hex') + const spawnNonce = crypto.randomBytes(8).toString('hex') + await helper(ssh, runtime, 'upload-token', [ownershipId, spawnNonce], token) + let spawned + + try { + spawned = await helper( + ssh, + runtime, + 'spawn', + [], + JSON.stringify({ ownershipId, spawnNonce, profile, hermesPath: runtime.hermesPath }) + ) + } catch (error) { + await helper(ssh, runtime, 'remove-token', [ownershipId, spawnNonce]) + throw error + } + + const owned = { + schemaVersion: LOCKFILE_SCHEMA_VERSION, + protocolVersion: PROTOCOL_VERSION, + ownershipId, + spawnNonce, + pid: spawned.pid, + creationTimeNs: spawned.creationTimeNs, + port: 0, + profile, + hermesPath: runtime.hermesPath, + hermesHome: runtime.hermesHome, + tokenFingerprint: [redacted:secret-assignment] + startedAt: new Date().toISOString() + } + + let localPort = 0 + let remotePort = 0 + + try { + // Write the ownership record IMMEDIATELY (port=0): if this attempt is + // superseded before readiness and cleanup cannot reach the box, the next + // connect still finds the lock and reaps the process by exact ownership. + // Inside the try: if this write itself fails, the catch still kills the + // just-spawned process via the in-memory record. + await helper(ssh, runtime, 'write-lock', [ownershipId], JSON.stringify(owned)) + remotePort = await waitReady(ssh, runtime, ownershipId, owned, readyTimeoutMs, signal) + localPort = await pickLocalPort() + await forward(localPort, remotePort) + const baseUrl = `http://127.0.0.1:${localPort}` + await waitForHermes(baseUrl, token) + assertCurrent(signal) + await helper(ssh, runtime, 'write-lock', [ownershipId], JSON.stringify({ ...owned, port: remotePort })) + + return { + baseUrl, + token, + remotePort, + localPort, + pid: spawned.pid, + reused: false, + platform: { os: 'Windows', arch: runtime.arch }, + hermesPath: runtime.hermesPath, + hermesVersion, + ownershipId, + spawnNonce, + creationTimeNs: spawned.creationTimeNs + } + } catch (error) { + if (localPort && remotePort) { + await cancelForward(localPort, remotePort) + } + + await cleanupOwned(ssh, runtime, ownershipId, owned) + throw error + } +} + +function buildWindowsInteractiveCommand(remoteCwd = '') { + const cwd = String(remoteCwd || '').trim() + const script = ['$ErrorActionPreference="Stop"'] + + if (cwd) { + script.push( + `if(Test-Path -LiteralPath ${psLiteral(cwd)} -PathType Container){Set-Location -LiteralPath ${psLiteral(cwd)}}` + ) + } + + script.push('$host.UI.RawUI.WindowTitle="Hermes SSH"', 'powershell.exe -NoLogo') + + return powerShellCommand(script.join(';')) +} + +export { + buildWindowsInteractiveCommand, + connectWindowsRemote, + detectRemotePlatform, + encodedPowerShell, + helper, + helperCommand, + powerShellCommand, + probeWindowsRemote, + psLiteral, + validLock +} -
apps/desktop/electron/windows-remote-live.test.ts@@ -0,0 +1,106 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { pickLocalPort, SshConnection } from './ssh-connection' +import { connectWindowsRemote } from './windows-remote-lifecycle' + +// Live test against a real Windows host over SSH. Opt-in: set the env trio to +// your test rig; skipped everywhere else (CI, other machines). +// HERMES_WIN_SSH_HOST ssh alias/host of the Windows box +// HERMES_WIN_SSH_USER remote user +// HERMES_WIN_SSH_HERMES absolute path to the remote hermes.exe under test +const liveHost = process.env.HERMES_WIN_SSH_HOST || '' +const liveUser = process.env.HERMES_WIN_SSH_USER || '' +const configuredHermes = process.env.HERMES_WIN_SSH_HERMES || '' +const ownershipId = '89abcdef0123456789abcdef01234567' + +function fetchJson(url, token, path) { + return fetch(`${url}${path}`, { headers: { 'X-Hermes-Session-Token': token } }).then(async response => { + if (!response.ok) { + throw new Error(`${response.status}: ${await response.text()}`) + } + + return response.json() + }) +} + +test.skipIf(!liveHost || !liveUser || !configuredHermes)( + 'live Windows remote lifecycle spawns, authenticates, reuses, and cleans exact ownership', + async () => { + const ssh = new SshConnection({ host: liveHost, user: liveUser, port: 22, keyPath: '' }, { mux: true }) + await ssh.open() + + const common = { + ssh, + ownershipId, + profile: '', + remoteHermesPath: configuredHermes, + pickLocalPort, + forward: (local, remote) => ssh.forward(local, remote), + cancelForward: (local, remote) => ssh.cancelForward(local, remote), + waitForHermes: async (baseUrl, token) => { + for (let i = 0; i < 40; i++) { + try { + await fetchJson(baseUrl, token, '/api/status') + + return + } catch { + void 0 + } + + await new Promise(resolve => setTimeout(resolve, 250)) + } + + throw new Error('status timeout') + }, + probeReuseProof: async (baseUrl, token, nonce) => { + const body: any = await fetchJson(baseUrl, token, '/api/ssh/ownership') + + return body.sshOwnerNonce === nonce ? 'authenticated-ok' : 'authenticated-stale' + }, + rememberLog: () => {} + } + + let first + let second + + try { + first = await connectWindowsRemote(common) + assert.equal(first.platform.os, 'Windows') + assert.equal(first.reused, false) + const status: any = await fetchJson(first.baseUrl, first.token, '/api/status') + assert.ok(status) + await ssh.cancelForward(first.localPort, first.remotePort) + second = await connectWindowsRemote({ ...common, reuseToken: [redacted:secret-assignment] }) + assert.equal(second.reused, true) + assert.equal(second.pid, first.pid) + assert.equal(second.spawnNonce, first.spawnNonce) + } finally { + if (second) { + await ssh.cancelForward(second.localPort, second.remotePort) + } + + const runtimeScript = `& '${configuredHermes.replace('hermes.exe', 'python.exe')}' -m hermes_cli.windows_ssh_runtime read-lock '${ownershipId}'` + + const lock: any = JSON.parse( + await ssh.exec(`powershell.exe -NoProfile -NonInteractive -Command "${runtimeScript}"`) + ) + + if (lock) { + const python = configuredHermes.replace('hermes.exe', 'python.exe') + const terminate = `& '${python}' -m hermes_cli.windows_ssh_runtime terminate '${lock.pid}' '${lock.creationTimeNs}' '${lock.hermesPath}' '${lock.spawnNonce}'` + await ssh.exec(`powershell.exe -NoProfile -NonInteractive -Command "${terminate}"`) + await ssh.exec( + `powershell.exe -NoProfile -NonInteractive -Command "& '${python}' -m hermes_cli.windows_ssh_runtime remove-lock '${ownershipId}'"` + ) + await ssh.exec( + `powershell.exe -NoProfile -NonInteractive -Command "& '${python}' -m hermes_cli.windows_ssh_runtime remove-log '${ownershipId}' '${lock.spawnNonce}'"` + ) + } + + await ssh.close() + } + }, + 90_000 +) -
apps/desktop/src/app/contrib/wiring.tsx@@ -71,7 +71,9 @@ import { ModelVisibilityOverlay } from '../model-visibility-overlay' import { PetGenerateOverlay } from '../pet-generate/pet-generate-overlay' import { FileActionDialogs } from '../right-sidebar/file-actions' import { RemoteFolderPicker } from '../right-sidebar/files/remote-picker' +import { resetProjectTreeState } from '../right-sidebar/files/use-project-tree' import { PersistentTerminal } from '../right-sidebar/terminal/persistent' +import { closeAllTerminals } from '../right-sidebar/terminal/terminals' import { CRON_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE, syncWorkspaceIsPage } from '../routes' import { SessionPickerOverlay } from '../session-picker-overlay' import { SessionSwitcher } from '../session-switcher' @@ -173,6 +175,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { openCommandCenterSection, openStarmap, profilesOpen, + resetOverlayReturnRoute, settingsOpen, starmapOpen, toggleCommandCenter @@ -632,6 +635,12 @@ export function ContribWiring({ children }: { children: ReactNode }) { ) useGatewayBoot({ + beforeConnectionSwitch: () => { + startFreshSessionDraft({ preserveRoute: true, workspaceTarget: null }) + resetOverlayReturnRoute() + resetProjectTreeState() + closeAllTerminals() + }, handleGatewayEvent: handleGatewayEventWithPlugins, onConnectionReady: c => { connectionRef.current = c -
apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx@@ -18,6 +18,7 @@ import { useGatewayBoot } from './use-gateway-boot' // post-boot reconnect loop. type Listener = (ev: unknown) => void +let connectionApplied: null | (() => void) = null // Minimal WebSocket stand-in implementing only what json-rpc-gateway.connect() // touches: readyState, add/removeEventListener('open'|'error'|'close'), close(). @@ -97,7 +98,13 @@ function fakeDesktop() { })), onBootProgress: vi.fn(() => () => undefined), onBackendExit: vi.fn(() => () => undefined), - onConnectionApplied: vi.fn(() => () => undefined), + onConnectionApplied: vi.fn(callback => { + connectionApplied = callback + + return () => { + connectionApplied = null + } + }), onPowerResume: vi.fn(() => () => undefined), onWindowStateChanged: vi.fn(() => () => undefined), touchBackend: vi.fn(async () => undefined), @@ -105,8 +112,12 @@ function fakeDesktop() { } } -function Harness({ refreshSessions }: { refreshSessions?: () => Promise<void> } = {}) { +function Harness({ + beforeConnectionSwitch = () => undefined, + refreshSessions +}: { beforeConnectionSwitch?: () => void; refreshSessions?: () => Promise<void> } = {}) { useGatewayBoot({ + beforeConnectionSwitch, handleGatewayEvent: () => undefined, onConnectionReady: () => undefined, onGatewayReady: () => undefined, @@ -123,6 +134,7 @@ beforeEach(() => { vi.useFakeTimers() FakeWebSocket.mode = 'open' FakeWebSocket.instances = [] + connectionApplied = null ;(globalThis as { WebSocket: unknown }).WebSocket = FakeWebSocket ;(window as { hermesDesktop?: unknown }).hermesDesktop = fakeDesktop() $gatewayState.set('idle') @@ -199,6 +211,18 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () => expect($desktopBoot.get().error).toBeTruthy() }) + it('resets the old machine context before connecting an applied gateway', async () => { + const beforeConnectionSwitch = vi.fn() + render(<Harness beforeConnectionSwitch={beforeConnectionSwitch} />) + await flushAsync() + expect(connectionApplied).not.toBeNull() + + act(() => connectionApplied?.()) + expect(beforeConnectionSwitch).toHaveBeenCalledTimes(1) + await flushAsync() + expect($gatewayState.get()).toBe('open') + }) + it('a remote that drops post-boot keeps looping with NO boot.error (the dead-end CONNECTING combo)', async () => { render(<Harness />) await flushAsync() -
apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts@@ -48,6 +48,7 @@ import type { RpcEvent } from '@/types/hermes' const RECONNECT_ESCALATE_AFTER = 6 interface GatewayBootOptions { + beforeConnectionSwitch: () => void handleGatewayEvent: (event: RpcEvent) => void onConnectionReady: ( connection: Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null @@ -58,6 +59,7 @@ interface GatewayBootOptions { } export function useGatewayBoot({ + beforeConnectionSwitch, handleGatewayEvent, onConnectionReady, onGatewayReady, @@ -65,6 +67,7 @@ export function useGatewayBoot({ refreshSessions }: GatewayBootOptions) { const callbacksRef = useRef({ + beforeConnectionSwitch, handleGatewayEvent, onConnectionReady, onGatewayReady, @@ -73,6 +76,7 @@ export function useGatewayBoot({ }) callbacksRef.current = { + beforeConnectionSwitch, handleGatewayEvent, onConnectionReady, onGatewayReady, @@ -265,6 +269,7 @@ export function useGatewayBoot({ reconnectAttempt = 0 escalated = false reauthNotified = false + callbacksRef.current.beforeConnectionSwitch() wipeSessionListsForGatewaySwitch() try { -
apps/desktop/src/app/session/hooks/use-session-actions.test.tsx@@ -90,9 +90,11 @@ function storedSession(overrides: Partial<SessionInfo> = {}): SessionInfo { } function Harness({ + navigate = vi.fn(), onReady, requestGateway }: { + navigate?: ReturnType<typeof vi.fn> onReady: (handle: HarnessHandle) => void requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T> }) { @@ -106,7 +108,7 @@ function Harness({ ensureSessionState: () => ({}) as ClientSessionState, getRouteToken: () => 'token', getRoutedStoredSessionId: () => null, - navigate: vi.fn() as never, + navigate: navigate as never, requestGateway, resetViewSync: vi.fn(), runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()), @@ -404,6 +406,25 @@ async function createWith( return createParams } +describe('startFreshSessionDraft', () => { + afterEach(() => cleanup()) + + it('can reset machine-bound session state without closing the current overlay route', async () => { + const navigate = vi.fn() + const requestGateway = vi.fn(async () => ({}) as never) + let handle: HarnessHandle | null = null + + render(<Harness navigate={navigate} onReady={value => (handle = value)} requestGateway={requestGateway} />) + await waitFor(() => expect(handle).not.toBeNull()) + + act(() => handle!.startFreshSessionDraft({ preserveRoute: true, workspaceTarget: null })) + + expect(navigate).not.toHaveBeenCalled() + expect($currentCwd.get()).toBe('') + expect($newChatWorkspaceTarget.get()).toBeNull() + }) +}) + describe('createBackendSessionForSend profile routing', () => { afterEach(() => { cleanup() -
apps/desktop/src/app/session/hooks/use-session-actions/index.ts@@ -177,6 +177,7 @@ async function desktopSessionCreateParams(cwd: string): Promise<Record<string, u } interface FreshSessionDraftOptions { + preserveRoute?: boolean replaceRoute?: boolean workspaceTarget?: NewChatWorkspaceTarget } @@ -270,6 +271,7 @@ export function useSessionActions({ const startFreshSessionDraft = useCallback( (options: boolean | FreshSessionDraftOptions = false) => { const draftOptions = typeof options === 'boolean' ? { replaceRoute: options } : options + const preserveRoute = draftOptions.preserveRoute ?? false const replaceRoute = draftOptions.replaceRoute ?? false const hasWorkspaceTarget = @@ -290,7 +292,11 @@ export function useSessionActions({ // rebind race, so leaving the old id here could revive it on a very fast // New Chat -> Enter sequence. onFreshDraftRouteIntent?.() - navigate(NEW_CHAT_ROUTE, { replace: replaceRoute }) + + if (!preserveRoute) { + navigate(NEW_CHAT_ROUTE, { replace: replaceRoute }) + } + setActiveSessionId(null) activeSessionIdRef.current = null setSelectedStoredSessionId(null) -
apps/desktop/src/app/settings/gateway-settings.tsx@@ -3,11 +3,24 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Tip } from '@/components/ui/tooltip' import type { DesktopAuthProvider, DesktopCloudAgent, DesktopCloudOrg, DesktopConnectionProbeResult } from '@/global' import { useI18n } from '@/i18n' import { ExternalLink } from '@/lib/external-link' -import { AlertCircle, Check, Cloud, FileText, Globe, HelpCircle, Loader2, LogIn, Monitor, RefreshCw } from '@/lib/icons' +import { + AlertCircle, + Check, + Cloud, + FileText, + Globe, + HelpCircle, + Loader2, + LogIn, + Monitor, + RefreshCw, + Terminal +} from '@/lib/icons' import { selectableCardClass } from '@/lib/selectable-card' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' @@ -15,8 +28,9 @@ import { $profiles, refreshActiveProfile } from '@/store/profile' import { CONTROL_TEXT } from './constants' import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives' +import { enrichSelectedSshHost, selectSshHost } from './ssh-host-selection' -type Mode = 'local' | 'remote' | 'cloud' +type Mode = 'local' | 'remote' | 'cloud' | 'ssh' type AuthMode = 'oauth' | 'token' type ProbeStatus = 'idle' | 'probing' | 'done' | 'error' // Hermes Cloud discovery lifecycle for the cloud-mode panel. @@ -31,8 +45,15 @@ interface GatewaySettingsState { remoteTokenSet: boolean remoteUrl: string cloudOrg: string + sshHost: string + sshUser: string + sshPort: number | null + sshKeyPath: string + sshRemoteHermesPath: string } +const SSH_HOST_CUSTOM = '__custom__' + const EMPTY_STATE: GatewaySettingsState = { envOverride: false, mode: 'local', @@ -41,7 +62,12 @@ const EMPTY_STATE: GatewaySettingsState = { remoteTokenPreview: null, remoteTokenSet: [redacted:secret-assignment] remoteUrl: '', - cloudOrg: '' + cloudOrg: '', + sshHost: '', + sshUser: '', + sshPort: null, + sshKeyPath: '', + sshRemoteHermesPath: '' } export function savedCloudConnectionUrl(config: Pick<GatewaySettingsState, 'mode' | 'remoteUrl'>): string { @@ -128,6 +154,14 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { const [state, setState] = useState<GatewaySettingsState>(EMPTY_STATE) const [remoteToken, setRemoteToken] = useState('') const [lastTest, setLastTest] = useState<null | string>(null) + const [sshHostSuggestions, setSshHostSuggestions] = useState<string[]>([]) + const [sshCustomHost, setSshCustomHost] = useState(false) + const sshResolveSeq = useRef(0) + const sshTestSeq = useRef(0) + const saveSeq = useRef(0) + const signingSeq = useRef(0) + const cloudConnectSeq = useRef(0) + const contextSeq = useRef(0) const [connectedCloudUrl, setConnectedCloudUrl] = useState('') const acceptSavedConfig = (config: GatewaySettingsState) => { @@ -337,6 +371,48 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { // per-profile scopes are the named, non-default profiles. const namedProfiles = useMemo(() => profiles.filter(profile => profile.name !== 'default'), [profiles]) + useEffect(() => { + // One-directional: a saved host that isn't in the suggestions must render + // the free-text input (rehydration). Never force custom OFF here — that + // instantly snapped the just-clicked-Custom (empty-host) input back to the + // dropdown, making a raw-IP host impossible to type. The way back to the + // dropdown is the input's onBlur (empty host + suggestions). + if (state.sshHost && !sshHostSuggestions.includes(state.sshHost)) { + setSshCustomHost(true) + } + }, [state.sshHost, sshHostSuggestions]) + + useEffect(() => { + if (state.mode !== 'ssh' || !window.hermesDesktop?.sshConfigHosts) { + return + } + + let cancelled = false + void window.hermesDesktop + .sshConfigHosts() + .then(result => { + if (!cancelled) { + setSshHostSuggestions(result.hosts) + } + }) + .catch(() => { + if (!cancelled) { + setSshHostSuggestions([]) + } + }) + + return () => void (cancelled = true) + }, [state.mode]) + + useEffect(() => { + contextSeq.current += 1 + sshTestSeq.current += 1 + saveSeq.current += 1 + signingSeq.current += 1 + cloudConnectSeq.current += 1 + setLastTest(null) + }, [scope, state.mode, state.sshHost, state.sshUser, state.sshPort, state.sshKeyPath, state.sshRemoteHermesPath]) + const oauthConnected = state.remoteOauthConnected const canUseRemote = useMemo(() => { @@ -356,10 +432,17 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { profile: scope ?? undefined, remoteAuthMode: authMode, remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined, - remoteUrl: trimmedUrl + remoteUrl: trimmedUrl, + sshHost: state.sshHost.trim(), + sshUser: state.sshUser.trim() || undefined, + sshPort: state.sshPort, + sshKeyPath: state.sshKeyPath.trim() || undefined, + sshRemoteHermesPath: state.sshRemoteHermesPath.trim() }) const save = async (apply: boolean) => { + const seq = ++saveSeq.current + if (state.mode === 'remote' && !canUseRemote) { notify({ kind: 'warning', @@ -377,6 +460,10 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { ? await window.hermesDesktop.applyConnectionConfig(payload()) : await window.hermesDesktop.saveConnectionConfig(payload()) + if (seq !== saveSeq.current) { + return + } + acceptSavedConfig(next) setRemoteToken('') notify({ @@ -385,9 +472,35 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { message: apply ? g.restartingMessage : g.savedMessage }) } catch (err) { - notifyError(err, apply ? g.applyFailed : g.saveFailed) + if (seq !== saveSeq.current) { + return + } + + const sshError = err && typeof err === 'object' && 'sshError' in err ? String(err.sshError) : '' + + const errors = { + 'auth-failed': g.sshErrAuth, + 'hermes-not-found': g.sshErrNotInstalled, + 'host-key-changed': g.sshErrHostKey, + timeout: g.sshErrTimeout, + unreachable: g.sshErrUnreachable, + 'unsupported-platform': g.sshErrPlatform, + 'update-required': g.sshErrUpdateRequired + } + + if (state.mode === 'ssh' && sshError) { + notify({ + kind: 'error', + title: apply ? g.applyFailed : g.saveFailed, + message: (errors as Record<string, string>)[sshError] || g.sshErrUnknown + }) + } else { + notifyError(err, apply ? g.applyFailed : g.saveFailed) + } } finally { - setSaving(false) + if (seq === saveSeq.current) { + setSaving(false) + } } } @@ -395,6 +508,8 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { // the URL the login window needs), then open the gateway login window and // refresh the connection status from the saved config once it completes. const signIn = async () => { + const seq = ++signingSeq.current + if (!trimmedUrl) { notify({ kind: 'warning', title: g.incompleteTitle, message: g.enterUrlFirst }) @@ -413,10 +528,18 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { remoteUrl: trimmedUrl }) + if (seq !== signingSeq.current) { + return + } + acceptSavedConfig(saved) const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl) + if (seq !== signingSeq.current) { + return + } + if (result.connected) { const refreshed = await window.hermesDesktop.getConnectionConfig(scope) acceptSavedConfig(refreshed) @@ -429,24 +552,38 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { }) } } catch (err) { - notifyError(err, g.signInFailed) + if (seq === signingSeq.current) { + notifyError(err, g.signInFailed) + } } finally { - setSigningIn(false) + if (seq === signingSeq.current) { + setSigningIn(false) + } } } const signOut = async () => { + const seq = ++signingSeq.current setSigningIn(true) try { await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined) const refreshed = await window.hermesDesktop.getConnectionConfig(scope) + + if (seq !== signingSeq.current) { + return + } + acceptSavedConfig(refreshed) notify({ kind: 'success', title: g.signedOutTitle, message: g.signedOutMessage }) } catch (err) { - notifyError(err, g.signOutFailed) + if (seq === signingSeq.current) { + notifyError(err, g.signOutFailed) + } } finally { - setSigningIn(false) + if (seq === signingSeq.current) { + setSigningIn(false) + } } } @@ -458,6 +595,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { // needsOrgSelection we surface the org list and show a picker instead. const discoverCloud = async (org?: string) => { const desktop = window.hermesDesktop + const seq = contextSeq.current if (!desktop?.cloud) { return @@ -468,6 +606,10 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { try { const result = await desktop.cloud.discover(org) + if (seq !== contextSeq.current) { + return + } + if ('needsOrgSelection' in result && result.needsOrgSelection) { // Multi-org user with no org chosen yet: show the picker. Don't clear a // previously-chosen org list on a refresh. @@ -495,6 +637,10 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { setCloudDiscover('done') } catch (err) { + if (seq !== contextSeq.current) { + return + } + setCloudAgents([]) setCloudDiscover('error') @@ -579,6 +725,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { const cloudSignIn = async () => { const desktop = window.hermesDesktop + const seq = ++signingSeq.current if (!desktop?.cloud) { return @@ -588,20 +735,30 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { try { const result = await desktop.cloud.login() + + if (seq !== signingSeq.current) { + return + } + setCloudSignedIn(result.signedIn) if (result.signedIn) { await discoverCloud() } } catch (err) { - notifyError(err, g.cloudSignInFailed) + if (seq === signingSeq.current) { + notifyError(err, g.cloudSignInFailed) + } } finally { - setCloudSigningIn(false) + if (seq === signingSeq.current) { + setCloudSigningIn(false) + } } } const cloudSignOut = async () => { const desktop = window.hermesDesktop + const seq = ++signingSeq.current if (!desktop?.cloud) { return @@ -611,6 +768,11 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { try { await desktop.cloud.logout() + + if (seq !== signingSeq.current) { + return + } + setCloudSignedIn(false) setCloudAgents([]) setCloudOrgs([]) @@ -618,9 +780,13 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { setCloudDiscover('idle') notify({ kind: 'success', title: g.cloudSignedOutTitle, message: g.cloudSignedOutMessage }) } catch (err) { - notifyError(err, g.signOutFailed) + if (seq === signingSeq.current) { + notifyError(err, g.signOutFailed) + } } finally { - setCloudSigningIn(false) + if (seq === signingSeq.current) { + setCloudSigningIn(false) + } } } @@ -628,6 +794,8 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { // prompt — the shared portal session auto-approves), then persist a cloud-mode // connection pointed at its dashboardUrl and apply it (soft-reconnects in place). const connectCloudAgent = async (agent: DesktopCloudAgent) => { + const seq = contextSeq.current + if (!agent.dashboardUrl) { return } @@ -643,6 +811,10 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { try { const result = await desktop.cloud.agentSignIn(agent.dashboardUrl) + if (seq !== contextSeq.current) { + return + } + if (!result.connected) { notify({ kind: 'warning', @@ -665,20 +837,113 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { cloudOrg: cloudOrgRef.current ?? undefined }) + if (seq !== contextSeq.current) { + return + } + acceptSavedConfig(next) notify({ kind: 'success', title: g.cloudConnectedTitle, message: g.cloudConnectedTo(agent.name) }) } catch (err) { + if (seq !== contextSeq.current) { + return + } + if (err && typeof err === 'object' && 'needsCloudLogin' in err) { setCloudSignedIn(false) } notifyError(err, g.cloudConnectFailed) } finally { - setCloudConnectingId(null) + if (seq === contextSeq.current) { + setCloudConnectingId(null) + } + } + } + + const resolveSshHost = async (host: string) => { + if (!host || !window.hermesDesktop?.sshResolveHost) { + return + } + + const seq = ++sshResolveSeq.current + + try { + const resolved = await window.hermesDesktop.sshResolveHost(host) + + if (seq !== sshResolveSeq.current) { + return + } + + setState(current => enrichSelectedSshHost(current, host, resolved)) + } catch { + return + } + } + + const selectHost = (value: string) => { + if (value === SSH_HOST_CUSTOM) { + setSshCustomHost(true) + setState(current => selectSshHost(current, '')) + + return + } + + setSshCustomHost(false) + setState(current => selectSshHost(current, value)) + void resolveSshHost(value) + } + + const testSsh = async () => { + const seq = ++sshTestSeq.current + + if (!state.sshHost.trim()) { + notify({ kind: 'warning', title: g.incompleteTitle, message: g.sshIncompleteHost }) + + return + } + + setTesting(true) + setLastTest(null) + + try { + const result = await window.hermesDesktop.testConnectionConfig(payload()) + + if (seq !== sshTestSeq.current) { + return + } + + if (!result.reachable) { + const errors = { + 'auth-failed': g.sshErrAuth, + 'hermes-not-found': g.sshErrNotInstalled, + 'host-key-changed': g.sshErrHostKey, + timeout: g.sshErrTimeout, + unreachable: g.sshErrUnreachable, + 'unsupported-platform': g.sshErrPlatform, + 'update-required': g.sshErrUpdateRequired, + unknown: g.sshErrUnknown + } + + throw new Error(errors[result.sshError || 'unknown'] || result.error || g.sshErrUnknown) + } + + const message = g.sshReachable(result.host || state.sshHost, result.remotePlatform || '?') + setLastTest(message) + notify({ kind: 'success', title: g.reachableTitle, message }) + } catch (err) { + if (seq === sshTestSeq.current) { + notifyError(err, g.testFailed) + } + } finally { + if (seq === sshTestSeq.current) { + setTesting(false) + } } } const testRemote = async () => { + const seq = ++sshTestSeq.current + if (!canUseRemote) { notify({ kind: 'warning', @@ -701,13 +966,21 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { remoteUrl: trimmedUrl }) - const message = g.connectedTo(result.baseUrl, result.version ?? undefined) + if (seq !== sshTestSeq.current) { + return + } + + const message = g.connectedTo(result.baseUrl || trimmedUrl, result.version ?? undefined) setLastTest(message) notify({ kind: 'success', title: g.reachableTitle, message }) } catch (err) { - notifyError(err, g.testFailed) + if (seq === sshTestSeq.current) { + notifyError(err, g.testFailed) + } } finally { - setTesting(false) + if (seq === sshTestSeq.current) { + setTesting(false) + } } } @@ -770,7 +1043,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { <div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)"> {g.modeTitle} </div> - <div className="grid auto-rows-fr grid-cols-1 gap-2 min-[42rem]:grid-cols-3"> + <div className="grid auto-rows-fr grid-cols-1 gap-2 sm:grid-cols-2 min-[72rem]:grid-cols-4"> <ModeCard active={state.mode === 'local'} description={g.localDesc} @@ -796,6 +1069,15 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { onSelect={() => setState(current => ({ ...current, mode: 'remote' }))} title={g.remoteTitle} /> + <ModeCard + active={state.mode === 'ssh'} + description={g.sshDesc} + disabled={state.envOverride} + hint={g.sshTrustHint} + icon={Terminal} + onSelect={() => setState(current => ({ ...current, mode: 'ssh' }))} + title={g.sshTitle} + /> </div> </div> @@ -1033,6 +1315,109 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { </div> ) : null} + {state.mode === 'ssh' && !state.envOverride ? ( + <div className="mt-5 grid gap-1"> + {sshHostSuggestions.length > 0 && !sshCustomHost ? ( + <ListRow + action={ + <Select + onValueChange={selectHost} + value={sshHostSuggestions.includes(state.sshHost) ? state.sshHost : SSH_HOST_CUSTOM} + > + <SelectTrigger className={cn('h-8', CONTROL_TEXT)}> + <SelectValue placeholder={g.sshHostPick} /> + </SelectTrigger> + <SelectContent> + {sshHostSuggestions.map(host => ( + <SelectItem key={host} value={host}> + {host} + </SelectItem> + ))} + <SelectItem value={SSH_HOST_CUSTOM}>{g.sshHostCustom}</SelectItem> + </SelectContent> + </Select> + } + description={g.sshHostPickDesc} + title={g.sshHostPickTitle} + /> + ) : ( + <ListRow + action={ + <Input + autoFocus={sshCustomHost} + className={cn('h-8', CONTROL_TEXT)} + onBlur={() => { + // Empty host on blur with suggestions available = the user backed + // out of Custom; return to the dropdown. + if (!state.sshHost.trim() && sshHostSuggestions.length > 0) { + setSshCustomHost(false) + + return + } + + void resolveSshHost(state.sshHost) + }} + onChange={event => setState(current => selectSshHost(current, event.target.value))} + value={state.sshHost} + /> + } + description={g.sshHostDesc} + title={g.sshHostTitle} + /> + )} + <ListRow + action={ + <Input + className={cn('h-8', CONTROL_TEXT)} + onChange={event => setState(current => ({ ...current, sshUser: event.target.value }))} + placeholder={g.sshUserPlaceholder} + value={state.sshUser} + /> + } + description={g.sshUserDesc} + title={g.sshUserTitle} + /> + <ListRow + action={ + <Input + className={cn('h-8', CONTROL_TEXT)} + inputMode="numeric" + onChange={event => + setState(current => ({ ...current, sshPort: event.target.value ? Number(event.target.value) : null })) + } + placeholder="22" + value={state.sshPort ?? ''} + /> + } + description={g.sshPortDesc} + title={g.sshPortTitle} + /> + <ListRow + action={ + <Input + className={cn('h-8 font-mono', CONTROL_TEXT)} + onChange={event => setState(current => ({ ...current, sshKeyPath: event.target.value }))} + value={state.sshKeyPath} + /> + } + description={g.sshKeyDesc} + title={g.sshKeyTitle} + /> + <ListRow + action={ + <Input + className={cn('h-8 font-mono', CONTROL_TEXT)} + onChange={event => setState(current => ({ ...current, sshRemoteHermesPath: event.target.value }))} + placeholder={g.sshHermesPathPlaceholder} + value={state.sshRemoteHermesPath} + /> + } + description={g.sshHermesPathDesc} + title={g.sshHermesPathTitle} + /> + </div> + ) : null} + {lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null} {/* Test/Save apply to local + remote. Cloud connects via the agent picker @@ -1051,6 +1436,17 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { {testing ? <Loader2 className="animate-spin" /> : null} {g.testRemote} </Button> + ) : state.mode === 'ssh' ? ( + <Button + className="mr-auto" + disabled={testing || !state.sshHost.trim()} + onClick={() => void testSsh()} + size="sm" + variant="text" + > + {testing ? <Loader2 className="animate-spin" /> : null} + {g.sshTestConnection} + </Button> ) : null} {embedded ? null : ( <Button -
apps/desktop/src/app/settings/ssh-host-selection.test.ts@@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' + +import { enrichSelectedSshHost, selectSshHost } from './ssh-host-selection' + +const state = { + mode: 'ssh', + sshHost: 'linux-box', + sshUser: 'operator', + sshPort: 2222, + sshKeyPath: '/keys/linux', + sshRemoteHermesPath: '/opt/hermes' +} + +describe('selectSshHost', () => { + it('clears host-specific fields when the selected host changes', () => { + expect(selectSshHost(state, 'mac-box')).toEqual({ + mode: 'ssh', + sshHost: 'mac-box', + sshUser: '', + sshPort: null, + sshKeyPath: '', + sshRemoteHermesPath: '' + }) + }) + + it('preserves state when reselecting the same host', () => { + expect(selectSshHost(state, state.sshHost)).toBe(state) + }) + + it('enriches only the host that produced the ssh config result', () => { + const selected = selectSshHost(state, 'mac-box') + expect( + enrichSelectedSshHost(selected, 'mac-box', { + identityFile: '~/.ssh/id_ed25519', + port: 22, + user: 'hermes' + }) + ).toMatchObject({ + sshHost: 'mac-box', + sshUser: 'hermes', + sshPort: null, + sshKeyPath: '~/.ssh/id_ed25519' + }) + expect(enrichSelectedSshHost(state, 'mac-box', { user: 'wrong' })).toBe(state) + }) +}) -
apps/desktop/src/app/settings/ssh-host-selection.ts@@ -0,0 +1,43 @@ +type SshHostState = { + sshHost: string + sshUser: string + sshPort: number | null + sshKeyPath: string + sshRemoteHermesPath: string +} + +type ResolvedSshHost = { + identityFile?: string | null + port?: number | null + user?: string | null +} + +function selectSshHost<T extends SshHostState>(state: T, host: string): T { + if (host === state.sshHost) { + return state + } + + return { + ...state, + sshHost: host, + sshUser: '', + sshPort: null, + sshKeyPath: '', + sshRemoteHermesPath: '' + } +} + +function enrichSelectedSshHost<T extends SshHostState>(state: T, host: string, resolved: ResolvedSshHost): T { + if (state.sshHost !== host) { + return state + } + + return { + ...state, + sshUser: state.sshUser || resolved.user || '', + sshPort: state.sshPort ?? (resolved.port === 22 ? null : (resolved.port ?? null)), + sshKeyPath: state.sshKeyPath || resolved.identityFile || '' + } +} + +export { enrichSelectedSshHost, selectSshHost } -
apps/desktop/src/app/shell/hooks/use-overlay-routing.ts@@ -47,6 +47,10 @@ export function useOverlayRouting() { [navigate] ) + const resetOverlayReturnRoute = useCallback(() => { + returnPathRef.current = NEW_CHAT_ROUTE + }, []) + const closeOverlayToPreviousRoute = useCallback( () => navigate(returnPathRef.current || NEW_CHAT_ROUTE, { replace: true }), [navigate] @@ -75,6 +79,7 @@ export function useOverlayRouting() { openCommandCenterSection, openStarmap, profilesOpen, + resetOverlayReturnRoute, settingsOpen, starmapOpen, toggleCommandCenter -
apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx@@ -42,7 +42,7 @@ import { } from '@/store/updates' import type { StatusResponse } from '@/types/hermes' -import { CRON_ROUTE } from '../../routes' +import { CRON_ROUTE, SETTINGS_ROUTE } from '../../routes' import type { StatusbarItem } from '../statusbar-controls' const EMPTY_USAGE = { calls: 0, input: 0, output: 0, total: 0 } as const @@ -289,8 +289,38 @@ export function useStatusbarItems({ copy ]) + const connectionItem = useMemo<StatusbarItem | null>(() => { + if (connection?.mode !== 'remote' || !connection.remoteHost) { + return null + } + + const ssh = connection.remoteKind === 'ssh' + const cloud = connection.remoteKind === 'cloud' + + return { + className: cn( + 'px-2 -ml-1 font-medium', + ssh ? 'bg-primary text-primary-foreground' : 'bg-accent text-accent-foreground' + ), + icon: <Terminal className="size-3" />, + id: 'connection', + label: ssh + ? copy.connectionSsh(connection.remoteHost) + : cloud + ? copy.connectionCloud(connection.remoteHost) + : copy.connectionRemote(connection.remoteHost), + title: ssh + ? copy.connectionSshTooltip(connection.remoteHost) + : cloud + ? copy.connectionCloudTooltip(connection.remoteHost) + : copy.connectionRemoteTooltip(connection.remoteHost), + to: `${SETTINGS_ROUTE}?tab=gateway` + } + }, [connection?.mode, connection?.remoteHost, connection?.remoteKind, copy]) + const coreLeftStatusbarItems = useMemo<readonly StatusbarItem[]>( () => [ + ...(connectionItem ? [connectionItem] : []), { className: `w-7 justify-center px-0${commandCenterOpen ? ' bg-accent/55 text-foreground' : ''}`, icon: <Command className="size-3.5" />, @@ -386,6 +416,7 @@ export function useStatusbarItems({ [ agentsOpen, commandCenterOpen, + connectionItem, copy, currentCwd, fileMenu.copyPath, -
apps/desktop/src/components/boot-failure-overlay.tsx@@ -13,7 +13,13 @@ import { notify, notifyError } from '@/store/notifications' import { $desktopOnboarding } from '@/store/onboarding' import type { RemoteReauth } from './boot-failure-reauth' -import { deriveProviderShape, isRemoteConfig, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth' +import { + deriveProviderShape, + isRemoteConfig, + isRemoteReauthFailure, + signInLabel, + sshFailureMessage +} from './boot-failure-reauth' // The recovery "Gateway settings" view embeds the real Settings → Gateway panel // (identical URL/auth/test/save controls — no parallel form to drift). Lazy so @@ -44,6 +50,7 @@ export function BootFailureOverlay() { const [logs, setLogs] = useState<string[]>([]) const [showLogs, setShowLogs] = useState(false) const [remoteReauth, setRemoteReauth] = useState<RemoteReauth | null>(null) + const [connectionConfig, setConnectionConfig] = useState<DesktopConnectionConfig | null>(null) // A remote/cloud backend that failed to boot is fixable from gateway settings, // so the escape hatch earns emphasis (local failures keep it as a quiet ghost). const [remoteFailure, setRemoteFailure] = useState(false) @@ -75,6 +82,7 @@ export function BootFailureOverlay() { useEffect(() => { if (!visible) { setRemoteReauth(null) + setConnectionConfig(null) setRemoteFailure(false) setView('recovery') @@ -102,6 +110,7 @@ export function BootFailureOverlay() { return } + setConnectionConfig(config) setRemoteFailure(isRemoteConfig(config)) if (!isRemoteReauthFailure(config, boot.error)) { @@ -314,7 +323,7 @@ export function BootFailureOverlay() { <div className="grid gap-4 p-5 pt-0"> <div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-xs text-destructive"> - {boot.error} + {sshFailureMessage(connectionConfig, boot.error, t.settings.gateway)} </div> <div className="grid gap-2"> -
apps/desktop/src/components/boot-failure-reauth.test.ts@@ -7,7 +7,8 @@ import { isRemoteConfig, isRemoteReauthError, isRemoteReauthFailure, - signInLabel + signInLabel, + sshFailureMessage } from './boot-failure-reauth' function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnectionConfig { @@ -21,6 +22,11 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnec remoteTokenSet: [redacted:secret-assignment] remoteUrl: 'https://box:9119', cloudOrg: '', + sshHost: '', + sshUser: '', + sshPort: null, + sshKeyPath: '', + sshRemoteHermesPath: '', ...overrides } } @@ -31,8 +37,20 @@ describe('isRemoteConfig', () => { expect(isRemoteConfig(config({ mode: 'cloud', remoteOauthConnected: true }))).toBe(true) }) - it('false for local, for a remote with no URL, and for nullish', () => { + it('recognizes SSH as remote recovery without treating it as OAuth reauth', () => { + const ssh = config({ mode: 'ssh' as never, remoteUrl: '', remoteAuthMode: 'token' }) as DesktopConnectionConfig & { + sshHost: string + } + + ssh.sshHost = 'remote-box' + + expect(isRemoteConfig(ssh)).toBe(true) + expect(isRemoteReauthFailure(ssh, 'SSH authentication failed.')).toBe(false) + }) + + it('false for local, incomplete SSH, a remote with no URL, and nullish', () => { expect(isRemoteConfig(config({ mode: 'local' }))).toBe(false) + expect(isRemoteConfig(config({ mode: 'ssh' as never, remoteUrl: '' }))).toBe(false) expect(isRemoteConfig(config({ remoteUrl: '' }))).toBe(false) expect(isRemoteConfig(null)).toBe(false) }) @@ -93,6 +111,16 @@ describe('isRemoteReauthError', () => { }) }) +describe('sshFailureMessage', () => { + it('localizes SSH failures without changing non-SSH errors', () => { + const copy = { sshErrAuth: 'localized auth', sshErrUnknown: 'localized unknown' } + const ssh = config({ mode: 'ssh', sshHost: 'box', remoteUrl: '' }) + expect(sshFailureMessage(ssh, 'SSH authentication failed', copy)).toBe('localized auth') + expect(sshFailureMessage(ssh, 'unexpected failure', copy)).toBe('localized unknown') + expect(sshFailureMessage(config(), 'raw remote error', copy)).toBe('raw remote error') + }) +}) + describe('deriveProviderShape', () => { it('generic copy when there are no providers', () => { expect(deriveProviderShape([])).toEqual({ isPassword: [redacted:secret-assignment] providerLabel: 'your identity provider' }) -
apps/desktop/src/components/boot-failure-reauth.ts@@ -31,7 +31,16 @@ const DEFAULT_SIGN_IN_COPY: SignInCopy = { // Gateway (edit URL / token / sign in) — the local Retry/Repair buttons target // the bundled backend and can't help. Drives the escape-hatch emphasis. export function isRemoteConfig(config: DesktopConnectionConfig | null | undefined): boolean { - return Boolean(config && (config.mode === 'remote' || config.mode === 'cloud') && config.remoteUrl) + if (!config) { + return false + } + + const ssh = config as DesktopConnectionConfig & { sshHost?: string } + + return ( + ((config.mode === 'remote' || config.mode === 'cloud') && Boolean(config.remoteUrl)) || + ((config.mode as string) === 'ssh' && Boolean(ssh.sshHost)) + ) } // True when a boot error is auth-shaped — the refresh token was rejected or the @@ -55,6 +64,59 @@ export function isRemoteReauthError(error: string | null | undefined): boolean { // — see isRemoteReauthError). Only re-establishing the remote session fixes it; // the local Retry/Repair buttons can't. 'cloud' counts as remote (it resolves to // a remote oauth backend), so a lapsed cloud session is the same failure. +export function sshFailureMessage( + config: DesktopConnectionConfig | null | undefined, + error: string | null | undefined, + copy: { + sshErrAuth?: string + sshErrHostKey?: string + sshErrNotInstalled?: string + sshErrPlatform?: string + sshErrTimeout?: string + sshErrUpdateRequired?: string + sshErrUnreachable?: string + sshErrUnknown?: string + } +): string { + const raw = String(error || '') + + if (config?.mode !== 'ssh') { + return raw + } + + const text = raw.toLowerCase() + + if (text.includes('host key')) { + return copy.sshErrHostKey || raw + } + + if (text.includes('auth')) { + return copy.sshErrAuth || raw + } + + if (text.includes('not installed') || text.includes('not found')) { + return copy.sshErrNotInstalled || raw + } + + if (text.includes('unsupported')) { + return copy.sshErrPlatform || raw + } + + if (text.includes('timed out') || text.includes('timeout')) { + return copy.sshErrTimeout || raw + } + + if (text.includes('update')) { + return copy.sshErrUpdateRequired || raw + } + + if (text.includes('unreachable') || text.includes('could not reach')) { + return copy.sshErrUnreachable || raw + } + + return copy.sshErrUnknown || raw +} + export function isRemoteReauthFailure( config: DesktopConnectionConfig | null | undefined, error?: string | null -
apps/desktop/src/global.d.ts@@ -60,6 +60,8 @@ declare global { saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig> applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig> testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionTestResult> + sshConfigHosts: () => Promise<DesktopSshHostsResult> + sshResolveHost: (host: string) => Promise<DesktopSshResolveResult> probeConnectionConfig: (remoteUrl: string) => Promise<DesktopConnectionProbeResult> oauthLoginConnectionConfig: (remoteUrl: string) => Promise<DesktopOauthLoginResult> oauthLogoutConnectionConfig: (remoteUrl?: string) => Promise<DesktopOauthLogoutResult> @@ -395,6 +397,10 @@ export interface HermesConnection { // (cloud-auto-discovery Q3/Q6), so this never carries 'cloud'. mode?: 'local' | 'remote' authMode?: 'oauth' | 'token' + remoteHost?: string + remoteIdentity?: string + remoteKind?: 'cloud' | 'ssh' | 'url' + remoteHermesVersion?: string nativeOverlayWidth: number source?: 'env' | 'local' | 'settings' token: string @@ -430,7 +436,7 @@ export interface DesktopConnectionConfig { // remoteAuthMode 'oauth') but is remembered as cloud so settings reopens into // the cloud picker. Resolution treats cloud exactly as remote // (cloud-auto-discovery Q3/Q6). - mode: 'local' | 'remote' | 'cloud' + mode: 'local' | 'remote' | 'cloud' | 'ssh' // The profile this config describes, or null for the global/default // connection. Per-profile entries let a profile point at its own backend. profile: null | string @@ -443,10 +449,15 @@ export interface DesktopConnectionConfig { // connected instance was discovered under, so Settings → Gateway can reopen // into that org. Empty string for remote/local. cloudOrg: string + sshHost: string + sshUser: string + sshPort: number | null + sshKeyPath: string + sshRemoteHermesPath: string } export interface DesktopConnectionConfigInput { - mode: 'local' | 'remote' | 'cloud' + mode: 'local' | 'remote' | 'cloud' | 'ssh' // When set, the save/apply/test targets this profile's per-profile remote // override instead of the global connection. profile?: null | string @@ -456,12 +467,44 @@ export interface DesktopConnectionConfigInput { // For a 'cloud' connection: the selected Hermes Cloud org (slug or id) to // persist so Settings can reopen into it. Ignored for remote/local modes. cloudOrg?: string + sshHost?: string + sshUser?: string + sshPort?: number | null + sshKeyPath?: string + sshRemoteHermesPath?: string } export interface DesktopConnectionTestResult { - baseUrl: string - ok: boolean - version: string | null + baseUrl?: string + ok?: boolean + version?: string | null + reachable?: boolean + sshError?: + | 'auth-failed' + | 'hermes-not-found' + | 'host-key-changed' + | 'timeout' + | 'unreachable' + | 'unsupported-platform' + | 'update-required' + | 'unknown' + | null + error?: string | null + host?: string + remoteHermesPath?: string + remoteHermesVersion?: string + remotePlatform?: string +} + +export interface DesktopSshResolveResult { + hostname: string | null + identityFile: string | null + port: number | null + user: string | null +} + +export interface DesktopSshHostsResult { + hosts: string[] } export interface DesktopAuthProvider { -
apps/desktop/src/i18n/en.ts@@ -645,7 +645,44 @@ export const en: Translations = { signOutFailed: 'Sign-out failed', testFailed: 'Remote gateway test failed', applyFailed: 'Could not apply gateway settings', - saveFailed: 'Could not save gateway settings' + saveFailed: 'Could not save gateway settings', + sshTitle: 'Connect via SSH', + sshDesc: + 'Hermes is launched on the remote over SSH and tunneled to this app — nothing to start or expose yourself. Requires working key-based SSH access to the host.', + sshTrustHint: 'The first presented host key is trusted and pinned; later changes fail closed.', + sshHostTitle: 'Host', + sshHostDesc: 'user@host, or a Host alias from ~/.ssh/config.', + sshHostPick: 'Select a host…', + sshHostPickTitle: 'Host', + sshHostPickDesc: 'A Host alias from ~/.ssh/config, or Custom to type one.', + sshHostCustom: 'Custom (enter manually)…', + sshUserTitle: 'User', + sshUserDesc: 'Blank = ~/.ssh/config or your current user.', + sshUserPlaceholder: 'from ~/.ssh/config', + sshPortTitle: 'Port', + sshPortDesc: 'Blank = 22 or the ~/.ssh/config port.', + sshKeyTitle: 'Identity file', + sshKeyDesc: 'Private key path. Blank = ssh-agent or ~/.ssh/config.', + sshHermesPathTitle: 'Hermes path (optional)', + sshHermesPathDesc: 'Full path to the remote hermes binary. Blank = auto-detect.', + sshHermesPathPlaceholder: 'auto-detect', + sshTestConnection: 'Test SSH', + sshConnect: 'Connect', + sshButtonsHint: 'Save applies on the next launch. Connect reconnects now.', + sshReachable: (host, platform) => `Reachable: ${host} (${platform}) — Hermes found`, + sshIncompleteHost: 'Enter an SSH host before connecting.', + sshErrUnreachable: 'Could not reach that host over SSH. Check the host, port, and your network.', + sshErrAuth: + 'SSH authentication failed. Load your key into the ssh-agent (ssh-add) or set an IdentityFile in ~/.ssh/config — Hermes runs ssh non-interactively.', + sshErrHostKey: + 'The host key has CHANGED since you last connected. Verify this is expected, then run ssh-keygen -R <host> and reconnect.', + sshErrNotInstalled: + 'Hermes is not installed on the remote host. Install it there (curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh) or set the Hermes path.', + sshErrPlatform: + 'Unsupported remote platform. Hermes Desktop SSH mode supports Linux, macOS, and Windows remote hosts.', + sshErrTimeout: 'SSH connection timed out. The host may be unreachable or asleep.', + sshErrUpdateRequired: 'Update Hermes on the remote host before connecting with Desktop SSH.', + sshErrUnknown: 'SSH connection failed.' }, keys: { loading: 'Loading API keys and credentials...', @@ -2169,6 +2206,12 @@ export const en: Translations = { desktopVersion: version => `Hermes Desktop v${version}`, backendVersion: version => `Backend v${version}`, clientLabel: version => `client v${version}`, + connectionSsh: host => `SSH: ${host}`, + connectionRemote: host => `Remote: ${host}`, + connectionCloud: host => `Cloud: ${host}`, + connectionCloudTooltip: host => `Connected to Hermes Cloud at ${host} · click to manage`, + connectionSshTooltip: host => `Connected over SSH to ${host} · click to manage`, + connectionRemoteTooltip: host => `Connected to remote backend ${host} · click to manage`, backendLabel: version => `backend v${version}`, commit: sha => `commit ${sha}`, branch: branch => `branch ${branch}`, -
apps/desktop/src/i18n/ja.ts@@ -717,7 +717,44 @@ export const ja = defineLocale({ signOutFailed: 'サインアウトに失敗しました', testFailed: 'リモートゲートウェイのテストに失敗しました', applyFailed: 'ゲートウェイ設定を適用できませんでした', - saveFailed: 'ゲートウェイ設定を保存できませんでした' + saveFailed: 'ゲートウェイ設定を保存できませんでした', + sshTitle: 'SSH で接続', + sshDesc: + 'Hermes は SSH 経由でリモート上に起動され、このアプリにトンネルされます。リモート側で何かを起動・公開する必要はありません。ホストへの鍵ベースの SSH アクセスが前提です。', + sshTrustHint: '初回に提示されたホスト鍵を信頼して固定し、以後の変更は拒否します。', + sshHostTitle: 'ホスト', + sshHostDesc: 'user@host、または ~/.ssh/config の Host エイリアス。', + sshHostPick: 'ホストを選択…', + sshHostPickTitle: 'ホスト', + sshHostPickDesc: '~/.ssh/config の Host エイリアス、または「カスタム」で手入力。', + sshHostCustom: 'カスタム(手入力)…', + sshUserTitle: 'ユーザー', + sshUserDesc: '空欄 = ~/.ssh/config または現在のユーザー。', + sshUserPlaceholder: '~/.ssh/config から', + sshPortTitle: 'ポート', + sshPortDesc: '空欄 = 22 または ~/.ssh/config のポート。', + sshKeyTitle: '鍵ファイル', + sshKeyDesc: '秘密鍵のパス。空欄 = ssh-agent または ~/.ssh/config。', + sshHermesPathTitle: 'Hermes パス(任意)', + sshHermesPathDesc: 'リモートの hermes バイナリへのフルパス。空欄 = 自動検出。', + sshHermesPathPlaceholder: '自動検出', + sshTestConnection: 'SSH をテスト', + sshConnect: '接続', + sshButtonsHint: '「保存」は次回起動時に適用され、「接続」は今すぐ再接続します。', + sshReachable: (host, platform) => `接続可能: ${host}(${platform})— Hermes を検出`, + sshIncompleteHost: '接続する前に SSH ホストを入力してください。', + sshErrUnreachable: 'SSH でそのホストに到達できませんでした。ホスト、ポート、ネットワークを確認してください。', + sshErrAuth: + 'SSH 認証に失敗しました。鍵を ssh-agent に読み込む(ssh-add)か、~/.ssh/config に IdentityFile を設定してください。Hermes は非対話的に ssh を実行します。', + sshErrHostKey: + '前回の接続以降、ホスト鍵が変更されています。想定どおりか確認し、ssh-keygen -R <host> を実行してから再接続してください。', + sshErrNotInstalled: + 'リモートホストに Hermes がインストールされていません。リモートでインストールする(curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh)か、Hermes パスを設定してください。', + sshErrPlatform: + 'サポートされていないリモートプラットフォームです。Hermes Desktop の SSH モードは Linux、macOS、Windows のリモートホストに対応しています。', + sshErrTimeout: 'SSH 接続がタイムアウトしました。ホストが到達不能、またはスリープ中の可能性があります。', + sshErrUpdateRequired: 'Desktop SSH で接続する前に、リモートホストの Hermes を更新してください。', + sshErrUnknown: 'SSH 接続に失敗しました。' }, keys: { loading: 'API キーと認証情報を読み込み中...', @@ -2095,6 +2132,12 @@ export const ja = defineLocale({ desktopVersion: version => `Hermes Desktop v${version}`, backendVersion: version => `バックエンド v${version}`, clientLabel: version => `クライアント v${version}`, + connectionSsh: host => `SSH: ${host}`, + connectionRemote: host => `リモート: ${host}`, + connectionCloud: host => `クラウド: ${host}`, + connectionCloudTooltip: host => `Hermes Cloud ${host} に接続中 · クリックして管理`, + connectionSshTooltip: host => `SSH 経由で ${host} に接続中 · クリックして管理`, + connectionRemoteTooltip: host => `リモートバックエンド ${host} に接続中 · クリックして管理`, backendLabel: version => `バックエンド v${version}`, commit: sha => `コミット ${sha}`, branch: branch => `ブランチ ${branch}`, -
apps/desktop/src/i18n/types.ts@@ -549,6 +549,38 @@ export interface Translations { testFailed: string applyFailed: string saveFailed: string + sshTitle: string + sshDesc: string + sshTrustHint: string + sshHostTitle: string + sshHostDesc: string + sshHostPick: string + sshHostPickTitle: string + sshHostPickDesc: string + sshHostCustom: string + sshUserTitle: string + sshUserDesc: string + sshUserPlaceholder: string + sshPortTitle: string + sshPortDesc: string + sshKeyTitle: string + sshKeyDesc: string + sshHermesPathTitle: string + sshHermesPathDesc: string + sshHermesPathPlaceholder: string + sshTestConnection: string + sshConnect: string + sshButtonsHint: string + sshReachable: (host: string, platform: string) => string + sshIncompleteHost: string + sshErrUnreachable: string + sshErrAuth: string + sshErrHostKey: string + sshErrNotInstalled: string + sshErrPlatform: string + sshErrTimeout: string + sshErrUpdateRequired: string + sshErrUnknown: string } keys: { loading: string @@ -1797,6 +1829,12 @@ export interface Translations { desktopVersion: (version: string) => string backendVersion: (version: string) => string clientLabel: (version: string) => string + connectionSsh: (host: string) => string + connectionRemote: (host: string) => string + connectionCloud: (host: string) => string + connectionCloudTooltip: (host: string) => string + connectionSshTooltip: (host: string) => string + connectionRemoteTooltip: (host: string) => string backendLabel: (version: string) => string commit: (sha: string) => string branch: (branch: string) => string -
apps/desktop/src/i18n/zh-hant.ts@@ -696,7 +696,42 @@ export const zhHant = defineLocale({ signOutFailed: '登出失敗', testFailed: '遠端閘道測試失敗', applyFailed: '無法套用閘道設定', - saveFailed: '無法儲存閘道設定' + saveFailed: '無法儲存閘道設定', + sshTitle: '透過 SSH 連線', + sshDesc: + 'Hermes 會透過 SSH 在遠端啟動並以通道連線到本應用程式——無需自行啟動或公開任何服務。前提:已具備到該主機的金鑰 SSH 存取。', + sshTrustHint: '首次提供的主機金鑰會被信任並固定;後續變更將被拒絕。', + sshHostTitle: '主機', + sshHostDesc: 'user@host,或 ~/.ssh/config 中的 Host 別名。', + sshHostPick: '選擇主機…', + sshHostPickTitle: '主機', + sshHostPickDesc: '~/.ssh/config 中的 Host 別名,或選擇「自訂」手動輸入。', + sshHostCustom: '自訂(手動輸入)…', + sshUserTitle: '使用者', + sshUserDesc: '留空 = ~/.ssh/config 或目前使用者。', + sshUserPlaceholder: '來自 ~/.ssh/config', + sshPortTitle: '連接埠', + sshPortDesc: '留空 = 22 或 ~/.ssh/config 中的連接埠。', + sshKeyTitle: '金鑰檔案', + sshKeyDesc: '私密金鑰路徑。留空 = ssh-agent 或 ~/.ssh/config。', + sshHermesPathTitle: 'Hermes 路徑(選用)', + sshHermesPathDesc: '遠端 hermes 執行檔的完整路徑。留空 = 自動偵測。', + sshHermesPathPlaceholder: '自動偵測', + sshTestConnection: '測試 SSH', + sshConnect: '連線', + sshButtonsHint: '「儲存」會在下次啟動時生效,「連線」則立即重新連線。', + sshReachable: (host, platform) => `可連線:${host}(${platform})——已找到 Hermes`, + sshIncompleteHost: '連線前請輸入 SSH 主機。', + sshErrUnreachable: '無法透過 SSH 連線到該主機。請檢查主機、連接埠和網路。', + sshErrAuth: + 'SSH 驗證失敗。請將金鑰載入 ssh-agent(ssh-add),或在 ~/.ssh/config 中設定 IdentityFile——Hermes 以非互動方式執行 ssh。', + sshErrHostKey: '自上次連線以來主機金鑰已變更。請確認這是預期的,然後執行 ssh-keygen -R <host> 並重新連線。', + sshErrNotInstalled: + '遠端主機上未安裝 Hermes。請在遠端安裝(curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh)或設定 Hermes 路徑。', + sshErrPlatform: '不支援的遠端平台。Hermes Desktop 的 SSH 模式支援 Linux、macOS 和 Windows 遠端主機。', + sshErrTimeout: 'SSH 連線逾時。主機可能無法存取或處於睡眠狀態。', + sshErrUpdateRequired: '使用 Desktop SSH 連線前,請更新遠端主機上的 Hermes。', + sshErrUnknown: 'SSH 連線失敗。' }, keys: { loading: '正在載入 API 金鑰和憑證...', @@ -2028,6 +2063,12 @@ export const zhHant = defineLocale({ desktopVersion: version => `Hermes Desktop v${version}`, backendVersion: version => `後端 v${version}`, clientLabel: version => `用戶端 v${version}`, + connectionSsh: host => `SSH: ${host}`, + connectionRemote: host => `遠端: ${host}`, + connectionCloud: host => `雲端: ${host}`, + connectionCloudTooltip: host => `已連線到 Hermes Cloud ${host} · 點擊管理`, + connectionSshTooltip: host => `已透過 SSH 連線到 ${host} · 點擊管理`, + connectionRemoteTooltip: host => `已連線到遠端後端 ${host} · 點擊管理`, backendLabel: version => `後端 v${version}`, commit: sha => `提交 ${sha}`, branch: branch => `分支 ${branch}`, -
apps/desktop/src/i18n/zh.ts@@ -839,7 +839,42 @@ export const zh: Translations = { signOutFailed: '退出登录失败', testFailed: '远程网关测试失败', applyFailed: '无法应用网关设置', - saveFailed: '无法保存网关设置' + saveFailed: '无法保存网关设置', + sshTitle: '通过 SSH 连接', + sshDesc: + 'Hermes 会通过 SSH 在远程启动并以隧道连接到本应用——无需自行启动或暴露任何服务。前提:已具备到该主机的密钥 SSH 访问。', + sshTrustHint: '首次提供的主机密钥会被信任并固定;后续变更将被拒绝。', + sshHostTitle: '主机', + sshHostDesc: 'user@host,或 ~/.ssh/config 中的 Host 别名。', + sshHostPick: '选择主机…', + sshHostPickTitle: '主机', + sshHostPickDesc: '~/.ssh/config 中的 Host 别名,或选择"自定义"手动输入。', + sshHostCustom: '自定义(手动输入)…', + sshUserTitle: '用户', + sshUserDesc: '留空 = ~/.ssh/config 或当前用户。', + sshUserPlaceholder: '来自 ~/.ssh/config', + sshPortTitle: '端口', + sshPortDesc: '留空 = 22 或 ~/.ssh/config 中的端口。', + sshKeyTitle: '密钥文件', + sshKeyDesc: '私钥路径。留空 = ssh-agent 或 ~/.ssh/config。', + sshHermesPathTitle: 'Hermes 路径(可选)', + sshHermesPathDesc: '远程 hermes 可执行文件的完整路径。留空 = 自动检测。', + sshHermesPathPlaceholder: '自动检测', + sshTestConnection: '测试 SSH', + sshConnect: '连接', + sshButtonsHint: '“保存”将在下次启动时生效,“连接”则立即重新连接。', + sshReachable: (host, platform) => `可连接:${host}(${platform})——已找到 Hermes`, + sshIncompleteHost: '连接前请输入 SSH 主机。', + sshErrUnreachable: '无法通过 SSH 连接到该主机。请检查主机、端口和网络。', + sshErrAuth: + 'SSH 认证失败。请将密钥加载到 ssh-agent(ssh-add),或在 ~/.ssh/config 中设置 IdentityFile——Hermes 以非交互方式运行 ssh。', + sshErrHostKey: '自上次连接以来主机密钥已更改。请确认这是预期的,然后运行 ssh-keygen -R <host> 并重新连接。', + sshErrNotInstalled: + '远程主机上未安装 Hermes。请在远程安装(curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh)或设置 Hermes 路径。', + sshErrPlatform: '不支持的远程平台。Hermes Desktop 的 SSH 模式支持 Linux、macOS 和 Windows 远程主机。', + sshErrTimeout: 'SSH 连接超时。主机可能无法访问或处于休眠状态。', + sshErrUpdateRequired: '使用 Desktop SSH 连接前,请更新远程主机上的 Hermes。', + sshErrUnknown: 'SSH 连接失败。' }, keys: { loading: '正在加载 API 密钥和凭据...', @@ -2339,6 +2374,12 @@ export const zh: Translations = { desktopVersion: version => `Hermes Desktop v${version}`, backendVersion: version => `后端 v${version}`, clientLabel: version => `客户端 v${version}`, + connectionSsh: host => `SSH: ${host}`, + connectionRemote: host => `远程: ${host}`, + connectionCloud: host => `云端: ${host}`, + connectionCloudTooltip: host => `已连接到 Hermes Cloud ${host} · 点击管理`, + connectionSshTooltip: host => `已通过 SSH 连接到 ${host} · 点击管理`, + connectionRemoteTooltip: host => `已连接到远程后端 ${host} · 点击管理`, backendLabel: version => `后端 v${version}`, commit: sha => `提交 ${sha}`, branch: branch => `分支 ${branch}`, -
apps/desktop/src/lib/desktop-fs.test.ts@@ -5,6 +5,7 @@ import { $connection } from '@/store/session' import { desktopDefaultCwd, desktopFileDiff, + desktopFsCacheKey, desktopGitRoot, readDesktopDir, readDesktopFileDataUrl, @@ -122,6 +123,56 @@ describe('desktop filesystem facade', () => { expect(api).toHaveBeenCalledWith({ path: '/api/fs/default-cwd', profile: 'remote-docker' }) }) + it('keys SSH filesystem caches by stable host identity instead of the forwarded port', () => { + $connection.set({ + mode: 'remote', + remoteKind: 'ssh', + remoteHost: 'operator@remote-box', + baseUrl: 'http://127.0.0.1:41001' + } as never) + const first = desktopFsCacheKey() + + $connection.set({ + mode: 'remote', + remoteKind: 'ssh', + remoteHost: 'operator@remote-box', + baseUrl: 'http://127.0.0.1:52002' + } as never) + + expect(desktopFsCacheKey()).toBe(first) + expect(first).toContain('operator@remote-box') + expect(first).not.toContain('41001') + }) + + it('separates SSH filesystem caches by ownership and profile', () => { + $connection.set({ + mode: 'remote', + remoteKind: 'ssh', + remoteHost: 'host-a', + remoteIdentity: 'owner-a', + profile: 'one' + } as never) + const first = desktopFsCacheKey() + $connection.set({ + mode: 'remote', + remoteKind: 'ssh', + remoteHost: 'host-a', + remoteIdentity: 'owner-b', + profile: 'one' + } as never) + const otherOwner = desktopFsCacheKey() + $connection.set({ + mode: 'remote', + remoteKind: 'ssh', + remoteHost: 'host-a', + remoteIdentity: 'owner-a', + profile: 'two' + } as never) + + expect(otherOwner).not.toBe(first) + expect(desktopFsCacheKey()).not.toBe(first) + }) + it('routes file diffs through backend git in remote mode', async () => { $connection.set({ mode: 'remote' } as never) -
apps/desktop/src/lib/desktop-fs.ts@@ -21,7 +21,12 @@ function connectionCacheKey(connection: HermesConnection | null) { return 'local:' } - return `${connection.mode || 'local'}:${connection.profile || ''}:${connection.baseUrl || ''}` + const target = + connection.remoteKind === 'ssh' + ? connection.remoteIdentity || connection.remoteHost || '' + : connection.baseUrl || '' + + return `${connection.mode || 'local'}:${connection.remoteKind || ''}:${connection.profile || ''}:${target}` } export function desktopFsCacheKey() { -
hermes_cli/main.py@@ -391,6 +391,7 @@ def _try_termux_ultrafast_version() -> bool: import argparse import hashlib import json +import re import shlex import shutil import stat @@ -12500,8 +12501,97 @@ def _maybe_setup_dashboard_auth_interactively(args) -> None: print() +def _read_ssh_session_token_file(path: str) -> str: + """Read and unlink a Desktop SSH token from its private runtime directory.""" + if sys.platform == "win32": + from hermes_cli.windows_ssh_runtime import read_token + return read_token(path) + + import stat as _stat + from pathlib import Path as _Path + from hermes_constants import get_hermes_home as _get_hermes_home + + if not os.path.isabs(path): + raise SystemExit("--ssh-session-token-file must be absolute") + + token_path = [redacted:secret-assignment] + token_root = [redacted:secret-assignment] / "desktop-ssh" + try: + relative = token_path.relative_to(token_root) + except ValueError as exc: + raise SystemExit("--ssh-session-token-file must be under the desktop-ssh directory") from exc + if len(relative.parts) != 2 or not re.fullmatch(r"[0-9a-f]{32}", relative.parts[0]): + raise SystemExit("--ssh-session-token-file has an invalid runtime path") + if not re.fullmatch(r"[0-9a-f]{16}\.token", relative.parts[1]): + raise SystemExit("--ssh-session-token-file has an invalid filename") + + directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) + file_flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + root_fd = -1 + directory_fd = -1 + file_fd = -1 + try: + try: + root_fd = os.open(token_root, directory_flags) + root_stat = os.fstat(root_fd) + if not _stat.S_ISDIR(root_stat.st_mode): + raise SystemExit("--ssh-session-token-file has an unsafe runtime root") + if hasattr(os, "getuid") and root_stat.st_uid != os.getuid(): + raise SystemExit("--ssh-session-token-file runtime root has the wrong owner") + directory_fd = os.open(relative.parts[0], directory_flags, dir_fd=root_fd) + directory_stat = os.fstat(directory_fd) + if not _stat.S_ISDIR(directory_stat.st_mode): + raise SystemExit("--ssh-session-token-file has an unsafe parent directory") + if hasattr(os, "getuid") and directory_stat.st_uid != os.getuid(): + raise SystemExit("--ssh-session-token-file parent has the wrong owner") + if (directory_stat.st_mode & 0o777) != 0o700: + raise SystemExit("--ssh-session-token-file parent has unsafe permissions") + file_fd = os.open(relative.parts[1], file_flags, dir_fd=directory_fd) + except SystemExit: + raise + except OSError as exc: + if exc.errno == getattr(__import__("errno"), "ELOOP", -1): + raise SystemExit("--ssh-session-token-file is a symlink") from exc + raise SystemExit("--ssh-session-token-file is not accessible") from exc + + file_stat = os.fstat(file_fd) + if not _stat.S_ISREG(file_stat.st_mode): + raise SystemExit("--ssh-session-token-file is not a regular file") + if file_stat.st_size != 64: + raise SystemExit("--ssh-session-token-file contains an invalid token") + if hasattr(os, "getuid") and file_stat.st_uid != os.getuid(): + raise SystemExit("--ssh-session-token-file has the wrong owner") + if hasattr(os, "getuid") and (file_stat.st_mode & 0o777) & ~0o600: + raise SystemExit("--ssh-session-token-file has unsafe permissions") + + with os.fdopen(file_fd, "r") as token_stream: + file_fd = -1 + token = [redacted:secret-assignment] + + if not re.fullmatch(r"[0-9a-f]{64}", token): + raise SystemExit("--ssh-session-token-file contains an invalid token") + return token + finally: + if file_fd >= 0: + os.close(file_fd) + if directory_fd >= 0: + try: + os.unlink(relative.parts[1], dir_fd=directory_fd) + except OSError: + pass + os.close(directory_fd) + if root_fd >= 0: + os.close(root_fd) + + def cmd_dashboard(args): """Start the web UI server, or (with --stop/--status) manage running ones.""" + _token_file = [redacted:secret-assignment] "ssh_session_token_file", None) + if _token_file and ( + getattr(args, "status", False) or getattr(args, "stop", False) + ): + raise SystemExit("--ssh-session-token-file cannot be used with --status or --stop") + # --status: report running dashboards and exit, no deps needed. if getattr(args, "status", False): count = _report_dashboard_status() @@ -12524,6 +12614,12 @@ def cmd_dashboard(args): # ready sentinel. Resolved once and threaded through the re-exec, the # build gate, and start_server. _headless_backend = getattr(args, "headless_backend", False) + _ssh_owner_nonce = getattr(args, "ssh_owner_nonce", None) + if _ssh_owner_nonce and not re.fullmatch(r"[0-9a-f]{16}", _ssh_owner_nonce): + raise SystemExit("--ssh-owner-nonce must be 16 lowercase hex characters") + _ssh_session_token = None + if _token_file and not _headless_backend: + raise SystemExit("--ssh-session-token-file is only valid with hermes serve") # ── Unified profile launch routing ──────────────────────────────── # The dashboard is a MACHINE management surface: it can read/write any @@ -12577,6 +12673,10 @@ def cmd_dashboard(args): "--host", args.host, "--open-profile", _launch_profile, ] + if _ssh_owner_nonce: + reexec_argv.extend(["--ssh-owner-nonce", _ssh_owner_nonce]) + if _token_file: + reexec_argv.extend(["--ssh-session-token-file", _token_file]) if args.no_open: reexec_argv.append("--no-open") if getattr(args, "insecure", False): @@ -12613,6 +12713,9 @@ def cmd_dashboard(args): else: os.execvpe(sys.executable, reexec_argv, env) + if _token_file: + _ssh_session_token = [redacted:secret-assignment] + # Attach gui.log early so dashboard startup/build failures are captured in # the same logs directory as every other Hermes surface. try: @@ -12753,6 +12856,8 @@ def cmd_dashboard(args): allow_public=getattr(args, "insecure", False), initial_profile=getattr(args, "open_profile", "") or "", headless=_headless_backend, + ssh_session_token=[redacted:secret-assignment] + ssh_owner_nonce=_ssh_owner_nonce, ) -
hermes_cli/subcommands/dashboard.py@@ -149,6 +149,20 @@ def build_dashboard_parser( serve_parser.add_argument( "--no-open", action="store_true", help=argparse.SUPPRESS ) + serve_parser.add_argument( + "--ssh-session-token-file", + dest="ssh_session_token_file", + metavar="PATH", + default=None, + help="Read a one-shot Desktop SSH session token from PATH", + ) + serve_parser.add_argument( + "--ssh-owner-nonce", + dest="ssh_owner_nonce", + metavar="NONCE", + default=None, + help="Identify a Desktop-owned SSH backend process", + ) # `headless_backend` marks the lean path: desktop/remote clients speak pure # JSON-RPC/WS, so `serve` skips the web UI build AND never serves the SPA # (cmd_dashboard exports HERMES_SERVE_HEADLESS=1). `dashboard` leaves it -
hermes_cli/web_server.py@@ -280,6 +280,18 @@ def _get_pty_active_session_files(app: "FastAPI") -> dict[str, Path]: # --------------------------------------------------------------------------- _SESSION_TOKEN = [redacted:secret-assignment]"HERMES_DASHBOARD_SESSION_TOKEN") or secrets.token_urlsafe(32) _SESSION_HEADER_NAME = "X-Hermes-Session-Token" +_SSH_OWNER_NONCE: Optional[str] = None + + +def _apply_ssh_session_token(token: str) -> None: + global _SESSION_TOKEN + if token: + _SESSION_TOKEN = token + + +def _apply_ssh_owner_nonce(nonce: Optional[str]) -> None: + global _SSH_OWNER_NONCE + _SSH_OWNER_NONCE = nonce # In-browser Chat tab (/chat, /api/pty, /api/ws, …). Always enabled: the # desktop app and the dashboard's own Chat tab both drive the agent over the @@ -2774,6 +2786,14 @@ def _collect_profile_gateway_topology() -> Dict[str, Any]: return {"profiles": profile_names, "gateway_mode": mode, "gateways": gateways} [redacted:email]("/api/ssh/ownership") +async def get_ssh_ownership(request: Request): + _require_token(request) + if not _SSH_OWNER_NONCE: + raise HTTPException(status_code=404, detail="SSH ownership is not active") + return {"ok": True, "sshOwnerNonce": _SSH_OWNER_NONCE, "protocolVersion": 1} + + @app.get("/api/status") async def get_status(profile: Optional[str] = None): status_scope = None @@ -19145,6 +19165,8 @@ def start_server( allow_public: bool = False, initial_profile: str = "", headless: bool = False, + ssh_session_token: [redacted:secret-assignment] = None, + ssh_owner_nonce: Optional[str] = None, ): """Start the web UI server. @@ -19156,7 +19178,13 @@ def start_server( ``headless`` is the ``serve`` path: the JSON-RPC/WS backend with no UI build and no SPA mount (mount_spa() honours ``HERMES_SERVE_HEADLESS``), so the banner announces the bind rather than a browser URL. + + ``ssh_session_token`` and ``ssh_owner_nonce`` are process-local Desktop SSH + bootstrap state. Neither is persisted or exported to child processes. """ + _apply_ssh_session_token(ssh_session_token or "") + _apply_ssh_owner_nonce(ssh_owner_nonce) + import uvicorn try: -
hermes_cli/windows_ssh_runtime.py@@ -0,0 +1,505 @@ +"""Native Windows trust boundary for Desktop SSH backend lifecycle.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path +from typing import Any + +from hermes_constants import get_hermes_home + +_HEX32 = re.compile(r"[0-9a-f]{32}\Z") +_HEX16 = re.compile(r"[0-9a-f]{16}\Z") +_HEX64 = re.compile(r"[0-9a-f]{64}\Z") +_MAX_JSON = 1024 * 1024 +_MAX_LOG = 512 * 1024 +_OPEN_REPARSE_POINT = 0x00200000 +_DELETE_ON_CLOSE = 0x04000000 +_MOVE_REPLACE_EXISTING = 0x00000001 +_MOVE_WRITE_THROUGH = 0x00000008 + + +def _win32() -> tuple[Any, ...]: + if sys.platform != "win32": + raise RuntimeError("Windows SSH runtime is only available on Windows") + import ntsecuritycon + import pywintypes + import win32api + import win32con + import win32file + import win32process + import win32security + return ntsecuritycon, pywintypes, win32api, win32con, win32file, win32process, win32security + + +def _ownership(value: str) -> str: + if not _HEX32.fullmatch(value or ""): + raise ValueError("invalid ownership ID") + return value + + +def _nonce(value: str) -> str: + if not _HEX16.fullmatch(value or ""): + raise ValueError("invalid spawn nonce") + return value + + +def _root() -> Path: + return get_hermes_home() / "desktop-ssh" + + +def _directory(ownership_id: str) -> Path: + return _root() / _ownership(ownership_id) + + +def _token_path(ownership_id: str, spawn_nonce: str) -> Path: + return _directory(ownership_id) / f"{_nonce(spawn_nonce)}.token" + + +def _log_path(ownership_id: str, spawn_nonce: str) -> Path: + return _directory(ownership_id) / f"{_nonce(spawn_nonce)}.log" + + +def _lock_path(ownership_id: str) -> Path: + return _directory(ownership_id) / "backend.lock.json" + + +def _current_sid(): + _, _, win32api, win32con, _, _, win32security = _win32() + token = [redacted:secret-assignment] win32con.TOKEN_QUERY) + return win32security.GetTokenInformation(token, win32security.TokenUser)[0] + + +def _system_sid(): + return _win32()[6].ConvertStringSidToSid("S-1-5-18") + + +def _security_attributes(): + ntsecuritycon, _, _, _, _, _, win32security = _win32() + owner = _current_sid() + acl = win32security.ACL() + for sid in (owner, _system_sid()): + acl.AddAccessAllowedAceEx(win32security.ACL_REVISION, 0, ntsecuritycon.FILE_ALL_ACCESS, sid) + descriptor = win32security.SECURITY_DESCRIPTOR() + descriptor.SetSecurityDescriptorOwner(owner, False) + descriptor.SetSecurityDescriptorDacl(True, acl, False) + # Protect the DACL so inheritable parent ACEs (%LOCALAPPDATA% grants) are not merged in. + descriptor.SetSecurityDescriptorControl(win32security.SE_DACL_PROTECTED, win32security.SE_DACL_PROTECTED) + attributes = win32security.SECURITY_ATTRIBUTES() + attributes.SECURITY_DESCRIPTOR = descriptor + return attributes + + +def _allowed_sids(): + win32security = _win32()[6] + return {win32security.ConvertSidToStringSid(_current_sid()), + win32security.ConvertSidToStringSid(_system_sid())} + + +def _verify_security(handle) -> None: + _, _, _, _, _, _, win32security = _win32() + info = win32security.OWNER_SECURITY_INFORMATION | win32security.DACL_SECURITY_INFORMATION + descriptor = win32security.GetSecurityInfo(handle, win32security.SE_FILE_OBJECT, info) + owner = descriptor.GetSecurityDescriptorOwner() + owner_value = win32security.ConvertSidToStringSid(owner) + if owner_value not in _allowed_sids(): + raise OSError("Windows SSH runtime object has the wrong owner") + dacl = descriptor.GetSecurityDescriptorDacl() + if dacl is None: + raise OSError("Windows SSH runtime object has a null DACL") + allowed = _allowed_sids() + allow_types = { + win32security.ACCESS_ALLOWED_ACE_TYPE, + win32security.ACCESS_ALLOWED_OBJECT_ACE_TYPE, + getattr(win32security, "ACCESS_ALLOWED_CALLBACK_ACE_TYPE", 9), + getattr(win32security, "ACCESS_ALLOWED_CALLBACK_OBJECT_ACE_TYPE", 11), + } + for index in range(dacl.GetAceCount()): + ace = dacl.GetAce(index) + ace_type = ace[0][0] + mask = ace[1] + sid = ace[-1] + if ace_type in allow_types and mask: + if win32security.ConvertSidToStringSid(sid) not in allowed: + raise OSError("Windows SSH runtime object has a permissive DACL") + + +def _open(path: Path, access: int, creation: int, flags: int, share: int = 0): + _, _, _, _, win32file, _, _ = _win32() + handle = win32file.CreateFile(str(path), access, share, _security_attributes(), creation, flags, None) + try: + actual = win32file.GetFinalPathNameByHandle(handle, 0) + if actual.startswith("\\\\?\\"): + actual = actual[4:] + expected = os.path.abspath(str(path)) + if os.path.normcase(actual) != os.path.normcase(expected): + raise OSError("Windows SSH runtime handle escaped its expected path") + attributes = win32file.GetFileInformationByHandle(handle)[0] + if attributes & 0x400: + raise OSError("Windows SSH runtime path contains a reparse point") + _verify_security(handle) + return handle + except BaseException: + win32file.CloseHandle(handle) + raise + + +def _ensure_directory(path: Path) -> None: + _, pywintypes, _, win32con, win32file, _, _ = _win32() + if path.parent != path: + _ensure_directory(path.parent) if path.parent not in (Path(path.anchor), path) and not path.parent.exists() else None + if not path.exists(): + try: + win32file.CreateDirectory(str(path), _security_attributes()) + except pywintypes.error as exc: + if exc.winerror != 183: + raise + handle = _open(path, win32con.GENERIC_READ | win32con.READ_CONTROL, win32con.OPEN_EXISTING, + win32con.FILE_FLAG_BACKUP_SEMANTICS | _OPEN_REPARSE_POINT, + win32con.FILE_SHARE_READ | win32con.FILE_SHARE_WRITE | win32con.FILE_SHARE_DELETE) + win32file.CloseHandle(handle) + + +def _ensure_scope(ownership_id: str) -> Path: + root = _root() + _ensure_directory(root) + directory = _directory(ownership_id) + _ensure_directory(directory) + return directory + + +def upload_token(ownership_id: str, spawn_nonce: str, token: [redacted:secret-assignment] -> dict[str, Any]: + _, _, _, win32con, win32file, _, _ = _win32() + if len(token) != 64 or not _HEX64.fullmatch(token.decode("ascii", errors="ignore")): + raise ValueError("invalid session token") + _ensure_scope(ownership_id) + path = _token_path(ownership_id, spawn_nonce) + try: + handle = _open(path, win32con.GENERIC_WRITE | win32con.READ_CONTROL, win32con.CREATE_NEW, + win32con.FILE_ATTRIBUTE_NORMAL | _OPEN_REPARSE_POINT) + try: + win32file.WriteFile(handle, token) + win32file.FlushFileBuffers(handle) + finally: + win32file.CloseHandle(handle) + except BaseException: + path.unlink(missing_ok=True) + raise + return {"path": str(path)} + + +def read_token(path_value: str) -> str: + _, _, _, win32con, win32file, _, _ = _win32() + path = Path(path_value) + root = _root() + try: + relative = path.relative_to(root) + except ValueError as exc: + raise SystemExit("--ssh-session-token-file must be under the desktop-ssh directory") from exc + if len(relative.parts) != 2 or not _HEX32.fullmatch(relative.parts[0]) or not re.fullmatch(r"[0-9a-f]{16}\.token", relative.parts[1]): + raise SystemExit("--ssh-session-token-file has an invalid runtime path") + flags = win32con.FILE_ATTRIBUTE_NORMAL | _OPEN_REPARSE_POINT | _DELETE_ON_CLOSE + try: + handle = _open(path, win32con.GENERIC_READ | win32con.READ_CONTROL | win32con.DELETE, + win32con.OPEN_EXISTING, flags) + except Exception as exc: + raise SystemExit("--ssh-session-token-file is not accessible") from exc + try: + _, data = win32file.ReadFile(handle, 65) + finally: + win32file.CloseHandle(handle) + token = [redacted:secret-assignment]"ascii", errors="ignore") + if len(token) != 64 or not _HEX64.fullmatch(token): + raise SystemExit("--ssh-session-token-file contains an invalid token") + return token + + +def _read_json_stdin() -> dict[str, Any]: + raw = sys.stdin.buffer.read(_MAX_JSON + 1) + if len(raw) > _MAX_JSON: + raise ValueError("runtime payload is too large") + parsed = json.loads(raw) + if not isinstance(parsed, dict): + raise ValueError("runtime payload must be an object") + return parsed + + +def read_lock(ownership_id: str) -> dict[str, Any] | None: + _, pywintypes, _, win32con, win32file, _, _ = _win32() + _ensure_scope(ownership_id) + path = _lock_path(ownership_id) + try: + handle = _open(path, win32con.GENERIC_READ | win32con.READ_CONTROL, win32con.OPEN_EXISTING, + win32con.FILE_ATTRIBUTE_NORMAL | _OPEN_REPARSE_POINT, + win32con.FILE_SHARE_READ) + except pywintypes.error as exc: + if exc.winerror in (2, 3): + return None + raise + try: + _, data = win32file.ReadFile(handle, _MAX_JSON + 1) + finally: + win32file.CloseHandle(handle) + if len(data) > _MAX_JSON: + return None + try: + parsed = json.loads(data) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + return parsed if isinstance(parsed, dict) else None + + +def write_lock(ownership_id: str, payload: dict[str, Any]) -> None: + _, _, _, win32con, win32file, _, _ = _win32() + directory = _ensure_scope(ownership_id) + data = json.dumps(payload, separators=(",", ":")).encode() + if len(data) > _MAX_JSON: + raise ValueError("lock payload is too large") + temporary = directory / f".{os.urandom(8).hex()}.lock.tmp" + handle = _open(temporary, win32con.GENERIC_WRITE | win32con.READ_CONTROL, win32con.CREATE_NEW, + win32con.FILE_ATTRIBUTE_NORMAL | _OPEN_REPARSE_POINT) + try: + win32file.WriteFile(handle, data) + win32file.FlushFileBuffers(handle) + finally: + win32file.CloseHandle(handle) + win32file.MoveFileEx(str(temporary), str(_lock_path(ownership_id)), + _MOVE_REPLACE_EXISTING | _MOVE_WRITE_THROUGH) + + +def remove_artifact(path: Path) -> bool: + _, pywintypes, _, win32con, win32file, _, _ = _win32() + try: + handle = _open(path, win32con.DELETE | win32con.READ_CONTROL, win32con.OPEN_EXISTING, + win32con.FILE_ATTRIBUTE_NORMAL | _OPEN_REPARSE_POINT | _DELETE_ON_CLOSE) + except pywintypes.error as exc: + if exc.winerror in (2, 3): + return False + raise + win32file.CloseHandle(handle) + return True + + +def process_state(pid: int, creation_time_ns: int, hermes_path: str, spawn_nonce: str) -> dict[str, Any]: + import psutil + _nonce(spawn_nonce) + try: + process = psutil.Process(pid) + except psutil.NoSuchProcess as exc: + return {"alive": False, "owned": False, "indeterminate": False, "reason": type(exc).__name__} + try: + actual_creation = int(process.create_time() * 1_000_000_000) + argv = process.cmdline() + except psutil.NoSuchProcess as exc: + return {"alive": False, "owned": False, "indeterminate": False, "reason": type(exc).__name__} + except psutil.AccessDenied as exc: + return {"alive": True, "owned": False, "indeterminate": True, "reason": type(exc).__name__} + if actual_creation != creation_time_ns: + return {"alive": False, "owned": False, "indeterminate": False, "reason": "creation-time", + "actualCreationTimeNs": str(actual_creation), "expectedCreationTimeNs": str(creation_time_ns)} + if not argv: + return {"alive": True, "owned": False, "indeterminate": True, "reason": "argv-unavailable"} + expected = os.path.normcase(os.path.abspath(hermes_path)) + arg0 = os.path.normcase(os.path.abspath(argv[0])) + # argv[0] is either the hermes exe directly, or (normal case) the base Python + # interpreter -- its exact path varies by venv/uv layout, so match on "a python + # running our module". We launch via `-c` bootstrap, so it shows as + # `-c <bootstrap that runs hermes_cli.main>`; also accept a plain `-m` launch. + # Identity is anchored by the unforgeable creation-time + secret owner-nonce below. + is_python = os.path.basename(arg0).startswith("python") + launches_module = ( + argv[1:3] == ["-m", "hermes_cli.main"] + or (len(argv) > 2 and argv[1] == "-c" and "hermes_cli.main" in argv[2]) + ) + executable_match = arg0 == expected or (is_python and launches_module) + try: + serve = argv.index("serve") + owner = argv.index("--ssh-owner-nonce", serve + 1) + owned = executable_match and "--isolated" in argv[serve + 1:] and argv[owner + 1] == spawn_nonce + except (ValueError, IndexError): + owned = False + return {"alive": process.is_running(), "owned": owned, "indeterminate": False, + "creationTimeNs": str(actual_creation), "reason": "owned" if owned else "argv", + "argv": argv[:20], "expectedExecutable": expected} + + +def terminate_owned(pid: int, creation_time_ns: int, hermes_path: str, spawn_nonce: str) -> bool: + state = process_state(pid, creation_time_ns, hermes_path, spawn_nonce) + if not state["alive"] or not state["owned"]: + return False + import psutil + process = psutil.Process(pid) + if int(process.create_time() * 1_000_000_000) != creation_time_ns: + return False + process.terminate() + try: + process.wait(5) + except psutil.TimeoutExpired: + process.kill() + process.wait(5) + return True + + +def _resolve_direct_interpreter(python_entry: str) -> tuple[str, list[str]]: + """Resolve the venv launcher to the base interpreter it would exec, plus the + sys.path to reproduce. On Windows a venv Scripts\\python.exe is a launcher stub + that spawns the real interpreter as a CHILD (two PIDs); spawning the base + interpreter directly with the launcher's sys.path injected yields ONE process + that both owns the port and is the process we lock. + + Also resolves hermes_cli's own location and prepends its parent, because the + launcher finds the module via cwd / an editable-install path hook that a bare + PYTHONPATH does not reproduce.""" + query = ( + "import sys,json,os,importlib.util as u;" + "s=u.find_spec('hermes_cli');" + "root=os.path.dirname(os.path.dirname(s.origin)) if s and s.origin else '';" + "print(json.dumps({'base':getattr(sys,'_base_executable','') or sys.executable," + "'path':[p for p in sys.path if p],'root':root}))" + ) + out = subprocess.run([python_entry, "-c", query], capture_output=True, text=True, timeout=30) + if out.returncode != 0: + raise ValueError("could not resolve the base Python interpreter") + info = json.loads(out.stdout.strip().splitlines()[-1]) + base = info["base"] + if not base or not os.path.isfile(base): + raise ValueError("base Python interpreter was not found") + # drop editable-install finder markers ('__editable__.*.finder.__path_hook__') + # that PYTHONPATH cannot reproduce; keep only real filesystem entries. + py_path = [p for p in info.get("path", []) if os.path.exists(p)] + # the hermes_cli package parent must be explicit -- the launcher resolves it via + # cwd or an editable path hook, neither of which survives a bare PYTHONPATH spawn. + root = info.get("root") or "" + if root and os.path.isdir(root) and root not in py_path: + py_path.insert(0, root) + if not root or not os.path.isdir(root): + raise ValueError("could not locate the hermes_cli package") + return base, py_path + + +def spawn_backend(payload: dict[str, Any]) -> dict[str, Any]: + ownership_id = _ownership(str(payload["ownershipId"])) + spawn_nonce = _nonce(str(payload["spawnNonce"])) + configured_path = str(payload["hermesPath"]) + if not os.path.isabs(configured_path): + raise ValueError("Hermes path must be absolute") + hermes_path = os.path.abspath(configured_path) + token_path = [redacted:secret-assignment] spawn_nonce)) + log_path = _log_path(ownership_id, spawn_nonce) + profile = str(payload.get("profile") or "") + if len(profile) > 256 or any(ch in profile for ch in "\x00\r\n"): + raise ValueError("invalid profile") + venv_dir = os.path.dirname(hermes_path) + python_entry = os.path.join(venv_dir, "python.exe") + if not os.path.isfile(python_entry): + raise ValueError("Hermes Python runtime was not found") + base_python, sys_path = _resolve_direct_interpreter(python_entry) + # Seed sys.path IN-PROCESS via a -c bootstrap rather than exporting PYTHONPATH: + # PYTHONPATH would be inherited by every subprocess the running backend spawns + # (terminal tool, user scripts), shadowing their imports. This keeps the path + # scoped to the backend process alone. + bootstrap = ( + "import sys,runpy;" + f"sys.path[:0]={sys_path!r};" + "runpy.run_module('hermes_cli.main',run_name='__main__',alter_sys=True)" + ) + args = [base_python, "-c", bootstrap] + if profile: + args.extend(["--profile", profile]) + args.extend(["serve", "--isolated", "--host", "127.0.0.1", "--port", "0", + "--ssh-session-token-file", token_path, "--ssh-owner-nonce", spawn_nonce]) + # VIRTUAL_ENV preserves venv identity; PYTHONPATH is deliberately NOT set (see above). + env = dict(os.environ) + env["VIRTUAL_ENV"] = os.path.dirname(venv_dir) + env.pop("PYTHONPATH", None) + _ensure_scope(ownership_id) + creationflags = 0x00000008 | 0x00000200 | 0x01000000 + _, _, _, win32con, win32file, _, _ = _win32() + log_handle = _open(log_path, win32con.GENERIC_WRITE | win32con.READ_CONTROL, + win32con.CREATE_NEW, win32con.FILE_ATTRIBUTE_NORMAL | _OPEN_REPARSE_POINT, + win32con.FILE_SHARE_READ | win32con.FILE_SHARE_WRITE) + import msvcrt + log_fd = msvcrt.open_osfhandle(int(log_handle), os.O_WRONLY) + with os.fdopen(log_fd, "wb", buffering=0) as log_stream: + process = subprocess.Popen(args, stdin=subprocess.DEVNULL, stdout=log_stream, stderr=log_stream, + close_fds=True, creationflags=creationflags, env=env) + creation_time_ns = int(__import__("psutil").Process(process.pid).create_time() * 1_000_000_000) + return {"pid": process.pid, "creationTimeNs": str(creation_time_ns), + "logPath": str(log_path), "tokenPath": token_path} + + +def inspect_hermes(hermes_path: str) -> dict[str, Any]: + path = os.path.abspath(hermes_path) + if not os.path.isabs(hermes_path) or not os.path.isfile(path): + raise ValueError("Hermes path is not an executable file") + version = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=20) + help_result = subprocess.run([path, "serve", "--help"], capture_output=True, text=True, timeout=20) + help_text = help_result.stdout + help_result.stderr + return { + "path": path, + "version": (version.stdout + version.stderr).splitlines()[0] if version.returncode == 0 else "", + "supported": "--ssh-session-token-file" in help_text and "--ssh-owner-nonce" in help_text, + } + + +def dispatch(argv: list[str]) -> Any: + if not argv: + raise ValueError("missing operation") + operation = argv[0] + if operation == "probe": + import platform + return {"os": "Windows", "arch": platform.machine(), "hermesHome": str(get_hermes_home()), "python": sys.executable} + if operation == "upload-token" and len(argv) == 3: + return upload_token(argv[1], argv[2], sys.stdin.buffer.read(65)) + if operation == "read-lock" and len(argv) == 2: + return read_lock(argv[1]) + if operation == "write-lock" and len(argv) == 2: + write_lock(argv[1], _read_json_stdin()); return {"ok": True} + if operation == "remove-lock" and len(argv) == 2: + return {"removed": remove_artifact(_lock_path(argv[1]))} + if operation == "remove-token" and len(argv) == 3: + return {"removed": remove_artifact(_token_path(argv[1], argv[2]))} + if operation == "read-log" and len(argv) == 3: + path = _log_path(argv[1], argv[2]) + _, pywintypes, _, win32con, win32file, _, _ = _win32() + try: + handle = _open(path, win32con.GENERIC_READ | win32con.READ_CONTROL, + win32con.OPEN_EXISTING, win32con.FILE_ATTRIBUTE_NORMAL | _OPEN_REPARSE_POINT, + win32con.FILE_SHARE_READ | win32con.FILE_SHARE_WRITE) + except pywintypes.error as exc: + if exc.winerror in (2, 3): + return {"content": ""} + raise + try: + _, data = win32file.ReadFile(handle, _MAX_LOG) + finally: + win32file.CloseHandle(handle) + return {"content": data.decode(errors="replace")} + if operation == "remove-log" and len(argv) == 3: + return {"removed": remove_artifact(_log_path(argv[1], argv[2]))} + if operation == "spawn": + return spawn_backend(_read_json_stdin()) + if operation == "inspect" and len(argv) == 2: + return inspect_hermes(argv[1]) + if operation == "process-state" and len(argv) == 5: + return process_state(int(argv[1]), int(argv[2]), argv[3], argv[4]) + if operation == "terminate" and len(argv) == 5: + return {"terminated": terminate_owned(int(argv[1]), int(argv[2]), argv[3], argv[4])} + raise ValueError("invalid operation") + + +def main() -> None: + try: + print(json.dumps(dispatch(sys.argv[1:]), separators=(",", ":"))) + except Exception as exc: + print(json.dumps({"error": str(exc)}, separators=(",", ":")), file=sys.stderr) + raise SystemExit(1) + + +if __name__ == "__main__": + main() -
pyproject.toml@@ -116,6 +116,10 @@ dependencies = [ "python-multipart>=0.0.9,<1", "ptyprocess>=0.7.0,<1; sys_platform != 'win32'", "pywinpty>=2.0.0,<3; sys_platform == 'win32'", + # Desktop SSH's Windows remote runtime (hermes_cli/windows_ssh_runtime.py) + # imports win32security/win32file/etc. directly — declare pywin32 rather than + # relying on the concurrent-log-handler → portalocker transitive chain. + "pywin32>=306,<312; sys_platform == 'win32'", # Image resize recovery for the vision tools. Pillow shrinks oversized images # (>5 MB or >8000px) at embed time; without it the byte AND pixel-dimension # shrink paths no-op, so an oversized image bakes into immutable history and -
tests/hermes_cli/test_ssh_ownership_endpoint.py@@ -0,0 +1,38 @@ +from fastapi.testclient import TestClient + +from hermes_cli import web_server + + +def test_ssh_ownership_endpoint_requires_token_and_returns_exact_nonce(monkeypatch): + token = "t" * 64 + nonce = "0123456789abcdef" + monkeypatch.setattr(web_server, "_SESSION_TOKEN", token) + monkeypatch.setattr(web_server, "_SSH_OWNER_NONCE", nonce) + web_server.app.state.auth_required = False + client = TestClient(web_server.app) + + assert client.get("/api/ssh/ownership").status_code == 401 + response = client.get( + "/api/ssh/ownership", + headers={"X-Hermes-Session-Token": token}, + ) + assert response.status_code == 200 + assert response.json() == { + "ok": True, + "sshOwnerNonce": nonce, + "protocolVersion": 1, + } + + +def test_ssh_ownership_endpoint_is_absent_without_owner_nonce(monkeypatch): + token = "t" * 64 + monkeypatch.setattr(web_server, "_SESSION_TOKEN", token) + monkeypatch.setattr(web_server, "_SSH_OWNER_NONCE", None) + web_server.app.state.auth_required = False + client = TestClient(web_server.app) + + response = client.get( + "/api/ssh/ownership", + headers={"X-Hermes-Session-Token": token}, + ) + assert response.status_code == 404 -
tests/hermes_cli/test_ssh_session_token_parser.py@@ -0,0 +1,94 @@ +import argparse +import os + +import pytest +from hermes_constants import set_hermes_home_override, reset_hermes_home_override + +from hermes_cli.main import _read_ssh_session_token_file, cmd_dashboard +from hermes_cli.subcommands.dashboard import build_dashboard_parser + + +def dashboard_parser(): + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command") + build_dashboard_parser( + subparsers, + cmd_dashboard=lambda _args: None, + cmd_dashboard_register=lambda _args: None, + ) + return parser + + +def test_serve_help_advertises_secure_ssh_bootstrap_flags(capsys): + with pytest.raises(SystemExit) as exit_info: + dashboard_parser().parse_args(["serve", "--help"]) + assert exit_info.value.code == 0 + output = capsys.readouterr().out + assert "--ssh-session-token-file PATH" in output + assert "--ssh-owner-nonce NONCE" in output + + +def test_serve_accepts_owner_nonce(): + args = dashboard_parser().parse_args(["serve", "--ssh-owner-nonce", "0123456789abcdef"]) + assert args.ssh_owner_nonce == "0123456789abcdef" + + [redacted:email]("operation", ["--status", "--stop"]) +def test_one_shot_token_file_rejects_non_starting_operations(operation): + args = dashboard_parser().parse_args([ + "serve", operation, "--ssh-session-token-file", "/tmp/token", + ]) + with pytest.raises(SystemExit, match="cannot be used"): + cmd_dashboard(args) + + +def test_token_file_is_read_and_unlinked_through_private_directory(tmp_path, monkeypatch): + home = tmp_path / "home" + hermes_home = home / ".hermes" + token_dir = [redacted:secret-assignment] / "desktop-ssh" / ("a" * 32) + token_dir.mkdir(parents=True, mode=0o700) + token_path = [redacted:secret-assignment] / "0123456789abcdef.token" + token_path.write_text("b" * 64) + token_path.chmod(0o600) + override = set_hermes_home_override(hermes_home) + try: + assert _read_ssh_session_token_file(str(token_path)) == "b" * 64 + assert not token_path.exists() + finally: + reset_hermes_home_override(override) + + [redacted:email](os.name == "nt", reason="POSIX symlink contract") +def test_token_file_rejects_symlink(tmp_path, monkeypatch): + home = tmp_path / "home" + token_dir = home / ".hermes" / "desktop-ssh" / ("a" * 32) + token_dir.mkdir(parents=True, mode=0o700) + target = tmp_path / "token" + target.write_text("b" * 64) + target.chmod(0o600) + token_path = [redacted:secret-assignment] / "0123456789abcdef.token" + token_path.symlink_to(target) + override = set_hermes_home_override(home / ".hermes") + try: + with pytest.raises(SystemExit, match="symlink|not accessible"): + _read_ssh_session_token_file(str(token_path)) + assert not token_path.exists() + assert target.read_text() == "b" * 64 + finally: + reset_hermes_home_override(override) + + +def test_token_file_rejects_parent_escape(tmp_path, monkeypatch): + home = tmp_path / "home" + token_root = home / ".hermes" / "desktop-ssh" + token_root.mkdir(parents=True, mode=0o700) + escaped = token_root.parent / "0123456789abcdef.token" + escaped.write_text("b" * 64) + escaped.chmod(0o600) + override = set_hermes_home_override(home / ".hermes") + try: + with pytest.raises(SystemExit, match="invalid runtime path"): + _read_ssh_session_token_file(str(token_root / ".." / escaped.name)) + assert escaped.exists() + finally: + reset_hermes_home_override(override) -
tests/test_web_server.py@@ -69,6 +69,22 @@ async def shutdown(self, sockets=None): return captured +def test_start_server_applies_process_local_ssh_bootstrap_state(monkeypatch): + captured = _stub_uvicorn(monkeypatch) + + web_server.start_server( + host="127.0.0.1", + port=0, + open_browser=False, + ssh_session_token="s" * 64, + ssh_owner_nonce="0123456789abcdef", + ) + + assert web_server._SESSION_TOKEN == "s" * 64 + assert web_server._SSH_OWNER_NONCE == "0123456789abcdef" + assert captured["port"] == 0 + + def test_start_server_disables_ws_ping_on_loopback(monkeypatch): """Loopback binds (the Desktop case) MUST disable uvicorn's protocol-level keepalive ping so an event-loop stall can never trigger a false disconnect. -
uv.lock@@ -1539,6 +1539,7 @@ dependencies = [ { name = "pyjwt", extra = ["crypto"] }, { name = "python-dotenv" }, { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "pywinpty", marker = "sys_platform == 'win32'" }, { name = "pyyaml" }, { name = "requests" }, @@ -1823,6 +1824,7 @@ requires-dist = [ { name = "python-multipart", marker = "extra == 'web'", specifier = "==0.0.27" }, { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = "==22.6" }, { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = "==22.6" }, + { name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=306,<312" }, { name = "pywinpty", marker = "sys_platform == 'win32'", specifier = ">=2.0.0,<3" }, { name = "pyyaml", specifier = "==6.0.3" }, { name = "qrcode", marker = "extra == 'dingtalk'", specifier = "==7.4.2" },
-
-
#73926 fix(desktop): resolve local plugin root independent of remote backend (#66899)
files:
apps/desktop/electron/main.ts,apps/desktop/electron/preload.ts,apps/desktop/src/app/settings/plugins-settings.tsx,apps/desktop/src/contrib/runtime-loader.test.ts,apps/desktop/src/contrib/runtime-loader.ts,apps/desktop/src/global.d.ts
7 PRs closed without merging
From the close comments: superseded by another PR 4 · done elsewhere 2 · merged via another change 1
-
#39122 feat(desktop): remote workspace + terminal over SSH (#38671, #38369, #38464) · done elsewhere →
#68130,#38671,#38369,#50707,#45893 -
#42497 fix(desktop): skip remote session cwd in Files panel to prevent ENOENT · superseded by another PR →
8878484 -
#42936 fix(desktop): show clear message for file browser in remote gateway mode · superseded by another PR →
#42497,#42878 -
#43069 fix(desktop): guard CWD overwrite from remote session.info events · superseded by another PR →
8878484,#43042,#42878 -
#43434 feat(desktop): browse remote backend files · done elsewhere →
#42878
Closed issues · 7
- Closed#38671Desktop Remote Gateway should support a true remote workspace/file browser
- Closed#42431Desktop Files panel reads local filesystem but defaults to remote session cwd → always ENOENT in remote mode
- Closed#42878[Bug]: Desktop file browser shows 'unreadable' (ENOENT) when connected to remote gateway — reads server cwd on local filesystem
- Closed#43042Desktop file browser ENOENT: session.info CWD overwrite ignores local filesystem
- Closed#43270[Bug]: Desktop file browser ENOENT when connected to WSL backend — Electron reads WSL paths that Node.js Windows cannot resolve
- Closed#66899Desktop runtime plugins cannot resolve their local directory with a remote backend
- Closed#68267Desktop disk plugins disappear in remote gateway mode
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.09: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab