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

2open issues
0open PRs
19items grouped
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.

These 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

  • Issue#38369Desktop Remote Gateway should make Terminal/File Explorer execution target explicit
  • Issue#39553[Feature]: Desktop Remote - working directory and terminal show server file/process

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #44326 feat(desktop): browse remote backend files (salvage #43434)

    merged to main

    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

    merged to main

    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)

    merged to main

    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
Show 2 more
  • #66911 fix(desktop): resolve local plugin root independent of remote backend (#66899) · merged via another change → #73926
  • #68276 fix(desktop): load disk plugins from local Hermes home · superseded by another PR → eaecca4, #73926, #66911, #66152
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.00 means 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

At a glance

Subsystem
apps/desktop
Merged PRs
#44326, #68130, #73926
Closed issues
7
Attempts
7 PRs closed without merging
Fix link checked
29 Jul 2026

For your agent

The same facts, typed.