{"repo":"NousResearch/hermes-agent","public_id":"38369","display_label":"Desktop remote backend file/path integration issues","summary":"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.","subsystem":"apps/desktop","counts":{"members":19,"open_issues":2,"open_issues_distinct":2,"open_prs":0,"closed_issues":7,"merged_prs":3,"attempt_prs":7},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":44326},"resolution":{"state":"partially_resolved","by":"pr-44326,pr-73926"},"freshness":{"state":"verified","last_verified_at":"2026-07-29T06:53:20+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.09333333333333332},"fix_edges":[{"issue_number":38671,"pr_number":68130,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":42878,"pr_number":44326,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":66899,"pr_number":73926,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":68267,"pr_number":73926,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":38369,"title":"Desktop Remote Gateway should make Terminal/File Explorer execution target explicit","url":null,"state":"open"},{"kind":"issue","number":38671,"title":"Desktop Remote Gateway should support a true remote workspace/file browser","url":null,"state":"closed"},{"kind":"issue","number":39553,"title":"[Feature]: Desktop Remote - working directory and terminal show server file/process","url":null,"state":"open"},{"kind":"issue","number":42431,"title":"Desktop Files panel reads local filesystem but defaults to remote session cwd → always ENOENT in remote mode","url":null,"state":"closed"},{"kind":"issue","number":42878,"title":"[Bug]: Desktop file browser shows 'unreadable' (ENOENT) when connected to remote gateway — reads server cwd on local filesystem","url":null,"state":"closed"},{"kind":"issue","number":43042,"title":"Desktop file browser ENOENT: session.info CWD overwrite ignores local filesystem","url":null,"state":"closed"},{"kind":"issue","number":43270,"title":"[Bug]: Desktop file browser ENOENT when connected to WSL backend — Electron reads WSL paths that Node.js Windows cannot resolve","url":null,"state":"closed"},{"kind":"issue","number":66899,"title":"Desktop runtime plugins cannot resolve their local directory with a remote backend","url":null,"state":"closed"},{"kind":"issue","number":68267,"title":"Desktop disk plugins disappear in remote gateway mode","url":null,"state":"closed"},{"kind":"pr","number":39122,"title":"feat(desktop): remote workspace + terminal over SSH (#38671, #38369, #38464)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#68130","number":68130},{"kind":"pr","value":"#38671","number":38671},{"kind":"pr","value":"#38369","number":38369},{"kind":"pr","value":"#68130","number":68130},{"kind":"pr","value":"#50707","number":50707},{"kind":"pr","value":"#45893","number":45893}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":42497,"title":"fix(desktop): skip remote session cwd in Files panel to prevent ENOENT","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"8878484f85192a0d443a4c856b8e9f424ac46d2c","number":0}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":42936,"title":"fix(desktop): show clear message for file browser in remote gateway mode","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#42497","number":42497},{"kind":"pr","value":"#42878","number":42878},{"kind":"pr","value":"#42497","number":42497},{"kind":"pr","value":"#42497","number":42497}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":43069,"title":"fix(desktop): guard CWD overwrite from remote session.info events","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"8878484f85192a0d443a4c856b8e9f424ac46d2c","number":0},{"kind":"pr","value":"#43042","number":43042},{"kind":"pr","value":"#42878","number":42878}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":43434,"title":"feat(desktop): browse remote backend files","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#42878","number":42878}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":44326,"title":"feat(desktop): browse remote backend files (salvage #43434)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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","tests/hermes_cli/test_web_server_fs.py"],"fix_hunks":[{"file":"apps/desktop/src/app/chat/right-rail/preview-file.tsx","hunks":["@@ -13,6 +13,7 @@ import { Streamdown } from 'streamdown'\n import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'\n import { PageLoader } from '@/components/page-loader'\n import { translateNow, useI18n } from '@/i18n'\n+import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'\n import { cn } from '@/lib/utils'\n import type { PreviewTarget } from '@/store/preview'\n ","@@ -180,15 +181,13 @@ function looksBinaryBytes(bytes: Uint8Array) {\n }\n \n async function readTextPreview(filePath: string) {\n-  if (window.hermesDesktop.readFileText) {\n-    try {\n-      return await window.hermesDesktop.readFileText(filePath)\n-    } catch (error) {\n-      const message = error instanceof Error ? error.message : String(error)\n-\n-      if (!message.includes(\"No handler registered for 'hermes:readFileText'\")) {\n-        throw error\n-      }\n+  try {\n+    return await readDesktopFileText(filePath)\n+  } catch (error) {\n+    const message = error instanceof Error ? error.message : String(error)\n+\n+    if (!message.includes(\"No handler registered for 'hermes:readFileText'\")) {\n+      throw error\n     }\n   }\n ","@@ -448,7 +447,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar\n         if (isImage) {\n           // Prefer bytes the caller already handed us (a pasted/dropped\n           // screenshot) over re-reading a path that may be transient/unreadable.\n-          const dataUrl = target.dataUrl || (await window.hermesDesktop.readFileDataUrl(filePath))\n+          const dataUrl = target.dataUrl || (await readDesktopFileDataUrl(filePath))\n \n           if (active) {\n             setState({ dataUrl, loading: false })"]},{"file":"apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx","hunks":["@@ -1,11 +1,50 @@\n import { act, cleanup, render } from '@testing-library/react'\n-import { afterEach, describe, expect, it, vi } from 'vitest'\n+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\n+\n+import { $connection } from '@/store/session'\n \n import { PreviewPane } from './preview-pane'\n \n describe('PreviewPane console state', () => {\n+  beforeEach(() => {\n+    vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0))\n+    vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))\n+  })\n+\n   afterEach(() => {\n     cleanup()\n+    $connection.set(null)\n+    vi.unstubAllGlobals()\n+  })\n+\n+  it('does not watch backend-only remote filesystem previews locally', () => {\n+    const watchPreviewFile = vi.fn(async () => ({ id: 'watch-1', path: '/remote/file.txt' }))\n+    const onPreviewFileChanged = vi.fn(() => vi.fn())\n+    $connection.set({ mode: 'remote' } as never)\n+    vi.stubGlobal('window', {\n+      ...window,\n+      hermesDesktop: {\n+        onPreviewFileChanged,\n+        watchPreviewFile\n+      }\n+    })\n+\n+    render(\n+      <PreviewPane\n+        setTitlebarToolGroup={vi.fn()}\n+        target={{\n+          kind: 'file',\n+          label: 'file.txt',\n+          path: '/remote/file.txt',\n+          previewKind: 'text',\n+          source: '/remote/file.txt',\n+          url: 'file:///remote/file.txt'\n+        }}\n+      />\n+    )\n+\n+    expect(watchPreviewFile).not.toHaveBeenCalled()\n+    expect(onPreviewFileChanged).not.toHaveBeenCalled()\n   })\n \n   it('does not rebuild the pane titlebar group for streamed console logs', () => {"]},{"file":"apps/desktop/src/app/chat/right-rail/preview-pane.tsx","hunks":["@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'\n import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'\n import { Tip } from '@/components/ui/tooltip'\n import { type Translations, useI18n } from '@/i18n'\n+import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'\n import { Bug } from '@/lib/icons'\n import { cn } from '@/lib/utils'\n import { notify, notifyError } from '@/store/notifications'","@@ -406,6 +407,7 @@ export function PreviewPane({\n   useEffect(() => {\n     if (\n       target.kind !== 'file' ||\n+      isDesktopFsRemoteMode() ||\n       !window.hermesDesktop?.watchPreviewFile ||\n       !window.hermesDesktop?.onPreviewFileChanged\n     ) {"]},{"file":"apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts","hunks":["@@ -3,6 +3,7 @@ import { useEffect, useRef } from 'react'\n import type { HermesConnection } from '@/global'\n import { HermesGateway } from '@/hermes'\n import { translateNow } from '@/i18n'\n+import { desktopDefaultCwd } from '@/lib/desktop-fs'\n import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'\n import {\n   $desktopBoot,","@@ -25,12 +26,16 @@ import {\n import { notify, notifyError } from '@/store/notifications'\n import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile'\n import {\n+  $activeSessionId,\n   $attentionSessionIds,\n   $connection,\n+  $currentCwd,\n   $sessions,\n   $workingSessionIds,\n   ensureDefaultWorkspaceCwd,\n   setConnection,\n+  setCurrentBranch,\n+  setCurrentCwd,\n   setSessionsLoading\n } from '@/store/session'\n import type { RpcEvent } from '@/types/hermes'","@@ -353,6 +358,11 @@ export function useGatewayBoot({\n           progress: 97\n         })\n         await ensureDefaultWorkspaceCwd()\n+        const remoteDefault = await desktopDefaultCwd().catch(() => null)\n+        if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) {\n+          setCurrentCwd(remoteDefault.cwd)\n+          setCurrentBranch(remoteDefault.branch || '')\n+        }\n         await callbacksRef.current.refreshHermesConfig()\n \n         if (cancelled) {"]},{"file":"apps/desktop/src/app/right-sidebar/files/ipc.ts","hunks":["@@ -1,5 +1,6 @@\n import ignore from 'ignore'\n \n+import { desktopFsCacheKey, desktopGitRoot, readDesktopDir, readDesktopFileDataUrl } from '@/lib/desktop-fs'\n import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'\n \n export type ProjectTreeEntry = HermesReadDirEntry","@@ -63,15 +64,11 @@ function ancestorDirs(root: string, dir: string) {\n }\n \n async function gitRootFor(start: string) {\n-  if (!window.hermesDesktop?.gitRoot) {\n-    return null\n-  }\n-\n-  const key = clean(start)\n+  const key = `${desktopFsCacheKey()}:${clean(start)}`\n   let cached = gitRootCache.get(key)\n \n   if (!cached) {\n-    cached = window.hermesDesktop.gitRoot(key)\n+    cached = desktopGitRoot(start)\n     gitRootCache.set(key, cached)\n   }\n ","@@ -80,18 +77,14 @@ async function gitRootFor(start: string) {\n \n /** Read .gitignore at `dir` if it actually exists — never probe missing files. */\n async function readGitignore(dir: string): Promise<GitignoreRule | null> {\n-  if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) {\n-    return null\n-  }\n-\n   try {\n-    const listing = await window.hermesDesktop.readDir(dir)\n+    const listing = await readDesktopDir(dir)\n \n     if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) {\n       return null\n     }\n \n-    const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`))\n+    const text = decodeDataUrl(await readDesktopFileDataUrl(`${dir}/.gitignore`))\n \n     return { base: dir, ig: ignore().add(text) }\n   } catch {","@@ -100,11 +93,11 @@ async function readGitignore(dir: string): Promise<GitignoreRule | null> {\n }\n \n async function gitignoreFor(dir: string) {\n-  const key = clean(dir)\n+  const key = `${desktopFsCacheKey()}:${clean(dir)}`\n   let cached = gitignoreCache.get(key)\n \n   if (!cached) {\n-    cached = readGitignore(key)\n+    cached = readGitignore(clean(dir))\n     gitignoreCache.set(key, cached)\n   }\n ","@@ -142,9 +135,10 @@ export async function readProjectDir(dirPath: string, rootPath = dirPath): Promi\n     return { entries: [], error: 'no-bridge' }\n   }\n \n-  const result = await window.hermesDesktop.readDir(dirPath)\n+  const result = await readDesktopDir(dirPath)\n+  const entries = result?.entries ?? []\n \n-  return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) }\n+  return { ...result, entries: await filterIgnored(entries, rootPath, dirPath) }\n }\n \n export function clearProjectDirCache(rootPath?: string) {","@@ -155,7 +149,7 @@ export function clearProjectDirCache(rootPath?: string) {\n     return\n   }\n \n-  const key = clean(rootPath)\n+  const key = `${desktopFsCacheKey()}:${clean(rootPath)}`\n   gitRootCache.delete(key)\n   gitignoreCache.delete(key)\n }"]},{"file":"apps/desktop/src/app/right-sidebar/files/remote-picker.tsx","hunks":["@@ -0,0 +1,177 @@\n+import { useEffect, useMemo, useState } from 'react'\n+\n+import { Button } from '@/components/ui/button'\n+import { Codicon } from '@/components/ui/codicon'\n+import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'\n+import { useI18n } from '@/i18n'\n+import { readDesktopDir, setDesktopFsRemotePicker } from '@/lib/desktop-fs'\n+import { cn } from '@/lib/utils'\n+\n+function clean(path: string) {\n+  return path.replace(/\\/+$/, '') || '/'\n+}\n+\n+function parentDir(path: string) {\n+  const value = clean(path)\n+  if (value === '/') {\n+    return '/'\n+  }\n+  const parent = value.slice(0, value.lastIndexOf('/'))\n+  return parent || '/'\n+}\n+\n+function pathName(path: string) {\n+  return path.split('/').filter(Boolean).pop() || path\n+}\n+\n+interface PendingSelection {\n+  defaultPath: string\n+  resolve: (paths: string[]) => void\n+  title: string\n+}\n+\n+export function RemoteFolderPicker() {\n+  const { t } = useI18n()\n+  const r = t.rightSidebar\n+  const [pending, setPending] = useState<PendingSelection | null>(null)\n+  const [currentPath, setCurrentPath] = useState('/')\n+  const [entries, setEntries] = useState<Array<{ name: string; path: string }>>([])\n+  const [error, setError] = useState<string | null>(null)\n+  const [loading, setLoading] = useState(false)\n+\n+  useEffect(() => {\n+    setDesktopFsRemotePicker({\n+      selectPaths: options =>\n+        new Promise(resolve => {\n+          const defaultPath = clean(options?.defaultPath || '/')\n+          setCurrentPath(defaultPath)\n+          setPending({ defaultPath, resolve, title: options?.title || r.remotePickerTitle })\n+        })\n+    })\n+    return () => setDesktopFsRemotePicker(null)\n+  }, [r.remotePickerTitle])\n+\n+  useEffect(() => {\n+    if (!pending) {\n+      return\n+    }\n+\n+    let active = true\n+    setLoading(true)\n+    setError(null)\n+\n+    void readDesktopDir(currentPath)\n+      .then(result => {\n+        if (!active) {\n+          return\n+        }\n+        if (result.error) {\n+          setError(result.error)\n+          setEntries([])\n+          return\n+        }\n+        setEntries(result.entries.filter(entry => entry.isDirectory).map(entry => ({ name: entry.name, path: entry.path })))\n+      })\n+      .catch(err => {\n+        if (active) {\n+          setError(err instanceof Error ? err.message : String(err))\n+          setEntries([])\n+        }\n+      })\n+      .finally(() => {\n+        if (active) {\n+          setLoading(false)\n+        }\n+      })\n+\n+    return () => {\n+      active = false\n+    }\n+  }, [currentPath, pending])\n+\n+  const crumbs = useMemo(() => {\n+    const parts = clean(currentPath).split('/').filter(Boolean)\n+    const out = [{ label: '/', path: '/' }]\n+    let acc = ''\n+    for (const part of parts) {\n+      acc += `/${part}`\n+      out.push({ label: part, path: acc })\n+    }\n+    return out\n+  }, [currentPath])\n+\n+  const close = (paths: string[] = []) => {\n+    pending?.resolve(paths)\n+    setPending(null)\n+    setEntries([])\n+    setError(null)\n+  }\n+\n+  return (\n+    <Dialog onOpenChange={open => !open && close()} open={Boolean(pending)}>\n+      <DialogContent className=\"max-w-lg gap-0 overflow-hidden p-0\">\n+        <div className=\"border-b border-border/70 px-4 py-3\">\n+          <DialogTitle className=\"text-sm\">{pending?.title || r.remotePickerTitle}</DialogTitle>\n+          <DialogDescription className=\"mt-1 text-xs\">{r.remotePickerDescription}</DialogDescription>\n+        </div>\n+\n+        <div className=\"flex min-h-[22rem] flex-col\">\n+          <div className=\"flex flex-wrap items-center gap-1 border-b border-border/50 px-3 py-2 text-xs text-muted-foreground\">\n+            {crumbs.map((crumb, index) => (\n+              <button\n+                className={cn('rounded px-1.5 py-0.5 hover:bg-muted hover:text-foreground', index === crumbs.length - 1 && 'text-foreground')}\n+                key={crumb.path}\n+                onClick={() => setCurrentPath(crumb.path)}\n+                type=\"button\"\n+              >\n+                {crumb.label}\n+              </button>\n+            ))}\n+          </div>\n+\n+          <div className=\"min-h-0 flex-1 overflow-y-auto p-2\">\n+            <FolderRow disabled={currentPath === '/'} name=\"..\" onClick={() => setCurrentPath(parentDir(currentPath))} />\n+            {loading ? (\n+              <div className=\"flex items-center gap-2 px-2 py-3 text-xs text-muted-foreground\">\n+                <Codicon name=\"loading\" size=\"0.8rem\" spinning />\n+                {r.loadingFiles}\n+              </div>\n+            ) : error ? (\n+              <div className=\"px-2 py-3 text-xs text-destructive\">{r.unreadableBody(error)}</div>\n+            ) : entries.length === 0 ? (\n+              <div className=\"px-2 py-3 text-xs text-muted-foreground\">{r.emptyBody}</div>\n+            ) : (\n+              entries.map(entry => <FolderRow key={entry.path} name={pathName(entry.path)} onClick={() => setCurrentPath(entry.path)} />)\n+            )}\n+          </div>\n+        </div>\n+\n+        <div className=\"flex items-center justify-between gap-2 border-t border-border/70 px-4 py-3\">\n+          <div className=\"min-w-0 truncate text-xs text-muted-foreground\">{currentPath}</div>\n+          <div className=\"flex shrink-0 items-center gap-2\">\n+            <Button onClick={() => close()} size=\"sm\" variant=\"ghost\">\n+              {t.common.cancel}\n+            </Button>\n+            <Button onClick={() => close([currentPath])} size=\"sm\">\n+              {r.remotePickerSelect}\n+            </Button>\n+          </div>\n+        </div>\n+      </DialogContent>\n+    </Dialog>\n+  )\n+}\n+\n+function FolderRow({ disabled = false, name, onClick }: { disabled?: boolean; name: string; onClick: () => void }) {\n+  return (\n+    <button\n+      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\"\n+      disabled={disabled}\n+      onClick={onClick}\n+      type=\"button\"\n+    >\n+      <Codicon name=\"folder\" size=\"0.875rem\" />\n+      <span className=\"min-w-0 truncate\">{name}</span>\n+    </button>\n+  )\n+}"]},{"file":"apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts","hunks":["@@ -1,19 +1,24 @@\n-import { act, renderHook, waitFor } from '@testing-library/react'\n+import { act, cleanup, renderHook, waitFor } from '@testing-library/react'\n import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\n \n+import { $connection } from '@/store/session'\n import type { HermesReadDirResult } from '@/global'\n \n+import { clearProjectDirCache, readProjectDir } from './ipc'\n import { resetProjectTreeState, useProjectTree } from './use-project-tree'\n \n const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()\n \n beforeEach(() => {\n+  $connection.set(null)\n   resetProjectTreeState()\n   readDir.mockReset()\n   ;(window as unknown as { hermesDesktop: { readDir: typeof readDir } }).hermesDesktop = { readDir }\n })\n \n afterEach(() => {\n+  cleanup()\n+  $connection.set(null)\n   resetProjectTreeState()\n   delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop\n })","@@ -106,6 +111,36 @@ describe('useProjectTree', () => {\n     expect(readDir).toHaveBeenCalledTimes(1)\n   })\n \n+  it('reads gitignore from the real path while caching per connection', async () => {\n+    const readFileDataUrl = vi.fn(async () => `data:text/plain;base64,${btoa('ignored.log\\n')}`)\n+    const gitRoot = vi.fn(async () => '/repo')\n+    readDir.mockImplementation(async path => {\n+      if (path === '/repo') return ok([{ name: '.gitignore', path: '/repo/.gitignore', isDirectory: false }])\n+      if (path === '/repo/src') {\n+        return ok([\n+          { name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false },\n+          { name: 'ignored.log', path: '/repo/src/ignored.log', isDirectory: false }\n+        ])\n+      }\n+      throw new Error(`unexpected path ${path}`)\n+    })\n+    ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { gitRoot, readDir, readFileDataUrl }\n+\n+    $connection.set({ baseUrl: 'local-a', mode: 'local' } as never)\n+    await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({\n+      entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }]\n+    })\n+    expect(readDir).toHaveBeenCalledWith('/repo')\n+    expect(readDir).not.toHaveBeenCalledWith(expect.stringContaining('local-a'))\n+\n+    $connection.set({ baseUrl: 'local-b', mode: 'local' } as never)\n+    clearProjectDirCache()\n+    await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({\n+      entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }]\n+    })\n+    expect(readDir.mock.calls.filter(([path]) => path === '/repo')).toHaveLength(2)\n+  })\n+\n   it('captures per-folder error code and shows an error placeholder child', async () => {\n     readDir.mockResolvedValueOnce(ok([{ name: 'priv', path: '/p/priv', isDirectory: true }]))\n     readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })"]},{"file":"apps/desktop/src/app/right-sidebar/files/use-project-tree.ts","hunks":["@@ -2,6 +2,8 @@ import { useStore } from '@nanostores/react'\n import { atom } from 'nanostores'\n import { useCallback, useEffect, useMemo } from 'react'\n \n+import { $connection } from '@/store/session'\n+\n import { clearProjectDirCache, readProjectDir } from './ipc'\n \n export interface TreeNode {","@@ -96,6 +98,7 @@ const initialState: ProjectTreeState = {\n const inflight = new Set<string>()\n const $projectTree = atom<ProjectTreeState>(initialState)\n let nextRootRequestId = 0\n+let lastConnectionKey = ''\n \n function setProjectTree(updater: (current: ProjectTreeState) => ProjectTreeState) {\n   $projectTree.set(updater($projectTree.get()))","@@ -157,6 +160,7 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}\n }\n \n export function resetProjectTreeState() {\n+  lastConnectionKey = ''\n   clearProjectTree()\n   clearProjectDirCache()\n }","@@ -170,6 +174,8 @@ export function resetProjectTreeState() {\n  */\n export function useProjectTree(cwd: string): UseProjectTreeResult {\n   const state = useStore($projectTree)\n+  const connection = useStore($connection)\n+  const connectionKey = `${connection?.mode || 'local'}:${connection?.profile || ''}:${connection?.baseUrl || ''}`\n \n   const refreshRoot = useCallback(() => loadRoot(cwd, { force: true }), [cwd])\n ","@@ -248,8 +254,15 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {\n   )\n \n   useEffect(() => {\n+    const connectionChanged = lastConnectionKey !== '' && lastConnectionKey !== connectionKey\n+    lastConnectionKey = connectionKey\n+    if (connectionChanged) {\n+      clearProjectDirCache()\n+      void loadRoot(cwd, { force: true })\n+      return\n+    }\n     void loadRoot(cwd)\n-  }, [cwd])\n+  }, [connectionKey, cwd])\n \n   return useMemo(\n     () => ({"]},{"file":"apps/desktop/src/app/right-sidebar/index.tsx","hunks":["@@ -7,6 +7,7 @@ import { Codicon } from '@/components/ui/codicon'\n import { Loader } from '@/components/ui/loader'\n import { Tip } from '@/components/ui/tooltip'\n import { useI18n } from '@/i18n'\n+import { selectDesktopPaths } from '@/lib/desktop-fs'\n import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'\n import { cn } from '@/lib/utils'\n import { $panesFlipped } from '@/store/layout'","@@ -16,6 +17,7 @@ import { $currentCwd } from '@/store/session'\n \n import { SidebarPanelLabel } from '../shell/sidebar-label'\n \n+import { RemoteFolderPicker } from './files/remote-picker'\n import { ProjectTree } from './files/tree'\n import { useProjectTree } from './files/use-project-tree'\n ","@@ -54,7 +56,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd\n   const canCollapse = Object.values(openState).some(Boolean)\n \n   const chooseFolder = async () => {\n-    const selected = await window.hermesDesktop?.selectPaths({\n+    const selected = await selectDesktopPaths({\n       defaultPath: hasCwd ? currentCwd : undefined,\n       directories: true,\n       multiple: false,","@@ -90,6 +92,8 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd\n           : 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'\n       )}\n     >\n+      <RemoteFolderPicker />\n+\n       <FilesystemTab\n         canCollapse={canCollapse}\n         collapseNonce={collapseNonce}"]},{"file":"apps/desktop/src/i18n/en.ts","hunks":["@@ -1532,6 +1532,9 @@ export const en: Translations = {\n     terminal: 'Terminal',\n     noFolderSelected: 'No folder selected',\n     changeCwdTitle: 'Change working directory',\n+    remotePickerTitle: 'Choose remote folder',\n+    remotePickerDescription: 'Browse folders on the connected backend.',\n+    remotePickerSelect: 'Select folder',\n     folderTip: cwd => `${cwd} — click to change folder`,\n     openFolder: 'Open folder',\n     refreshTree: 'Refresh tree',"]},{"file":"apps/desktop/src/i18n/ja.ts","hunks":["@@ -1665,6 +1665,9 @@ export const ja = defineLocale({\n     terminal: 'ターミナル',\n     noFolderSelected: 'フォルダーが選択されていません',\n     changeCwdTitle: '作業ディレクトリを変更',\n+    remotePickerTitle: 'リモートフォルダーを選択',\n+    remotePickerDescription: '接続中のバックエンド上のフォルダーを参照します。',\n+    remotePickerSelect: 'フォルダーを選択',\n     folderTip: cwd => `${cwd} — クリックしてフォルダーを変更`,\n     openFolder: 'フォルダーを開く',\n     refreshTree: 'ツリーを更新',"]},{"file":"apps/desktop/src/i18n/types.ts","hunks":["@@ -1194,6 +1194,9 @@ export interface Translations {\n     terminal: string\n     noFolderSelected: string\n     changeCwdTitle: string\n+    remotePickerTitle: string\n+    remotePickerDescription: string\n+    remotePickerSelect: string\n     folderTip: (cwd: string) => string\n     openFolder: string\n     refreshTree: string"]},{"file":"apps/desktop/src/i18n/zh-hant.ts","hunks":["@@ -1626,6 +1626,9 @@ export const zhHant = defineLocale({\n     terminal: '終端機',\n     noFolderSelected: '未選擇資料夾',\n     changeCwdTitle: '變更工作目錄',\n+    remotePickerTitle: '選擇遠端資料夾',\n+    remotePickerDescription: '瀏覽已連線後端上的資料夾。',\n+    remotePickerSelect: '選擇資料夾',\n     folderTip: cwd => `${cwd} — 點擊以變更資料夾`,\n     openFolder: '開啟資料夾',\n     refreshTree: '重新整理檔案樹',"]},{"file":"apps/desktop/src/i18n/zh.ts","hunks":["@@ -1712,6 +1712,9 @@ export const zh: Translations = {\n     terminal: '终端',\n     noFolderSelected: '未选择文件夹',\n     changeCwdTitle: '更改工作目录',\n+    remotePickerTitle: '选择远程文件夹',\n+    remotePickerDescription: '浏览已连接后端上的文件夹。',\n+    remotePickerSelect: '选择文件夹',\n     folderTip: cwd => `${cwd} — 点击更改文件夹`,\n     openFolder: '打开文件夹',\n     refreshTree: '刷新文件树',"]},{"file":"apps/desktop/src/lib/desktop-fs.test.ts","hunks":["@@ -0,0 +1,116 @@\n+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\n+\n+import { $connection } from '@/store/session'\n+\n+import {\n+  desktopDefaultCwd,\n+  desktopGitRoot,\n+  readDesktopDir,\n+  readDesktopFileDataUrl,\n+  readDesktopFileText,\n+  selectDesktopPaths,\n+  setDesktopFsRemotePicker\n+} from './desktop-fs'\n+\n+const readDir = vi.fn(async () => ({ entries: [{ name: 'local', path: '/local', isDirectory: true }] }))\n+const readFileText = vi.fn(async () => ({ path: '/local/file.txt', text: 'local', byteSize: 5 }))\n+const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,bG9jYWw=')\n+const gitRoot = vi.fn(async () => '/local')\n+const selectPaths = vi.fn(async () => ['/local'])\n+const api = vi.fn(async ({ path }: { path: string }) => {\n+  if (path.startsWith('/api/fs/list?')) return { entries: [{ name: 'remote', path: '/remote', isDirectory: true }] }\n+  if (path.startsWith('/api/fs/read-text?')) return { path: '/remote/file.txt', text: 'remote', byteSize: 6 }\n+  if (path.startsWith('/api/fs/read-data-url?')) return { dataUrl: 'data:text/plain;base64,cmVtb3Rl' }\n+  if (path.startsWith('/api/fs/git-root?')) return { root: '/remote' }\n+  if (path === '/api/fs/default-cwd') return { cwd: '/backend/project', branch: 'main' }\n+  throw new Error(`unexpected path ${path}`)\n+})\n+\n+function stubBridge() {\n+  vi.stubGlobal('window', {\n+    hermesDesktop: {\n+      api,\n+      gitRoot,\n+      readDir,\n+      readFileDataUrl,\n+      readFileText,\n+      selectPaths\n+    }\n+  })\n+}\n+\n+describe('desktop filesystem facade', () => {\n+  beforeEach(() => {\n+    stubBridge()\n+    $connection.set(null)\n+  })\n+\n+  afterEach(() => {\n+    vi.unstubAllGlobals()\n+    vi.clearAllMocks()\n+    $connection.set(null)\n+    setDesktopFsRemotePicker(null)\n+  })\n+\n+  it('uses local Electron filesystem methods in local mode', async () => {\n+    $connection.set({ mode: 'local' } as never)\n+\n+    await expect(readDesktopDir('/work')).resolves.toEqual({ entries: [{ name: 'local', path: '/local', isDirectory: true }] })\n+    await expect(readDesktopFileText('/work/file.txt')).resolves.toMatchObject({ text: 'local' })\n+    await expect(readDesktopFileDataUrl('/work/file.txt')).resolves.toBe('data:text/plain;base64,bG9jYWw=')\n+    await expect(desktopGitRoot('/work')).resolves.toBe('/local')\n+    await expect(selectDesktopPaths({ directories: true })).resolves.toEqual(['/local'])\n+\n+    expect(readDir).toHaveBeenCalledWith('/work')\n+    expect(readFileText).toHaveBeenCalledWith('/work/file.txt')\n+    expect(readFileDataUrl).toHaveBeenCalledWith('/work/file.txt')\n+    expect(gitRoot).toHaveBeenCalledWith('/work')\n+    expect(selectPaths).toHaveBeenCalledWith({ directories: true })\n+    expect(api).not.toHaveBeenCalled()\n+  })\n+\n+  it('routes filesystem reads through authenticated backend REST in remote mode', async () => {\n+    $connection.set({ mode: 'remote' } as never)\n+\n+    await expect(readDesktopDir('/home/user/project')).resolves.toMatchObject({ entries: [{ name: 'remote' }] })\n+    await expect(readDesktopFileText('/home/user/project/a b.txt')).resolves.toMatchObject({ text: 'remote' })\n+    await expect(readDesktopFileDataUrl('/home/user/project/a b.txt')).resolves.toBe('data:text/plain;base64,cmVtb3Rl')\n+    await expect(desktopGitRoot('/home/user/project')).resolves.toBe('/remote')\n+    await expect(desktopDefaultCwd()).resolves.toEqual({ cwd: '/backend/project', branch: 'main' })\n+\n+    expect(api).toHaveBeenCalledWith({ path: '/api/fs/list?path=%2Fhome%2Fuser%2Fproject' })\n+    expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-text?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' })\n+    expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-data-url?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' })\n+    expect(api).toHaveBeenCalledWith({ path: '/api/fs/git-root?path=%2Fhome%2Fuser%2Fproject' })\n+    expect(api).toHaveBeenCalledWith({ path: '/api/fs/default-cwd' })\n+    expect(readDir).not.toHaveBeenCalled()\n+    expect(readFileText).not.toHaveBeenCalled()\n+    expect(readFileDataUrl).not.toHaveBeenCalled()\n+    expect(gitRoot).not.toHaveBeenCalled()\n+  })\n+\n+  it('uses the registered in-app directory picker in remote mode', async () => {\n+    const remoteSelect = vi.fn(async () => ['/remote/project'])\n+    $connection.set({ mode: 'remote' } as never)\n+    setDesktopFsRemotePicker({ selectPaths: remoteSelect })\n+\n+    await expect(selectDesktopPaths({ defaultPath: '/remote', directories: true, multiple: false })).resolves.toEqual([\n+      '/remote/project'\n+    ])\n+\n+    expect(remoteSelect).toHaveBeenCalledWith({ defaultPath: '/remote', directories: true, multiple: false })\n+    expect(selectPaths).not.toHaveBeenCalled()\n+  })\n+\n+  it('does not treat the remote directory picker as a general file picker', async () => {\n+    const remoteSelect = vi.fn(async () => ['/remote/project'])\n+    $connection.set({ mode: 'remote' } as never)\n+    setDesktopFsRemotePicker({ selectPaths: remoteSelect })\n+\n+    await expect(selectDesktopPaths({ directories: false, multiple: false })).resolves.toEqual([])\n+    await expect(selectDesktopPaths({ directories: true, multiple: true })).resolves.toEqual([])\n+\n+    expect(remoteSelect).not.toHaveBeenCalled()\n+    expect(selectPaths).not.toHaveBeenCalled()\n+  })\n+})"]},{"file":"apps/desktop/src/lib/desktop-fs.ts","hunks":["@@ -0,0 +1,95 @@\n+import { $connection } from '@/store/session'\n+\n+import type { HermesConnection, HermesReadDirResult, HermesReadFileTextResult, HermesSelectPathsOptions } from '@/global'\n+\n+export interface DesktopFsRemotePicker {\n+  selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>\n+}\n+\n+let remotePicker: DesktopFsRemotePicker | null = null\n+\n+export function setDesktopFsRemotePicker(next: DesktopFsRemotePicker | null) {\n+  remotePicker = next\n+}\n+\n+function connectionCacheKey(connection: HermesConnection | null) {\n+  if (!connection) {\n+    return 'local:'\n+  }\n+  return `${connection.mode || 'local'}:${connection.profile || ''}:${connection.baseUrl || ''}`\n+}\n+\n+export function desktopFsCacheKey() {\n+  return connectionCacheKey($connection.get())\n+}\n+\n+export function isDesktopFsRemoteMode() {\n+  return $connection.get()?.mode === 'remote'\n+}\n+\n+function fsPath(endpoint: string, filePath: string) {\n+  return `/api/fs/${endpoint}?path=${encodeURIComponent(filePath)}`\n+}\n+\n+function bridge() {\n+  const desktop = window.hermesDesktop\n+  if (!desktop) {\n+    throw new Error('Hermes Desktop bridge is unavailable')\n+  }\n+  return desktop\n+}\n+\n+export async function readDesktopDir(path: string): Promise<HermesReadDirResult> {\n+  const desktop = bridge()\n+  if (!isDesktopFsRemoteMode()) {\n+    return desktop.readDir(path)\n+  }\n+  return desktop.api<HermesReadDirResult>({ path: fsPath('list', path) })\n+}\n+\n+export async function readDesktopFileText(path: string): Promise<HermesReadFileTextResult> {\n+  const desktop = bridge()\n+  if (!isDesktopFsRemoteMode()) {\n+    return desktop.readFileText(path)\n+  }\n+  return desktop.api<HermesReadFileTextResult>({ path: fsPath('read-text', path) })\n+}\n+\n+export async function readDesktopFileDataUrl(path: string): Promise<string> {\n+  const desktop = bridge()\n+  if (!isDesktopFsRemoteMode()) {\n+    return desktop.readFileDataUrl(path)\n+  }\n+\n+  const result = await desktop.api<string | { dataUrl?: string }>({ path: fsPath('read-data-url', path) })\n+  return typeof result === 'string' ? result : result.dataUrl || ''\n+}\n+\n+export async function desktopGitRoot(path: string): Promise<string | null> {\n+  const desktop = bridge()\n+  if (!isDesktopFsRemoteMode()) {\n+    return desktop.gitRoot ? desktop.gitRoot(path) : null\n+  }\n+\n+  const result = await desktop.api<{ root: string | null }>({ path: fsPath('git-root', path) })\n+  return result.root\n+}\n+\n+export async function desktopDefaultCwd(): Promise<{ branch: string; cwd: string } | null> {\n+  if (!isDesktopFsRemoteMode()) {\n+    return null\n+  }\n+\n+  return bridge().api<{ branch: string; cwd: string }>({ path: '/api/fs/default-cwd' })\n+}\n+\n+export async function selectDesktopPaths(options?: HermesSelectPathsOptions): Promise<string[]> {\n+  const desktop = bridge()\n+  if (!isDesktopFsRemoteMode()) {\n+    return desktop.selectPaths(options)\n+  }\n+  if (!options?.directories || options.multiple !== false) {\n+    return []\n+  }\n+  return remotePicker ? remotePicker.selectPaths(options) : []\n+}"]},{"file":"apps/desktop/src/lib/local-preview.ts","hunks":["@@ -1,3 +1,4 @@\n+import { isDesktopFsRemoteMode, readDesktopFileText } from '@/lib/desktop-fs'\n import type { PreviewTarget } from '@/store/preview'\n \n const HTML_EXTENSIONS = new Set(['.htm', '.html'])","@@ -107,6 +108,26 @@ export function localPreviewTarget(rawTarget: string, cwd?: string | null): Prev\n   }\n }\n \n+async function enrichPreviewTarget(target: PreviewTarget | null): Promise<PreviewTarget | null> {\n+  if (!isDesktopFsRemoteMode() || !target || target.kind !== 'file' || target.previewKind === 'image') {\n+    return target\n+  }\n+\n+  try {\n+    const result = await readDesktopFileText(target.path || target.source)\n+    return {\n+      ...target,\n+      binary: result.binary,\n+      byteSize: result.byteSize,\n+      language: result.language || target.language,\n+      large: (result.byteSize ?? 0) > 512 * 1024,\n+      mimeType: result.mimeType\n+    }\n+  } catch {\n+    return target\n+  }\n+}\n+\n export async function normalizeOrLocalPreviewTarget(\n   rawTarget: string,\n   cwd?: string | null","@@ -115,12 +136,12 @@ export async function normalizeOrLocalPreviewTarget(\n     const normalized = await window.hermesDesktop?.normalizePreviewTarget?.(rawTarget, cwd || undefined)\n \n     if (normalized) {\n-      return normalized\n+      return enrichPreviewTarget(normalized)\n     }\n   } catch {\n     // Running Electron may still have the old HTML-only preview IPC. Fall\n     // through to renderer-side local classification so text/images still open.\n   }\n \n-  return localPreviewTarget(rawTarget, cwd)\n+  return enrichPreviewTarget(localPreviewTarget(rawTarget, cwd))\n }"]},{"file":"apps/desktop/src/store/session.test.ts","hunks":["@@ -5,12 +5,14 @@ import type { SessionInfo } from '@/types/hermes'\n import {\n   $activeSessionId,\n   $attentionSessionIds,\n+  $connection,\n   $currentCwd,\n   $workingSessionIds,\n   applyConfiguredDefaultProjectDir,\n   getRecentlySettledSessionIds,\n   mergeSessionPage,\n   sessionPinId,\n+  setCurrentCwd,\n   setSessionAttention,\n   setSessionWorking,\n   workspaceCwdForNewSession","@@ -184,9 +186,12 @@ describe('mergeSessionPage', () => {\n describe('workspaceCwdForNewSession', () => {\n   afterEach(() => {\n     applyConfiguredDefaultProjectDir(null)\n+    $connection.set(null)\n     $currentCwd.set('')\n     $activeSessionId.set(null)\n     window.localStorage.removeItem('hermes.desktop.workspace-cwd')\n+    window.localStorage.removeItem('hermes.desktop.workspace-cwd.remote.http%3A%2F%2Fbackend-a.default')\n+    window.localStorage.removeItem('hermes.desktop.workspace-cwd.remote.http%3A%2F%2Fbackend-b.default')\n   })\n \n   it('prefers the configured default over the sticky remembered workspace', () => {","@@ -216,6 +221,26 @@ describe('workspaceCwdForNewSession', () => {\n     expect($currentCwd.get()).toBe('/live/session/path')\n     expect(workspaceCwdForNewSession()).toBe('/home/user/configured')\n   })\n+\n+  it('keeps remote workspace memory separate from local and other remotes', () => {\n+    window.localStorage.setItem('hermes.desktop.workspace-cwd', '/local/project')\n+    $currentCwd.set('/live/session/path')\n+    $connection.set({ baseUrl: 'http://backend-a', mode: 'remote' } as never)\n+\n+    expect(workspaceCwdForNewSession()).toBe('')\n+\n+    setCurrentCwd('/backend/project-a')\n+    expect(workspaceCwdForNewSession()).toBe('/backend/project-a')\n+\n+    $connection.set({ baseUrl: 'http://backend-b', mode: 'remote' } as never)\n+    expect(workspaceCwdForNewSession()).toBe('')\n+\n+    setCurrentCwd('/backend/project-b')\n+    expect(workspaceCwdForNewSession()).toBe('/backend/project-b')\n+\n+    $connection.set(null)\n+    expect(workspaceCwdForNewSession()).toBe('/local/project')\n+  })\n })\n \n describe('getRecentlySettledSessionIds', () => {"]},{"file":"apps/desktop/src/store/session.ts","hunks":["@@ -10,13 +10,19 @@ type Updater<T> = T | ((current: T) => T)\n \n const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'\n \n-// Cached copy of Settings → Sessions → Default project directory. The main\n-// process persists this in project-dir.json, but the renderer must also honor it\n-// when seeding $currentCwd — otherwise PR #37586's sticky localStorage home dir\n-// wins and new sessions ignore the user's explicit picker choice.\n let configuredDefaultProjectDir = ''\n \n-export const getRememberedWorkspaceCwd = (): string => storedString(WORKSPACE_CWD_KEY)?.trim() || ''\n+function workspaceCwdKey(connection: HermesConnection | null = $connection.get()): string {\n+  if (connection?.mode !== 'remote') {\n+    return WORKSPACE_CWD_KEY\n+  }\n+\n+  const base = encodeURIComponent(connection.baseUrl || 'remote')\n+  const profile = encodeURIComponent(connection.profile || 'default')\n+  return `${WORKSPACE_CWD_KEY}.remote.${base}.${profile}`\n+}\n+\n+export const getRememberedWorkspaceCwd = (): string => storedString(workspaceCwdKey())?.trim() || ''\n \n export const getConfiguredDefaultProjectDir = (): string => configuredDefaultProjectDir\n ","@@ -54,6 +60,13 @@ export async function ensureDefaultWorkspaceCwd(): Promise<void> {\n     }\n   }\n \n+  const remembered = getRememberedWorkspaceCwd()\n+\n+  if ($connection.get()?.mode === 'remote') {\n+    seedLiveCwd(remembered)\n+    return\n+  }\n+\n   if (configured) {\n     const { cwd } = await sanitize(configured)\n     seedLiveCwd(cwd)","@@ -61,8 +74,10 @@ export async function ensureDefaultWorkspaceCwd(): Promise<void> {\n     return\n   }\n \n-  const { cwd } = await sanitize(getRememberedWorkspaceCwd())\n-  seedLiveCwd(cwd)\n+  if (remembered) {\n+    const { cwd } = await sanitize(remembered)\n+    seedLiveCwd(cwd)\n+  }\n }\n \n export function applyConfiguredDefaultProjectDir(dir: null | string | undefined): void {","@@ -238,15 +253,16 @@ export const setYoloActive = (next: Updater<boolean>) => updateAtom($yoloActive,\n \n export const setCurrentCwd = (next: Updater<string>) => {\n   updateAtom($currentCwd, next)\n-  // Keep localStorage in sync with the atom: a real folder is remembered, an\n-  // empty cwd clears the key (|| null → removeItem).\n-  persistString(WORKSPACE_CWD_KEY, $currentCwd.get().trim() || null)\n+  persistString(workspaceCwdKey(), $currentCwd.get().trim() || null)\n }\n \n-/** Workspace for a brand-new chat. Explicit Settings override wins; otherwise\n- *  fall back to the sticky last-used folder, then whatever is already live. */\n-export const workspaceCwdForNewSession = (): string =>\n-  getConfiguredDefaultProjectDir() || getRememberedWorkspaceCwd() || $currentCwd.get().trim()\n+export const workspaceCwdForNewSession = (): string => {\n+  if ($connection.get()?.mode === 'remote') {\n+    return getRememberedWorkspaceCwd()\n+  }\n+\n+  return getConfiguredDefaultProjectDir() || getRememberedWorkspaceCwd() || $currentCwd.get().trim()\n+}\n \n export const setCurrentBranch = (next: Updater<string>) => updateAtom($currentBranch, next)\n export const setCurrentUsage = (next: Updater<UsageStats>) => updateAtom($currentUsage, next)"]},{"file":"hermes_cli/web_server.py","hunks":["@@ -920,6 +920,178 @@ class ManagedFilesPolicy:\n     can_change_path: bool\n \n \n+_FS_READDIR_HIDDEN = {\n+    \".git\",\n+    \".hg\",\n+    \".svn\",\n+    \".cache\",\n+    \".next\",\n+    \".turbo\",\n+    \".venv\",\n+    \"__pycache__\",\n+    \"build\",\n+    \"dist\",\n+    \"node_modules\",\n+    \"target\",\n+    \"venv\",\n+}\n+_FS_DATA_URL_MAX_BYTES = 16 * 1024 * 1024\n+_FS_TEXT_SOURCE_MAX_BYTES = 64 * 1024 * 1024\n+_FS_TEXT_PREVIEW_MAX_BYTES = 512 * 1024\n+_FS_PREVIEW_LANGUAGE_BY_EXT = {\n+    \".c\": \"c\",\n+    \".conf\": \"ini\",\n+    \".cpp\": \"cpp\",\n+    \".css\": \"css\",\n+    \".csv\": \"csv\",\n+    \".go\": \"go\",\n+    \".graphql\": \"graphql\",\n+    \".h\": \"c\",\n+    \".hpp\": \"cpp\",\n+    \".html\": \"html\",\n+    \".java\": \"java\",\n+    \".js\": \"javascript\",\n+    \".json\": \"json\",\n+    \".jsx\": \"jsx\",\n+    \".kt\": \"kotlin\",\n+    \".lua\": \"lua\",\n+    \".md\": \"markdown\",\n+    \".mjs\": \"javascript\",\n+    \".py\": \"python\",\n+    \".rb\": \"ruby\",\n+    \".rs\": \"rust\",\n+    \".sh\": \"shell\",\n+    \".sql\": \"sql\",\n+    \".svg\": \"xml\",\n+    \".toml\": \"toml\",\n+    \".ts\": \"typescript\",\n+    \".tsx\": \"tsx\",\n+    \".txt\": \"text\",\n+    \".xml\": \"xml\",\n+    \".yaml\": \"yaml\",\n+    \".yml\": \"yaml\",\n+    \".zsh\": \"shell\",\n+}\n+_FS_MIME_TYPES = {\n+    \".avi\": \"video/x-msvideo\",\n+    \".bmp\": \"image/bmp\",\n+    \".flac\": \"audio/flac\",\n+    \".gif\": \"image/gif\",\n+    \".jpeg\": \"image/jpeg\",\n+    \".jpg\": \"image/jpeg\",\n+    \".m4a\": \"audio/mp4\",\n+    \".mkv\": \"video/x-matroska\",\n+    \".mov\": \"video/quicktime\",\n+    \".mp3\": \"audio/mpeg\",\n+    \".mp4\": \"video/mp4\",\n+    \".ogg\": \"audio/ogg\",\n+    \".opus\": \"audio/ogg; codecs=opus\",\n+    \".png\": \"image/png\",\n+    \".svg\": \"image/svg+xml\",\n+    \".wav\": \"audio/wav\",\n+    \".webm\": \"video/webm\",\n+    \".webp\": \"image/webp\",\n+}\n+\n+\n+def _fs_path(raw_path: str) -> Path:\n+    raw = str(raw_path or \"\").strip()\n+    if not raw:\n+        raise HTTPException(status_code=400, detail=\"Path is required\")\n+    if \"\\0\" in raw:\n+        raise HTTPException(status_code=400, detail=\"Invalid path\")\n+    try:\n+        if raw.lower().startswith(\"file:\"):\n+            parsed = urllib.parse.urlparse(raw)\n+            if parsed.netloc and parsed.netloc not in {\"\", \"localhost\"}:\n+                raise ValueError\n+            raw = urllib.request.url2pathname(parsed.path)\n+        candidate = Path(raw).expanduser()\n+        if not candidate.is_absolute():\n+            candidate = Path.cwd() / candidate\n+        return candidate.resolve(strict=False)\n+    except (OSError, RuntimeError, ValueError):\n+        raise HTTPException(status_code=400, detail=\"Invalid path\")\n+\n+\n+def _fs_mime_type(path: Path) -> str:\n+    suffix = path.suffix.lower()\n+    if suffix in _FS_MIME_TYPES:\n+        return _FS_MIME_TYPES[suffix]\n+    guessed, _ = mimetypes.guess_type(str(path))\n+    return guessed or \"application/octet-stream\"\n+\n+\n+def _fs_looks_binary(data: bytes) -> bool:\n+    if not data:\n+        return False\n+    if b\"\\0\" in data:\n+        return True\n+    suspicious = sum(1 for byte in data if byte < 32 and byte not in {9, 10, 13})\n+    return suspicious / len(data) > 0.12\n+\n+\n+def _fs_regular_file(path: Path) -> tuple[Path, os.stat_result]:\n+    target = _fs_path(str(path))\n+    try:\n+        st = target.stat()\n+    except FileNotFoundError:\n+        raise HTTPException(status_code=404, detail=\"File not found\")\n+    except NotADirectoryError:\n+        raise HTTPException(status_code=404, detail=\"File not found\")\n+    except PermissionError:\n+        raise HTTPException(status_code=403, detail=\"File is not readable\")\n+    except OSError as exc:\n+        raise HTTPException(status_code=400, detail=str(exc) or \"Invalid path\")\n+    if stat.S_ISDIR(st.st_mode):\n+        raise HTTPException(status_code=400, detail=\"Path points to a directory\")\n+    if not stat.S_ISREG(st.st_mode):\n+        raise HTTPException(status_code=400, detail=\"Only regular files can be read\")\n+    return target, st\n+\n+\n+def _fs_find_git_root(start: Path) -> str | None:\n+    directory = start\n+    for _ in range(50):\n+        try:\n+            if (directory / \".git\").exists():\n+                return str(directory)\n+        except OSError:\n+            return None\n+        parent = directory.parent\n+        if parent == directory:\n+            return None\n+        directory = parent\n+    return None\n+\n+\n+def _fs_default_cwd() -> str:\n+    cfg_terminal = load_config().get(\"terminal\") or {}\n+    raw = str(cfg_terminal.get(\"cwd\") or os.environ.get(\"TERMINAL_CWD\") or \"\").strip()\n+    if raw and raw not in {\".\", \"auto\", \"cwd\"}:\n+        try:\n+            candidate = Path(raw).expanduser().resolve(strict=False)\n+            if candidate.is_dir():\n+                return str(candidate)\n+        except (OSError, RuntimeError):\n+            pass\n+    return str(Path.cwd())\n+\n+\n+def _fs_git_branch(cwd: str) -> str:\n+    try:\n+        result = subprocess.run(\n+            [\"git\", \"-C\", cwd, \"branch\", \"--show-current\"],\n+            capture_output=True,\n+            text=True,\n+            timeout=2,\n+            check=False,\n+        )\n+        return result.stdout.strip() if result.returncode == 0 else \"\"\n+    except Exception:\n+        return \"\"\n+\n+\n def _media_serve_roots() -> list[Path]:\n     \"\"\"Directories ``GET /api/media`` is allowed to read from.\n ","@@ -1264,6 +1436,87 @@ async def delete_managed_file(payload: ManagedFileDelete, request: Request):\n     return {\"ok\": True, \"path\": display_path, **_managed_response_meta(policy)}\n \n \n[redacted:email](\"/api/fs/list\")\n+async def fs_list(path: str):\n+    target = _fs_path(path)\n+    try:\n+        entries = []\n+        with os.scandir(target) as scan:\n+            for entry in scan:\n+                if entry.name in _FS_READDIR_HIDDEN:\n+                    continue\n+                entries.append({\n+                    \"name\": entry.name,\n+                    \"path\": str(target / entry.name),\n+                    \"isDirectory\": entry.is_dir(follow_symlinks=False),\n+                })\n+        entries.sort(key=lambda item: (not item[\"isDirectory\"], item[\"name\"].lower(), item[\"name\"]))\n+        return {\"entries\": entries}\n+    except FileNotFoundError:\n+        return {\"entries\": [], \"error\": \"ENOENT\"}\n+    except NotADirectoryError:\n+        return {\"entries\": [], \"error\": \"ENOTDIR\"}\n+    except PermissionError:\n+        return {\"entries\": [], \"error\": \"EACCES\"}\n+    except OSError as exc:\n+        return {\"entries\": [], \"error\": getattr(exc, \"strerror\", None) or \"read-error\"}\n+\n+\n[redacted:email](\"/api/fs/read-text\")\n+async def fs_read_text(path: str):\n+    target, st = _fs_regular_file(_fs_path(path))\n+    if st.st_size > _FS_TEXT_SOURCE_MAX_BYTES:\n+        raise HTTPException(status_code=413, detail=\"File too large\")\n+    bytes_to_read = min(st.st_size, _FS_TEXT_PREVIEW_MAX_BYTES)\n+    try:\n+        with target.open(\"rb\") as handle:\n+            data = handle.read(bytes_to_read)\n+    except PermissionError:\n+        raise HTTPException(status_code=403, detail=\"File is not readable\")\n+    except OSError as exc:\n+        raise HTTPException(status_code=400, detail=str(exc) or \"File read failed\")\n+    return {\n+        \"binary\": _fs_looks_binary(data[:4096]),\n+        \"byteSize\": st.st_size,\n+        \"language\": _FS_PREVIEW_LANGUAGE_BY_EXT.get(target.suffix.lower(), \"text\"),\n+        \"mimeType\": _fs_mime_type(target),\n+        \"path\": str(target),\n+        \"text\": data.decode(\"utf-8\", errors=\"replace\"),\n+        \"truncated\": st.st_size > _FS_TEXT_PREVIEW_MAX_BYTES,\n+    }\n+\n+\n[redacted:email](\"/api/fs/read-data-url\")\n+async def fs_read_data_url(path: str):\n+    target, st = _fs_regular_file(_fs_path(path))\n+    if st.st_size > _FS_DATA_URL_MAX_BYTES:\n+        raise HTTPException(status_code=413, detail=\"File too large\")\n+    try:\n+        encoded = base64.b64encode(target.read_bytes()).decode(\"ascii\")\n+    except PermissionError:\n+        raise HTTPException(status_code=403, detail=\"File is not readable\")\n+    except OSError as exc:\n+        raise HTTPException(status_code=400, detail=str(exc) or \"File read failed\")\n+    return {\"dataUrl\": f\"data:{_fs_mime_type(target)};base64,{encoded}\"}\n+\n+\n[redacted:email](\"/api/fs/git-root\")\n+async def fs_git_root(path: str):\n+    target = _fs_path(path)\n+    try:\n+        st = target.stat()\n+        start = target if stat.S_ISDIR(st.st_mode) else target.parent\n+    except OSError:\n+        start = target\n+    return {\"root\": _fs_find_git_root(start)}\n+\n+\n[redacted:email](\"/api/fs/default-cwd\")\n+async def fs_default_cwd():\n+    cwd = _fs_default_cwd()\n+    return {\"cwd\": cwd, \"branch\": _fs_git_branch(cwd)}\n+\n+\n @app.get(\"/api/status\")\n async def get_status():\n     current_ver, latest_ver = check_config_version()"]},{"file":"tests/hermes_cli/test_web_server_fs.py","hunks":["@@ -0,0 +1,188 @@\n+import base64\n+from pathlib import Path\n+\n+import pytest\n+\n+from hermes_cli import web_server\n+\n+pytest.importorskip(\"starlette.testclient\")\n+from starlette.testclient import TestClient\n+\n+\n[redacted:email]\n+def client(monkeypatch):\n+    previous_auth_required = getattr(web_server.app.state, \"auth_required\", None)\n+    web_server.app.state.auth_required = False\n+    test_client = TestClient(web_server.app)\n+    test_client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN\n+    try:\n+        yield test_client\n+    finally:\n+        if previous_auth_required is None:\n+            try:\n+                delattr(web_server.app.state, \"auth_required\")\n+            except AttributeError:\n+                pass\n+        else:\n+            web_server.app.state.auth_required = previous_auth_required\n+\n+\n+def test_fs_list_sorts_and_hides_noise(client, tmp_path):\n+    root = tmp_path / \"project\"\n+    root.mkdir()\n+    (root / \"b.txt\").write_text(\"b\")\n+    (root / \"a_dir\").mkdir()\n+    (root / \"a.txt\").write_text(\"a\")\n+    (root / \"node_modules\").mkdir()\n+    (root / \".git\").mkdir()\n+\n+    response = client.get(\"/api/fs/list\", params={\"path\": str(root)})\n+\n+    assert response.status_code == 200\n+    entries = response.json()[\"entries\"]\n+    assert [entry[\"name\"] for entry in entries] == [\"a_dir\", \"a.txt\", \"b.txt\"]\n+    assert entries[0] == {\"name\": \"a_dir\", \"path\": str(root / \"a_dir\"), \"isDirectory\": True}\n+    assert all(entry[\"name\"] not in {\".git\", \"node_modules\"} for entry in entries)\n+\n+\n+def test_fs_list_accepts_relative_paths(client, tmp_path, monkeypatch):\n+    monkeypatch.chdir(tmp_path)\n+    (tmp_path / \"rel\").mkdir()\n+    (tmp_path / \"rel\" / \"file.txt\").write_text(\"ok\")\n+\n+    response = client.get(\"/api/fs/list\", params={\"path\": \"rel\"})\n+\n+    assert response.status_code == 200\n+    assert response.json()[\"entries\"] == [\n+        {\"name\": \"file.txt\", \"path\": str(tmp_path / \"rel\" / \"file.txt\"), \"isDirectory\": False}\n+    ]\n+\n+\n+def test_fs_list_missing_path_returns_structured_error(client, tmp_path):\n+    response = client.get(\"/api/fs/list\", params={\"path\": str(tmp_path / \"missing\")})\n+\n+    assert response.status_code == 200\n+    assert response.json() == {\"entries\": [], \"error\": \"ENOENT\"}\n+\n+\n+def test_fs_read_text_matches_preview_shape_and_truncates(client, tmp_path, monkeypatch):\n+    monkeypatch.setattr(web_server, \"_FS_TEXT_SOURCE_MAX_BYTES\", 32)\n+    monkeypatch.setattr(web_server, \"_FS_TEXT_PREVIEW_MAX_BYTES\", 5)\n+    target = tmp_path / \"sample.py\"\n+    target.write_text(\"print('hello')\")\n+\n+    response = client.get(\"/api/fs/read-text\", params={\"path\": str(target)})\n+\n+    assert response.status_code == 200\n+    assert response.json() == {\n+        \"binary\": False,\n+        \"byteSize\": 14,\n+        \"language\": \"python\",\n+        \"mimeType\": \"text/x-python\",\n+        \"path\": str(target),\n+        \"text\": \"print\",\n+        \"truncated\": True,\n+    }\n+\n+\n+def test_fs_read_text_rejects_source_over_cap(client, tmp_path, monkeypatch):\n+    monkeypatch.setattr(web_server, \"_FS_TEXT_SOURCE_MAX_BYTES\", 4)\n+    target = tmp_path / \"large.txt\"\n+    target.write_text(\"12345\")\n+\n+    response = client.get(\"/api/fs/read-text\", params={\"path\": str(target)})\n+\n+    assert response.status_code == 413\n+\n+\n+def test_fs_read_text_flags_binary(client, tmp_path):\n+    target = tmp_path / \"blob.bin\"\n+    target.write_bytes(b\"hello\\x00world\")\n+\n+    response = client.get(\"/api/fs/read-text\", params={\"path\": str(target)})\n+\n+    assert response.status_code == 200\n+    body = response.json()\n+    assert body[\"binary\"] is True\n+    assert body[\"text\"].startswith(\"hello\")\n+\n+\n+def test_fs_read_data_url_returns_capped_data_url(client, tmp_path, monkeypatch):\n+    monkeypatch.setattr(web_server, \"_FS_DATA_URL_MAX_BYTES\", 16)\n+    target = tmp_path / \"image.png\"\n+    target.write_bytes(b\"pngbytes\")\n+\n+    response = client.get(\"/api/fs/read-data-url\", params={\"path\": str(target)})\n+\n+    assert response.status_code == 200\n+    assert response.json() == {\"dataUrl\": \"data:image/png;base64,\" + base64.b64encode(b\"pngbytes\").decode(\"ascii\")}\n+\n+\n+def test_fs_read_data_url_rejects_over_cap(client, tmp_path, monkeypatch):\n+    monkeypatch.setattr(web_server, \"_FS_DATA_URL_MAX_BYTES\", 3)\n+    target = tmp_path / \"image.png\"\n+    target.write_bytes(b\"1234\")\n+\n+    response = client.get(\"/api/fs/read-data-url\", params={\"path\": str(target)})\n+\n+    assert response.status_code == 413\n+\n+\n+def test_fs_git_root_for_nested_file(client, tmp_path):\n+    (tmp_path / \".git\").mkdir()\n+    nested = tmp_path / \"pkg\" / \"mod\"\n+    nested.mkdir(parents=True)\n+    target = nested / \"file.py\"\n+    target.write_text(\"x\")\n+\n+    response = client.get(\"/api/fs/git-root\", params={\"path\": str(target)})\n+\n+    assert response.status_code == 200\n+    assert response.json() == {\"root\": str(tmp_path)}\n+\n+\n+def test_fs_git_root_returns_null_outside_repo(client, tmp_path):\n+    response = client.get(\"/api/fs/git-root\", params={\"path\": str(tmp_path)})\n+\n+    assert response.status_code == 200\n+    assert response.json() == {\"root\": None}\n+\n+\n+def test_fs_default_cwd_prefers_existing_terminal_cwd(client, tmp_path, monkeypatch):\n+    monkeypatch.setattr(web_server, \"load_config\", lambda: {\"terminal\": {\"cwd\": str(tmp_path)}})\n+    monkeypatch.setenv(\"TERMINAL_CWD\", str(tmp_path / \"env\"))\n+    monkeypatch.setattr(web_server.Path, \"cwd\", lambda: tmp_path / \"process\")\n+    monkeypatch.setattr(web_server, \"_fs_git_branch\", lambda cwd: \"main\")\n+\n+    response = client.get(\"/api/fs/default-cwd\")\n+\n+    assert response.status_code == 200\n+    assert response.json() == {\"cwd\": str(tmp_path), \"branch\": \"main\"}\n+\n+\n+def test_fs_default_cwd_falls_back_when_terminal_cwd_is_invalid(client, tmp_path, monkeypatch):\n+    fallback = tmp_path / \"backend\"\n+    fallback.mkdir()\n+    monkeypatch.setattr(web_server, \"load_config\", lambda: {\"terminal\": {\"cwd\": \"/client/missing\"}})\n+    monkeypatch.setenv(\"TERMINAL_CWD\", \"/client/missing\")\n+    monkeypatch.setattr(web_server.Path, \"cwd\", lambda: fallback)\n+    monkeypatch.setattr(web_server, \"_fs_git_branch\", lambda cwd: \"\")\n+\n+    response = client.get(\"/api/fs/default-cwd\")\n+\n+    assert response.status_code == 200\n+    assert response.json() == {\"cwd\": str(fallback), \"branch\": \"\"}\n+\n+\n+def test_fs_endpoints_require_auth(tmp_path):\n+    client = TestClient(web_server.app)\n+    target = tmp_path / \"secret.txt\"\n+    target.write_text(\"secret\")\n+\n+    list_response = client.get(\"/api/fs/list\", params={\"path\": str(tmp_path)})\n+    read_response = client.get(\"/api/fs/read-text\", params={\"path\": str(target)})\n+    default_response = client.get(\"/api/fs/default-cwd\")\n+\n+    assert list_response.status_code == 401\n+    assert read_response.status_code == 401\n+    assert default_response.status_code == 401\n"]}]},{"kind":"pr","number":66911,"title":"fix(desktop): resolve local plugin root independent of remote backend (#66899)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#73926","number":73926}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":68130,"title":"feat(desktop): SSH remote-backend connection mode","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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","apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx","apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts","apps/desktop/src/app/session/hooks/use-session-actions.test.tsx","apps/desktop/src/app/session/hooks/use-session-actions/index.ts","apps/desktop/src/app/settings/gateway-settings.tsx","apps/desktop/src/app/settings/ssh-host-selection.test.ts","apps/desktop/src/app/settings/ssh-host-selection.ts","apps/desktop/src/app/shell/hooks/use-overlay-routing.ts","apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx","apps/desktop/src/components/boot-failure-overlay.tsx","apps/desktop/src/components/boot-failure-reauth.test.ts","apps/desktop/src/components/boot-failure-reauth.ts","apps/desktop/src/global.d.ts","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","hermes_cli/main.py","hermes_cli/subcommands/dashboard.py","hermes_cli/web_server.py","hermes_cli/windows_ssh_runtime.py","pyproject.toml","tests/hermes_cli/test_ssh_ownership_endpoint.py","tests/hermes_cli/test_ssh_session_token_parser.py","tests/test_web_server.py"],"fix_hunks":[{"file":"apps/desktop/electron/connection-apply.test.ts","hunks":["@@ -0,0 +1,100 @@\n+import { describe, expect, it, vi } from 'vitest'\n+\n+import { applyConnectionChange, commitConnectionFailure, resolveTerminalConnection } from './connection-apply'\n+\n+function deferred() {\n+  let resolve!: () => void\n+\n+  const promise = new Promise<void>(done => {\n+    resolve = done\n+  })\n+\n+  return { promise, resolve }\n+}\n+\n+describe('applyConnectionChange', () => {\n+  it.each([['SSH A to SSH B'], ['SSH to Cloud'], ['Cloud to SSH']])(\n+    'serializes %s behind bootstrap rollback before teardown and apply',\n+    async () => {\n+      const gate = deferred()\n+      const events: string[] = []\n+\n+      const run = applyConnectionChange({\n+        cancelAndWait: async () => {\n+          events.push('cancel')\n+          await gate.promise\n+          events.push('drained')\n+        },\n+        isPrimary: true,\n+        scope: '',\n+        sendApplied: () => events.push('applied'),\n+        stopPool: vi.fn(),\n+        teardownPrimary: async () => {\n+          events.push('primary')\n+        },\n+        teardownSsh: async () => {\n+          events.push('ssh')\n+        }\n+      })\n+\n+      await Promise.resolve()\n+      expect(events).toEqual(['cancel'])\n+      gate.resolve()\n+      await run\n+      expect(events).toEqual(['cancel', 'drained', 'ssh', 'primary', 'applied'])\n+    }\n+  )\n+\n+  it('tears down only a non-primary scope without applying the primary connection', async () => {\n+    const events: string[] = []\n+    await applyConnectionChange({\n+      cancelAndWait: async scope => {\n+        events.push(`cancel:${scope}`)\n+      },\n+      isPrimary: false,\n+      scope: 'worker',\n+      sendApplied: () => events.push('applied'),\n+      stopPool: scope => events.push(`pool:${scope}`),\n+      teardownPrimary: async () => {\n+        events.push('primary')\n+      },\n+      teardownSsh: async scope => {\n+        events.push(`ssh:${scope}`)\n+      }\n+    })\n+    expect(events).toEqual(['cancel:worker', 'ssh:worker', 'pool:worker'])\n+  })\n+})\n+\n+describe('resolveTerminalConnection', () => {\n+  it('joins an in-flight backend before resolving the SSH terminal target', async () => {\n+    const target = { ssh: {}, scope: '' }\n+    const getTarget = vi.fn().mockReturnValueOnce('pending').mockReturnValueOnce(target)\n+    const ensureBackend = vi.fn(async () => undefined)\n+\n+    await expect(resolveTerminalConnection(getTarget, ensureBackend)).resolves.toBe(target)\n+    expect(ensureBackend).toHaveBeenCalledOnce()\n+  })\n+\n+  it('does not start a local terminal while configured SSH remains unavailable', async () => {\n+    await expect(\n+      resolveTerminalConnection(\n+        () => 'pending',\n+        async () => undefined\n+      )\n+    ).rejects.toThrow('not ready')\n+  })\n+})\n+\n+describe('commitConnectionFailure', () => {\n+  it('prevents a stale bootstrap from publishing failure state', () => {\n+    const stale = Promise.resolve('stale')\n+    const current = Promise.resolve('current')\n+    const commit = vi.fn()\n+\n+    expect(commitConnectionFailure(current, stale, commit)).toBe(false)\n+    expect(commit).not.toHaveBeenCalled()\n+    expect(commitConnectionFailure(current, current, commit)).toBe(true)\n+    expect(commit).toHaveBeenCalledOnce()\n+  })\n+})"]},{"file":"apps/desktop/electron/connection-apply.ts","hunks":["@@ -0,0 +1,50 @@\n+async function applyConnectionChange({\n+  cancelAndWait,\n+  isPrimary,\n+  scope,\n+  sendApplied,\n+  stopPool,\n+  teardownPrimary,\n+  teardownSsh\n+}) {\n+  await cancelAndWait(scope)\n+  await teardownSsh(scope)\n+\n+  if (!isPrimary) {\n+    stopPool(scope)\n+\n+    return\n+  }\n+\n+  await teardownPrimary()\n+  sendApplied()\n+}\n+\n+function commitConnectionFailure(current, starting, commit) {\n+  if (current !== starting) {\n+    return false\n+  }\n+\n+  commit()\n+\n+  return true\n+}\n+\n+async function resolveTerminalConnection(getTarget, ensureBackend) {\n+  let target = getTarget()\n+\n+  if (target !== 'pending') {\n+    return target\n+  }\n+\n+  await ensureBackend()\n+  target = getTarget()\n+\n+  if (target === 'pending') {\n+    throw new Error('Remote connection is not ready yet. Try again in a moment.')\n+  }\n+\n+  return target\n+}\n+\n+export { applyConnectionChange, commitConnectionFailure, resolveTerminalConnection }"]},{"file":"apps/desktop/electron/connection-config.test.ts","hunks":["@@ -26,14 +26,19 @@ import {\n   gatewayTicketFailure,\n   gatewayWsUrlIpcResult,\n   isGatewayAuthRejection,\n+  localProfileEntry,\n   modeIsRemoteLike,\n   normalizeRemoteBaseUrl,\n+  normalizeSshConfig,\n   normAuthMode,\n   pathWithGlobalRemoteProfile,\n+  profileHasRemoteConnection,\n   profileRemoteOverride,\n+  profileSshOverride,\n   resolveAuthMode,\n   resolveTestWsUrl,\n   RT_COOKIE_VARIANTS,\n+  savedProfileSsh,\n   tokenPreview\n } from './connection-config'\n ","@@ -127,6 +132,61 @@ test('profileRemoteOverride tolerates a missing/!object profiles map', () => {\n   assert.equal(profileRemoteOverride(null, 'coder'), null)\n })\n \n+test('SSH remains separate from URL-shaped remote modes', () => {\n+  assert.equal(modeIsRemoteLike('ssh'), false)\n+  const config = { profiles: { coder: { mode: 'ssh', host: 'alice@box:2222', keyPath: '/key' } } }\n+  assert.equal(profileRemoteOverride(config, 'coder'), null)\n+  assert.deepEqual(profileSshOverride(config, 'coder'), {\n+    mode: 'ssh',\n+    host: 'box',\n+    user: 'alice',\n+    port: 2222,\n+    keyPath: '/key'\n+  })\n+})\n+\n+test('normalizeSshConfig handles IPv6 and strict port bounds', () => {\n+  assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: '::1', port: 22 }), {\n+    mode: 'ssh',\n+    host: '::1'\n+  })\n+  assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: '[::1]:2222' }), {\n+    mode: 'ssh',\n+    host: '::1',\n+    port: 2222\n+  })\n+  assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box', port: '2222junk' }), {\n+    mode: 'ssh',\n+    host: 'box'\n+  })\n+  assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box', port: 65536 }), {\n+    mode: 'ssh',\n+    host: 'box'\n+  })\n+})\n+\n+test('localProfileEntry preserves inactive SSH drafts but drops Cloud state', () => {\n+  const ssh = { mode: 'ssh', host: 'box', user: 'alice', remoteHermesPath: '/hermes' }\n+  assert.deepEqual(localProfileEntry(ssh), { mode: 'local', savedSsh: ssh })\n+  assert.deepEqual(localProfileEntry({ mode: 'local', savedSsh: ssh }), {\n+    mode: 'local',\n+    savedSsh: ssh\n+  })\n+  assert.equal(localProfileEntry({ mode: 'cloud', url: 'https://agent' }), null)\n+})\n+\n+test('saved SSH drafts are inactive and explicit overrides take precedence', () => {\n+  const saved = { mode: 'ssh', host: 'saved' }\n+  const config: any = { profiles: { coder: { mode: 'local', savedSsh: saved } } }\n+  assert.deepEqual(savedProfileSsh(config, 'coder'), saved)\n+  assert.equal(profileSshOverride(config, 'coder'), null)\n+  assert.equal(profileHasRemoteConnection(config, 'coder'), false)\n+\n+  config.profiles.coder = { mode: 'ssh', host: 'active' }\n+  assert.deepEqual(profileSshOverride(config, 'coder'), { mode: 'ssh', host: 'active' })\n+  assert.equal(profileHasRemoteConnection(config, 'coder'), true)\n+})\n+\n // --- pathWithGlobalRemoteProfile ---\n \n test('pathWithGlobalRemoteProfile appends profile in global remote mode', () => {"]},{"file":"apps/desktop/electron/connection-config.ts","hunks":["@@ -203,6 +203,127 @@ function modeIsRemoteLike(mode) {\n   return mode === 'remote' || mode === 'cloud'\n }\n \n+function normalizeSshConfig(entry) {\n+  if (!entry || typeof entry !== 'object' || entry.mode !== 'ssh') {\n+    return null\n+  }\n+\n+  let host = String(entry.host || '').trim()\n+\n+  if (!host) {\n+    return null\n+  }\n+\n+  let parsedUser\n+  let parsedPort\n+  const at = host.indexOf('@')\n+\n+  if (at > 0) {\n+    parsedUser = host.slice(0, at)\n+    host = host.slice(at + 1)\n+  }\n+\n+  const bracketed = /^\\[([^\\]]+)](?::(\\d+))?$/.exec(host)\n+\n+  if (bracketed) {\n+    host = bracketed[1]\n+\n+    if (bracketed[2]) {\n+      parsedPort = Number(bracketed[2])\n+    }\n+  } else if ((host.match(/:/g) || []).length === 1) {\n+    const [name, rawPort] = host.split(':')\n+\n+    if (/^\\d+$/.test(rawPort)) {\n+      host = name\n+      parsedPort = Number(rawPort)\n+    }\n+  }\n+\n+  if (!host) {\n+    return null\n+  }\n+\n+  const out: any = { mode: 'ssh', host }\n+  const user = String(entry.user || '').trim() || parsedUser || ''\n+\n+  if (user) {\n+    out.user = user\n+  }\n+\n+  const rawExplicitPort = String(entry.port ?? '').trim()\n+  const explicitPort = /^\\d+$/.test(rawExplicitPort) ? Number(rawExplicitPort) : null\n+  const port = explicitPort ?? parsedPort\n+\n+  if (Number.isInteger(port) && port > 0 && port <= 65535 && port !== 22) {\n+    out.port = port\n+  }\n+\n+  const keyPath = String(entry.keyPath || '').trim()\n+\n+  if (keyPath) {\n+    out.keyPath = keyPath\n+  }\n+\n+  const remoteHermesPath = String(entry.remoteHermesPath || '').trim()\n+\n+  if (remoteHermesPath) {\n+    out.remoteHermesPath = remoteHermesPath\n+  }\n+\n+  return out\n+}\n+\n+function profileSshOverride(config, profile) {\n+  const key = connectionScopeKey(profile)\n+  const entry = key ? config?.profiles?.[key] : null\n+\n+  return normalizeSshConfig(entry)\n+}\n+\n+function savedProfileSsh(config, profile) {\n+  const key = connectionScopeKey(profile)\n+  const entry = key ? config?.profiles?.[key] : null\n+\n+  if (!entry || entry.mode !== 'local') {\n+    return null\n+  }\n+\n+  return normalizeSshConfig(entry.savedSsh)\n+}\n+\n+function profileHasRemoteConnection(config, profile) {\n+  return Boolean(profileRemoteOverride(config, profile) || profileSshOverride(config, profile))\n+}\n+\n+function localProfileEntry(existing) {\n+  const ssh = normalizeSshConfig(existing) || normalizeSshConfig(existing?.savedSsh)\n+\n+  return ssh ? { mode: 'local', savedSsh: ssh } : null\n+}\n+\n+function hostLabelFromBaseUrl(baseUrl) {\n+  const raw = String(baseUrl || '').trim()\n+\n+  if (!raw) {\n+    return null\n+  }\n+\n+  try {\n+    const parsed = new URL(raw)\n+\n+    if (!parsed.hostname) {\n+      return null\n+    }\n+\n+    return parsed.port && parsed.port !== '80' && parsed.port !== '443'\n+      ? `${parsed.hostname}:${parsed.port}`\n+      : parsed.hostname\n+  } catch {\n+    return null\n+  }\n+}\n+\n /**\n  * Select a profile's explicit remote override from a connection config, or null\n  * when it has none (so the caller falls back to env → global remote → local).","@@ -372,15 +493,21 @@ export {\n   cookiesHaveSession,\n   gatewayTicketFailure,\n   gatewayWsUrlIpcResult,\n+  hostLabelFromBaseUrl,\n   isGatewayAuthRejection,\n+  localProfileEntry,\n   modeIsRemoteLike,\n   normalizeRemoteBaseUrl,\n+  normalizeSshConfig,\n   normAuthMode,\n   pathWithGlobalRemoteProfile,\n   PRIVY_SESSION_COOKIE_VARIANTS,\n+  profileHasRemoteConnection,\n   profileRemoteOverride,\n+  profileSshOverride,\n   resolveAuthMode,\n   resolveTestWsUrl,\n   RT_COOKIE_VARIANTS,\n+  savedProfileSsh,\n   tokenPreview\n }"]},{"file":"apps/desktop/electron/desktop-installation.test.ts","hunks":["@@ -0,0 +1,105 @@\n+import assert from 'node:assert/strict'\n+import fs from 'node:fs'\n+import os from 'node:os'\n+import path from 'node:path'\n+\n+import { test } from 'vitest'\n+\n+import { loadOrCreateInstallationId, parseInstallationId, sshOwnershipId } from './desktop-installation'\n+\n+const ID_A = '11111111-1111-4111-8111-111111111111'\n+const ID_B = '22222222-2222-4222-8222-222222222222'\n+\n+function withTempDir(run) {\n+  const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-installation-'))\n+\n+  try {\n+    return run(directory)\n+  } finally {\n+    fs.rmSync(directory, { recursive: true, force: true })\n+  }\n+}\n+\n+test('parseInstallationId accepts only a version-4 UUID record', () => {\n+  assert.equal(parseInstallationId(JSON.stringify({ installationId: ID_A.toUpperCase() })), ID_A)\n+  assert.equal(parseInstallationId(JSON.stringify({ installationId: 'not-an-id' })), '')\n+  assert.equal(parseInstallationId('{}'), '')\n+  assert.equal(parseInstallationId('{'), '')\n+})\n+\n+test('loadOrCreateInstallationId persists and reuses one installation ID', () =>\n+  withTempDir(directory => {\n+    const filePath = path.join(directory, 'desktop-installation.json')\n+    assert.equal(\n+      loadOrCreateInstallationId(filePath, () => ID_A),\n+      ID_A\n+    )\n+    assert.equal(\n+      loadOrCreateInstallationId(filePath, () => ID_B),\n+      ID_A\n+    )\n+    assert.equal(fs.statSync(filePath).mode & 0o777, 0o600)\n+  }))\n+\n+test('loadOrCreateInstallationId tightens an existing identity file', () =>\n+  withTempDir(directory => {\n+    const filePath = path.join(directory, 'desktop-installation.json')\n+    fs.writeFileSync(filePath, JSON.stringify({ installationId: ID_A }), { mode: 0o644 })\n+    assert.equal(\n+      loadOrCreateInstallationId(filePath, () => ID_B),\n+      ID_A\n+    )\n+\n+    if (process.platform !== 'win32') {\n+      assert.equal(fs.statSync(filePath).mode & 0o777, 0o600)\n+    }\n+  }))\n+\n+test('loadOrCreateInstallationId replaces a malformed existing record', () =>\n+  withTempDir(directory => {\n+    const filePath = path.join(directory, 'desktop-installation.json')\n+    fs.writeFileSync(filePath, '{', { mode: 0o600 })\n+    assert.equal(\n+      loadOrCreateInstallationId(filePath, () => ID_A),\n+      ID_A\n+    )\n+    assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).installationId, ID_A)\n+  }))\n+\n+test('loadOrCreateInstallationId replaces an existing symlink', () =>\n+  withTempDir(directory => {\n+    if (process.platform === 'win32') {\n+      return\n+    }\n+\n+    const target = path.join(directory, 'target.json')\n+    const filePath = path.join(directory, 'desktop-installation.json')\n+    fs.writeFileSync(target, JSON.stringify({ installationId: ID_B }), { mode: 0o600 })\n+    fs.symlinkSync(target, filePath)\n+    assert.equal(\n+      loadOrCreateInstallationId(filePath, () => ID_A),\n+      ID_A\n+    )\n+    assert.equal(fs.lstatSync(filePath).isSymbolicLink(), false)\n+    assert.equal(JSON.parse(fs.readFileSync(target, 'utf8')).installationId, ID_B)\n+  }))\n+\n+test('loadOrCreateInstallationId replaces a malformed destination without a repair lock', () =>\n+  withTempDir(directory => {\n+    const filePath = path.join(directory, 'desktop-installation.json')\n+    fs.writeFileSync(filePath, '{', { mode: 0o600 })\n+    assert.equal(\n+      loadOrCreateInstallationId(filePath, () => ID_A),\n+      ID_A\n+    )\n+    assert.equal(fs.existsSync(`${filePath}.lock`), false)\n+  }))\n+\n+test('sshOwnershipId is stable, scoped, and does not disclose the UUID', () => {\n+  const global = sshOwnershipId(ID_A, '')\n+  assert.match(global, /^[0-9a-f]{32}$/)\n+  assert.equal(global, sshOwnershipId(ID_A, ''))\n+  assert.notEqual(global, sshOwnershipId(ID_A, 'worker'))\n+  assert.ok(!global.includes(ID_A.slice(0, 8)))\n+  assert.throws(() => sshOwnershipId('bad', ''))\n+})"]},{"file":"apps/desktop/electron/desktop-installation.ts","hunks":["@@ -0,0 +1,137 @@\n+import crypto from 'node:crypto'\n+import fs from 'node:fs'\n+import path from 'node:path'\n+\n+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\n+\n+function parseInstallationId(raw) {\n+  try {\n+    const value = JSON.parse(String(raw || ''))?.installationId\n+\n+    return INSTALLATION_ID_RE.test(value) ? value.toLowerCase() : ''\n+  } catch {\n+    return ''\n+  }\n+}\n+\n+function readInstallationId(filePath) {\n+  try {\n+    const stat = fs.lstatSync(filePath)\n+\n+    if (!stat.isFile() || stat.isSymbolicLink()) {\n+      return ''\n+    }\n+\n+    if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {\n+      return ''\n+    }\n+\n+    if (process.platform !== 'win32' && (stat.mode & 0o777) !== 0o600) {\n+      fs.chmodSync(filePath, 0o600)\n+    }\n+\n+    return parseInstallationId(fs.readFileSync(filePath, 'utf8'))\n+  } catch {\n+    return ''\n+  }\n+}\n+\n+function waitForRepair() {\n+  const buffer = new SharedArrayBuffer(4)\n+  Atomics.wait(new Int32Array(buffer), 0, 0, 25)\n+}\n+\n+function loadOrCreateInstallationId(filePath, randomUUID = crypto.randomUUID) {\n+  const existing = readInstallationId(filePath)\n+\n+  if (existing) {\n+    return existing\n+  }\n+\n+  fs.mkdirSync(path.dirname(filePath), { recursive: true })\n+  const installationId = randomUUID().toLowerCase()\n+\n+  if (!INSTALLATION_ID_RE.test(installationId)) {\n+    throw new Error('Could not generate a valid desktop installation ID.')\n+  }\n+\n+  const repairPath = `${filePath}.repair.lock`\n+\n+  for (let attempt = 0; attempt < 40; attempt++) {\n+    let repairFd\n+\n+    try {\n+      repairFd = fs.openSync(repairPath, 'wx', 0o600)\n+    } catch (error: any) {\n+      if (error?.code !== 'EEXIST') {\n+        throw error\n+      }\n+\n+      const winner = readInstallationId(filePath)\n+\n+      if (winner) {\n+        return winner\n+      }\n+\n+      waitForRepair()\n+\n+      continue\n+    }\n+\n+    try {\n+      const winner = readInstallationId(filePath)\n+\n+      if (winner) {\n+        return winner\n+      }\n+\n+      try {\n+        const stat = fs.lstatSync(filePath)\n+\n+        if (!stat.isFile() && !stat.isSymbolicLink()) {\n+          throw new Error('Desktop installation ID path is not a regular file.')\n+        }\n+\n+        if (!stat.isSymbolicLink() && typeof process.getuid === 'function' && stat.uid !== process.getuid()) {\n+          throw new Error('Desktop installation ID is owned by another user.')\n+        }\n+\n+        fs.unlinkSync(filePath)\n+      } catch (error: any) {\n+        if (error?.code !== 'ENOENT') {\n+          throw error\n+        }\n+      }\n+\n+      fs.writeFileSync(filePath, JSON.stringify({ installationId }), { encoding: 'utf8', flag: 'wx', mode: 0o600 })\n+\n+      return installationId\n+    } finally {\n+      if (repairFd !== undefined) {\n+        fs.closeSync(repairFd)\n+      }\n+\n+      try {\n+        fs.unlinkSync(repairPath)\n+      } catch {\n+        void 0\n+      }\n+    }\n+  }\n+\n+  throw new Error('Could not repair the desktop installation ID.')\n+}\n+\n+function sshOwnershipId(installationId, scope) {\n+  if (!INSTALLATION_ID_RE.test(String(installationId || ''))) {\n+    throw new Error('Desktop installation ID is invalid.')\n+  }\n+\n+  return crypto\n+    .createHash('sha256')\n+    .update(`${installationId}\\0${String(scope || '')}`)\n+    .digest('hex')\n+    .slice(0, 32)\n+}\n+\n+export { INSTALLATION_ID_RE, loadOrCreateInstallationId, parseInstallationId, readInstallationId, sshOwnershipId }"]},{"file":"apps/desktop/electron/main.ts","hunks":["@@ -39,6 +39,7 @@ import { waitForDashboardPortAnnouncement } from './backend-ready'\n import { shouldLatchBackendStartFailure } from './backend-start-failure'\n import { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } from './bootstrap-platform'\n import { runBootstrap } from './bootstrap-runner'\n+import { applyConnectionChange, resolveTerminalConnection } from './connection-apply'\n import {\n   authModeFromStatus,\n   buildGatewayWsUrl,","@@ -49,16 +50,23 @@ import {\n   cookiesHaveSession,\n   gatewayTicketFailure,\n   gatewayWsUrlIpcResult,\n+  hostLabelFromBaseUrl,\n+  localProfileEntry,\n   modeIsRemoteLike,\n   normalizeRemoteBaseUrl,\n+  normalizeSshConfig,\n   normAuthMode,\n   pathWithGlobalRemoteProfile,\n+  profileHasRemoteConnection,\n   profileRemoteOverride,\n+  profileSshOverride,\n   resolveAuthMode,\n   resolveTestWsUrl,\n+  savedProfileSsh,\n   tokenPreview\n } from './connection-config'\n import { adoptServedDashboardToken } from './dashboard-token'\n+import { loadOrCreateInstallationId, sshOwnershipId } from './desktop-installation'\n import {\n   buildPosixCleanupScript,\n   buildWindowsCleanupScript,","@@ -111,6 +119,7 @@ import { ensureMainWindow } from './main-window-lifecycle'\n import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'\n import { createKeepAwake } from './power-save'\n import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing'\n+import * as remoteLifecycle from './remote-lifecycle'\n import { RemoteLivenessTracker, RemoteRevalidationCoordinator, revalidateRemoteConnection } from './remote-liveness'\n import {\n   buildSessionWindowUrl,","@@ -121,6 +130,15 @@ import {\n   SESSION_WINDOW_MIN_WIDTH\n } from './session-windows'\n import { ensureSpawnHelperExecutable } from './spawn-helper-perms'\n+import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator'\n+import { collectSshConfigHosts, parseSshGOutput } from './ssh-config'\n+import {\n+  buildInteractiveSshArgs,\n+  createSshProbeConnection,\n+  pickLocalPort,\n+  redactSecrets,\n+  SshConnection\n+} from './ssh-connection'\n import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width'\n import { resolveBehindCount, shouldCountCommits } from './update-count'\n import { readLiveUpdateMarker, writeUpdateMarker } from './update-marker'","@@ -151,6 +169,12 @@ import {\n   getVenvSitePackagesEntries,\n   resolveVenvHermesCommand\n } from './windows-hermes-path'\n+import {\n+  buildWindowsInteractiveCommand,\n+  connectWindowsRemote,\n+  detectRemotePlatform,\n+  helper\n+} from './windows-remote-lifecycle'\n import {\n   alreadyHasNoSandbox,\n   buildNoSandboxRelaunchArgs,","@@ -507,6 +531,7 @@ const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstr\n const BOOTSTRAP_MARKER_SCHEMA_VERSION = 1\n \n const DESKTOP_CONNECTION_CONFIG_PATH = path.join(app.getPath('userData'), 'connection.json')\n+const DESKTOP_INSTALLATION_PATH = path.join(app.getPath('userData'), 'desktop-installation.json')\n const DESKTOP_UPDATE_CONFIG_PATH = path.join(app.getPath('userData'), 'updates.json')\n const DESKTOP_WINDOW_STATE_PATH = path.join(app.getPath('userData'), 'window-state.json')\n // active-profile.json records which Hermes profile the desktop launches its","@@ -4590,18 +4615,36 @@ function closePreviewWatchers() {\n   }\n }\n \n-async function waitForHermes(baseUrl, token) {\n+async function waitForHermes(baseUrl, token, signal?) {\n   const deadline = Date.now() + 45_000\n   let lastError = null\n \n   while (Date.now() < deadline) {\n+    if (signal?.aborted) {\n+      const error: any = new Error('SSH bootstrap was superseded by newer connection settings.')\n+      error.kind = 'superseded'\n+      throw error\n+    }\n+\n     try {\n       await fetchJson(`${baseUrl}/api/status`, token)\n \n       return\n     } catch (error) {\n       lastError = error\n-      await new Promise(resolve => setTimeout(resolve, 500))\n+      await new Promise((resolve, reject) => {\n+        const timer = setTimeout(resolve, 500)\n+        signal?.addEventListener(\n+          'abort',\n+          () => {\n+            clearTimeout(timer)\n+            const aborted: any = new Error('SSH bootstrap was superseded by newer connection settings.')\n+            aborted.kind = 'superseded'\n+            reject(aborted)\n+          },\n+          { once: true }\n+        )\n+      })\n     }\n   }\n ","@@ -4936,7 +4979,9 @@ function setAndPersistZoomLevel(window, zoomLevel) {\n   // downgrade or JSON read failure still finds a sane value).\n   window.webContents\n     .executeJavaScript(\n-      `try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`\n+      `try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {\n+      void 0\n+    }`\n     )\n     .catch(error => rememberLog(`[zoom] persist failed: ${error?.message || error}`))\n }","@@ -6042,16 +6087,39 @@ function sanitizeConnectionProfiles(raw: Record<string, any>) {\n       continue\n     }\n \n+    if (entry.mode === 'ssh') {\n+      const ssh = normalizeSshConfig(entry)\n+\n+      if (ssh) {\n+        if (entry.token && typeof entry.token === 'object') {\n+          ssh.token = [redacted:secret-assignment]\n+        }\n+\n+        out[name] = ssh\n+      }\n+\n+      continue\n+    }\n+\n     const cleaned: {\n       mode: 'remote' | 'local' | 'cloud'\n       url?: string\n       authMode?: string\n       token?: object\n       org?: string\n+      savedSsh?: object\n     } = {\n       mode: modeIsRemoteLike(entry.mode) ? entry.mode : 'local'\n     }\n \n+    if (cleaned.mode === 'local') {\n+      const savedSsh = normalizeSshConfig(entry.savedSsh)\n+\n+      if (savedSsh) {\n+        cleaned.savedSsh = savedSsh\n+      }\n+    }\n+\n     const url = String(entry.url || '').trim()\n \n     if (url) {","@@ -6109,7 +6177,7 @@ function readDesktopConnectionConfig() {\n       // backward compatibility with configs written before OAuth support.\n       remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'\n       config = {\n-        mode: modeIsRemoteLike(parsed.mode) ? parsed.mode : 'local',\n+        mode: parsed.mode === 'ssh' ? 'ssh' : modeIsRemoteLike(parsed.mode) ? parsed.mode : 'local',\n         remote,\n         // Per-profile remote overrides: each profile may point at its own\n         // backend (local spawn or its own remote URL). Preserved verbatim so","@@ -6176,15 +6244,15 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon\n   const block = key ? scoped || {} : config.remote || {}\n \n   const envOverride = key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)\n+  const savedMode = key ? scoped?.mode : config.mode\n+  const ssh = savedMode === 'ssh' ? normalizeSshConfig(block) : null\n+\n+  const savedSsh = savedMode === 'local' ? (key ? savedProfileSsh(config, key) : normalizeSshConfig(block)) : null\n \n   const remoteToken = [redacted:secret-assignment]\n   const authMode = normAuthMode(block.authMode)\n   const remoteUrl = envOverride ? String(process.env.HERMES_DESKTOP_REMOTE_URL || '') : String(block.url || '')\n-  // The env override forces a plain remote connection. Otherwise reflect the\n-  // saved mode, preserving 'cloud' (a Hermes Cloud connection — Q6) so the UI\n-  // reopens into the cloud picker; any non-remote-like value collapses to local.\n-  const savedMode = key ? scoped?.mode : config.mode\n-  const mode = envOverride ? 'remote' : modeIsRemoteLike(savedMode) ? savedMode : 'local'\n+  const mode = envOverride ? 'remote' : savedMode === 'ssh' ? 'ssh' : modeIsRemoteLike(savedMode) ? savedMode : 'local'\n \n   let remoteOauthConnected = false\n ","@@ -6212,6 +6280,11 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon\n     cloudOrg: mode === 'cloud' ? String(block.org || '') : '',\n     remoteTokenPreview: [redacted:secret-assignment]\n     remoteTokenSet: [redacted:secret-assignment]\n+    sshHost: (ssh || savedSsh)?.host || '',\n+    sshUser: (ssh || savedSsh)?.user || '',\n+    sshPort: (ssh || savedSsh)?.port || null,\n+    sshKeyPath: (ssh || savedSsh)?.keyPath || '',\n+    sshRemoteHermesPath: (ssh || savedSsh)?.remoteHermesPath || '',\n     // The env override only forces the global/primary connection; a per-profile\n     // scope is never overridden by HERMES_DESKTOP_REMOTE_URL.\n     envOverride","@@ -6250,7 +6323,7 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo\n   // 'cloud' and 'remote' both persist a remote-shaped block; 'cloud' is\n   // remembered as its own provenance (Q6) and resolves to remote downstream.\n   // Anything else collapses to local.\n-  const mode = modeIsRemoteLike(input.mode) ? input.mode : 'local'\n+  const mode = input.mode === 'ssh' ? 'ssh' : modeIsRemoteLike(input.mode) ? input.mode : 'local'\n   const remoteLike = modeIsRemoteLike(mode)\n \n   // The block being edited: a per-profile entry or the global remote block.","@@ -6263,7 +6336,8 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo\n   // block. (remote↔local toggles still preserve a real remote URL as before.)\n   const existingMode = key ? existing.profiles?.[key]?.mode : existing.mode\n   const leavingCloud = existingMode === 'cloud' && mode !== 'cloud'\n-  const existingBlock = leavingCloud ? {} : rawExistingBlock\n+  const leavingSsh = rawExistingBlock.mode === 'ssh' && mode !== 'ssh' && mode !== 'local'\n+  const existingBlock = leavingCloud || leavingSsh ? {} : rawExistingBlock\n   const remoteUrl = String(input.remoteUrl ?? existingBlock.url ?? '').trim()\n   // authMode: explicit input wins; otherwise inherit the saved value, default 'token'.\n   const authMode = resolveAuthMode(input.remoteAuthMode, existingBlock.authMode)","@@ -6279,6 +6353,22 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo\n       : { encoding: 'plain', value: incomingToken }\n     : existingBlock.token\n \n+  if (mode === 'ssh') {\n+    const sshBlock = buildSshBlock(input, savedProfileSsh(existing, key) || rawExistingBlock)\n+\n+    if (key) {\n+      const profiles = { ...(existing.profiles || {}), [key]: sshBlock }\n+\n+      return {\n+        mode: existing.mode === 'ssh' || modeIsRemoteLike(existing.mode) ? existing.mode : 'local',\n+        remote: existing.remote || {},\n+        profiles\n+      }\n+    }\n+\n+    return { mode: 'ssh', remote: sshBlock, profiles: existing.profiles || {} }\n+  }\n+\n   if (key) {\n     // Per-profile scope: a remote/cloud entry pins this profile to its own\n     // backend; a local entry clears the override so the profile inherits the","@@ -6288,11 +6378,17 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo\n     if (remoteLike) {\n       profiles[key] = { mode, ...buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg) }\n     } else {\n-      delete profiles[key]\n+      const localEntry = localProfileEntry(rawExistingBlock)\n+\n+      if (localEntry) {\n+        profiles[key] = localEntry\n+      } else {\n+        delete profiles[key]\n+      }\n     }\n \n     return {\n-      mode: modeIsRemoteLike(existing.mode) ? existing.mode : 'local',\n+      mode: existing.mode === 'ssh' || modeIsRemoteLike(existing.mode) ? existing.mode : 'local',\n       remote: existing.remote || {},\n       profiles\n     }","@@ -6300,19 +6396,62 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo\n \n   const nextRemote = remoteLike\n     ? buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg)\n-    : { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken }\n+    : existingMode === 'ssh'\n+      ? rawExistingBlock\n+      : { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken }\n \n   // Preserve per-profile overrides when saving the global connection.\n   return { mode, remote: nextRemote, profiles: existing.profiles || {} }\n }\n \n+// Build an SSH connection block from a save payload, preserving an\n+// already-adopted dashboard token from the existing block (the token is minted\n+// + reconciled at bootstrap, never user-entered). `mode: 'ssh'` is stamped so\n+// normalizeSshConfig/profileSshOverride recognize it.\n+function buildSshBlock(input: any, existingBlock: any = {}) {\n+  // `??` (not `||`) so an explicit '' (user CLEARED the field) wins over the\n+  // saved value; only a truly absent (undefined) field inherits.\n+  const merged = normalizeSshConfig({\n+    mode: 'ssh',\n+    host: input.sshHost ?? existingBlock.host,\n+    user: input.sshUser ?? existingBlock.user,\n+    port: input.sshPort ?? existingBlock.port,\n+    keyPath: input.sshKeyPath ?? existingBlock.keyPath,\n+    remoteHermesPath: input.sshRemoteHermesPath ?? existingBlock.remoteHermesPath\n+  })\n+\n+  if (!merged) {\n+    throw new Error('SSH host is required.')\n+  }\n+\n+  // Carry forward an already-adopted dashboard token unless the host changed\n+  // (a different host invalidates the old dashboard's token).\n+  if (existingBlock.token && existingBlock.host === merged.host) {\n+    merged.token = [redacted:secret-assignment]\n+  }\n+\n+  return merged\n+}\n+\n // Build a remote backend connection descriptor from an already-resolved remote\n // config. Handles both auth models (OAuth ws-ticket vs static session token)\n // and is shared by the per-profile, env, and global resolution paths. `token`\n // is the DECRYPTED static token (or null in OAuth mode). `source` is a label\n // for diagnostics ('profile' | 'env' | 'settings').\n-async function buildRemoteConnection(rawUrl, authMode, token, source) {\n+async function buildRemoteConnection(\n+  rawUrl,\n+  authMode,\n+  token,\n+  source,\n+  remoteHost?,\n+  remoteKind = 'url',\n+  remoteIdentity?\n+) {\n   const baseUrl = normalizeRemoteBaseUrl(rawUrl)\n+  // For token/oauth remotes the meaningful host is the real backend URL; for\n+  // SSH remotes the caller passes the entered/resolved host explicitly (the\n+  // baseUrl is a 127.0.0.1 tunnel and would be useless in the pill).\n+  const host = remoteHost || hostLabelFromBaseUrl(baseUrl)\n \n   if (authMode === 'oauth') {\n     // OAuth gateway: auth comes from the session cookies in the OAuth","@@ -6350,6 +6489,9 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) {\n       mode: 'remote',\n       source,\n       authMode: 'oauth',\n+      remoteHost: host || undefined,\n+      remoteIdentity,\n+      remoteKind,\n       // No static token in OAuth mode; REST is cookie-authed via the partition.\n       token: null,\n       wsUrl: buildGatewayWsUrlWithTicket(baseUrl, ticket)","@@ -6368,11 +6510,288 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) {\n     mode: 'remote',\n     source,\n     authMode: 'token',\n+    remoteHost: host || undefined,\n+    remoteIdentity,\n+    remoteKind,\n     token,\n     wsUrl: buildGatewayWsUrl(baseUrl, token)\n   }\n }\n \n+const sshConnections = new Map<string, any>()\n+const desktopInstallationId = loadOrCreateInstallationId(DESKTOP_INSTALLATION_PATH)\n+\n+const sshBootstrapCoordinator = createBootstrapCoordinator()\n+\n+let sshQuitTeardownDone = false\n+\n+function sshScopeKey(profile) {\n+  return connectionScopeKey(profile) || ''\n+}\n+\n+function sshOwnershipKey(profile) {\n+  return sshOwnershipId(desktopInstallationId, sshScopeKey(profile))\n+}\n+\n+function sshRememberLog(chunk) {\n+  rememberLog(redactSecrets(String(chunk == null ? '' : chunk)))\n+}\n+\n+async function sshProbeReuseProof(baseUrl, token, spawnNonce) {\n+  try {\n+    const proof: any = await fetchJson(`${baseUrl}/api/ssh/ownership`, token)\n+\n+    return proof?.ok === true && proof.sshOwnerNonce === spawnNonce && proof.protocolVersion === 1\n+      ? 'authenticated-ok'\n+      : 'authenticated-stale'\n+  } catch (error: any) {\n+    if (/^(401|403|404):/.test(String(error?.message || ''))) {\n+      return 'authenticated-stale'\n+    }\n+\n+    throw error\n+  }\n+}\n+\n+async function teardownSshConnection(profile) {\n+  const scope = sshScopeKey(profile)\n+  const state = sshConnections.get(scope)\n+\n+  if (!state) {\n+    return\n+  }\n+\n+  sshConnections.delete(scope)\n+\n+  for (const [id, info] of [...terminalSessions.entries()]) {\n+    if (info.sshScope === scope) {\n+      disposeTerminalSession(id)\n+    }\n+  }\n+\n+  try {\n+    if (state.localPort && state.remotePort) {\n+      await state.ssh.cancelForward(state.localPort, state.remotePort)\n+    }\n+  } catch {\n+    // best effort\n+  }\n+\n+  try {\n+    await state.ssh.close()\n+  } catch {\n+    // best effort\n+  }\n+}\n+\n+// CRITICAL: this must mirror resolveRemoteBackend's precedence, not just return\n+// any cached SSH state. A per-profile token/OAuth override wins over a global\n+// SSH connection — so if the active profile resolves to a NON-SSH backend, the\n+// terminal must NOT fall through to a global SSH host.\n+function activeSshTerminalTarget() {\n+  const profile = primaryProfileKey()\n+  const config = readDesktopConnectionConfig()\n+\n+  if (profileSshOverride(config, profile)) {\n+    const scope = sshScopeKey(profile)\n+    const state = sshConnections.get(scope)\n+\n+    return state && state.ssh ? { ssh: state.ssh, scope } : 'pending'\n+  }\n+\n+  if (profileRemoteOverride(config, profile)) {\n+    return null\n+  }\n+\n+  if (process.env.HERMES_DESKTOP_REMOTE_URL) {\n+    return null\n+  }\n+\n+  if (config.mode === 'ssh') {\n+    const state = sshConnections.get('')\n+\n+    return state && state.ssh ? { ssh: state.ssh, scope: '' } : 'pending'\n+  }\n+\n+  return null\n+}\n+\n+function effectiveSshConfigFingerprint(sshConfig) {\n+  const ssh =\n+    process.platform === 'win32'\n+      ? path.join(process.env.SystemRoot || 'C:\\\\Windows', 'System32', 'OpenSSH', 'ssh.exe')\n+      : 'ssh'\n+\n+  const args = ['-G']\n+\n+  if (sshConfig.port) {\n+    args.push('-p', String(sshConfig.port))\n+  }\n+\n+  if (sshConfig.keyPath) {\n+    args.push('-i', sshConfig.keyPath)\n+  }\n+\n+  args.push('--', sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host)\n+  const output = execFileSync(ssh, args, { encoding: 'utf8', timeout: 10_000, windowsHide: true })\n+\n+  return crypto.createHash('sha256').update(output).digest('hex')\n+}\n+\n+async function bootstrapSshConnection(profile, sshConfig, reuseToken, source) {\n+  const scope = sshScopeKey(profile)\n+  const effectiveConfigFingerprint = effectiveSshConfigFingerprint(sshConfig)\n+  const resolvedConfig = { ...sshConfig, effectiveConfigFingerprint }\n+  const fingerprint = sshConfigFingerprint(scope, resolvedConfig)\n+\n+  return sshBootstrapCoordinator.start(scope, fingerprint, lease =>\n+    bootstrapSshConnectionInner(profile, resolvedConfig, reuseToken, source, fingerprint, lease)\n+  )\n+}\n+\n+async function bootstrapSshConnectionInner(profile, sshConfig, reuseToken, source, fingerprint, lease) {\n+  const scope = sshScopeKey(profile)\n+  const hostLabel = sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host\n+  const existing = sshConnections.get(scope)\n+\n+  if (existing && existing.fingerprint !== fingerprint) {\n+    await teardownSshConnection(profile)\n+  }\n+\n+  let ssh = sshConnections.get(scope)?.ssh\n+\n+  if (ssh && !(await ssh.isAlive())) {\n+    try {\n+      await ssh.close()\n+    } catch {\n+      void 0\n+    }\n+\n+    ssh = null\n+    sshConnections.delete(scope)\n+  }\n+\n+  const created = !ssh\n+\n+  let removeForceCleanup = () => {}\n+\n+  if (created) {\n+    ssh = new SshConnection(\n+      { host: sshConfig.host, user: sshConfig.user, port: sshConfig.port, keyPath: sshConfig.keyPath },\n+      {\n+        rememberLog: sshRememberLog,\n+        ownershipId: sshOwnershipKey(profile),\n+        scope,\n+        effectiveConfigFingerprint: sshConfig.effectiveConfigFingerprint\n+      }\n+    )\n+    removeForceCleanup = lease.onForceCleanup(() => ssh.close())\n+    await ssh.open()\n+  }\n+\n+  let result\n+\n+  try {\n+    const platform = await detectRemotePlatform(ssh, sshConfig.remoteHermesPath || '')\n+    const lifecycle = platform.os === 'Windows' ? connectWindowsRemote : remoteLifecycle.connect\n+    result = await lifecycle({\n+      ssh,\n+      profile: connectionScopeKey(profile) || '',\n+      remoteHermesPath: sshConfig.remoteHermesPath || '',\n+      ownershipId: sshOwnershipKey(profile),\n+      reuseToken: reuseToken || '',\n+      forward: (localPort, remotePort) => ssh.forward(localPort, remotePort),\n+      cancelForward: (localPort, remotePort) => ssh.cancelForward(localPort, remotePort),\n+      pickLocalPort,\n+      waitForHermes: (baseUrl, token) => waitForHermes(baseUrl, token, lease.signal),\n+      probeReuseProof: sshProbeReuseProof,\n+      adoptServedToken: [redacted:secret-assignment]\n+      rememberLog: sshRememberLog,\n+      signal: lease.signal\n+    })\n+  } catch (error: any) {\n+    if (created) {\n+      try {\n+        await ssh.close()\n+      } catch {\n+        void 0\n+      }\n+    }\n+\n+    const err = new Error(error.message) as any\n+    err.sshError = error.kind || 'unknown'\n+    err.isSshBootstrap = true\n+    throw err\n+  }\n+\n+  try {\n+    lease.assertCurrent()\n+  } catch (error) {\n+    try {\n+      await ssh.cancelForward(result.localPort, result.remotePort)\n+      await ssh.close()\n+    } catch {\n+      void 0\n+    }\n+\n+    throw error\n+  }\n+\n+  persistSshConnectionToken(profile, source, result.token)\n+\n+  removeForceCleanup()\n+  sshConnections.set(scope, {\n+    ssh,\n+    fingerprint,\n+    localPort: result.localPort,\n+    remotePort: result.remotePort,\n+    pid: result.pid,\n+    host: sshConfig.host,\n+    hostLabel,\n+    hermesVersion: result.hermesVersion || '',\n+    remotePlatform: result.platform?.os || '',\n+    reused: result.reused\n+  })\n+\n+  sshRememberLog(\n+    `[ssh] connection ${result.reused ? 'REUSED' : 'spawned'} dashboard: ` +\n+      `${result.hermesVersion || 'hermes (version unknown)'} at ${result.hermesPath || '?'}`\n+  )\n+\n+  const connection = await buildRemoteConnection(\n+    result.baseUrl,\n+    'token',\n+    result.token,\n+    source,\n+    hostLabel,\n+    'ssh',\n+    result.ownershipId\n+  )\n+\n+  return { ...connection, remoteHermesVersion: result.hermesVersion || '' }\n+}\n+\n+function persistSshConnectionToken(profile, source, token) {\n+  try {\n+    const config = readDesktopConnectionConfig()\n+    const encrypted = encryptDesktopSecret(token)\n+\n+    if (source === 'profile') {\n+      const key = connectionScopeKey(profile)\n+\n+      if (key && config.profiles?.[key]?.mode === 'ssh') {\n+        config.profiles[key].token = encrypted\n+        writeDesktopConnectionConfig(config)\n+      }\n+    } else if (config.mode === 'ssh' && config.remote) {\n+      config.remote.token = encrypted\n+      writeDesktopConnectionConfig(config)\n+    }\n+  } catch (error: any) {\n+    sshRememberLog(`[ssh] could not persist served token: [redacted:secret-assignment]\n+  }\n+}\n+\n // Resolve the remote backend for a given profile, or null when that profile\n // should run a LOCAL backend. Precedence:\n //   1. explicit per-profile remote override (connection.json `profiles[name]`)","@@ -6386,12 +6805,27 @@ async function resolveRemoteBackend(profile) {\n   // 1. Per-profile override — \"a profile with its own remote host\". Wins even\n   //    over the env override so an explicitly-configured profile always\n   //    reaches its intended backend.\n+  const sshOverride = profileSshOverride(config, profile)\n+\n+  if (sshOverride) {\n+    const reuseToken = [redacted:secret-assignment]\n+\n+    return bootstrapSshConnection(profile, sshOverride, reuseToken, 'profile')\n+  }\n+\n   const override = profileRemoteOverride(config, profile)\n \n   if (override) {\n     const token = [redacted:secret-assignment] === 'oauth' ? null : decryptDesktopSecret(override.token)\n \n-    return buildRemoteConnection(override.url, override.authMode, token, 'profile')\n+    return buildRemoteConnection(\n+      override.url,\n+      override.authMode,\n+      token,\n+      'profile',\n+      undefined,\n+      config.profiles?.[connectionScopeKey(profile)]?.mode === 'cloud' ? 'cloud' : 'url'\n+    )\n   }\n \n   // 2. Env override (global, token-auth only).","@@ -6409,7 +6843,20 @@ async function resolveRemoteBackend(profile) {\n     return buildRemoteConnection(rawEnvUrl, 'token', rawEnvToken, 'env')\n   }\n \n-  // 3. Global remote (or cloud — cloud resolves to a remote backend, Q6).\n+  // 3. Global remote.\n+  if (config.mode === 'ssh') {\n+    const ssh = normalizeSshConfig({ mode: 'ssh', ...(config.remote || {}) })\n+\n+    if (!ssh) {\n+      throw new Error('SSH remote mode is selected but no host is configured.')\n+    }\n+\n+    const reuseToken = [redacted:secret-assignment]\n+\n+    return bootstrapSshConnection(null, ssh, reuseToken, 'settings')\n+  }\n+\n+  // Cloud resolves through the existing URL/OAuth path.\n   if (!modeIsRemoteLike(config.mode)) {\n     return null\n   }","@@ -6417,7 +6864,14 @@ async function resolveRemoteBackend(profile) {\n   const authMode = normAuthMode(config.remote?.authMode)\n   const token = authMode === 'oauth' ? null : decryptDesktopSecret(config.remote?.token)\n \n-  return buildRemoteConnection(config.remote?.url, authMode, token, 'settings')\n+  return buildRemoteConnection(\n+    config.remote?.url,\n+    authMode,\n+    token,\n+    'settings',\n+    undefined,\n+    config.mode === 'cloud' ? 'cloud' : 'url'\n+  )\n }\n \n // 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) {\n // not the local-disk fast path. These three helpers drive that (see\n // interceptSessionReadForRemote).\n function profileHasRemoteOverride(profile) {\n-  return Boolean(profileRemoteOverride(readDesktopConnectionConfig(), profile))\n+  return profileHasRemoteConnection(readDesktopConnectionConfig(), profile)\n }\n \n function configuredRemoteProfileNames() {\n   const config = readDesktopConnectionConfig()\n \n-  return Object.keys(config.profiles || {}).filter(name => profileRemoteOverride(config, name))\n+  return Object.keys(config.profiles || {}).filter(name => profileHasRemoteConnection(config, name))\n }\n \n // True when the app is in app-global remote mode (Settings → \"All profiles\" →","@@ -6443,7 +6897,9 @@ function globalRemoteActive() {\n     return true\n   }\n \n-  return modeIsRemoteLike(readDesktopConnectionConfig().mode)\n+  const mode = readDesktopConnectionConfig().mode\n+\n+  return modeIsRemoteLike(mode) || mode === 'ssh'\n }\n \n // True when the PRIMARY profile's backend resolves to a remote/cloud host —","@@ -6538,6 +6994,90 @@ async function probeRemoteAuthMode(rawUrl) {\n }\n \n async function testDesktopConnectionConfig(input: any = {}) {\n+  if (input.mode === 'ssh') {\n+    const sshConfig = normalizeSshConfig({\n+      mode: 'ssh',\n+      host: input.sshHost,\n+      user: input.sshUser,\n+      port: input.sshPort,\n+      keyPath: input.sshKeyPath,\n+      remoteHermesPath: input.sshRemoteHermesPath\n+    })\n+\n+    if (!sshConfig) {\n+      return { reachable: false, sshError: 'unreachable', error: 'SSH host is required.' }\n+    }\n+\n+    const ssh = createSshProbeConnection(\n+      { host: sshConfig.host, user: sshConfig.user, port: sshConfig.port, keyPath: sshConfig.keyPath },\n+      { rememberLog: sshRememberLog }\n+    )\n+\n+    try {\n+      // One bounded retry on TIMEOUT only: a cold Windows backend's first\n+      // PowerShell exec can exceed the budget (observed live), and a timeout is\n+      // indeterminate — unlike auth/host-key/unreachable, which are verdicts.\n+      let attempt = 0\n+\n+      for (;;) {\n+        try {\n+          await ssh.open()\n+          const platform: any = await detectRemotePlatform(ssh, sshConfig.remoteHermesPath || '')\n+          let hermesPath\n+          let hermesVersion\n+          let supported\n+\n+          if (platform.os === 'Windows') {\n+            const runtime = platform\n+            hermesPath = runtime.hermesPath\n+            const inspection = await helper(ssh, runtime, 'inspect', [runtime.hermesPath])\n+            hermesVersion = inspection.version\n+            supported = inspection.supported\n+          } else {\n+            hermesPath = await remoteLifecycle.locateHermes(ssh, sshConfig.remoteHermesPath || '')\n+            hermesVersion = await remoteLifecycle.probeHermesVersion(ssh, hermesPath)\n+            supported = await remoteLifecycle.remoteSupportsSshOwnership(ssh, hermesPath)\n+          }\n+\n+          if (!supported) {\n+            return {\n+              reachable: false,\n+              sshError: 'update-required',\n+              error: 'Update Hermes on the remote host before connecting with Desktop SSH.'\n+            }\n+          }\n+\n+          return {\n+            reachable: true,\n+            sshError: null,\n+            error: null,\n+            remotePlatform: `${platform.os}/${platform.arch}`,\n+            remoteHermesPath: hermesPath,\n+            remoteHermesVersion: hermesVersion,\n+            host: sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host\n+          }\n+        } catch (error: any) {\n+          if (error?.kind === 'timeout' && attempt === 0) {\n+            attempt += 1\n+            sshRememberLog('[ssh] test probe timed out once; retrying')\n+\n+            continue\n+          }\n+\n+          throw error\n+        }\n+      }\n+    } catch (error: any) {\n+      return { reachable: false, sshError: error.kind || 'unknown', error: error.message }\n+    } finally {\n+      try {\n+        await ssh.close()\n+      } catch {\n+        void 0\n+      }\n+    }\n+  }\n+\n   const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false })\n   const key = connectionScopeKey(input.profile)\n   // The block under test: a per-profile entry or the global remote. Coerce has","@@ -7063,6 +7603,9 @@ async function startHermes() {\n         mode: 'remote',\n         source: remote.source,\n         authMode: remote.authMode || 'token',\n+        remoteHost: remote.remoteHost,\n+        remoteKind: remote.remoteKind,\n+        remoteHermesVersion: remote.remoteHermesVersion,\n         token: [redacted:secret-assignment]\n         wsUrl: remote.wsUrl,\n         logs: hermesLog.slice(-80),","@@ -7864,8 +8407,8 @@ ipcMain.handle('hermes:connection:revalidate', async () => {\n   // Main and every session pop-out have their own renderer reconnect loop but\n   // share this primary connection. Coalesce simultaneous requests so one outage\n   // produces one failure observation rather than exhausting the whole streak.\n-  return remoteRevalidation.run(connectionPromise, () =>\n-    revalidateRemoteConnection({\n+  return remoteRevalidation.run(connectionPromise, async () => {\n+    const result = await revalidateRemoteConnection({\n       connectionPromise,\n       currentConnectionPromise: () => backendConnectionState.getPromise(),\n       log: rememberLog,","@@ -7873,7 +8416,22 @@ ipcMain.handle('hermes:connection:revalidate', async () => {\n       resetConnection: resetHermesConnection,\n       tracker: remoteLiveness\n     })\n-  )\n+\n+    // A rebuilt SSH connection must also tear down its tunnel/master before the\n+    // renderer re-dials (which only happens after this handler resolves), so the\n+    // fresh bootstrap can't reattach to a dying transport.\n+    if (result.rebuilt) {\n+      const conn = await connectionPromise.catch(() => null)\n+\n+      if (conn?.remoteKind === 'ssh') {\n+        const profile = primaryProfileKey()\n+        await sshBootstrapCoordinator.cancelAndWait(sshScopeKey(profile))\n+        await teardownSshConnection(profile)\n+      }\n+    }\n+\n+    return result\n+  })\n })\n ipcMain.handle('hermes:backend:touch', async (_event, profile) => {\n   touchPoolBackend(profile)","@@ -8103,6 +8661,50 @@ ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState())\n ipcMain.handle('hermes:connection-config:get', async (_event, profile) =>\n   sanitizeDesktopConnectionConfig(readDesktopConnectionConfig(), profile)\n )\n+ipcMain.handle('hermes:ssh-config:hosts', async () => ({ hosts: collectSshConfigHosts() }))\n+ipcMain.handle('hermes:ssh-config:resolve', async (_event, host) => {\n+  const value = String(host || '').trim()\n+\n+  if (!value) {\n+    throw new Error('SSH host is required.')\n+  }\n+\n+  const ssh =\n+    process.platform === 'win32'\n+      ? path.join(process.env.SystemRoot || 'C:\\\\Windows', 'System32', 'OpenSSH', 'ssh.exe')\n+      : 'ssh'\n+\n+  return new Promise((resolve, reject) => {\n+    const child = spawn(ssh, ['-G', '--', value], hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'pipe'] }))\n+    let stdout = ''\n+    let stderr = ''\n+\n+    const timer = setTimeout(() => {\n+      child.kill()\n+      reject(new Error('SSH config resolution timed out.'))\n+    }, 10_000)\n+\n+    child.stdout.on('data', chunk => {\n+      stdout += String(chunk)\n+    })\n+    child.stderr.on('data', chunk => {\n+      stderr += String(chunk)\n+    })\n+    child.once('error', error => {\n+      clearTimeout(timer)\n+      reject(error)\n+    })\n+    child.once('close', code => {\n+      clearTimeout(timer)\n+\n+      if (code !== 0) {\n+        reject(new Error(stderr.trim() || 'Could not resolve SSH host.'))\n+      } else {\n+        resolve(parseSshGOutput(stdout))\n+      }\n+    })\n+  })\n+})\n ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload))\n ipcMain.handle('hermes:connection-config:probe', async (_event, rawUrl) => probeRemoteAuthMode(rawUrl))\n ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => {","@@ -8164,19 +8766,17 @@ ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => {\n   writeDesktopConnectionConfig(config)\n \n   const key = connectionScopeKey(payload?.profile)\n-\n-  if (key && key !== primaryProfileKey()) {\n-    // Editing a NON-primary profile's connection: don't disturb the window's\n-    // primary backend. Drop the profile's pooled backend so the next switch\n-    // re-resolves against the new remote/local target.\n-    stopPoolBackend(key)\n-  } else {\n-    // Global / primary connection: soft re-home. Tear down the window backend\n-    // without resetting boot UI or reloading — the shell stays, the renderer\n-    // wipes session lists (skeletons) and re-dials on hermes:connection:applied.\n-    await teardownPrimaryBackendAndWait({ soft: true })\n-    sendConnectionApplied()\n-  }\n+  const scope = key || ''\n+\n+  await applyConnectionChange({\n+    cancelAndWait: value => sshBootstrapCoordinator.cancelAndWait(value),\n+    isPrimary: !key || key === primaryProfileKey(),\n+    scope,\n+    sendApplied: sendConnectionApplied,\n+    stopPool: stopPoolBackend,\n+    teardownPrimary: () => teardownPrimaryBackendAndWait({ soft: true }),\n+    teardownSsh: value => teardownSshConnection(value || null)\n+  })\n \n   return sanitizeDesktopConnectionConfig(config, payload?.profile)\n })","@@ -9254,15 +9854,30 @@ ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {\n   const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80)\n   const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24)\n \n-  const ptyProcess = nodePty.spawn(command, args, {\n-    cols,\n-    cwd,\n-    env: terminalShellEnv(),\n-    name: 'xterm-256color',\n-    rows\n-  })\n+  const sshTarget = await resolveTerminalConnection(activeSshTerminalTarget, () => ensureBackend(primaryProfileKey()))\n+  const remote = Boolean(sshTarget)\n+  const remoteState = remote ? sshConnections.get(sshTarget.scope) : null\n+\n+  const remoteCommand =\n+    remoteState?.remotePlatform === 'Windows'\n+      ? buildWindowsInteractiveCommand(String(payload?.cwd || '').trim())\n+      : undefined\n+\n+  const ptyProcess = remote\n+    ? nodePty.spawn(\n+        process.platform === 'win32'\n+          ? path.join(process.env.SystemRoot || 'C:\\\\Windows', 'System32', 'OpenSSH', 'ssh.exe')\n+          : 'ssh',\n+        buildInteractiveSshArgs(sshTarget.ssh, String(payload?.cwd || '').trim(), undefined, remoteCommand),\n+        { cols, cwd: app.getPath('home'), env: terminalShellEnv(), name: 'xterm-256color', rows }\n+      )\n+    : nodePty.spawn(command, args, { cols, cwd, env: terminalShellEnv(), name: 'xterm-256color', rows })\n \n-  terminalSessions.set(id, { pty: ptyProcess, webContentsId: event.sender.id })\n+  terminalSessions.set(id, {\n+    pty: ptyProcess,\n+    webContentsId: event.sender.id,\n+    ...(remote ? { sshScope: sshTarget.scope, remoteCwd: String(payload?.cwd || '') } : {})\n+  })\n \n   const send = (suffix, payload) => {\n     if (event.sender.isDestroyed()) {","@@ -9279,7 +9894,7 @@ ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {\n   })\n   event.sender.once('destroyed', () => disposeTerminalSession(id))\n \n-  return { cwd, id, shell: name }\n+  return { cwd: remote ? null : cwd, id, shell: remote ? 'ssh' : name }\n })\n \n ipcMain.handle('hermes:terminal:write', (_event, id, data) => {","@@ -9315,7 +9930,7 @@ ipcMain.handle('hermes:terminal:cwd', async (_event, id) => {\n     return null\n   }\n \n-  return readProcessCwd(sessionInfo.pty.pid)\n+  return sessionInfo.sshScope !== undefined ? null : readProcessCwd(sessionInfo.pty.pid)\n })\n \n ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || '')))","@@ -9808,7 +10423,24 @@ function configureSpellChecker() {\n   }\n }\n \n-app.on('before-quit', () => {\n+app.on('before-quit', event => {\n+  if ((sshConnections.size > 0 || sshBootstrapCoordinator.promises().length > 0) && !sshQuitTeardownDone) {\n+    event.preventDefault()\n+    sshBootstrapCoordinator.cancelAll()\n+    const scopes = [...sshConnections.keys()]\n+\n+    const pending = Promise.allSettled([\n+      ...scopes.map(scope => teardownSshConnection(scope || null)),\n+      ...sshBootstrapCoordinator.promises()\n+    ])\n+\n+    void Promise.race([pending, new Promise(resolve => setTimeout(resolve, 4_000))]).then(async () => {\n+      await sshBootstrapCoordinator.forceCleanupAll()\n+      sshQuitTeardownDone = true\n+      app.quit()\n+    })\n+  }\n+\n   // Clean quit mid-boot should not trip next-launch --no-sandbox (#38216).\n   // FATAL GPU aborts skip before-quit, leaving the `booting` marker in place.\n   // Keyed on sticky (not active): a manual --no-sandbox run still records a"]},{"file":"apps/desktop/electron/preload.ts","hunks":["@@ -41,6 +41,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', {\n   saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),\n   applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload),\n   testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload),\n+  sshConfigHosts: () => ipcRenderer.invoke('hermes:ssh-config:hosts'),\n+  sshResolveHost: host => ipcRenderer.invoke('hermes:ssh-config:resolve', host),\n   probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl),\n   oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl),\n   oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl),"]},{"file":"apps/desktop/electron/remote-lifecycle.test.ts","hunks":["@@ -0,0 +1,1017 @@\n+import assert from 'node:assert/strict'\n+\n+import { test } from 'vitest'\n+\n+import {\n+  buildSpawnCommand,\n+  cleanupStale,\n+  connect,\n+  expandRemotePath,\n+  fingerprintToken,\n+  isForwardBindCollision,\n+  locateHermes,\n+  LOCKFILE_SCHEMA_VERSION,\n+  lockfilePath,\n+  openForward,\n+  ownershipDirectory,\n+  pidIsOurDashboard,\n+  probeRemotePlatform,\n+  PROTOCOL_VERSION,\n+  readLockfile,\n+  READY_RE,\n+  remotePidAlive,\n+  remoteSupportsSshOwnership,\n+  scrapeReadyPort,\n+  spawnLogPath,\n+  spawnRemoteDashboard,\n+  validateRemotePath,\n+  writeLockfile\n+} from './remote-lifecycle'\n+\n+const OWNERSHIP_ID = '0123456789abcdef0123456789abcdef'\n+const SPAWN_NONCE = '0123456789abcdef'\n+\n+function ownedLock(over: any = {}) {\n+  return {\n+    schemaVersion: LOCKFILE_SCHEMA_VERSION,\n+    protocolVersion: PROTOCOL_VERSION,\n+    ownershipId: OWNERSHIP_ID,\n+    spawnNonce: SPAWN_NONCE,\n+    pid: 333,\n+    port: 40000,\n+    profile: '',\n+    hermesPath: '~/.local/bin/hermes',\n+    hermesHome: '~/.hermes',\n+    logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE),\n+    tokenFingerprint: [redacted:secret-assignment]'stored-token'),\n+    startedAt: '2026-07-14T00:00:00.000Z',\n+    ...over\n+  }\n+}\n+\n+// A fake SshConnection whose exec() is matched against an ordered list of\n+// [regex|fn, response|fn] rules. First match wins; unmatched commands return ''.\n+function fakeSsh(rules: any[] = []) {\n+  const calls: string[] = []\n+\n+  return {\n+    calls,\n+    async exec(cmd) {\n+      calls.push(cmd)\n+\n+      for (const [matcher, resp] of rules) {\n+        const hit = typeof matcher === 'function' ? matcher(cmd) : matcher.test(cmd)\n+\n+        if (hit) {\n+          const out = typeof resp === 'function' ? resp(cmd) : resp\n+\n+          if (out instanceof Error) {\n+            throw out\n+          }\n+\n+          return out\n+        }\n+      }\n+\n+      return ''\n+    }\n+  }\n+}\n+\n+test('locateHermes prefers the explicit profile path when executable', async () => {\n+  const ssh = fakeSsh([[/\\[ -x .*\\/opt\\/hermes/, 'OK']])\n+  assert.equal(await locateHermes(ssh, '/opt/hermes'), '/opt/hermes')\n+})\n+\n+test('locateHermes throws (no silent fallback) when an EXPLICIT path is not executable', async () => {\n+  // command -v WOULD find a different install, but an explicit path must not\n+  // silently fall back to it — that is the \"connected to the wrong hermes\" bug.\n+  const ssh = fakeSsh([\n+    [/command -v hermes/, '/home/u/.local/bin/hermes\\n'],\n+    [/\\[ -x .*\\.local\\/bin\\/hermes/, 'OK']\n+  ])\n+\n+  await assert.rejects(\n+    () => locateHermes(ssh, '/bad/path/hermes'),\n+    (err: any) => {\n+      assert.equal(err.kind, 'hermes-not-found')\n+      assert.match(err.message, /\\/bad\\/path\\/hermes/)\n+\n+      return true\n+    }\n+  )\n+})\n+\n+test('locateHermes falls back to the login-shell command -v probe', async () => {\n+  const ssh = fakeSsh([\n+    [/command -v hermes/, '/home/u/.local/bin/hermes\\n'],\n+    [/\\[ -x .*\\.local\\/bin\\/hermes/, 'OK']\n+  ])\n+\n+  assert.equal(await locateHermes(ssh, ''), '/home/u/.local/bin/hermes')\n+})\n+\n+test('locateHermes canonicalizes an installer wrapper to its executable target', async () => {\n+  const ssh = fakeSsh([\n+    [/command -v hermes/, '/home/u/.local/bin/hermes\\n'],\n+    [/\\[ -x .*\\.local\\/bin\\/hermes/, 'OK'],\n+    [/python3 -c/, '/home/u/.hermes/hermes-agent/venv/bin/hermes\\n']\n+  ])\n+\n+  assert.equal(await locateHermes(ssh, ''), '/home/u/.hermes/hermes-agent/venv/bin/hermes')\n+})\n+\n+test('locateHermes falls back to ~/.local/bin/hermes when the login-shell probe misses', async () => {\n+  // ~/.local/bin is the non-root installer's command location (scripts/install.sh).\n+  const ssh = fakeSsh([\n+    [/command -v hermes/, ''],\n+    [/\\[ -x .*\\.local\\/bin\\/hermes/, 'OK']\n+  ])\n+\n+  assert.equal(await locateHermes(ssh, ''), '~/.local/bin/hermes')\n+})\n+\n+test('locateHermes tries the conventional venv path last', async () => {\n+  const ssh = fakeSsh([[/\\[ -x .*venv\\/bin\\/hermes/, 'OK']])\n+  assert.equal(await locateHermes(ssh, ''), '~/.hermes/hermes-agent/venv/bin/hermes')\n+})\n+\n+test('locateHermes throws a hermes-not-found error with an install hint', async () => {\n+  const ssh = fakeSsh([]) // nothing is executable\n+  await assert.rejects(\n+    () => locateHermes(ssh, ''),\n+    (err: any) => {\n+      assert.equal(err.kind, 'hermes-not-found')\n+      assert.match(err.message, /install/i)\n+\n+      return true\n+    }\n+  )\n+})\n+\n+test('locateHermes uses a login shell for the command -v probe', async () => {\n+  const ssh = fakeSsh([\n+    [/command -v hermes/, '/x/hermes'],\n+    [/\\[ -x/, 'OK']\n+  ])\n+\n+  await locateHermes(ssh, '')\n+  assert.ok(\n+    ssh.calls.some(c => /bash -lc/.test(c)),\n+    'must probe in a login shell (PATH pitfall)'\n+  )\n+})\n+\n+test('probeRemotePlatform accepts Linux and macOS', async () => {\n+  assert.deepEqual(await probeRemotePlatform(fakeSsh([[/uname/, 'Linux\\nx86_64']])), {\n+    os: 'Linux',\n+    arch: 'x86_64'\n+  })\n+  assert.deepEqual(await probeRemotePlatform(fakeSsh([[/uname/, 'Darwin\\narm64']])), {\n+    os: 'Darwin',\n+    arch: 'arm64'\n+  })\n+})\n+\n+test('probeRemotePlatform rejects unsupported remote platforms', async () => {\n+  await assert.rejects(\n+    () => probeRemotePlatform(fakeSsh([[/uname/, 'MINGW64_NT\\nx86_64']])),\n+    (err: any) => {\n+      assert.equal(err.kind, 'unsupported-platform')\n+\n+      return true\n+    }\n+  )\n+})\n+\n+test('ownership paths are isolated by ownership ID and spawn nonce', () => {\n+  assert.equal(ownershipDirectory(OWNERSHIP_ID), `~/.hermes/desktop-ssh/${OWNERSHIP_ID}`)\n+  assert.equal(lockfilePath(OWNERSHIP_ID), `~/.hermes/desktop-ssh/${OWNERSHIP_ID}/backend.lock.json`)\n+  assert.equal(spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), `~/.hermes/desktop-ssh/${OWNERSHIP_ID}/${SPAWN_NONCE}.log`)\n+})\n+\n+test('readLockfile returns null for missing, empty, malformed, or wrong-schema', async () => {\n+  assert.equal(await readLockfile(fakeSsh([[/cat/, '']]), OWNERSHIP_ID), null)\n+  assert.equal(await readLockfile(fakeSsh([[/cat/, 'not json']]), OWNERSHIP_ID), null)\n+  assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify({ schemaVersion: 999 })]]), OWNERSHIP_ID), null)\n+  const good = ownedLock({ pid: 1, port: 2 })\n+  assert.deepEqual(await readLockfile(fakeSsh([[/cat/, JSON.stringify(good)]]), OWNERSHIP_ID), good)\n+})\n+\n+test('writeLockfile mkdir -ps and stamps the schema version', async () => {\n+  const ssh = fakeSsh([])\n+  await writeLockfile(ssh, OWNERSHIP_ID, ownedLock({ pid: 7, port: 9 }))\n+  const cmd = ssh.calls.join('\\n')\n+  assert.match(cmd, /mkdir -p/)\n+  assert.match(cmd, new RegExp(`\"schemaVersion\":${LOCKFILE_SCHEMA_VERSION}`))\n+})\n+\n+test('remotePidAlive maps kill -0 ALIVE/DEAD', async () => {\n+  assert.equal(await remotePidAlive(fakeSsh([[/kill -0/, 'ALIVE']]), 123), true)\n+  assert.equal(await remotePidAlive(fakeSsh([[/kill -0/, 'DEAD']]), 123), false)\n+  assert.equal(await remotePidAlive(fakeSsh([]), null), false)\n+})\n+\n+test('metadata and process proof transport failures remain indeterminate', async () => {\n+  const failure = new Error('connection reset')\n+  await assert.rejects(\n+    () => readLockfile(fakeSsh([[/cat/, failure]]), OWNERSHIP_ID),\n+    (error: any) => error.kind === 'transient-transport-error'\n+  )\n+  await assert.rejects(\n+    () => remotePidAlive(fakeSsh([[/kill -0/, failure]]), 123),\n+    (error: any) => error.kind === 'transient-transport-error'\n+  )\n+  await assert.rejects(\n+    () => pidIsOurDashboard(fakeSsh([[/print\\(\"OWNED\"/, failure]]), 5, SPAWN_NONCE, '/x/hermes'),\n+    (error: any) => error.kind === 'transient-transport-error'\n+  )\n+})\n+\n+test('pidIsOurDashboard requires the exact serve ownership nonce', async () => {\n+  const ours = `/x/hermes serve --isolated --ssh-owner-nonce ${SPAWN_NONCE}`\n+  assert.equal(await pidIsOurDashboard(fakeSsh([[/print\\(\"OWNED\"/, 'OWNED\\n']]), 5, SPAWN_NONCE, '/x/hermes'), true)\n+  assert.equal(\n+    await pidIsOurDashboard(\n+      fakeSsh([[/print\\(\"OWNED\"/, command => (command.includes('fedcba9876543210') ? 'FOREIGN\\n' : 'OWNED\\n')]]),\n+      5,\n+      'fedcba9876543210',\n+      '/x/hermes'\n+    ),\n+    false\n+  )\n+  assert.equal(await pidIsOurDashboard(fakeSsh([[/print\\(\"OWNED\"/, 'FOREIGN\\n']]), 5, SPAWN_NONCE, '/x/hermes'), false)\n+})\n+\n+test('cleanupStale kills ONLY a provably-ours pid, always drops the lockfile', async () => {\n+  const notOurs = fakeSsh([[/print\\(\"OWNED\"/, 'FOREIGN\\n']])\n+  await cleanupStale(notOurs, OWNERSHIP_ID, {\n+    pid: 5,\n+    spawnNonce: SPAWN_NONCE,\n+    hermesPath: '/x/hermes',\n+    logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE)\n+  })\n+  assert.ok(!notOurs.calls.some(c => /kill 5\\b/.test(c)), 'must not kill a pid that is not our dashboard')\n+  assert.ok(notOurs.calls.some(c => /rm -f/.test(c)))\n+\n+  const ours = fakeSsh([[/print\\(\"OWNED\"/, 'OWNED\\n']])\n+  await cleanupStale(ours, OWNERSHIP_ID, {\n+    pid: 9,\n+    spawnNonce: SPAWN_NONCE,\n+    hermesPath: '/x/hermes',\n+    logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE)\n+  })\n+  assert.ok(ours.calls.some(c => /kill 9\\b/.test(c)))\n+  assert.ok(ours.calls.some(c => /rm -f/.test(c)))\n+})\n+\n+test('buildSpawnCommand is headless serve, detached, token not in argv', () => {\n+  const cmd = buildSpawnCommand('/x/hermes', 'work', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) })\n+  assert.match(cmd, /serve --isolated/)\n+  assert.match(cmd, /--host 127\\.0\\.0\\.1 --port 0/)\n+  assert.doesNotMatch(cmd, /--skip-build|--no-open/)\n+  assert.doesNotMatch(cmd, /\\bdashboard\\b/)\n+  assert.match(cmd, /--profile/)\n+  assert.match(cmd, /work/)\n+  assert.match(cmd, /setsid/)\n+  assert.match(cmd, /<\\/dev\\/null/)\n+  assert.match(cmd, /echo \\$!/)\n+  assert.ok(!cmd.includes('tok_secret_value'), 'token must not appear in spawn command')\n+  assert.ok(!cmd.includes('HERMES_DASHBOARD_SESSION_TOKEN'), 'token env var must not appear')\n+})\n+\n+test('buildSpawnCommand always uses serve (legacy dashboard path removed)', () => {\n+  const cmd = buildSpawnCommand('/x/hermes', 'work', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) })\n+  assert.match(cmd, /serve --isolated/)\n+  assert.match(cmd, /--host 127\\.0\\.0\\.1 --port 0/)\n+  assert.doesNotMatch(cmd, /dashboard/)\n+  assert.doesNotMatch(cmd, /--skip-build/)\n+  assert.match(cmd, /setsid/)\n+})\n+\n+test('spawnRemoteDashboard returns exact ownership artifacts', async () => {\n+  const ssh = fakeSsh([\n+    [/grep -q ssh-session-token-file/, 'YES\\n'],\n+    [/python3 -c/, ''],\n+    [/printf '%s\\\\n'/, ''],\n+    [/setsid|nohup/, '4242\\n']\n+  ])\n+\n+  const { pid, spawnNonce, logPath } = await spawnRemoteDashboard(ssh, {\n+    hermesPath: '/x/hermes',\n+    profile: '',\n+    token: 'tk',\n+    ownershipId: OWNERSHIP_ID\n+  })\n+\n+  assert.equal(pid, 4242)\n+  assert.match(spawnNonce, /^[0-9a-f]{16}$/)\n+  assert.equal(logPath, spawnLogPath(OWNERSHIP_ID, spawnNonce))\n+})\n+\n+test('spawnRemoteDashboard always spawns serve (legacy dashboard path removed)', async () => {\n+  const ssh = fakeSsh([\n+    [/grep -q ssh-session-token-file/, 'YES\\n'],\n+    [/python3 -c/, ''],\n+    [/printf '%s\\\\n'/, ''],\n+    [/setsid|nohup/, '4242\\n']\n+  ])\n+\n+  await spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tk', ownershipId: OWNERSHIP_ID })\n+  const spawn = ssh.calls.find(c => /setsid|nohup/.test(c))\n+  assert.match(spawn, /serve --isolated/)\n+  assert.doesNotMatch(spawn, /\\bdashboard\\b/)\n+})\n+\n+test('READY_RE accepts both serve and dashboard sentinels', () => {\n+  assert.equal(READY_RE.exec('HERMES_BACKEND_READY port=4321')?.[1], '4321')\n+  assert.equal(READY_RE.exec('HERMES_DASHBOARD_READY port=8765')?.[1], '8765')\n+})\n+\n+test('spawnRemoteDashboard rejects when no pid is returned', async () => {\n+  const ssh = fakeSsh([\n+    [/grep -q ssh-session-token-file/, 'YES\\n'],\n+    [/python3 -c/, ''],\n+    [/printf '%s\\\\n'/, ''],\n+    [/setsid|nohup/, 'not-a-pid']\n+  ])\n+\n+  await assert.rejects(\n+    () => spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 't', ownershipId: OWNERSHIP_ID }),\n+    (err: any) => {\n+      assert.equal(err.kind, 'spawn-failed')\n+\n+      return true\n+    }\n+  )\n+})\n+\n+test('scrapeReadyPort reads only the named spawn log', async () => {\n+  const logPath = spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE)\n+  const ssh = fakeSsh([[/cat/, 'some noise\\nHERMES_DASHBOARD_READY port=51234\\n']])\n+  const port = await scrapeReadyPort(ssh, logPath, { timeoutMs: 1000 })\n+  assert.equal(port, 51234)\n+  assert.ok(ssh.calls.every(call => !call.includes('desktop-ssh.log')))\n+})\n+\n+test('scrapeReadyPort times out and reports a dead spawn', async () => {\n+  // never emits a READY line\n+  const ssh = fakeSsh([[/cat .*\\.log/, 'still starting...']])\n+  await assert.rejects(\n+    () => scrapeReadyPort(ssh, spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), { timeoutMs: 60 }),\n+    (err: any) => {\n+      assert.equal(err.kind, 'ready-timeout')\n+\n+      return true\n+    }\n+  )\n+  // dead process before announcement → spawn-failed\n+  await assert.rejects(\n+    () =>\n+      scrapeReadyPort(fakeSsh([[/cat/, '']]), spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), {\n+        timeoutMs: 1000,\n+        isAlive: async () => false\n+      }),\n+    (err: any) => {\n+      assert.equal(err.kind, 'spawn-failed')\n+\n+      return true\n+    }\n+  )\n+})\n+\n+function connectDeps(ssh, over: any = {}) {\n+  return {\n+    ssh,\n+    ownershipId: OWNERSHIP_ID,\n+    profile: '',\n+    forward: async () => {},\n+    cancelForward: async () => {},\n+    pickLocalPort: async () => 50001,\n+    waitForHermes: async () => {},\n+    probeReuseProof: async () => 'authenticated-ok',\n+    adoptServedToken: async (_baseUrl, spawn) => spawn || 'served-token',\n+    rememberLog: () => {},\n+    readyTimeoutMs: 2000,\n+    ...over\n+  }\n+}\n+\n+test('connect() spawns fresh when there is no lockfile, adopts the served token', async () => {\n+  const ssh = fakeSsh([\n+    [/uname/, 'Linux\\nx86_64'],\n+    [/\\[ -x/, 'OK'],\n+    [/cat .*lock\\.json/, ''], // no lockfile\n+    [/grep -q ssh-session-token-file/, 'YES\\n'],\n+    [/python3 -c/, ''], // token file write\n+    [/printf '%s\\\\n'/, ''],\n+    [/setsid/, '777\\n'],\n+    [/kill -0 777/, 'ALIVE'],\n+    [/cat .*\\.log/, 'HERMES_DASHBOARD_READY port=51999\\n']\n+  ])\n+\n+  const result = await connect(connectDeps(ssh, { adoptServedToken: async () => 'the-served-token' }))\n+  assert.equal(result.reused, false)\n+  assert.equal(result.remotePort, 51999)\n+  assert.equal(result.localPort, 50001)\n+  assert.equal(result.pid, 777)\n+  assert.equal(result.token, 'the-served-token')\n+  assert.equal(result.baseUrl, 'http://127.0.0.1:50001')\n+  assert.equal(result.tokenFingerprint, fingerprintToken('the-served-token'))\n+})\n+\n+test('connect() reuses a healthy dashboard when fingerprint + probe pass', async () => {\n+  const reuseToken = '[redacted:secret-assignment]'\n+  const lock = ownedLock({ tokenFingerprint: [redacted:secret-assignment] })\n+\n+  const ssh = fakeSsh([\n+    [/uname/, 'Linux\\nx86_64'],\n+    [/\\[ -x/, 'OK'],\n+    [/cat .*lock\\.json/, JSON.stringify(lock)],\n+    [/kill -0/, 'ALIVE'],\n+    [/print\\(\"OWNED\"/, 'OWNED\\n']\n+  ])\n+\n+  const result = await connect(connectDeps(ssh, { reuseToken, adoptServedToken: async (_b, t) => t }))\n+  assert.equal(result.reused, true)\n+  assert.equal(result.pid, 333)\n+  assert.equal(result.remotePort, 40000)\n+  // never spawned\n+  assert.ok(!ssh.calls.some(c => /setsid/.test(c)), 'reuse path must not spawn a new dashboard')\n+})\n+\n+test('connect() respawns when the lockfile hermesPath differs from the resolved path', async () => {\n+  const reuseToken = '[redacted:secret-assignment]'\n+  const lock = ownedLock({ hermesPath: '/old/stale/hermes', tokenFingerprint: [redacted:secret-assignment] })\n+\n+  const ssh = fakeSsh([\n+    [/uname/, 'Linux\\nx86_64'],\n+    [/\\[ -x/, 'OK'],\n+    [/cat .*lock\\.json/, JSON.stringify(lock)],\n+    [/kill -0/, 'ALIVE'],\n+    [/print\\(\"OWNED\"/, 'FOREIGN\\n'],\n+    [/--version/, 'Hermes Agent v0.18.2\\n'],\n+    [/grep -q ssh-session-token-file/, 'YES\\n'],\n+    [/python3 -c/, ''],\n+    [/setsid/, '890\\n'],\n+    [/cat .*\\.log/, 'HERMES_DASHBOARD_READY port=52050\\n']\n+  ])\n+\n+  const result = await connect(\n+    connectDeps(ssh, { reuseToken, remoteHermesPath: '/new/hermes', adoptServedToken: async () => 'fresh' })\n+  )\n+\n+  assert.equal(result.reused, false, 'must respawn, not reuse the old-path dashboard')\n+  assert.ok(\n+    ssh.calls.some(c => /setsid/.test(c)),\n+    'a fresh dashboard must be spawned'\n+  )\n+})\n+\n+test('connect() respawns when the lockfile protocolVersion is incompatible', async () => {\n+  const reuseToken = '[redacted:secret-assignment]'\n+\n+  const lock = {\n+    schemaVersion: LOCKFILE_SCHEMA_VERSION,\n+    protocolVersion: PROTOCOL_VERSION + 99,\n+    pid: 333,\n+    port: 40000,\n+    tokenFingerprint: [redacted:secret-assignment]\n+  }\n+\n+  const ssh = fakeSsh([\n+    [/uname/, 'Linux\\nx86_64'],\n+    [/\\[ -x/, 'OK'],\n+    [/cat .*lock\\.json/, JSON.stringify(lock)],\n+    [/kill -0 333/, 'ALIVE'],\n+    [/print\\(\"OWNED\"/, 'FOREIGN\\n'],\n+    [/grep -q ssh-session-token-file/, 'YES\\n'],\n+    [/python3 -c/, ''],\n+    [/setsid/, '901\\n'],\n+    [/kill -0 901/, 'ALIVE'],\n+    [/cat .*\\.log/, 'HERMES_DASHBOARD_READY port=44100\\n']\n+  ])\n+\n+  const result = await connect(connectDeps(ssh, { reuseToken, adoptServedToken: async () => 'fresh' }))\n+  assert.equal(result.reused, false, 'incompatible protocol must force a fresh spawn, not a reattach')\n+  assert.equal(result.pid, 901)\n+})\n+\n+test('connect() fresh spawn writes hermesHome + protocolVersion into the lockfile', async () => {\n+  const writes: string[] = []\n+\n+  const ssh = fakeSsh([\n+    [/uname/, 'Linux\\nx86_64'],\n+    [/\\[ -x/, 'OK'],\n+    [/cat .*lock\\.json/, ''], // no lockfile\n+    [/HERMES_HOME/, '/home/alice/.hermes\\n'],\n+    [/grep -q ssh-session-token-file/, 'YES\\n'],\n+    [/python3 -c/, ''],\n+    [/printf '%s\\\\n'/, ''],\n+    [/setsid/, '700\\n'],\n+    [/kill -0 700/, 'ALIVE'],\n+    [/cat .*\\.log/, 'HERMES_DASHBOARD_READY port=45500\\n'],\n+    [\n+      /printf '%s' '/,\n+      c => {\n+        writes.push(c)\n+\n+        return ''\n+      }\n+    ]\n+  ])\n+\n+  await connect(connectDeps(ssh, { adoptServedToken: async () => 'fresh' }))\n+  const lockWrite = writes.find(c => c.includes('schemaVersion')) || ''\n+  assert.match(lockWrite, new RegExp(`\"protocolVersion\":${PROTOCOL_VERSION}`))\n+  assert.match(lockWrite, /\"hermesHome\":\"\\/home\\/alice\\/\\.hermes\"/)\n+})\n+\n+test('connect() respawns when the lockfile pid is dead (killed dashboard)', async () => {\n+  const lock = ownedLock({ tokenFingerprint: [redacted:secret-assignment]'t') })\n+\n+  const ssh = fakeSsh([\n+    [/uname/, 'Linux\\nx86_64'],\n+    [/\\[ -x/, 'OK'],\n+    [/cat .*lock\\.json/, JSON.stringify(lock)],\n+    [/kill -0 333/, 'DEAD'],\n+    [/print\\(\"OWNED\"/, 'FOREIGN\\n'],\n+    [/grep -q ssh-session-token-file/, 'YES\\n'],\n+    [/python3 -c/, ''],\n+    [/setsid/, '888\\n'],\n+    [/kill -0 888/, 'ALIVE'],\n+    [/cat .*\\.log/, 'HERMES_DASHBOARD_READY port=42000\\n']\n+  ])\n+\n+  const result = await connect(connectDeps(ssh, { reuseToken: 't', adoptServedToken: async () => 'fresh' }))\n+  assert.equal(result.reused, false)\n+  assert.equal(result.pid, 888)\n+  assert.equal(result.remotePort, 42000)\n+  assert.ok(\n+    !ssh.calls.some(command => command.includes('pid=333') && command.includes('print(\"OWNED\"')),\n+    'a dead pid has no process identity to verify'\n+  )\n+})\n+\n+test('connect() respawns when the dashboard is wedged (alive pid, probe fails)', async () => {\n+  const reuseToken = '[redacted:secret-assignment]'\n+\n+  const lock = {\n+    schemaVersion: LOCKFILE_SCHEMA_VERSION,\n+    protocolVersion: PROTOCOL_VERSION,\n+    pid: 333,\n+    port: 40000,\n+    tokenFingerprint: [redacted:secret-assignment]\n+  }\n+\n+  const ssh = fakeSsh([\n+    [/uname/, 'Linux\\nx86_64'],\n+    [/\\[ -x/, 'OK'],\n+    [/cat .*lock\\.json/, JSON.stringify(lock)],\n+    [/kill -0/, 'ALIVE'],\n+    [/print\\(\"OWNED\"/, 'FOREIGN\\n'],\n+    [/grep -q ssh-session-token-file/, 'YES\\n'],\n+    [/python3 -c/, ''],\n+    [/setsid/, '999\\n'],\n+    [/kill -0 999/, 'ALIVE'],\n+    [/cat .*\\.log/, 'HERMES_DASHBOARD_READY port=43000\\n']\n+  ])\n+\n+  const result = await connect(\n+    connectDeps(ssh, {\n+      reuseToken,\n+      probeReuseProof: async () => 'authenticated-stale',\n+      adoptServedToken: async () => 'fresh'\n+    })\n+  )\n+\n+  assert.equal(result.reused, false)\n+  assert.equal(result.pid, 999)\n+  assert.equal(result.remotePort, 43000)\n+})\n+\n+test('connect() aborts on an unsupported remote platform before doing anything else', async () => {\n+  const ssh = fakeSsh([[/uname/, 'SunOS\\nsun4v']])\n+  await assert.rejects(\n+    () => connect(connectDeps(ssh)),\n+    (err: any) => {\n+      assert.equal(err.kind, 'unsupported-platform')\n+\n+      return true\n+    }\n+  )\n+  assert.ok(!ssh.calls.some(c => /setsid/.test(c)))\n+})\n+\n+test('openForward retries bind collisions only', async () => {\n+  const ports = [41001, 41002]\n+  const calls: number[] = []\n+\n+  const localPort = await openForward(\n+    {\n+      pickLocalPort: async () => ports.shift(),\n+      forward: async port => {\n+        calls.push(port)\n+\n+        if (calls.length === 1) {\n+          throw new Error('bind: Address already in use')\n+        }\n+      }\n+    },\n+    9119\n+  )\n+\n+  assert.equal(localPort, 41002)\n+  assert.deepEqual(calls, [41001, 41002])\n+  assert.equal(isForwardBindCollision(new Error('Permission denied')), false)\n+})\n+\n+test('connect() preserves an owned backend when a reuse transport throws', async () => {\n+  const reuseToken = '[redacted:secret-assignment]'\n+  const lock = ownedLock({ tokenFingerprint: [redacted:secret-assignment] })\n+\n+  const ssh = fakeSsh([\n+    [/uname/, 'Linux\\nx86_64'],\n+    [/\\[ -x/, 'OK'],\n+    [/cat .*lock\\.json/, JSON.stringify(lock)],\n+    [/kill -0/, 'ALIVE'],\n+    [/print\\(\"OWNED\"/, 'OWNED\\n']\n+  ])\n+\n+  await assert.rejects(\n+    () =>\n+      connect(\n+        connectDeps(ssh, {\n+          reuseToken,\n+          forward: async () => {\n+            throw new Error('network reset')\n+          }\n+        })\n+      ),\n+    /network reset/\n+  )\n+  assert.ok(!ssh.calls.some(cmd => /kill 333\\b/.test(cmd)))\n+})\n+\n+test('validateRemotePath accepts absolute POSIX paths', () => {\n+  assert.doesNotThrow(() => validateRemotePath('/usr/bin/hermes'))\n+  assert.doesNotThrow(() => validateRemotePath('/home/user/.hermes/hermes-agent/venv/bin/hermes'))\n+})\n+\n+test('validateRemotePath accepts ~/ prefix paths', () => {\n+  assert.doesNotThrow(() => validateRemotePath('~/bin/hermes'))\n+  assert.doesNotThrow(() => validateRemotePath('~/.hermes/logs/desktop-ssh.log'))\n+  assert.doesNotThrow(() => validateRemotePath('~'))\n+})\n+\n+test('validateRemotePath accepts paths with spaces and quotes', () => {\n+  assert.doesNotThrow(() => validateRemotePath('/home/user/my project/hermes'))\n+  assert.doesNotThrow(() => validateRemotePath(\"~/path with 'quotes'/file\"))\n+  assert.doesNotThrow(() => validateRemotePath('/path with \"double quotes\"/file'))\n+})\n+\n+test('validateRemotePath rejects relative paths', () => {\n+  assert.throws(() => validateRemotePath('hermes'), /absolute|relative/i)\n+  assert.throws(() => validateRemotePath('./bin/hermes'), /absolute|relative/i)\n+  assert.throws(() => validateRemotePath('../etc/passwd'), /absolute|relative/i)\n+})\n+\n+test('validateRemotePath rejects NUL and newline', () => {\n+  assert.throws(() => validateRemotePath('/usr/bin/hermes\\x00'), /unsafe/i)\n+  assert.throws(() => validateRemotePath('/usr/bin/hermes\\n'), /unsafe/i)\n+  assert.throws(() => validateRemotePath('/usr/bin/hermes\\r'), /unsafe/i)\n+})\n+\n+test('validateRemotePath preserves shell metacharacters as path data', () => {\n+  for (const p of ['/usr/$(whoami)/hermes', '/usr/`id`/hermes', '/usr/a;b|c&d<e>f']) {\n+    assert.doesNotThrow(() => validateRemotePath(p))\n+    assert.match(expandRemotePath(p), /^'/)\n+  }\n+})\n+\n+test('expandRemotePath expands ~/ to \"$HOME\"/', () => {\n+  const result = expandRemotePath('~/.hermes/logs/desktop-ssh.log')\n+  assert.match(result, /\\$HOME/)\n+  assert.ok(!result.includes('eval'), 'must not use eval')\n+  assert.ok(!result.includes('echo'), 'must not use echo for expansion')\n+})\n+\n+test('expandRemotePath returns quoted absolute paths unchanged', () => {\n+  const result = expandRemotePath('/usr/local/bin/hermes')\n+  assert.ok(result.includes('/usr/local/bin/hermes'))\n+  assert.ok(!result.includes('eval'))\n+})\n+\n+test('expandRemotePath preserves spaces as data', () => {\n+  const result = expandRemotePath('/home/user/my project/hermes')\n+  assert.ok(result.includes('my project'), 'spaces must be preserved, not split')\n+})\n+\n+test('buildSpawnCommand does not embed the token in the command string', () => {\n+  const cmd = buildSpawnCommand('/x/hermes', 'work', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) })\n+  assert.ok(!cmd.includes('super_secret_token_value'), 'token must not appear in the spawn command')\n+  assert.ok(!cmd.includes('HERMES_DASHBOARD_SESSION_TOKEN'), 'env var name must not appear')\n+})\n+\n+test('buildSpawnCommand includes --ssh-session-token-file when tokenFilePath is provided', () => {\n+  const cmd = buildSpawnCommand('/x/hermes', 'work', {\n+    tokenFilePath: [redacted:secret-assignment]\n+    logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE),\n+    spawnNonce: SPAWN_NONCE\n+  })\n+\n+  assert.match(cmd, /--ssh-session-token-file/)\n+  assert.match(cmd, /\\.hermes\\/desktop-ssh\\//)\n+})\n+\n+test('buildSpawnCommand always uses serve, never dashboard', () => {\n+  const cmd = buildSpawnCommand('/x/hermes', '', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) })\n+  assert.match(cmd, /serve --isolated/)\n+  assert.doesNotMatch(cmd, /\\bdashboard\\b/)\n+  assert.doesNotMatch(cmd, /--skip-build/)\n+  assert.doesNotMatch(cmd, /--no-open/)\n+})\n+\n+test('spawnRemoteDashboard removes a token file when upload reporting fails', async () => {\n+  const failure = new Error('channel closed')\n+\n+  const ssh = fakeSsh([\n+    [/grep -q ssh-session-token-file/, 'YES\\n'],\n+    [command => /python3 -c/.test(command) && !/rm -f/.test(command), failure],\n+    [/rm -f/, '']\n+  ])\n+\n+  await assert.rejects(\n+    () => spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tok', ownershipId: OWNERSHIP_ID }),\n+    /channel closed/\n+  )\n+  assert.ok(ssh.calls.some(command => /rm -f .*\\.token/.test(command)))\n+})\n+\n+test('spawnRemoteDashboard streams the token over stdin, not argv/env', async () => {\n+  const stdinCalls: string[] = []\n+  const calls: string[] = []\n+\n+  const ssh = {\n+    calls,\n+    async exec(cmd, opts?) {\n+      calls.push(cmd)\n+\n+      if (opts?.stdinData) {\n+        stdinCalls.push(opts.stdinData)\n+      }\n+\n+      if (/grep -q ssh-session-token-file/.test(cmd)) {\n+        return 'YES\\n'\n+      }\n+\n+      if (/python3 -c/.test(cmd)) {\n+        return ''\n+      }\n+\n+      if (/setsid|nohup/.test(cmd)) {\n+        return '4242\\n'\n+      }\n+\n+      if (/printf '%s\\\\n'/.test(cmd)) {\n+        return ''\n+      }\n+\n+      return ''\n+    }\n+  }\n+\n+  const { pid } = await spawnRemoteDashboard(ssh as any, {\n+    hermesPath: '/x/hermes',\n+    profile: '',\n+    token: '[redacted:secret-assignment]',\n+    ownershipId: OWNERSHIP_ID\n+  })\n+\n+  assert.equal(pid, 4242)\n+  assert.ok(stdinCalls.length > 0, 'token must be sent via stdin')\n+  assert.ok(\n+    stdinCalls.some(d => d === 'secret_token_val'),\n+    'stdin must contain the token'\n+  )\n+\n+  for (const cmd of calls) {\n+    assert.ok(!cmd.includes('secret_token_val'), `token leaked into command: ${cmd}`)\n+  }\n+})\n+\n+test('spawnRemoteDashboard upload uses exclusive-create and O_NOFOLLOW', async () => {\n+  const calls: string[] = []\n+\n+  const ssh = {\n+    calls,\n+    async exec(cmd, opts?) {\n+      calls.push(cmd)\n+\n+      if (/grep -q ssh-session-token-file/.test(cmd)) {\n+        return 'YES\\n'\n+      }\n+\n+      if (/python3 -c/.test(cmd)) {\n+        return ''\n+      }\n+\n+      if (/setsid|nohup/.test(cmd)) {\n+        return '4242\\n'\n+      }\n+\n+      if (/printf '%s\\\\n'/.test(cmd)) {\n+        return ''\n+      }\n+\n+      return ''\n+    }\n+  }\n+\n+  await spawnRemoteDashboard(ssh as any, {\n+    hermesPath: '/x/hermes',\n+    profile: '',\n+    token: 'tk',\n+    ownershipId: OWNERSHIP_ID\n+  })\n+  const uploadCmd = calls.find(c => /python3 -c/.test(c))\n+  assert.ok(uploadCmd, 'must use python3 -c for token upload')\n+  assert.match(uploadCmd, /O_EXCL/, 'upload must use O_EXCL to reject existing files')\n+  assert.match(uploadCmd, /O_NOFOLLOW/, 'upload must use O_NOFOLLOW to reject symlinks')\n+  assert.match(uploadCmd, /O_WRONLY/, 'upload must open write-only')\n+  assert.match(uploadCmd, /dir_fd=dd/, 'upload must create relative to the opened parent directory')\n+  assert.match(uploadCmd, /os\\.fstat\\(dd\\)/, 'upload must validate the opened parent directory')\n+  assert.ok(!uploadCmd.includes('tk'), 'token must not appear in the upload command')\n+})\n+\n+test('readLockfile rejects lock with non-integer pid', async () => {\n+  const lock = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 'not-a-number', port: 8080 }\n+  assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock)]]), OWNERSHIP_ID), null)\n+})\n+\n+test('readLockfile rejects lock with pid <= 0', async () => {\n+  const lock = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: -1, port: 8080 }\n+  assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock)]]), OWNERSHIP_ID), null)\n+})\n+\n+test('readLockfile rejects lock with port out of range', async () => {\n+  const lock = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 100, port: 99999 }\n+  assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock)]]), OWNERSHIP_ID), null)\n+  const lock2 = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 100, port: 0 }\n+  assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock2)]]), OWNERSHIP_ID), null)\n+})\n+\n+test('readLockfile accepts a complete owned lock', async () => {\n+  const lock = ownedLock({ pid: 42, port: 51234 })\n+  const result = await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock)]]), OWNERSHIP_ID)\n+  assert.deepEqual(result, lock)\n+})\n+\n+test('connect() reuse path does not write a token file', async () => {\n+  const reuseToken = '[redacted:secret-assignment]'\n+  const lock = ownedLock({ tokenFingerprint: [redacted:secret-assignment] })\n+\n+  const ssh = fakeSsh([\n+    [/uname/, 'Linux\\nx86_64'],\n+    [/\\[ -x/, 'OK'],\n+    [/cat .*lock\\.json/, JSON.stringify(lock)],\n+    [/kill -0/, 'ALIVE'],\n+    [/print\\(\"OWNED\"/, 'OWNED\\n']\n+  ])\n+\n+  const result = await connect(connectDeps(ssh, { reuseToken, adoptServedToken: async (_b, t) => t }))\n+  assert.equal(result.reused, true)\n+  assert.ok(!ssh.calls.some(c => /sys\\.stdin\\.buffer\\.read/.test(c)), 'reuse must not upload a token file')\n+})\n+\n+test('spawnRemoteDashboard fails with update-required when remote lacks --ssh-session-token-file', async () => {\n+  const ssh = fakeSsh([[/--ssh-session-token-file/, 'NO\\n']])\n+\n+  await assert.rejects(\n+    () => spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tk', ownershipId: OWNERSHIP_ID }),\n+    (err: any) => {\n+      assert.match(err.message, /update|upgrade/i)\n+      assert.equal(err.kind, 'update-required')\n+\n+      return true\n+    }\n+  )\n+})\n+\n+test('readLockfile rejects a log path outside the exact ownership and spawn path', async () => {\n+  const lock = ownedLock({ logPath: '~/.hermes/desktop-ssh/other.log' })\n+  const ssh = fakeSsh([[/cat .*lock\\.json/, JSON.stringify(lock)]])\n+  assert.equal(await readLockfile(ssh, OWNERSHIP_ID), null)\n+})\n+\n+test('cleanupStale never deletes a lock-supplied unexpected log path', async () => {\n+  const ssh = fakeSsh([[/print\\(\"OWNED\"/, 'OWNED\\n']])\n+  await cleanupStale(ssh, OWNERSHIP_ID, ownedLock({ logPath: '~/.hermes/unrelated.log' }))\n+  assert.ok(!ssh.calls.some(command => command.includes('unrelated.log')))\n+})\n+\n+test('pidIsOurDashboard requires an exact nonce option value', async () => {\n+  const prefix = `/x/hermes serve --isolated --ssh-owner-nonce ${SPAWN_NONCE}ff`\n+  const suffix = `/x/hermes serve --isolated --ssh-owner-nonce xx${SPAWN_NONCE}`\n+  assert.equal(await pidIsOurDashboard(fakeSsh([[/print\\(\"OWNED\"/, 'FOREIGN\\n']]), 5, SPAWN_NONCE, '/x/hermes'), false)\n+  assert.equal(await pidIsOurDashboard(fakeSsh([[/print\\(\"OWNED\"/, 'FOREIGN\\n']]), 5, SPAWN_NONCE, '/x/hermes'), false)\n+})\n+\n+test('connect removes the token file when a fresh backend fails after returning a pid', async () => {\n+  const ssh = fakeSsh([\n+    [/uname/, 'Linux\\nx86_64'],\n+    [/\\[ -x/, 'OK'],\n+    [/cat .*lock\\.json/, ''],\n+    [/grep -q ssh-session-token-file/, 'YES\\n'],\n+    [/python3 -c/, ''],\n+    [/setsid/, '999\\n'],\n+    [/kill -0 999/, 'DEAD']\n+  ])\n+\n+  await assert.rejects(() => connect(connectDeps(ssh)), /exited before announcing/i)\n+  assert.ok(ssh.calls.some(command => /rm -f .*\\.token/.test(command)))\n+})\n+\n+test('connect preserves an exact-owned backend when reuse proof transport fails', async () => {\n+  const reuseToken = '[redacted:secret-assignment]'\n+  const lock = ownedLock({ tokenFingerprint: [redacted:secret-assignment] })\n+\n+  const ssh = fakeSsh([\n+    [/uname/, 'Linux\\nx86_64'],\n+    [/\\[ -x/, 'OK'],\n+    [/cat .*lock\\.json/, JSON.stringify(lock)],\n+    [/kill -0/, 'ALIVE'],\n+    [/print\\(\"OWNED\"/, 'OWNED\\n']\n+  ])\n+\n+  await assert.rejects(\n+    () =>\n+      connect(\n+        connectDeps(ssh, {\n+          reuseToken,\n+          probeReuseProof: async () => {\n+            throw new Error('connection reset')\n+          }\n+        })\n+      ),\n+    (error: any) => error.kind === 'transient-transport-error'\n+  )\n+  assert.ok(!ssh.calls.some(command => /kill 333\\b/.test(command)))\n+  assert.ok(!ssh.calls.some(command => /rm -f .*backend\\.lock\\.json/.test(command)))\n+})\n+\n+test('connect replaces an exact-owned backend only after authenticated stale proof', async () => {\n+  const reuseToken = '[redacted:secret-assignment]'\n+  const lock = ownedLock({ tokenFingerprint: [redacted:secret-assignment] })\n+\n+  const ssh = fakeSsh([\n+    [/uname/, 'Linux\\nx86_64'],\n+    [/\\[ -x/, 'OK'],\n+    [/cat .*lock\\.json/, JSON.stringify(lock)],\n+    [/kill -0 333/, 'ALIVE'],\n+    [/print\\(\"OWNED\"/, 'OWNED\\n'],\n+    [/grep -q ssh-session-token-file/, 'YES\\n'],\n+    [/python3 -c/, ''],\n+    [/setsid/, '999\\n'],\n+    [/kill -0 999/, 'ALIVE'],\n+    [/cat .*\\.log/, 'HERMES_DASHBOARD_READY port=43000\\n']\n+  ])\n+\n+  const result = await connect(\n+    connectDeps(ssh, {\n+      reuseToken,\n+      probeReuseProof: async (_baseUrl, token, nonce) => {\n+        assert.equal(token, reuseToken)\n+        assert.equal(nonce, SPAWN_NONCE)\n+\n+        return 'authenticated-stale'\n+      },\n+      adoptServedToken: async () => 'fresh'\n+    })\n+  )\n+\n+  assert.equal(result.reused, false)\n+  assert.ok(ssh.calls.some(command => /kill 333\\b/.test(command)))\n+})\n+\n+test('remote SSH ownership capability requires both secure bootstrap flags', async () => {\n+  let helpProbe = ''\n+\n+  const supported = fakeSsh([\n+    [\n+      /serve --help/,\n+      command => {\n+        helpProbe = command\n+\n+        return 'YES\\n'\n+      }\n+    ]\n+  ])\n+\n+  assert.equal(await remoteSupportsSshOwnership(supported, '/x/hermes'), true)\n+  assert.match(helpProbe, /ssh-session-token-file/)\n+  assert.match(helpProbe, /ssh-owner-nonce/)\n+\n+  const unsupported = fakeSsh([[/serve --help/, 'NO\\n']])\n+  assert.equal(await remoteSupportsSshOwnership(unsupported, '/x/hermes'), false)\n+})"]},{"file":"apps/desktop/electron/remote-lifecycle.ts","hunks":["@@ -0,0 +1,906 @@\n+/**\n+ * remote-lifecycle.ts\n+ *\n+ * Pure, electron-free remote Hermes dashboard lifecycle over SSH for Desktop\n+ * SSH remote mode. Composes an SshConnection (injected) with HTTP probes\n+ * through the established tunnel (injected fetch) and the served-token adoption\n+ * step (injected). Knows how to:\n+ *\n+ *   - locate the Hermes install on the remote (login-shell probe),\n+ *   - gate the remote platform to Linux/macOS via `uname`,\n+ *   - reuse an existing desktop-dedicated dashboard via a lockfile + an\n+ *     AUTHENTICATED /api/status probe (pid liveness alone is insufficient),\n+ *   - spawn a fresh detached `--isolated --port 0` dashboard and scrape its\n+ *     `HERMES_DASHBOARD_READY port=<n>` readiness line,\n+ *   - adopt the token the dashboard actually serves (served-token adoption),\n+ *   - clean up a stale dashboard only when it is provably ours.\n+ *\n+ * No `import 'electron'` so it's unit-testable with `node --test`. main.ts wires\n+ * the real SshConnection, fetch, adoptServedDashboardToken, and waitForHermes in.\n+ *\n+ * The minted HERMES_DASHBOARD_SESSION_TOKEN is the SPAWN credential. After\n+ * readiness the caller runs served-token adoption against the tunneled baseUrl\n+ * and the SERVED token's fingerprint is what lands in the lockfile — so the\n+ * reuse probe checks the credential that actually authenticates /api/ws, not\n+ * the minted one (which the dashboard may regen).\n+ */\n+\n+import crypto from 'node:crypto'\n+\n+const LOCKFILE_SCHEMA_VERSION = 2\n+// Bumped when the desktop<->dashboard reuse contract changes in a way that makes\n+// an old running dashboard unsafe to reattach to (token handling, readiness/spawn\n+// args, served-token reconciliation). A mismatch forces a clean respawn.\n+const PROTOCOL_VERSION = 1\n+const READY_RE = /^HERMES_(?:BACKEND|DASHBOARD)_READY port=(\\d+)/m\n+const REMOTE_LOCK_DIR = '~/.hermes/desktop-ssh'\n+const SUPPORTED_REMOTE_OS = new Set(['Linux', 'Darwin'])\n+const DEFAULT_READY_TIMEOUT_MS = 45_000\n+const READY_POLL_INTERVAL_MS = 750\n+\n+function mintToken() {\n+  return crypto.randomBytes(32).toString('hex')\n+}\n+\n+// Fingerprint a token for the lockfile — never store the raw secret on the\n+// remote. SHA256, truncated.\n+function fingerprintToken(token) {\n+  return crypto\n+    .createHash('sha256')\n+    .update(String(token || ''))\n+    .digest('hex')\n+    .slice(0, 32)\n+}\n+\n+function validateOwnershipId(ownershipId) {\n+  const value = String(ownershipId || '')\n+\n+  if (!/^[0-9a-f]{32}$/.test(value)) {\n+    throw new Error('SSH ownership ID is invalid.')\n+  }\n+\n+  return value\n+}\n+\n+function validateSpawnNonce(spawnNonce) {\n+  const value = String(spawnNonce || '')\n+\n+  if (!/^[0-9a-f]{16}$/.test(value)) {\n+    throw new Error('SSH spawn nonce is invalid.')\n+  }\n+\n+  return value\n+}\n+\n+function ownershipDirectory(ownershipId) {\n+  return `${REMOTE_LOCK_DIR}/${validateOwnershipId(ownershipId)}`\n+}\n+\n+function lockfilePath(ownershipId) {\n+  return `${ownershipDirectory(ownershipId)}/backend.lock.json`\n+}\n+\n+function spawnLogPath(ownershipId, spawnNonce) {\n+  return `${ownershipDirectory(ownershipId)}/${validateSpawnNonce(spawnNonce)}.log`\n+}\n+\n+// shell-single-quote a value for safe interpolation into a remote command.\n+function shq(value) {\n+  return `'${String(value).replace(/'/g, `'\\\\''`)}'`\n+}\n+\n+function validateRemotePath(p) {\n+  const s = String(p || '')\n+\n+  if (!s) {\n+    throw new Error('Remote path must not be empty.')\n+  }\n+\n+  // eslint-disable-next-line no-control-regex -- deliberately reject NUL in remote paths\n+  if (/[\\x00\\n\\r]/.test(s)) {\n+    throw new Error('Unsafe remote path: contains NUL or newline.')\n+  }\n+\n+  if (s === '~' || s.startsWith('~/') || s.startsWith('/')) {\n+    return\n+  }\n+\n+  throw new Error(`Remote path must be absolute or start with ~/: \"${s}\"`)\n+}\n+\n+function expandRemotePath(p) {\n+  validateRemotePath(p)\n+\n+  if (p === '~') {\n+    return '\"$HOME\"'\n+  }\n+\n+  if (p.startsWith('~/')) {\n+    return '\"$HOME\"' + shq(p.slice(1))\n+  }\n+\n+  return shq(p)\n+}\n+\n+// Resolve the remote hermes executable. An EXPLICIT path is honored strictly\n+// (throws a path-naming error if not executable — never silently falls back to a\n+// different install). A BLANK path auto-detects: login-shell `command -v` (a\n+// non-login `ssh host cmd` PATH misses user installs), then known install paths.\n+async function locateHermes(ssh, remoteHermesPath) {\n+  const resolveLauncher = async (candidate: string) => {\n+    const script =\n+      'import os,shlex,sys\\n' +\n+      `p=os.path.expanduser(${shq(candidate)})\\n` +\n+      'out=p\\n' +\n+      'try:\\n' +\n+      ' data=open(p,\"r\",encoding=\"utf-8\",errors=\"ignore\").read(4096)\\n' +\n+      ' for line in data.splitlines():\\n' +\n+      '  words=shlex.split(line)\\n' +\n+      '  if len(words)>1 and words[0]==\"exec\":\\n' +\n+      '   target=os.path.expanduser(words[1])\\n' +\n+      '   if os.path.isabs(target) and os.access(target,os.X_OK):out=target\\n' +\n+      '   break\\n' +\n+      'except (OSError,ValueError):pass\\n' +\n+      'print(out)'\n+\n+    const resolved = (await ssh.exec(`python3 -c ${shq(script)}`)).trim()\n+\n+    return resolved || candidate\n+  }\n+\n+  const isExecutable = async (candidate: string) => {\n+    try {\n+      validateRemotePath(candidate)\n+      const ok = (await ssh.exec(`[ -x ${expandRemotePath(candidate)} ] && echo OK || true`)).trim()\n+\n+      return ok === 'OK'\n+    } catch {\n+      return false\n+    }\n+  }\n+\n+  if (remoteHermesPath) {\n+    if (await isExecutable(remoteHermesPath)) {\n+      return resolveLauncher(remoteHermesPath)\n+    }\n+\n+    const err: any = new Error(\n+      `The Hermes path you set is not an executable on the remote host: \"${remoteHermesPath}\". ` +\n+        'Check the path (it must be the full path to the `hermes` binary on the remote, e.g. ' +\n+        '~/hermes-agent/.venv/bin/hermes), or clear it to auto-detect.'\n+    )\n+\n+    err.kind = 'hermes-not-found'\n+    throw err\n+  }\n+\n+  const candidates: string[] = []\n+\n+  try {\n+    const found = (await ssh.exec(`bash -lc ${shq('command -v hermes')}`)).trim()\n+\n+    if (found) {\n+      candidates.push(found.split('\\n').pop().trim())\n+    }\n+  } catch {\n+    // ignore\n+  }\n+\n+  // Fallback candidates when the login-shell probe misses: the installer's\n+  // command locations (scripts/install.sh) — per-user, root/FHS, legacy venv.\n+  candidates.push('~/.local/bin/hermes')\n+  candidates.push('/usr/local/bin/hermes')\n+  candidates.push('~/.hermes/hermes-agent/venv/bin/hermes')\n+\n+  for (const candidate of candidates) {\n+    if (!candidate) {\n+      continue\n+    }\n+\n+    if (await isExecutable(candidate)) {\n+      return resolveLauncher(candidate)\n+    }\n+  }\n+\n+  const err: any = new Error(\n+    'Hermes is not installed on the remote host (could not find a `hermes` executable). ' +\n+      'Install it on the remote with:  curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh  ' +\n+      '— or set the Hermes path explicitly in the SSH connection settings.'\n+  )\n+\n+  err.kind = 'hermes-not-found'\n+  throw err\n+}\n+\n+// Probe the resolved binary's version string (first line of `<hermes> --version`,\n+// e.g. \"Hermes Agent v0.18.2 ...\"), or '' on failure. Surfaces WHICH hermes a\n+// connection uses, so a stale/unexpected install is visible.\n+async function probeHermesVersion(ssh, hermesPath) {\n+  try {\n+    const out = (await ssh.exec(`${expandRemotePath(hermesPath)} --version 2>&1`)).trim()\n+\n+    return (out.split('\\n')[0] || '').trim()\n+  } catch {\n+    return ''\n+  }\n+}\n+\n+async function probeRemotePlatform(ssh) {\n+  const out = (await ssh.exec('uname -s; uname -m')).trim().split('\\n')\n+  const osName = (out[0] || '').trim()\n+  const arch = (out[1] || '').trim()\n+\n+  if (!SUPPORTED_REMOTE_OS.has(osName)) {\n+    const err: any = new Error(\n+      `Unsupported remote platform \"${osName || 'unknown'}\". Hermes Desktop SSH mode supports Linux, macOS, and Windows remote hosts.`\n+    )\n+\n+    err.kind = 'unsupported-platform'\n+    throw err\n+  }\n+\n+  return { os: osName, arch }\n+}\n+\n+// The HERMES_HOME the remote dashboard will use (explicit env wins, else\n+// ~/.hermes). Recorded in the lockfile so a future reuse can tell it's the same\n+// state store; best-effort.\n+async function probeRemoteHermesHome(ssh) {\n+  try {\n+    const out = (await ssh.exec('echo \"${HERMES_HOME:-$HOME/.hermes}\"')).trim().split('\\n').pop()\n+\n+    return out || '~/.hermes'\n+  } catch (cause) {\n+    const error: any = new Error('Could not resolve the remote Hermes home.')\n+    error.kind = 'transient-transport-error'\n+    error.cause = cause\n+    throw error\n+  }\n+}\n+\n+async function readLockfile(ssh, ownershipId) {\n+  const lpath = lockfilePath(ownershipId)\n+  let raw\n+\n+  try {\n+    raw = await ssh.exec(`if [ ! -e ${expandRemotePath(lpath)} ]; then exit 0; fi; cat ${expandRemotePath(lpath)}`)\n+  } catch (cause) {\n+    const error: any = new Error('Could not read the SSH backend ownership record.')\n+    error.kind = 'transient-transport-error'\n+    error.cause = cause\n+    throw error\n+  }\n+\n+  const text = String(raw || '').trim()\n+\n+  if (!text) {\n+    return null\n+  }\n+\n+  let parsed\n+\n+  try {\n+    parsed = JSON.parse(text)\n+  } catch {\n+    return null\n+  }\n+\n+  if (!parsed || parsed.schemaVersion !== LOCKFILE_SCHEMA_VERSION) {\n+    return null\n+  }\n+\n+  const pid = parsed.pid\n+  const port = parsed.port\n+\n+  if (!Number.isInteger(pid) || pid <= 0 || pid > 4194304) {\n+    return null\n+  }\n+\n+  // port 0 = spawn-in-progress record (written before readiness); valid\n+  // ownership proof for cleanup, but never reusable.\n+  if (!Number.isInteger(port) || port < 0 || port > 65535) {\n+    return null\n+  }\n+\n+  if (parsed.ownershipId !== ownershipId || !/^[0-9a-f]{16}$/.test(parsed.spawnNonce || '')) {\n+    return null\n+  }\n+\n+  if (!/^[0-9a-f]{32}$/.test(parsed.tokenFingerprint || '')) {\n+    return null\n+  }\n+\n+  if (parsed.protocolVersion !== PROTOCOL_VERSION) {\n+    return null\n+  }\n+\n+  if (parsed.logPath !== spawnLogPath(ownershipId, parsed.spawnNonce)) {\n+    return null\n+  }\n+\n+  for (const field of ['profile', 'hermesPath', 'hermesHome', 'logPath', 'startedAt']) {\n+    if (typeof parsed[field] !== 'string' || parsed[field].length > 1024) {\n+      return null\n+    }\n+  }\n+\n+  return parsed\n+}\n+\n+async function writeLockfile(ssh, ownershipId, lock) {\n+  const directory = ownershipDirectory(ownershipId)\n+  const lpath = lockfilePath(ownershipId)\n+  const temporaryPath = `${directory}/.${crypto.randomBytes(8).toString('hex')}.lock.tmp`\n+  const json = JSON.stringify({ ...lock, schemaVersion: LOCKFILE_SCHEMA_VERSION })\n+  await ssh.exec(\n+    `umask 077 && mkdir -p ${expandRemotePath(directory)} && ` +\n+      `printf '%s' ${shq(json)} > ${expandRemotePath(temporaryPath)} && ` +\n+      `mv -f ${expandRemotePath(temporaryPath)} ${expandRemotePath(lpath)}`\n+  )\n+}\n+\n+async function removeLockfile(ssh, ownershipId) {\n+  const lpath = lockfilePath(ownershipId)\n+\n+  try {\n+    await ssh.exec(`rm -f ${expandRemotePath(lpath)}`)\n+  } catch {\n+    // best effort\n+  }\n+}\n+\n+async function remotePidAlive(ssh, pid) {\n+  if (!pid || !Number.isInteger(Number(pid))) {\n+    return false\n+  }\n+\n+  try {\n+    const out = (await ssh.exec(`kill -0 ${Number(pid)} 2>/dev/null && echo ALIVE || echo DEAD`)).trim()\n+\n+    return out === 'ALIVE'\n+  } catch (cause) {\n+    const error: any = new Error('Could not verify the SSH backend process.')\n+    error.kind = 'transient-transport-error'\n+    error.cause = cause\n+    throw error\n+  }\n+}\n+\n+// A pid is \"provably ours\" only if its remote cmdline carries our dashboard\n+// args — never kill a pid we can't positively identify as our dashboard.\n+async function pidIsOurDashboard(ssh, pid, spawnNonce, hermesPath = '') {\n+  if (!pid || !/^[0-9a-f]{16}$/.test(String(spawnNonce || '')) || !hermesPath) {\n+    return false\n+  }\n+\n+  try {\n+    const script =\n+      'import os,shlex,subprocess,sys\\n' +\n+      `pid=${Number(pid)}\\n` +\n+      `expected=os.path.expanduser(${shq(hermesPath)})\\n` +\n+      `nonce=${shq(spawnNonce)}\\n` +\n+      'try:\\n' +\n+      ' raw=open(f\"/proc/{pid}/cmdline\",\"rb\").read()\\n' +\n+      ' args=[x.decode(\"utf-8\",\"surrogateescape\") for x in raw.split(b\"\\\\0\") if x]\\n' +\n+      'except OSError:\\n' +\n+      ' line=subprocess.check_output([\"ps\",\"-o\",\"command=\",\"-p\",str(pid)],text=True).strip()\\n' +\n+      ' args=shlex.split(line)\\n' +\n+      'ok=False\\n' +\n+      'try:\\n' +\n+      ' serve=args.index(\"serve\")\\n' +\n+      ' owner=args.index(\"--ssh-owner-nonce\",serve+1)\\n' +\n+      ' direct=args[0]==expected\\n' +\n+      ' python_entry=len(args)>1 and args[1]==expected and os.path.basename(args[0]).startswith(\"python\")\\n' +\n+      ' ok=(direct or python_entry) and \"--isolated\" in args[serve+1:] and args[owner+1]==nonce\\n' +\n+      'except (ValueError,IndexError):pass\\n' +\n+      'print(\"OWNED\" if ok else \"FOREIGN\")'\n+\n+    const out = await ssh.exec(`python3 -c ${shq(script)}`)\n+\n+    return String(out || '').trim() === 'OWNED'\n+  } catch (cause) {\n+    const error: any = new Error('Could not verify SSH backend process ownership.')\n+    error.kind = 'transient-transport-error'\n+    error.cause = cause\n+    throw error\n+  }\n+}\n+\n+// Kill the stale dashboard ONLY if provably ours, then drop the lockfile.\n+async function cleanupStale(ssh, ownershipId, lock, pidAlive = true) {\n+  if (pidAlive && lock && (await pidIsOurDashboard(ssh, lock.pid, lock.spawnNonce, lock.hermesPath))) {\n+    try {\n+      const result = (\n+        await ssh.exec(\n+          `kill ${Number(lock.pid)} && ` +\n+            `i=0; while kill -0 ${Number(lock.pid)} 2>/dev/null; do ` +\n+            `i=$((i+1)); [ \"$i\" -ge 50 ] && exit 1; sleep 0.1; done`\n+        )\n+      ).trim()\n+\n+      void result\n+    } catch (cause) {\n+      const error: any = new Error('Could not terminate the stale SSH backend.')\n+      error.kind = 'transient-transport-error'\n+      error.cause = cause\n+      throw error\n+    }\n+  }\n+\n+  const expectedLogPath = lock?.spawnNonce ? spawnLogPath(ownershipId, lock.spawnNonce) : ''\n+\n+  if (lock?.logPath === expectedLogPath) {\n+    try {\n+      await ssh.exec(`rm -f ${expandRemotePath(lock.logPath)}`)\n+    } catch {\n+      void 0\n+    }\n+  }\n+\n+  await removeLockfile(ssh, ownershipId)\n+}\n+\n+// Detach so the backend survives the SSH channel closing: setsid (Linux)\n+// starts a new session; macOS has no setsid, so fall back to nohup (HUP-immune;\n+// fd-detachment is already handled by </dev/null + redirect + &).\n+function buildSpawnCommand(hermesPath, profile, opts: any = {}) {\n+  const hermes = expandRemotePath(hermesPath)\n+  const profileArgs = profile ? `--profile ${shq(profile)} ` : ''\n+  const logPath = expandRemotePath(opts.logPath)\n+  const tokenFilePath = [redacted:secret-assignment]\n+  const tokenArg = tokenFilePath ? ` --ssh-session-token-file ${expandRemotePath(tokenFilePath)}` : ''\n+  const ownerArg = opts.spawnNonce ? ` --ssh-owner-nonce ${validateSpawnNonce(opts.spawnNonce)}` : ''\n+  const subCmd = `serve --isolated --host 127.0.0.1 --port 0${tokenArg}${ownerArg}`\n+  const dashCmd = `env HERMES_DESKTOP=1 ${hermes} ${profileArgs}${subCmd}`\n+\n+  return (\n+    `mkdir -p \"$(dirname ${logPath})\" && ` +\n+    `\"$(command -v setsid || echo nohup)\" sh -c ${shq(`${dashCmd} </dev/null >> ${logPath} 2>&1 & echo $!`)}`\n+  )\n+}\n+\n+async function remoteSupportsSshOwnership(ssh, hermesPath) {\n+  const hermes = expandRemotePath(hermesPath)\n+\n+  const out = await ssh.exec(\n+    `help=\"$(${hermes} serve --help 2>&1)\"; ` +\n+      `printf '%s' \"$help\" | grep -q ssh-session-token-file && ` +\n+      `printf '%s' \"$help\" | grep -q ssh-owner-nonce && echo YES || echo NO`\n+  )\n+\n+  return String(out || '')\n+    .trim()\n+    .endsWith('YES')\n+}\n+\n+async function scrapeReadyPort(ssh, logPath, { timeoutMs = DEFAULT_READY_TIMEOUT_MS, isAlive, signal }: any = {}) {\n+  const deadline = Date.now() + timeoutMs\n+  const remoteLog = expandRemotePath(logPath)\n+\n+  while (Date.now() < deadline) {\n+    assertNotAborted(signal)\n+\n+    if (isAlive && !(await isAlive())) {\n+      const err: any = new Error('Remote dashboard process exited before announcing its port.')\n+      err.kind = 'spawn-failed'\n+      throw err\n+    }\n+\n+    let tail\n+\n+    try {\n+      tail = await ssh.exec(`cat ${remoteLog} 2>/dev/null || true`)\n+    } catch {\n+      tail = ''\n+    }\n+\n+    const m = READY_RE.exec(String(tail || ''))\n+\n+    if (m) {\n+      return parseInt(m[1], 10)\n+    }\n+\n+    await new Promise(r => setTimeout(r, READY_POLL_INTERVAL_MS))\n+  }\n+\n+  const err: any = new Error(`Timed out waiting for the remote dashboard to announce its port (${timeoutMs}ms).`)\n+  err.kind = 'ready-timeout'\n+  throw err\n+}\n+\n+async function spawnRemoteDashboard(ssh, { hermesPath, profile, token, ownershipId }) {\n+  if (!(await remoteSupportsSshOwnership(ssh, hermesPath))) {\n+    const err: any = new Error(\n+      'The remote Hermes install does not support --ssh-session-token-file and --ssh-owner-nonce. ' +\n+        'Update Hermes on the remote host to continue using Desktop SSH mode.'\n+    )\n+\n+    err.kind = 'update-required'\n+    throw err\n+  }\n+\n+  const spawnNonce = crypto.randomBytes(8).toString('hex')\n+  const tokenDir = [redacted:secret-assignment]\n+  const tokenFilePath = [redacted:secret-assignment]\n+  const logPath = spawnLogPath(ownershipId, spawnNonce)\n+\n+  const tokenUploadPy =\n+    'import os,sys,stat\\n' +\n+    `p=os.path.expanduser(${shq(tokenFilePath)})\\n` +\n+    'd=os.path.dirname(p)\\n' +\n+    'n=os.path.basename(p)\\n' +\n+    'os.makedirs(d,mode=0o700,exist_ok=True)\\n' +\n+    'df=os.O_RDONLY|getattr(os,\"O_DIRECTORY\",0)|getattr(os,\"O_NOFOLLOW\",0)\\n' +\n+    'dd=os.open(d,df)\\n' +\n+    'try:\\n' +\n+    ' s=os.fstat(dd)\\n' +\n+    ' if not stat.S_ISDIR(s.st_mode):raise SystemExit(\"unsafe token directory\")\\n' +\n+    ' if hasattr(os,\"getuid\") and s.st_uid!=os.getuid():raise SystemExit(\"token directory owner mismatch\")\\n' +\n+    ' if (s.st_mode&0o777)!=0o700:os.fchmod(dd,0o700)\\n' +\n+    ' fl=os.O_WRONLY|os.O_CREAT|os.O_EXCL|getattr(os,\"O_NOFOLLOW\",0)\\n' +\n+    ' now=__import__(\"time\").time()\\n' +\n+    ' for stale in os.listdir(dd):\\n' +\n+    '  if stale.endswith(\".token\") and len(stale)==22:\\n' +\n+    '   try:\\n' +\n+    '    ss=os.stat(stale,dir_fd=dd,follow_symlinks=False)\\n' +\n+    '    if stat.S_ISREG(ss.st_mode) and now-ss.st_mtime>3600:os.unlink(stale,dir_fd=dd)\\n' +\n+    '   except OSError:pass\\n' +\n+    ' fd=os.open(n,fl,0o600,dir_fd=dd)\\n' +\n+    ' try:os.write(fd,sys.stdin.buffer.read())\\n' +\n+    ' except BaseException:\\n' +\n+    '  try:os.unlink(n,dir_fd=dd)\\n' +\n+    '  except OSError:pass\\n' +\n+    '  raise\\n' +\n+    ' finally:os.close(fd)\\n' +\n+    'finally:os.close(dd)'\n+\n+  try {\n+    await ssh.exec(`python3 -c ${shq(tokenUploadPy)}`, { stdinData: token })\n+  } catch (error) {\n+    try {\n+      await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`)\n+    } catch {\n+      void 0\n+    }\n+\n+    throw error\n+  }\n+\n+  let out\n+\n+  try {\n+    out = await ssh.exec(buildSpawnCommand(hermesPath, profile, { spawnNonce, tokenFilePath, logPath }))\n+  } catch (error) {\n+    try {\n+      await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`)\n+    } catch {\n+      void 0\n+    }\n+\n+    throw error\n+  }\n+\n+  const pid = parseInt(\n+    String(out || '')\n+      .trim()\n+      .split('\\n')\n+      .pop(),\n+    10\n+  )\n+\n+  if (!Number.isInteger(pid) || pid <= 0) {\n+    try {\n+      await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`)\n+    } catch {\n+      void 0\n+    }\n+\n+    const err: any = new Error('Failed to launch the remote dashboard (no pid returned).')\n+    err.kind = 'spawn-failed'\n+    throw err\n+  }\n+\n+  return { pid, spawnNonce, logPath, tokenFilePath }\n+}\n+\n+// Best-effort forward teardown when a reuse attempt fails mid-flight, so we\n+// don't leak a forward before respawning. `deps.cancelForward` is optional.\n+async function cancelForwardSafe(deps, localPort, remotePort) {\n+  if (typeof deps.cancelForward !== 'function') {\n+    return\n+  }\n+\n+  try {\n+    await deps.cancelForward(localPort, remotePort)\n+  } catch {\n+    // best effort\n+  }\n+}\n+\n+function assertNotAborted(signal) {\n+  if (signal?.aborted) {\n+    const error: any = new Error('SSH bootstrap was cancelled.')\n+    error.kind = 'superseded'\n+    throw error\n+  }\n+}\n+\n+function isForwardBindCollision(error) {\n+  return /address already in use|cannot listen to port|bind.*failed/i.test(String(error?.message || error || ''))\n+}\n+\n+async function openForward(deps, remotePort, attempts = 3) {\n+  let lastError\n+\n+  for (let attempt = 0; attempt < attempts; attempt++) {\n+    const localPort = await deps.pickLocalPort()\n+\n+    try {\n+      await deps.forward(localPort, remotePort)\n+\n+      return localPort\n+    } catch (error) {\n+      lastError = error\n+\n+      if (!isForwardBindCollision(error) || attempt === attempts - 1) {\n+        throw error\n+      }\n+    }\n+  }\n+\n+  throw lastError\n+}\n+\n+/**\n+ * Establish (or reuse) a remote dashboard and a tunnel to it. `deps` injects the\n+ * opened SshConnection, forward/pickLocalPort/waitForHermes, a token-gated\n+ * probeReuseProof, and adoptServedToken. Returns the connection descriptor\n+ * { baseUrl, token, tokenFingerprint, remotePort, localPort, pid, reused, platform }.\n+ */\n+async function adoptOwnedServedToken(adoptServedToken, baseUrl, expectedToken, ssh, pid, label) {\n+  const token = await adoptServedToken(baseUrl, expectedToken, {\n+    childAlive: () => true,\n+    label\n+  })\n+\n+  if (!(await remotePidAlive(ssh, pid))) {\n+    const error: any = new Error(`${label} exited while its served token was being resolved.`)\n+    error.kind = token === expectedToken ? 'spawn-failed' : 'foreign-backend'\n+    throw error\n+  }\n+\n+  return token\n+}\n+\n+async function connect(deps) {\n+  const {\n+    ssh,\n+    profile = '',\n+    remoteHermesPath = '',\n+    ownershipId,\n+    forward,\n+    pickLocalPort,\n+    waitForHermes,\n+    probeReuseProof,\n+    adoptServedToken,\n+    rememberLog = () => {},\n+    readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS,\n+    signal\n+  } = deps\n+\n+  const log = msg => rememberLog(`[ssh-lifecycle] ${msg}`)\n+\n+  assertNotAborted(signal)\n+  const platform = await probeRemotePlatform(ssh)\n+  log(`remote platform ${platform.os}/${platform.arch}`)\n+  const hermesPath = await locateHermes(ssh, remoteHermesPath)\n+  log(`located hermes at ${hermesPath}`)\n+  const hermesVersion = await probeHermesVersion(ssh, hermesPath)\n+\n+  if (hermesVersion) {\n+    log(`remote hermes version: ${hermesVersion}`)\n+  }\n+\n+  const reuseToken = [redacted:secret-assignment] || ''\n+  const hermesHome = await probeRemoteHermesHome(ssh)\n+  const lock = await readLockfile(ssh, ownershipId)\n+\n+  if (lock) {\n+    const pidAlive = await remotePidAlive(ssh, lock.pid)\n+    const owned = pidAlive && (await pidIsOurDashboard(ssh, lock.pid, lock.spawnNonce, lock.hermesPath))\n+\n+    const reusable =\n+      pidAlive &&\n+      owned &&\n+      lock.port > 0 &&\n+      Boolean(reuseToken) &&\n+      lock.tokenFingerprint === fingerprintToken(reuseToken) &&\n+      lock.hermesPath === hermesPath &&\n+      lock.hermesHome === hermesHome\n+\n+    if (reusable) {\n+      assertNotAborted(signal)\n+      const localPort = await openForward(deps, lock.port)\n+\n+      try {\n+        const baseUrl = `http://127.0.0.1:${localPort}`\n+        let reuseClassification\n+\n+        try {\n+          reuseClassification = await probeReuseProof(baseUrl, reuseToken, lock.spawnNonce)\n+        } catch (cause) {\n+          const error: any = new Error('Could not verify the existing SSH backend.')\n+          error.kind = 'transient-transport-error'\n+          error.cause = cause\n+          throw error\n+        }\n+\n+        if (reuseClassification === 'authenticated-stale') {\n+          assertNotAborted(signal)\n+          await cancelForwardSafe(deps, localPort, lock.port)\n+          await cleanupStale(ssh, ownershipId, lock)\n+        } else if (reuseClassification === 'authenticated-ok') {\n+          const token = await adoptOwnedServedToken(\n+            adoptServedToken,\n+            baseUrl,\n+            reuseToken,\n+            ssh,\n+            lock.pid,\n+            'reused remote dashboard'\n+          )\n+\n+          assertNotAborted(signal)\n+          log(`reusing remote dashboard pid=${lock.pid} port=${lock.port}`)\n+\n+          return {\n+            baseUrl,\n+            token,\n+            tokenFingerprint: [redacted:secret-assignment]\n+            remotePort: lock.port,\n+            localPort,\n+            pid: lock.pid,\n+            reused: true,\n+            platform,\n+            hermesPath,\n+            hermesVersion,\n+            ownershipId,\n+            spawnNonce: lock.spawnNonce,\n+            logPath: lock.logPath\n+          }\n+        } else {\n+          const error: any = new Error('SSH reuse proof returned an invalid classification.')\n+          error.kind = 'transient-transport-error'\n+          throw error\n+        }\n+      } catch (error) {\n+        await cancelForwardSafe(deps, localPort, lock.port)\n+        throw error\n+      }\n+    } else {\n+      assertNotAborted(signal)\n+      await cleanupStale(ssh, ownershipId, lock, pidAlive)\n+    }\n+  }\n+\n+  assertNotAborted(signal)\n+  const spawnToken = [redacted:secret-assignment]\n+\n+  const { pid, spawnNonce, logPath, tokenFilePath } = await spawnRemoteDashboard(ssh, {\n+    hermesPath,\n+    profile,\n+    token: [redacted:secret-assignment]\n+    ownershipId\n+  })\n+\n+  log(`spawned remote dashboard pid=${pid}`)\n+\n+  const ownedSpawn = {\n+    ownershipId,\n+    spawnNonce,\n+    pid,\n+    port: 0,\n+    profile,\n+    hermesPath,\n+    hermesHome,\n+    logPath,\n+    tokenFingerprint: [redacted:secret-assignment]\n+    protocolVersion: PROTOCOL_VERSION,\n+    startedAt: new Date().toISOString()\n+  }\n+\n+  let localPort = 0\n+  let remotePort = 0\n+\n+  try {\n+    // Write the ownership record IMMEDIATELY (port=0): a supersede between\n+    // spawn and readiness whose cleanup cannot reach the box must not leave a\n+    // lockless orphan — the next connect reaps it by exact ownership via this\n+    // record. Inside the try: if this write itself fails, the catch still\n+    // kills the just-spawned process via the in-memory record.\n+    await writeLockfile(ssh, ownershipId, ownedSpawn)\n+    remotePort = await scrapeReadyPort(ssh, logPath, {\n+      timeoutMs: readyTimeoutMs,\n+      isAlive: () => remotePidAlive(ssh, pid),\n+      signal\n+    })\n+    assertNotAborted(signal)\n+    log(`remote dashboard bound port ${remotePort}`)\n+\n+    localPort = await openForward(deps, remotePort)\n+    assertNotAborted(signal)\n+    const baseUrl = `http://127.0.0.1:${localPort}`\n+    await waitForHermes(baseUrl, spawnToken)\n+    assertNotAborted(signal)\n+\n+    const token = await adoptOwnedServedToken(adoptServedToken, baseUrl, spawnToken, ssh, pid, 'remote dashboard')\n+\n+    assertNotAborted(signal)\n+    const tokenFingerprint = [redacted:secret-assignment]\n+    await writeLockfile(ssh, ownershipId, { ...ownedSpawn, port: remotePort, tokenFingerprint })\n+    assertNotAborted(signal)\n+\n+    return {\n+      baseUrl,\n+      token,\n+      tokenFingerprint,\n+      remotePort,\n+      localPort,\n+      pid,\n+      reused: false,\n+      platform,\n+      hermesPath,\n+      hermesVersion,\n+      ownershipId,\n+      spawnNonce,\n+      logPath\n+    }\n+  } catch (error) {\n+    if (localPort && remotePort) {\n+      await cancelForwardSafe(deps, localPort, remotePort)\n+    }\n+\n+    try {\n+      await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`)\n+    } catch {\n+      void 0\n+    }\n+\n+    await cleanupStale(ssh, ownershipId, ownedSpawn)\n+    throw error\n+  }\n+}\n+\n+export {\n+  adoptOwnedServedToken,\n+  buildSpawnCommand,\n+  cleanupStale,\n+  connect,\n+  DEFAULT_READY_TIMEOUT_MS,\n+  expandRemotePath,\n+  fingerprintToken,\n+  isForwardBindCollision,\n+  locateHermes,\n+  LOCKFILE_SCHEMA_VERSION,\n+  lockfilePath,\n+  mintToken,\n+  openForward,\n+  ownershipDirectory,\n+  pidIsOurDashboard,\n+  probeHermesVersion,\n+  probeRemoteHermesHome,\n+  probeRemotePlatform,\n+  PROTOCOL_VERSION,\n+  readLockfile,\n+  READY_RE,\n+  REMOTE_LOCK_DIR,\n+  remotePidAlive,\n+  remoteSupportsSshOwnership,\n+  removeLockfile,\n+  scrapeReadyPort,\n+  shq,\n+  spawnLogPath,\n+  spawnRemoteDashboard,\n+  SUPPORTED_REMOTE_OS,\n+  validateRemotePath,\n+  writeLockfile\n+}"]},{"file":"apps/desktop/electron/ssh-bootstrap-coordinator.test.ts","hunks":["@@ -0,0 +1,192 @@\n+import assert from 'node:assert/strict'\n+\n+import { test } from 'vitest'\n+\n+import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator'\n+\n+function deferred() {\n+  let resolve\n+  let reject\n+\n+  const promise = new Promise((ok, fail) => {\n+    resolve = ok\n+    reject = fail\n+  })\n+\n+  return { promise, reject, resolve }\n+}\n+\n+const config = { host: 'box', user: 'alice', port: 22, keyPath: '/key', remoteHermesPath: '/hermes' }\n+\n+test('sshConfigFingerprint covers scope and every connection field', () => {\n+  const base = sshConfigFingerprint('', config)\n+  assert.equal(base, sshConfigFingerprint('', { ...config }))\n+\n+  for (const [field, value] of Object.entries({\n+    host: 'other',\n+    user: 'bob',\n+    port: 2222,\n+    keyPath: '/other',\n+    remoteHermesPath: '/other-hermes',\n+    effectiveConfigFingerprint: 'changed-config'\n+  })) {\n+    assert.notEqual(base, sshConfigFingerprint('', { ...config, [field]: value }))\n+  }\n+\n+  assert.notEqual(base, sshConfigFingerprint('profile', config))\n+})\n+\n+test('same scope and fingerprint share one bootstrap', async () => {\n+  const coordinator = createBootstrapCoordinator()\n+  const gate = deferred()\n+  let runs = 0\n+\n+  const first = coordinator.start('', 'same', async () => {\n+    runs++\n+\n+    return gate.promise\n+  })\n+\n+  const second = coordinator.start('', 'same', async () => {\n+    runs++\n+\n+    return 'wrong'\n+  })\n+\n+  assert.equal(first, second)\n+  gate.resolve('done')\n+  assert.equal(await second, 'done')\n+  assert.equal(runs, 1)\n+})\n+\n+test('changed fingerprint waits for old rollback before starting', async () => {\n+  const coordinator = createBootstrapCoordinator()\n+  const gate = deferred()\n+  const events: string[] = []\n+  let oldLease\n+\n+  const oldPromise = coordinator.start('', 'old', async lease => {\n+    oldLease = lease\n+    events.push('old-start')\n+    await gate.promise\n+    events.push('old-rollback')\n+    lease.assertCurrent()\n+  })\n+\n+  await Promise.resolve()\n+\n+  const newPromise = coordinator.start('', 'new', async lease => {\n+    events.push('new-start')\n+    lease.assertCurrent()\n+\n+    return 'new'\n+  })\n+\n+  assert.equal(oldLease.signal.aborted, true)\n+  await Promise.resolve()\n+  assert.deepEqual(events, ['old-start'])\n+  gate.resolve()\n+  await assert.rejects(oldPromise, (error: any) => error.kind === 'superseded')\n+  assert.equal(await newPromise, 'new')\n+  assert.deepEqual(events, ['old-start', 'old-rollback', 'new-start'])\n+})\n+\n+test('forceCleanupAll runs registered pending resource cleanup', async () => {\n+  const coordinator = createBootstrapCoordinator()\n+  const gate = deferred()\n+  let cleaned = 0\n+\n+  const promise = coordinator.start('', 'x', async lease => {\n+    lease.onForceCleanup(async () => {\n+      cleaned++\n+    })\n+    await gate.promise\n+  })\n+\n+  await Promise.resolve()\n+  await coordinator.forceCleanupAll()\n+  assert.equal(cleaned, 1)\n+  gate.resolve()\n+  await promise\n+})\n+\n+test('cancelAll invalidates every pending scope and exposes promises for quit', async () => {\n+  const coordinator = createBootstrapCoordinator()\n+  const gates = [deferred(), deferred()]\n+\n+  const promises = gates.map((gate, index) =>\n+    coordinator.start(String(index), 'x', async lease => {\n+      await gate.promise\n+      lease.assertCurrent()\n+    })\n+  )\n+\n+  assert.equal(coordinator.promises().length, 2)\n+  coordinator.cancelAll()\n+  gates.forEach(gate => gate.resolve())\n+  const results = await Promise.allSettled(promises)\n+  assert.ok(results.every(result => result.status === 'rejected' && (result.reason as any).kind === 'superseded'))\n+})\n+\n+test('cancelAndWait drains only the requested scope', async () => {\n+  const coordinator = createBootstrapCoordinator()\n+  const firstGate = deferred()\n+  const secondGate = deferred()\n+\n+  const first = coordinator.start('first', 'x', async lease => {\n+    await firstGate.promise\n+    lease.assertCurrent()\n+  })\n+\n+  const second = coordinator.start('second', 'x', async lease => {\n+    await secondGate.promise\n+    lease.assertCurrent()\n+\n+    return 'second'\n+  })\n+\n+  await Promise.resolve()\n+  let drained = false\n+\n+  const drain = coordinator.cancelAndWait('first').then(() => {\n+    drained = true\n+  })\n+\n+  await Promise.resolve()\n+  assert.equal(drained, false)\n+  firstGate.resolve()\n+  await drain\n+  await assert.rejects(first, (error: any) => error.kind === 'superseded')\n+  assert.equal(coordinator.pending.has('second'), true)\n+  secondGate.resolve()\n+  assert.equal(await second, 'second')\n+})\n+\n+test('a generation started during cancelAndWait cannot run before the drain completes', async () => {\n+  const coordinator = createBootstrapCoordinator()\n+  const oldGate = deferred()\n+  const events: string[] = []\n+\n+  const old = coordinator.start('scope', 'old', async lease => {\n+    events.push('old-start')\n+    await oldGate.promise\n+    lease.assertCurrent()\n+  })\n+\n+  await Promise.resolve()\n+  const drain = coordinator.cancelAndWait('scope')\n+\n+  const next = coordinator.start('scope', 'new', async () => {\n+    events.push('new-start')\n+\n+    return 'new'\n+  })\n+\n+  await Promise.resolve()\n+  assert.deepEqual(events, ['old-start'])\n+  oldGate.resolve()\n+  await drain\n+  await assert.rejects(old, (error: any) => error.kind === 'superseded')\n+  assert.equal(await next, 'new')\n+  assert.deepEqual(events, ['old-start', 'new-start'])\n+})"]},{"file":"apps/desktop/electron/ssh-bootstrap-coordinator.ts","hunks":["@@ -0,0 +1,130 @@\n+import crypto from 'node:crypto'\n+\n+function sshConfigFingerprint(scope, config) {\n+  const parts = [\n+    scope,\n+    config.host,\n+    config.user,\n+    config.port,\n+    config.keyPath,\n+    config.remoteHermesPath,\n+    config.effectiveConfigFingerprint\n+  ]\n+\n+  return crypto\n+    .createHash('sha256')\n+    .update(JSON.stringify(parts.map(value => value ?? '')))\n+    .digest('hex')\n+}\n+\n+function createBootstrapCoordinator() {\n+  const active = new Set<any>()\n+  const pending = new Map<string, any>()\n+  const generations = new Map<string, number>()\n+  const drains = new Map<string, Promise<void>>()\n+\n+  function start(scope, fingerprint, run) {\n+    const current = pending.get(scope)\n+\n+    if (current?.fingerprint === fingerprint) {\n+      return current.promise\n+    }\n+\n+    current?.controller.abort()\n+\n+    const generation = (generations.get(scope) || 0) + 1\n+    generations.set(scope, generation)\n+    const controller = new AbortController()\n+    const forceCleanups = new Set<() => any>()\n+\n+    const lease = {\n+      signal: controller.signal,\n+      onForceCleanup(cleanup) {\n+        forceCleanups.add(cleanup)\n+\n+        return () => forceCleanups.delete(cleanup)\n+      },\n+      isCurrent: () => !controller.signal.aborted && generations.get(scope) === generation,\n+      assertCurrent() {\n+        if (!this.isCurrent()) {\n+          const error: any = new Error('SSH bootstrap was superseded by newer connection settings.')\n+          error.kind = 'superseded'\n+          throw error\n+        }\n+      }\n+    }\n+\n+    const drain = drains.get(scope) || Promise.resolve()\n+    const predecessor = current ? Promise.allSettled([current.promise, drain]) : drain\n+    const entry: any = { controller, fingerprint, forceCleanups, generation, promise: null, scope }\n+\n+    const promise = predecessor\n+      .then(() => {\n+        lease.assertCurrent()\n+\n+        return run(lease)\n+      })\n+      .finally(() => {\n+        forceCleanups.clear()\n+        active.delete(entry)\n+\n+        if (pending.get(scope)?.generation === generation) {\n+          pending.delete(scope)\n+        }\n+      })\n+\n+    entry.promise = promise\n+    active.add(entry)\n+    pending.set(scope, entry)\n+\n+    return promise\n+  }\n+\n+  function cancel(scope) {\n+    pending.get(scope)?.controller.abort()\n+  }\n+\n+  async function cancelAndWait(scope) {\n+    let release\n+\n+    const barrier = new Promise<void>(resolve => {\n+      release = resolve\n+    })\n+\n+    drains.set(scope, barrier)\n+    const entries = [...active].filter(entry => entry.scope === scope)\n+\n+    for (const entry of entries) {\n+      entry.controller.abort()\n+    }\n+\n+    try {\n+      await Promise.allSettled(entries.map(entry => entry.promise))\n+    } finally {\n+      if (drains.get(scope) === barrier) {\n+        drains.delete(scope)\n+      }\n+\n+      release()\n+    }\n+  }\n+\n+  function cancelAll() {\n+    for (const entry of active) {\n+      entry.controller.abort()\n+    }\n+  }\n+\n+  async function forceCleanupAll() {\n+    const cleanups = [...active].flatMap(entry => [...entry.forceCleanups])\n+    await Promise.allSettled(cleanups.map(cleanup => cleanup()))\n+  }\n+\n+  function promises() {\n+    return [...active].map(entry => entry.promise)\n+  }\n+\n+  return { active, cancel, cancelAll, cancelAndWait, forceCleanupAll, pending, promises, start }\n+}\n+\n+export { createBootstrapCoordinator, sshConfigFingerprint }"]},{"file":"apps/desktop/electron/ssh-config.test.ts","hunks":["@@ -0,0 +1,104 @@\n+import assert from 'node:assert/strict'\n+\n+import { test } from 'vitest'\n+\n+import { collectSshConfigHosts, parseSshConfigHosts, parseSshConfigIncludes, parseSshGOutput } from './ssh-config'\n+\n+test('parseSshConfigHosts keeps literal aliases and drops wildcard/negated patterns', () => {\n+  const cfg = [\n+    'Host devbox',\n+    '  HostName 10.0.0.5',\n+    'Host *.internal prod !staging glob*',\n+    'Host alpha beta',\n+    '# Host commented-out',\n+    'host lower-case'\n+  ].join('\\n')\n+\n+  assert.deepEqual(parseSshConfigHosts(cfg), ['devbox', 'prod', 'alpha', 'beta', 'lower-case'])\n+})\n+\n+test('parseSshConfigHosts de-duplicates', () => {\n+  assert.deepEqual(parseSshConfigHosts('Host box\\nHost box\\nHost box other'), ['box', 'other'])\n+})\n+\n+test('parseSshConfigIncludes extracts include tokens', () => {\n+  const cfg = 'Include ~/.ssh/config.d/*\\nInclude work_hosts personal_hosts\\n# Include ignored'\n+  assert.deepEqual(parseSshConfigIncludes(cfg), ['~/.ssh/config.d/*', 'work_hosts', 'personal_hosts'])\n+})\n+\n+test('collectSshConfigHosts follows Include directives (read-only)', () => {\n+  const files = {\n+    '/home/u/.ssh/config': 'Host main\\nInclude work\\nInclude ~/abs_inc',\n+    '/home/u/.ssh/work': 'Host work-box\\nInclude nested',\n+    '/home/u/.ssh/nested': 'Host deep',\n+    '/home/u/abs_inc': 'Host home-abs'\n+  }\n+\n+  const hosts = collectSshConfigHosts('/home/u/.ssh/config', {\n+    homeDir: '/home/u',\n+    readFile: p => files[p] ?? null\n+  })\n+\n+  assert.deepEqual(hosts.sort(), ['deep', 'home-abs', 'main', 'work-box'].sort())\n+})\n+\n+test('collectSshConfigHosts tolerates a missing config file', () => {\n+  assert.deepEqual(collectSshConfigHosts('/nope/config', { homeDir: '/home/u', readFile: () => null }), [])\n+})\n+\n+test('collectSshConfigHosts does not loop on a self-include cycle', () => {\n+  const files = {\n+    '/home/u/.ssh/config': 'Host a\\nInclude loop',\n+    '/home/u/.ssh/loop': 'Host b\\nInclude config' // points back at config\n+  }\n+\n+  const hosts = collectSshConfigHosts('/home/u/.ssh/config', {\n+    homeDir: '/home/u',\n+    readFile: p => files[p] ?? null\n+  })\n+\n+  assert.deepEqual(hosts.sort(), ['a', 'b'])\n+})\n+\n+test('collectSshConfigHosts expands globbed includes via injected globSync', () => {\n+  const files = {\n+    '/home/u/.ssh/config': 'Host root\\nInclude config.d/*',\n+    '/home/u/.ssh/config.d/10-work': 'Host work',\n+    '/home/u/.ssh/config.d/20-home': 'Host home'\n+  }\n+\n+  const hosts = collectSshConfigHosts('/home/u/.ssh/config', {\n+    homeDir: '/home/u',\n+    readFile: p => files[p] ?? null,\n+    globSync: pattern =>\n+      pattern.endsWith('config.d/*') ? ['/home/u/.ssh/config.d/10-work', '/home/u/.ssh/config.d/20-home'] : [pattern]\n+  })\n+\n+  assert.deepEqual(hosts.sort(), ['home', 'root', 'work'].sort())\n+})\n+\n+test('parseSshGOutput pulls hostname/user/port/identityfile', () => {\n+  const out = [\n+    'host devbox',\n+    'hostname 10.0.0.5',\n+    'user alice',\n+    'port 2222',\n+    'identityfile ~/.ssh/id_ed25519',\n+    'forwardagent no'\n+  ].join('\\n')\n+\n+  assert.deepEqual(parseSshGOutput(out), {\n+    hostname: '10.0.0.5',\n+    user: 'alice',\n+    port: 2222,\n+    identityFile: '~/.ssh/id_ed25519'\n+  })\n+})\n+\n+test('parseSshGOutput takes the FIRST identityfile and tolerates missing keys', () => {\n+  const out = 'hostname box\\nidentityfile ~/.ssh/a\\nidentityfile ~/.ssh/b'\n+  const parsed = parseSshGOutput(out)\n+  assert.equal(parsed.identityFile, '~/.ssh/a')\n+  assert.equal(parsed.user, null)\n+  assert.equal(parsed.port, null)\n+})"]},{"file":"apps/desktop/electron/ssh-config.ts","hunks":["@@ -0,0 +1,175 @@\n+/**\n+ * ssh-config.ts\n+ *\n+ * Pure, electron-free helpers for reading the user's OpenSSH client config:\n+ * `Host` aliases for the settings UI's suggestions, `Include` traversal\n+ * (read-only), and `ssh -G` output parsing. No `import 'electron'` so it's\n+ * unit-testable without Electron; main.ts wires the fs + `ssh -G` exec in.\n+ */\n+\n+import fs from 'node:fs'\n+import os from 'node:os'\n+import path from 'node:path'\n+\n+function parseSshConfigHosts(text) {\n+  const hosts: string[] = []\n+  const seen = new Set()\n+\n+  for (const rawLine of String(text || '').split('\\n')) {\n+    const line = rawLine.trim()\n+\n+    if (!line || line.startsWith('#')) {\n+      continue\n+    }\n+\n+    const m = /^host\\s+(.+)$/i.exec(line)\n+\n+    if (!m) {\n+      continue\n+    }\n+\n+    for (const pattern of m[1].split(/\\s+/)) {\n+      if (!pattern || pattern.includes('*') || pattern.includes('?') || pattern.startsWith('!')) {\n+        continue\n+      }\n+\n+      if (!seen.has(pattern)) {\n+        seen.add(pattern)\n+        hosts.push(pattern)\n+      }\n+    }\n+  }\n+\n+  return hosts\n+}\n+\n+function parseSshConfigIncludes(text) {\n+  const includes: string[] = []\n+\n+  for (const rawLine of String(text || '').split('\\n')) {\n+    const line = rawLine.trim()\n+\n+    if (!line || line.startsWith('#')) {\n+      continue\n+    }\n+\n+    const m = /^include\\s+(.+)$/i.exec(line)\n+\n+    if (!m) {\n+      continue\n+    }\n+\n+    for (const token of m[1].split(/\\s+/)) {\n+      if (token) {\n+        includes.push(token)\n+      }\n+    }\n+  }\n+\n+  return includes\n+}\n+\n+function collectSshConfigHosts(rootPath = '', deps: any = {}) {\n+  const readFile =\n+    deps.readFile ||\n+    (p => {\n+      try {\n+        return fs.readFileSync(p, 'utf8')\n+      } catch {\n+        return null\n+      }\n+    })\n+\n+  const homeDir = deps.homeDir || os.homedir()\n+  const root = rootPath || path.join(homeDir, '.ssh', 'config')\n+  const sshDir = path.join(homeDir, '.ssh')\n+\n+  const out: string[] = []\n+  const seen = new Set()\n+  const visited = new Set()\n+\n+  const resolveIncludePath = token => {\n+    if (token.startsWith('~/')) {\n+      return path.join(homeDir, token.slice(2))\n+    }\n+\n+    if (path.isAbsolute(token)) {\n+      return token\n+    }\n+\n+    return path.join(sshDir, token)\n+  }\n+\n+  const walk = (filePath, depth) => {\n+    if (depth > 8 || visited.has(filePath)) {\n+      return\n+    }\n+\n+    visited.add(filePath)\n+    const text = readFile(filePath)\n+\n+    if (text == null) {\n+      return\n+    }\n+\n+    for (const host of parseSshConfigHosts(text)) {\n+      if (!seen.has(host)) {\n+        seen.add(host)\n+        out.push(host)\n+      }\n+    }\n+\n+    for (const token of parseSshConfigIncludes(text)) {\n+      const target = resolveIncludePath(token)\n+      const expanded = deps.globSync ? deps.globSync(target) : [target]\n+\n+      for (const p of expanded) {\n+        walk(p, depth + 1)\n+      }\n+    }\n+  }\n+\n+  walk(root, 0)\n+\n+  return out\n+}\n+\n+function parseSshGOutput(text) {\n+  const out: { hostname: string | null; user: string | null; port: number | null; identityFile: string | null } = {\n+    hostname: null,\n+    user: null,\n+    port: null,\n+    identityFile: null\n+  }\n+\n+  for (const rawLine of String(text || '').split('\\n')) {\n+    const line = rawLine.trim()\n+\n+    if (!line) {\n+      continue\n+    }\n+\n+    const sp = line.indexOf(' ')\n+\n+    if (sp === -1) {\n+      continue\n+    }\n+\n+    const key = line.slice(0, sp).toLowerCase()\n+    const value = line.slice(sp + 1).trim()\n+\n+    if (key === 'hostname' && !out.hostname) {\n+      out.hostname = value\n+    } else if (key === 'user' && !out.user) {\n+      out.user = value\n+    } else if (key === 'port' && !out.port) {\n+      out.port = Number.parseInt(value, 10) || null\n+    } else if (key === 'identityfile' && !out.identityFile) {\n+      out.identityFile = value\n+    }\n+  }\n+\n+  return out\n+}\n+\n+export { collectSshConfigHosts, parseSshConfigHosts, parseSshConfigIncludes, parseSshGOutput }"]},{"file":"apps/desktop/electron/ssh-connection.test.ts","hunks":["@@ -0,0 +1,866 @@\n+import assert from 'node:assert/strict'\n+import { EventEmitter } from 'node:events'\n+import fs from 'node:fs'\n+import os from 'node:os'\n+import path from 'node:path'\n+\n+import { test } from 'vitest'\n+\n+import {\n+  baseSshOptions,\n+  buildControlArgs,\n+  buildExecArgs,\n+  buildInteractiveSshArgs,\n+  buildMasterArgs,\n+  classifySshError,\n+  controlSocketPath,\n+  createSshProbeConnection,\n+  forwardSpec,\n+  hostArgs,\n+  redactSecrets,\n+  runSsh,\n+  SSH_ERROR,\n+  SshConnection,\n+  sshErrorMessage,\n+  stopTunnelChild,\n+  target,\n+  validateSshTarget\n+} from './ssh-connection'\n+\n+test('redactSecrets scrubs the spawn-time session token env var', () => {\n+  const line = 'setsid env HERMES_DASHBOARD_SESSION_TOKEN=[redacted:secret-assignment] HERMES_DESKTOP=1 hermes dashboard'\n+  const out = redactSecrets(line)\n+  assert.ok(!out.includes('abc123deadbeef'))\n+  assert.match(out, /HERMES_DASHBOARD_SESSION_TOKEN=[redacted:secret-assignment]\n+  // non-secret env vars are preserved\n+  assert.match(out, /HERMES_DESKTOP=1/)\n+})\n+\n+test('redactSecrets scrubs ?token= and ?ticket= URL params', () => {\n+  assert.match(redactSecrets('ws://127.0.0.1:5000/api/ws?token=supersecret'), /\\?token=[redacted:secret-assignment]\n+  assert.match(redactSecrets('ws://127.0.0.1:5000/api/ws?ticket=onetimeticket'), /\\?ticket=<redacted>/)\n+  assert.match(redactSecrets('GET /x?a=1&token=zzz HTTP'), /&token=[redacted:secret-assignment]\n+  assert.ok(!redactSecrets('?token=supersecret').includes('supersecret'))\n+})\n+\n+test('redactSecrets scrubs Authorization and X-Hermes-Session-Token headers', () => {\n+  assert.match(redactSecrets('Authorization: Bearer tok_9999'), /Authorization: Bearer <redacted>/)\n+  assert.ok(!redactSecrets('Authorization: Bearer tok_9999').includes('tok_9999'))\n+  assert.match(redactSecrets('X-Hermes-Session-Token: [redacted:secret-assignment]'), /X-Hermes-Session-Token: [redacted:secret-assignment]\n+  assert.ok(!redactSecrets('X-Hermes-Session-Token: [redacted:secret-assignment]').includes('hdr_888'))\n+})\n+\n+test('redactSecrets handles null/undefined and non-secret text untouched', () => {\n+  assert.equal(redactSecrets(null), '')\n+  assert.equal(redactSecrets(undefined), '')\n+  assert.equal(redactSecrets('uname -s -m'), 'uname -s -m')\n+})\n+\n+test('controlSocketPath is stable, short, and host-distinct', () => {\n+  const a = controlSocketPath('me', 'box1', 22, '/tmp/d')\n+  const a2 = controlSocketPath('me', 'box1', 22, '/tmp/d')\n+  const b = controlSocketPath('me', 'box2', 22, '/tmp/d')\n+  assert.equal(a, a2, 'same triple → same socket (ControlMaster reuse)')\n+  assert.notEqual(a, b, 'different host → different socket')\n+  // 16 hex chars + .sock keeps the basename short for sun_path 104-byte limit\n+  assert.match(a, /\\/[0-9a-f]{16}\\.sock$/)\n+})\n+\n+test('controlSocketPath default base stays under sun_path even with the temp-listener suffix', () => {\n+  // OpenSSH binds a temporary listener at `<ControlPath>.<16 random chars>` (a\n+  // 17-byte suffix) while opening the master. The macOS regression was the\n+  // default base under os.tmpdir() (/var/folders/.../T/) pushing it over 104.\n+  const p = controlSocketPath('hermes', 'remote-build-server', 22) // no baseDir → default\n+  const worstCase = `${p}.0123456789abcdef` // mimic the .<16-char> temp suffix\n+  assert.ok(\n+    worstCase.length <= 104,\n+    `default control socket + temp suffix must fit sun_path (got ${worstCase.length}: ${worstCase})`\n+  )\n+  // And it must NOT live under the deeply-nested macOS per-user temp dir.\n+  assert.ok(!p.includes('/var/folders/'), 'default base must not be os.tmpdir() on macOS')\n+})\n+\n+test('baseSshOptions carries the house ControlMaster/BatchMode/accept-new policy', () => {\n+  const opts = baseSshOptions('/tmp/x.sock', 15000)\n+  const joined = opts.join(' ')\n+  assert.match(joined, /ControlPath=\\/tmp\\/x\\.sock/)\n+  assert.match(joined, /ControlMaster=auto/)\n+  assert.match(joined, /ControlPersist=\\d+/)\n+  assert.match(joined, /BatchMode=yes/)\n+  assert.match(joined, /StrictHostKeyChecking=accept-new/)\n+  assert.match(joined, /ExitOnForwardFailure=yes/)\n+  assert.match(joined, /ConnectTimeout=15/)\n+  assert.ok(!joined.includes('StrictHostKeyChecking=no'), 'never disables host-key checking')\n+})\n+\n+test('hostArgs adds -p only for non-default port and -i only with a key', () => {\n+  assert.deepEqual(hostArgs({ port: 22 }), [])\n+  assert.deepEqual(hostArgs({ port: 2222 }), ['-p', '2222'])\n+  assert.deepEqual(hostArgs({ port: 22, keyPath: '/k' }), ['-i', '/k'])\n+  assert.deepEqual(hostArgs({ port: 2200, keyPath: '/k' }), ['-p', '2200', '-i', '/k'])\n+})\n+\n+test('target builds user@host or bare host', () => {\n+  assert.equal(target('me', 'box'), 'me@box')\n+  assert.equal(target('', 'box'), 'box')\n+})\n+\n+test('buildExecArgs ends with host then the remote command', () => {\n+  const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }\n+  const args = buildExecArgs(conn, 'command -v hermes', 15000)\n+  assert.equal(args[args.length - 1], 'command -v hermes')\n+  assert.equal(args[args.length - 2], 'me@box')\n+  assert.ok(args.includes('BatchMode=yes'))\n+})\n+\n+test('buildControlArgs places -O <op> first and never appends a remote command', () => {\n+  const conn = { user: 'me', host: 'box', port: 2222, keyPath: '/k', controlPath: '/tmp/x.sock' }\n+  const args = buildControlArgs(conn, 'forward', ['-L', forwardSpec(5000, 6000)], 15000)\n+  assert.equal(args[0], '-O')\n+  assert.equal(args[1], 'forward')\n+  assert.ok(args.includes('-L'))\n+  assert.ok(args.includes('127.0.0.1:5000:127.0.0.1:6000'))\n+  assert.equal(args[args.length - 1], 'me@box')\n+})\n+\n+test('buildMasterArgs requests a backgrounded master (-M -N -f)', () => {\n+  const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }\n+  const args = buildMasterArgs(conn, 15000)\n+  assert.ok(args.includes('-M'))\n+  assert.ok(args.includes('-N'))\n+  assert.ok(args.includes('-f'))\n+})\n+\n+test('forwardSpec binds the local end to 127.0.0.1 only', () => {\n+  assert.equal(forwardSpec(5000, 6000), '127.0.0.1:5000:127.0.0.1:6000')\n+  assert.ok(forwardSpec(5000, 6000).startsWith('127.0.0.1:'))\n+  assert.ok(!forwardSpec(5000, 6000).startsWith('0.0.0.0'))\n+})\n+\n+test('buildInteractiveSshArgs requests a PTY, reuses the control master, execs a login shell', () => {\n+  const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }\n+  const args = buildInteractiveSshArgs(conn, '', 15000)\n+  assert.equal(args[0], '-tt', 'forces a PTY so the remote sees a real terminal')\n+  assert.ok(args.join(' ').includes('ControlPath=/tmp/x.sock'), 'reuses the existing master (no new auth)')\n+  assert.equal(args[args.length - 2], 'me@box')\n+  assert.equal(args[args.length - 1], 'exec \"$SHELL\" -l')\n+})\n+\n+test('buildInteractiveSshArgs cds into the remote cwd (best-effort) before the shell', () => {\n+  const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }\n+  const args = buildInteractiveSshArgs(conn, '/home/me/project', 15000)\n+  const remoteCmd = args[args.length - 1]\n+  assert.match(remoteCmd, /^cd '\\/home\\/me\\/project' 2>\\/dev\\/null; exec \"\\$SHELL\" -l$/)\n+})\n+\n+test('buildInteractiveSshArgs single-quotes a cwd with quotes safely', () => {\n+  const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }\n+  const args = buildInteractiveSshArgs(conn, \"/tmp/a'b\", 15000)\n+  // the embedded quote must be escaped, not break out of the quoting\n+  assert.ok(args[args.length - 1].startsWith(\"cd '/tmp/a'\"))\n+  assert.ok(args[args.length - 1].includes('exec \"$SHELL\" -l'))\n+})\n+\n+test('classifySshError detects a changed host key (fail-closed)', () => {\n+  assert.equal(\n+    classifySshError('@@@@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @@@@'),\n+    SSH_ERROR.HOST_KEY_CHANGED\n+  )\n+  assert.equal(classifySshError('Host key verification failed.'), SSH_ERROR.HOST_KEY_CHANGED)\n+  assert.equal(classifySshError('Offending ECDSA key in /home/u/.ssh/known_hosts:5'), SSH_ERROR.HOST_KEY_CHANGED)\n+})\n+\n+test('classifySshError detects auth failure', () => {\n+  assert.equal(classifySshError('Permission denied (publickey).'), SSH_ERROR.AUTH_FAILED)\n+  assert.equal(classifySshError('Too many authentication failures'), SSH_ERROR.AUTH_FAILED)\n+})\n+\n+test('classifySshError detects unreachable', () => {\n+  assert.equal(classifySshError('ssh: Could not resolve hostname nope'), SSH_ERROR.UNREACHABLE)\n+  assert.equal(classifySshError('connect to host x port 22: Connection refused'), SSH_ERROR.UNREACHABLE)\n+})\n+\n+test('sshErrorMessage gives actionable guidance for auth and host-key-change', () => {\n+  const conn = { user: 'me', host: 'box', port: 22 }\n+  assert.match(sshErrorMessage(SSH_ERROR.AUTH_FAILED, conn, 'Permission denied'), /ssh-agent|ssh-add|IdentityFile/)\n+  assert.match(sshErrorMessage(SSH_ERROR.HOST_KEY_CHANGED, conn, 'CHANGED'), /ssh-keygen -R box/)\n+})\n+\n+// A fake child process that emits a scripted result on next tick.\n+function fakeChild({ code = 0, stdout = '', stderr = '', errorEvent = null, hang = false }: any = {}) {\n+  const child: any = new EventEmitter()\n+  child.stdout = new EventEmitter()\n+  child.stderr = new EventEmitter()\n+\n+  child.kill = () => {\n+    child._killed = true\n+  }\n+\n+  if (hang) {\n+    return child // never emits close → drives the timeout path\n+  }\n+\n+  process.nextTick(() => {\n+    if (errorEvent) {\n+      child.emit('error', errorEvent)\n+\n+      return\n+    }\n+\n+    if (stdout) {\n+      child.stdout.emit('data', Buffer.from(stdout))\n+    }\n+\n+    if (stderr) {\n+      child.stderr.emit('data', Buffer.from(stderr))\n+    }\n+\n+    child.emit('close', code)\n+  })\n+\n+  return child\n+}\n+\n+// Build a spawnFn that returns scripted children per ssh invocation, recording\n+// the args it was called with.\n+function scriptedSpawn(scripts) {\n+  const calls: any[] = []\n+  let i = 0\n+\n+  const fn: any = (_cmd, args) => {\n+    calls.push(args)\n+    const script = typeof scripts === 'function' ? scripts(args, i) : scripts[Math.min(i, scripts.length - 1)]\n+    i += 1\n+\n+    return fakeChild(script || {})\n+  }\n+\n+  fn.calls = calls\n+\n+  return fn\n+}\n+\n+test('open() establishes the master when not already alive', async () => {\n+  // `-O check` fails first (not alive) → master opens (code 0). Track which\n+  // ssh ops ran rather than re-probing with the same always-failing check.\n+  const ops: string[] = []\n+\n+  const spawnFn = scriptedSpawn(args => {\n+    ops.push(args.includes('check') ? 'check' : args.includes('-M') ? 'master' : 'other')\n+\n+    if (args.includes('check')) {\n+      return { code: 255, stderr: 'no control path' }\n+    }\n+\n+    return { code: 0 }\n+  })\n+\n+  const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })\n+  await conn.open()\n+  assert.deepEqual(ops, ['check', 'master'], 'probes liveness first, then opens the master')\n+})\n+\n+test('open() is a no-op when the master is already alive and execs verify', async () => {\n+  const ops: string[] = []\n+\n+  const spawnFn = scriptedSpawn(args => {\n+    ops.push(args.includes('check') ? 'check' : args.includes('exit 0') ? 'verify' : 'master')\n+\n+    return { code: 0 } // check succeeds → alive; verify exec succeeds → trusted\n+  })\n+\n+  const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })\n+  await conn.open()\n+  assert.deepEqual(ops, ['check', 'verify'], 'alive master is exec-verified, then trusted without reopening')\n+})\n+\n+test('open() evicts a wedged master (check passes, exec hangs) and dials fresh', async () => {\n+  // The macOS mode-switch wedge: ControlPersist master answers -O check but\n+  // every exec through it hangs. open() must verify, evict (-O exit), and\n+  // establish a fresh master instead of trusting the corpse.\n+  const ops: string[] = []\n+\n+  const spawnFn = scriptedSpawn(args => {\n+    if (args.includes('check')) {\n+      ops.push('check')\n+\n+      return { code: 0 }\n+    }\n+\n+    if (args.includes('exit 0')) {\n+      ops.push('verify')\n+\n+      return { hang: true }\n+    }\n+\n+    if (args.includes('-O')) {\n+      ops.push('evict')\n+\n+      return { code: 0 }\n+    }\n+\n+    ops.push('master')\n+\n+    return { code: 0 }\n+  })\n+\n+  const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d', connectTimeoutMs: 50 })\n+\n+  await conn.open()\n+  assert.deepEqual(\n+    ops,\n+    ['check', 'verify', 'evict', 'master'],\n+    'wedged master: verified, evicted, then a fresh master is dialed'\n+  )\n+})\n+\n+test('close() removes the control socket when -O exit fails', async () => {\n+  const dir = path.join(os.tmpdir(), `hermes-ssh-close-${process.pid}-${Date.now()}`)\n+  fs.mkdirSync(dir, { recursive: true, mode: 0o700 })\n+\n+  const spawnFn = scriptedSpawn(args => {\n+    if (args.includes('check')) {\n+      return { code: 255 }\n+    } // not alive → open dials master\n+\n+    if (args.includes('-M')) {\n+      return { code: 0 }\n+    }\n+\n+    return { code: 255, stderr: 'mux: master gone' } // -O exit fails\n+  })\n+\n+  const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir })\n+  await conn.open()\n+  fs.writeFileSync(conn.controlPath, '') // simulate the lingering socket file\n+  await conn.close()\n+  assert.ok(!fs.existsSync(conn.controlPath), 'failed -O exit drops the socket so the next open dials fresh')\n+  fs.rmSync(dir, { recursive: true, force: true })\n+})\n+\n+test('open() creates the control-socket directory if it does not exist', async () => {\n+  const dir = path.join(os.tmpdir(), `hermes-ssh-test-${process.pid}-${Date.now()}`)\n+  assert.ok(!fs.existsSync(dir), 'precondition: control dir absent')\n+  const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))\n+  const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir })\n+\n+  try {\n+    await conn.open()\n+    assert.ok(fs.existsSync(dir), 'open() created the control-socket directory before spawning ssh')\n+  } finally {\n+    try {\n+      fs.rmSync(dir, { recursive: true, force: true })\n+    } catch {\n+      /* ignore */\n+    }\n+  }\n+})\n+\n+test('open() surfaces a classified auth error', async () => {\n+  const spawnFn = scriptedSpawn(args => {\n+    if (args.includes('check')) {\n+      return { code: 255 }\n+    }\n+\n+    return { code: 255, stderr: 'Permission denied (publickey).' }\n+  })\n+\n+  const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })\n+  await assert.rejects(\n+    () => conn.open(),\n+    (err: any) => {\n+      assert.equal(err.kind, SSH_ERROR.AUTH_FAILED)\n+      assert.match(err.message, /ssh-agent|ssh-add/)\n+\n+      return true\n+    }\n+  )\n+})\n+\n+test('exec() returns stdout on success and rejects (classified) on failure', async () => {\n+  const okSpawn = scriptedSpawn([{ code: 0, stdout: 'Linux\\n' }])\n+  const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn: okSpawn, controlDir: '/tmp/d' })\n+  assert.equal((await conn.exec('uname -s')).trim(), 'Linux')\n+\n+  const failSpawn = scriptedSpawn([{ code: 1, stderr: 'ssh: Could not resolve hostname box' }])\n+  const conn2 = new SshConnection({ host: 'box', user: 'me' }, { spawnFn: failSpawn, controlDir: '/tmp/d' })\n+  await assert.rejects(\n+    () => conn2.exec('uname -s'),\n+    (err: any) => {\n+      assert.equal(err.kind, SSH_ERROR.UNREACHABLE)\n+\n+      return true\n+    }\n+  )\n+})\n+\n+test('exec() treats a hung ssh as a timeout (half-open connection)', async () => {\n+  const spawnFn = scriptedSpawn([{ hang: true }])\n+  const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })\n+  await assert.rejects(\n+    () => conn.exec('uname -s', { timeoutMs: 30 }),\n+    (err: any) => {\n+      assert.equal(err.kind, SSH_ERROR.TIMEOUT)\n+\n+      return true\n+    }\n+  )\n+})\n+\n+test('forward() issues -O forward with a loopback-bound -L spec', async () => {\n+  const spawnFn = scriptedSpawn([{ code: 0 }])\n+  const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })\n+  await conn.forward(5000, 6000)\n+  const args = spawnFn.calls[0]\n+  assert.equal(args[0], '-O')\n+  assert.equal(args[1], 'forward')\n+  assert.ok(args.includes('127.0.0.1:5000:127.0.0.1:6000'))\n+})\n+\n+test('lifecycle logging passes through redaction', async () => {\n+  const logs: string[] = []\n+  const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))\n+\n+  const conn = new SshConnection(\n+    { host: 'box', user: 'me' },\n+    { spawnFn, controlDir: '/tmp/d', rememberLog: l => logs.push(l) }\n+  )\n+\n+  await conn.open()\n+\n+  // none of the emitted log lines may carry a raw token-shaped secret\n+  for (const line of logs) {\n+    assert.ok(!/token=[redacted:secret-assignment]\n+  }\n+\n+  assert.ok(logs.some(l => l.includes('[ssh]')))\n+})\n+\n+test('no-mux: ssh args carry no ControlMaster/ControlPath options', async () => {\n+  const spawnFn = scriptedSpawn({ code: 0 })\n+  const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false })\n+  await conn.open()\n+\n+  for (const args of spawnFn.calls) {\n+    assert.ok(!args.some(a => /ControlMaster|ControlPath|ControlPersist/.test(a)), `mux option leaked: ${args}`)\n+  }\n+})\n+\n+test('no-mux: open() verifies auth with a one-shot exec, no -M master', async () => {\n+  const spawnFn = scriptedSpawn({ code: 0 })\n+  const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false })\n+  await conn.open()\n+  assert.ok(!spawnFn.calls.some(args => args.includes('-M')), 'no master should be spawned')\n+  assert.ok(\n+    spawnFn.calls.some(args => args[args.length - 1] === 'exit 0'),\n+    'liveness/openness via one-shot exec'\n+  )\n+})\n+\n+test('SSH probe never creates or closes a ControlMaster', async () => {\n+  const spawnFn = scriptedSpawn({ code: 0 })\n+  const conn = createSshProbeConnection({ host: 'box', user: 'me' }, { spawnFn })\n+  await conn.open()\n+  await conn.close()\n+  const args = spawnFn.calls.flat()\n+  assert.ok(!args.includes('-M'))\n+  assert.ok(!args.includes('-O'))\n+  assert.ok(!args.some(value => /Control(?:Master|Path|Persist)/.test(value)))\n+})\n+\n+test('no-mux: open() classifies auth failure', async () => {\n+  const spawnFn = scriptedSpawn([{ code: 255, stderr: 'me@box: Permission denied (publickey).' }])\n+  const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false })\n+  await assert.rejects(conn.open(), (err: any) => err.kind === 'auth-failed')\n+})\n+\n+test('no-mux: forward spawns a persistent -N -L child; cancel + close kill it', async () => {\n+  // Real listener stands in for the tunnel's local end so waitForLocalPort sees it.\n+  const net = await import('node:net')\n+  const srv = net.createServer()\n+  await new Promise<void>(r => srv.listen(0, '127.0.0.1', () => r()))\n+  const localPort = (srv.address() as any).port\n+  const tunnels: any[] = []\n+\n+  const spawnFn: any = (_cmd, args) => {\n+    const child: any = new EventEmitter()\n+    child.stderr = new EventEmitter()\n+    child.exitCode = null\n+\n+    child.kill = () => {\n+      child._killed = true\n+      child.exitCode = 0\n+      process.nextTick(() => child.emit('exit', 0))\n+\n+      return true\n+    }\n+\n+    if (args.includes('-N')) {\n+      tunnels.push({ args, child })\n+      process.nextTick(() =>\n+        child.stderr.emit('data', Buffer.from(`Local forwarding listening on 127.0.0.1 port ${localPort}.`))\n+      )\n+    } else {\n+      process.nextTick(() => child.emit('close', 0))\n+    }\n+\n+    if (!args.includes('-N')) {\n+      child.stdout = new EventEmitter()\n+      process.nextTick(() => child.emit('close', 0))\n+    }\n+\n+    return child\n+  }\n+\n+  const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false })\n+  await conn.forward(localPort, 9119)\n+  assert.equal(tunnels.length, 1, 'one persistent tunnel child')\n+  assert.ok(tunnels[0].args.includes('-L'), 'tunnel child carries -L spec')\n+  assert.ok(!tunnels[0].args.some(a => /ControlPath/.test(a)))\n+\n+  await conn.cancelForward(localPort, 9119)\n+  assert.ok(tunnels[0].child._killed, 'cancelForward kills the tunnel child')\n+\n+  conn._opened = true\n+  await conn.close() // no-mux close never runs ssh -O exit; must not throw\n+  srv.close()\n+})\n+\n+test('no-mux: forward fails fast when the tunnel child dies (bad spec/auth)', async () => {\n+  const spawnFn: any = (_cmd, args) => {\n+    const child: any = new EventEmitter()\n+    child.stderr = new EventEmitter()\n+    child.exitCode = null\n+\n+    child.kill = () => {}\n+\n+    if (args.includes('-N')) {\n+      process.nextTick(() => {\n+        child.stderr.emit('data', Buffer.from('Permission denied (publickey).'))\n+        child.exitCode = 255\n+      })\n+    }\n+\n+    return child\n+  }\n+\n+  const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false, forwardTimeoutMs: 2000 })\n+  await assert.rejects(conn.forward(1, 9119), (err: any) => err.kind === 'auth-failed')\n+})\n+\n+test('no-mux: an unrelated listener cannot mask a delayed bind failure', async () => {\n+  const net = await import('node:net')\n+  const srv = net.createServer()\n+  await new Promise<void>(resolve => srv.listen(0, '127.0.0.1', resolve))\n+  const localPort = (srv.address() as any).port\n+\n+  const spawnFn: any = (_cmd, args) => {\n+    const child: any = new EventEmitter()\n+    child.stderr = new EventEmitter()\n+    child.exitCode = null\n+\n+    child.kill = () => {}\n+\n+    if (args.includes('-N')) {\n+      setTimeout(() => {\n+        child.stderr.emit('data', Buffer.from(`bind [127.0.0.1]:${localPort}: Address already in use`))\n+        child.exitCode = 255\n+        child.emit('exit', 255)\n+      }, 20)\n+    }\n+\n+    return child\n+  }\n+\n+  const conn = new SshConnection({ host: 'box' }, { spawnFn, mux: false, forwardTimeoutMs: 1000 })\n+  await assert.rejects(conn.forward(localPort, 9119), /address already in use/i)\n+  srv.close()\n+})\n+\n+test('no-mux: tunnel death after readiness makes the connection unhealthy', async () => {\n+  const net = await import('node:net')\n+  const srv = net.createServer()\n+  await new Promise<void>(resolve => srv.listen(0, '127.0.0.1', resolve))\n+  const localPort = (srv.address() as any).port\n+  let tunnel\n+\n+  const spawnFn: any = (_cmd, args) => {\n+    const child: any = new EventEmitter()\n+    child.stdout = new EventEmitter()\n+    child.stderr = new EventEmitter()\n+    child.exitCode = null\n+\n+    child.kill = () => {}\n+\n+    if (args.includes('-N')) {\n+      tunnel = child\n+      process.nextTick(() =>\n+        child.stderr.emit('data', Buffer.from(`Local forwarding listening on 127.0.0.1 port ${localPort}.`))\n+      )\n+    } else {\n+      process.nextTick(() => child.emit('close', 0))\n+    }\n+\n+    return child\n+  }\n+\n+  const conn = new SshConnection({ host: 'box' }, { spawnFn, mux: false })\n+  await conn.open()\n+  await conn.forward(localPort, 9119)\n+  tunnel.emit('exit', 255)\n+  assert.equal(await conn.isAlive(), false)\n+  srv.close()\n+})\n+\n+test('validateSshTarget rejects a host starting with a dash (option injection)', () => {\n+  assert.throws(() => validateSshTarget('-oProxyCommand=evil', '', 22), /unsafe/i)\n+  assert.throws(() => validateSshTarget('--version', '', 22), /unsafe/i)\n+})\n+\n+test('validateSshTarget rejects control characters in host', () => {\n+  assert.throws(() => validateSshTarget('host\\x00evil', '', 22), /unsafe/i)\n+  assert.throws(() => validateSshTarget('host\\nnewline', '', 22), /unsafe/i)\n+  assert.throws(() => validateSshTarget('host\\ttab', '', 22), /unsafe/i)\n+})\n+\n+test('validateSshTarget rejects control characters in user', () => {\n+  assert.throws(() => validateSshTarget('box', 'me\\x00root', 22), /unsafe/i)\n+  assert.throws(() => validateSshTarget('box', '-oForward=yes', 22), /unsafe/i)\n+})\n+\n+test('validateSshTarget rejects ports outside 1-65535', () => {\n+  assert.throws(() => validateSshTarget('box', '', 0), /port/i)\n+  assert.throws(() => validateSshTarget('box', '', 65536), /port/i)\n+  assert.throws(() => validateSshTarget('box', '', -1), /port/i)\n+  assert.throws(() => validateSshTarget('box', '', NaN), /port/i)\n+})\n+\n+test('validateSshTarget accepts valid targets', () => {\n+  assert.doesNotThrow(() => validateSshTarget('my-host.example.com', 'alice', 22))\n+  assert.doesNotThrow(() => validateSshTarget('192.168.1.1', '', 2222))\n+  assert.doesNotThrow(() => validateSshTarget('::1', 'root', 22))\n+})\n+\n+test('SshConnection constructor rejects hostile host/user/port', () => {\n+  assert.throws(() => new SshConnection({ host: '-oProxyCommand=evil' }), /unsafe/i)\n+  assert.throws(() => new SshConnection({ host: 'box', user: '-oForward' }), /unsafe/i)\n+  assert.throws(() => new SshConnection({ host: 'box', port: 99999 }), /port/i)\n+})\n+\n+test('buildExecArgs inserts -- before the destination', () => {\n+  const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }\n+  const args = buildExecArgs(conn, 'uname -s', 15000)\n+  const ddIdx = args.indexOf('--')\n+  assert.ok(ddIdx >= 0, 'must contain --')\n+  assert.equal(args[ddIdx + 1], 'me@box', '-- immediately precedes the destination')\n+  assert.equal(args[ddIdx + 2], 'uname -s', 'remote command follows destination')\n+})\n+\n+test('buildMasterArgs inserts -- before the destination', () => {\n+  const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }\n+  const args = buildMasterArgs(conn, 15000)\n+  const ddIdx = args.indexOf('--')\n+  assert.ok(ddIdx >= 0, 'must contain --')\n+  assert.equal(args[ddIdx + 1], 'me@box')\n+})\n+\n+test('buildControlArgs inserts -- before the destination', () => {\n+  const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }\n+  const args = buildControlArgs(conn, 'check', [], 15000)\n+  const ddIdx = args.indexOf('--')\n+  assert.ok(ddIdx >= 0, 'must contain --')\n+  assert.equal(args[ddIdx + 1], 'me@box')\n+})\n+\n+test('buildInteractiveSshArgs inserts -- before the destination', () => {\n+  const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }\n+  const args = buildInteractiveSshArgs(conn, '', 15000)\n+  const ddIdx = args.indexOf('--')\n+  assert.ok(ddIdx >= 0, 'must contain --')\n+  assert.equal(args[ddIdx + 1], 'me@box')\n+})\n+\n+test('hostArgs rejects a keyPath with control characters', () => {\n+  assert.throws(() => hostArgs({ keyPath: '/tmp/key\\x00inject' }), /unsafe/i)\n+})\n+\n+test('hostArgs rejects a keyPath starting with a dash', () => {\n+  assert.throws(() => hostArgs({ keyPath: '-oProxyCommand=evil' }), /unsafe/i)\n+})\n+\n+test('hostArgs accepts valid key paths', () => {\n+  assert.deepEqual(hostArgs({ keyPath: '/home/user/.ssh/id_ed25519' }), ['-i', '/home/user/.ssh/id_ed25519'])\n+  assert.deepEqual(hostArgs({ keyPath: '~/.ssh/id_rsa' }), ['-i', '~/.ssh/id_rsa'])\n+})\n+\n+test('runSsh delivers stdinData to the child and does not log it', async () => {\n+  let stdinWritten = ''\n+\n+  const spawnFn: any = (_cmd, _args, opts) => {\n+    const child: any = new EventEmitter()\n+    child.stdout = new EventEmitter()\n+    child.stderr = new EventEmitter()\n+\n+    child.kill = () => {}\n+    child.stdin = {\n+      end(data) {\n+        stdinWritten = String(data)\n+      }\n+    }\n+    assert.equal(opts.stdio[0], 'pipe', 'stdin must be pipe when stdinData is provided')\n+    process.nextTick(() => child.emit('close', 0))\n+\n+    return child\n+  }\n+\n+  await runSsh(['host', 'cat'], { timeoutMs: 5000, spawnFn, stdinData: 'secret-token-value' })\n+  assert.equal(stdinWritten, 'secret-token-value', 'stdinData must be written to child.stdin')\n+})\n+\n+test('open() rejects a control-dir that is a symlink', async () => {\n+  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-test-'))\n+  const real = path.join(tmp, 'real')\n+  const link = path.join(tmp, 'link')\n+  fs.mkdirSync(real, { mode: 0o700 })\n+  fs.symlinkSync(real, link)\n+  const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))\n+  const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: link })\n+  await assert.rejects(conn.open(), /symlink|unsafe/i)\n+  fs.rmSync(tmp, { recursive: true, force: true })\n+})\n+\n+test('open() enforces 0700 on an existing control dir with lax permissions', async () => {\n+  if (process.platform === 'win32') {\n+    return\n+  }\n+\n+  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-test-'))\n+  const dir = path.join(tmp, 'ctrl')\n+  fs.mkdirSync(dir, { mode: 0o755 })\n+  const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))\n+  const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir })\n+  await conn.open()\n+  const stat = fs.statSync(dir)\n+  assert.equal(stat.mode & 0o777, 0o700, 'control dir must be tightened to 0700')\n+  fs.rmSync(tmp, { recursive: true, force: true })\n+})\n+\n+test('control socket identity separates installation scope and key identity', () => {\n+  const base = controlSocketPath('me', 'box', 22, '/tmp/d', {\n+    ownershipId: 'installation-a',\n+    scope: 'primary',\n+    keyPath: '/keys/id'\n+  })\n+\n+  assert.equal(\n+    base,\n+    controlSocketPath('me', 'box', 22, '/tmp/d', {\n+      ownershipId: 'installation-a',\n+      scope: 'primary',\n+      keyPath: '/keys/./id'\n+    })\n+  )\n+  assert.notEqual(\n+    base,\n+    controlSocketPath('me', 'box', 22, '/tmp/d', {\n+      ownershipId: 'installation-a',\n+      scope: 'worker',\n+      keyPath: '/keys/id'\n+    })\n+  )\n+  assert.notEqual(\n+    base,\n+    controlSocketPath('me', 'box', 22, '/tmp/d', {\n+      ownershipId: 'installation-b',\n+      scope: 'primary',\n+      keyPath: '/keys/id'\n+    })\n+  )\n+  assert.notEqual(\n+    base,\n+    controlSocketPath('me', 'box', 22, '/tmp/d', {\n+      ownershipId: 'installation-a',\n+      scope: 'primary',\n+      keyPath: '/keys/other'\n+    })\n+  )\n+  assert.notEqual(\n+    base,\n+    controlSocketPath('me', 'box', 22, '/tmp/d', {\n+      ownershipId: 'installation-a',\n+      scope: 'primary',\n+      keyPath: '/keys/id',\n+      effectiveConfigFingerprint: 'changed-config'\n+    })\n+  )\n+})\n+\n+test('closing one scope addresses only that scope control master', async () => {\n+  const firstSpawn = scriptedSpawn({ code: 0 })\n+  const secondSpawn = scriptedSpawn({ code: 0 })\n+\n+  const first = new SshConnection(\n+    { host: 'box', user: 'me' },\n+    {\n+      spawnFn: firstSpawn,\n+      controlDir: '/tmp/d',\n+      ownershipId: 'installation',\n+      scope: 'first'\n+    }\n+  )\n+\n+  const second = new SshConnection(\n+    { host: 'box', user: 'me' },\n+    {\n+      spawnFn: secondSpawn,\n+      controlDir: '/tmp/d',\n+      ownershipId: 'installation',\n+      scope: 'second'\n+    }\n+  )\n+\n+  first._opened = true\n+  second._opened = true\n+  await first.close()\n+  assert.notEqual(first.controlPath, second.controlPath)\n+  assert.ok(firstSpawn.calls[0].includes(`ControlPath=${first.controlPath}`))\n+  assert.ok(!firstSpawn.calls[0].includes(`ControlPath=${second.controlPath}`))\n+  assert.equal(second._opened, true)\n+})\n+\n+test('failed ControlMaster close disowns the master instead of retrying it', async () => {\n+  // Old contract kept _opened=true for a retry — which left wedged ControlPersist\n+  // masters trusted and reattachable (the macOS mode-switch livelock). New\n+  // contract: a master that refuses -O exit is disowned — socket dropped,\n+  // connection marked closed — so the next open dials fresh.\n+  const spawnFn = scriptedSpawn([{ code: 255, stderr: 'master refused exit' }])\n+  const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })\n+  conn._opened = true\n+  await conn.close()\n+  assert.equal(conn._opened, false)\n+  assert.equal(spawnFn.calls.length, 1)\n+})\n+\n+test('stopTunnelChild waits for process exit', async () => {\n+  const child: any = new EventEmitter()\n+  child.exitCode = null\n+\n+  child.kill = () => {\n+    process.nextTick(() => {\n+      child.exitCode = 0\n+      child.emit('exit', 0)\n+    })\n+\n+    return true\n+  }\n+\n+  let stopped = false\n+\n+  const stopping = stopTunnelChild(child).then(() => {\n+    stopped = true\n+  })\n+\n+  assert.equal(stopped, false)\n+  await stopping\n+  assert.equal(stopped, true)\n+})"]},{"file":"apps/desktop/electron/ssh-connection.ts","hunks":["@@ -0,0 +1,921 @@\n+/**\n+ * ssh-connection.ts\n+ *\n+ * Pure, electron-free OpenSSH ControlMaster connection manager for Desktop SSH\n+ * remote mode. Uses the system `ssh` client (not a JS SSH library) so it\n+ * inherits ~/.ssh/config, the agent, jump hosts (ProxyJump), and hardware keys\n+ * for free — the same rationale as tools/environments/ssh.py.\n+ *\n+ * No `import 'electron'` so it is unit-testable without Electron. main.ts\n+ * wires it into the electron-coupled lifecycle.\n+ *\n+ * Conventions mirrored from tools/environments/ssh.py:\n+ *   - ControlMaster=auto + ControlPersist so one TCP/auth handshake is reused\n+ *     across exec/forward operations.\n+ *   - Hashed control-socket filename under a short tmpdir to stay under the\n+ *     104-byte sun_path limit macOS enforces on Unix domain sockets.\n+ *   - BatchMode=yes for every programmatic invocation — a spawned ssh must\n+ *     never hang on an interactive prompt (passphrase / 2FA). If auth needs\n+ *     interactivity we fail fast and tell the user to load the key into their\n+ *     agent.\n+ *\n+ * Host-key policy: StrictHostKeyChecking=accept-new (trust-on-first-use, log\n+ * the fingerprint), never `no`. A host-key *change* fails closed with the\n+ * verbatim OpenSSH error surfaced to the UI.\n+ *\n+ * Every operation is raced against a hard timeout. A half-open TCP connection\n+ * after laptop sleep can leave ssh hanging indefinitely rather than erroring;\n+ * timeout is treated as connection-dead so the caller does a full reconnect\n+ * rather than retrying in place.\n+ */\n+\n+import { spawn } from 'node:child_process'\n+import crypto from 'node:crypto'\n+import fs from 'node:fs'\n+import net from 'node:net'\n+import os from 'node:os'\n+import path from 'node:path'\n+\n+const DEFAULT_CONNECT_TIMEOUT_MS = 15_000\n+const DEFAULT_EXEC_TIMEOUT_MS = 20_000\n+const DEFAULT_FORWARD_TIMEOUT_MS = 15_000\n+const CONTROL_PERSIST_SECONDS = 300\n+\n+// eslint-disable-next-line no-control-regex -- deliberately reject control chars in ssh targets\n+const _CONTROL_CHAR_RE = /[\\x00-\\x1f\\x7f]/\n+\n+function validateSshTarget(host, user, port) {\n+  if (!host || typeof host !== 'string') {\n+    throw new Error('Unsafe SSH target: host is required.')\n+  }\n+\n+  if (host.startsWith('-')) {\n+    throw new Error(`Unsafe SSH target: host must not start with a dash (\"${host}\").`)\n+  }\n+\n+  if (_CONTROL_CHAR_RE.test(host)) {\n+    throw new Error('Unsafe SSH target: host contains control characters.')\n+  }\n+\n+  if (user && _CONTROL_CHAR_RE.test(user)) {\n+    throw new Error('Unsafe SSH target: user contains control characters.')\n+  }\n+\n+  if (user && user.startsWith('-')) {\n+    throw new Error(`Unsafe SSH target: user must not start with a dash (\"${user}\").`)\n+  }\n+\n+  const p = Number(port)\n+\n+  if (!Number.isInteger(p) || p < 1 || p > 65535) {\n+    throw new Error(`Unsafe SSH port: ${port} (must be 1-65535).`)\n+  }\n+}\n+\n+function validateKeyPath(keyPath) {\n+  if (!keyPath) {\n+    return\n+  }\n+\n+  if (_CONTROL_CHAR_RE.test(keyPath)) {\n+    throw new Error('Unsafe SSH key path: contains control characters.')\n+  }\n+\n+  if (keyPath.startsWith('-')) {\n+    throw new Error(`Unsafe SSH key path: must not start with a dash (\"${keyPath}\").`)\n+  }\n+}\n+\n+// Token / secret redaction\n+\n+const _REDACTIONS: Array<[RegExp, string]> = [\n+  [/(HERMES_DASHBOARD_SESSION_TOKEN=[redacted:secret-assignment] '$1<redacted>'],\n+  [/(X-Hermes-Session-Token[\"']?\\s*[:=]\\s*[\"']?)([^\\s\"'&]+)/gi, '$1<redacted>'],\n+  [/(Authorization[\"']?\\s*:\\s*Bearer\\s+)(\\S+)/gi, '$1<redacted>'],\n+  [/([?&](?:token|ticket)=)([^\\s&\"']+)/gi, '$1<redacted>']\n+]\n+\n+function redactSecrets(text) {\n+  let out = String(text == null ? '' : text)\n+\n+  for (const [re, repl] of _REDACTIONS) {\n+    out = out.replace(re, repl)\n+  }\n+\n+  return out\n+}\n+\n+// Control-socket path\n+\n+// Hash user@host:port to a short, stable, filesystem-safe socket id — stable\n+// across reconnects so ControlMaster reuse works, short so the full path stays\n+// under sun_path's 104-byte limit.\n+//\n+// CRITICAL (macOS): the base dir must be SHORT. os.tmpdir() on macOS is the\n+// per-user `/var/folders/xx/yyyy…/T/` (~49 bytes), and OpenSSH binds a\n+// TEMPORARY listener at `<ControlPath>.<16 random chars>` while establishing\n+// the master — so a path that itself fits 104 still overflows at bind time. We\n+// root under a short per-user base (`~/.hermes/desktop-ssh`) so even worst case\n+// (~72 bytes on macOS) stays clear. Windows has no AF_UNIX sun_path limit.\n+function controlSocketPath(user, host, port, baseDir?, identity: any = {}) {\n+  const dir = baseDir || defaultControlDir()\n+  const keyPathIdentity = path.normalize(String(identity.keyPath || ''))\n+\n+  const parts = [\n+    identity.ownershipId || '',\n+    identity.scope || '',\n+    user || '',\n+    host,\n+    Number(port),\n+    keyPathIdentity,\n+    identity.effectiveConfigFingerprint || ''\n+  ]\n+\n+  const id = crypto.createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16)\n+\n+  return path.join(dir, `${id}.sock`)\n+}\n+\n+function defaultControlDir() {\n+  // POSIX: a SHORT, PER-USER base stays under the socket limit AND avoids a\n+  // world-shared /tmp dir (no symlink-hijack surface). Created 0700 in open().\n+  if (process.platform === 'win32') {\n+    return path.join(os.tmpdir(), 'hermes-desktop-ssh')\n+  }\n+\n+  return path.join(os.homedir(), '.hermes', 'desktop-ssh')\n+}\n+\n+// Command construction (pure — the unit tests exercise these directly)\n+\n+// Mux (POSIX): ControlMaster options so exec/forward share one authenticated\n+// connection. No-mux (Windows OpenSSH never implemented mux sockets): plain\n+// per-invocation options — each ssh call authenticates on its own.\n+function baseSshOptions(controlPath, connectTimeoutMs?) {\n+  const connectSecs = Math.max(1, Math.round((connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS) / 1000))\n+\n+  const mux = controlPath\n+    ? [\n+        '-o',\n+        `ControlPath=${controlPath}`,\n+        '-o',\n+        'ControlMaster=auto',\n+        '-o',\n+        `ControlPersist=${CONTROL_PERSIST_SECONDS}`\n+      ]\n+    : []\n+\n+  return [\n+    ...mux,\n+    '-o',\n+    'BatchMode=yes',\n+    '-o',\n+    'StrictHostKeyChecking=accept-new',\n+    '-o',\n+    'ExitOnForwardFailure=yes',\n+    '-o',\n+    `ConnectTimeout=${connectSecs}`\n+  ]\n+}\n+\n+// Non-default port and explicit identity file, shared by exec/master/forward.\n+function hostArgs({ port, keyPath }: { port?: number | string; keyPath?: string } = {}) {\n+  const args: string[] = []\n+\n+  if (port && Number(port) !== 22) {\n+    args.push('-p', String(port))\n+  }\n+\n+  if (keyPath) {\n+    validateKeyPath(keyPath)\n+    args.push('-i', keyPath)\n+  }\n+\n+  return args\n+}\n+\n+function target(user, host) {\n+  return user ? `${user}@${host}` : host\n+}\n+\n+function buildExecArgs(conn, remoteCommand, connectTimeoutMs?) {\n+  return [\n+    ...baseSshOptions(conn.controlPath, connectTimeoutMs),\n+    ...hostArgs(conn),\n+    '--',\n+    target(conn.user, conn.host),\n+    remoteCommand\n+  ]\n+}\n+\n+function buildControlArgs(conn, op, extra: string[] = [], connectTimeoutMs?) {\n+  return [\n+    '-O',\n+    op,\n+    ...extra,\n+    ...baseSshOptions(conn.controlPath, connectTimeoutMs),\n+    ...hostArgs(conn),\n+    '--',\n+    target(conn.user, conn.host)\n+  ]\n+}\n+\n+// Open the master explicitly: `-M -N -f` backgrounds ssh once the master is up,\n+// so the spawn resolves when the connection is established (or fails fast under\n+// BatchMode if auth is non-interactive-only).\n+function buildMasterArgs(conn, connectTimeoutMs?) {\n+  return [\n+    '-M',\n+    '-N',\n+    '-f',\n+    ...baseSshOptions(conn.controlPath, connectTimeoutMs),\n+    ...hostArgs(conn),\n+    '--',\n+    target(conn.user, conn.host)\n+  ]\n+}\n+\n+// Interactive `ssh -tt` for the INTERIM remote terminal (SSH mode only). Reuses\n+// the existing ControlMaster socket so NO new auth handshake happens — the\n+// master is already open, so this attaches instantly and never prompts.\n+//\n+// NOTE(remote-terminal): interim until the dashboard /api/terminal WebSocket\n+// lands (specs/desktop-remote-terminal.md); delete this path then.\n+function buildInteractiveSshArgs(conn, remoteCwd, connectTimeoutMs?, remoteCommand?) {\n+  const args = [\n+    '-tt',\n+    ...baseSshOptions(conn.controlPath, connectTimeoutMs),\n+    ...hostArgs(conn),\n+    '--',\n+    target(conn.user, conn.host)\n+  ]\n+\n+  if (remoteCommand) {\n+    args.push(remoteCommand)\n+\n+    return args\n+  }\n+\n+  const cwd = String(remoteCwd || '').trim()\n+\n+  if (cwd) {\n+    const q = `'${cwd.replace(/'/g, `'\\\\''`)}'`\n+    args.push(`cd ${q} 2>/dev/null; exec \"$SHELL\" -l`)\n+  } else {\n+    args.push('exec \"$SHELL\" -l')\n+  }\n+\n+  return args\n+}\n+\n+// Bind the local end to 127.0.0.1 ONLY — never 0.0.0.0 — so the tunnel does not\n+// re-expose the remote dashboard to the client's LAN.\n+function forwardSpec(localPort, remotePort, remoteHost = '127.0.0.1') {\n+  return `127.0.0.1:${localPort}:${remoteHost}:${remotePort}`\n+}\n+\n+// Error classification — distinct, actionable messages for the UI\n+\n+const SSH_ERROR = {\n+  UNREACHABLE: 'unreachable',\n+  AUTH_FAILED: 'auth-failed',\n+  HOST_KEY_CHANGED: 'host-key-changed',\n+  TIMEOUT: 'timeout',\n+  UNKNOWN: 'unknown'\n+}\n+\n+// Order matters: the host-key-change banner also contains \"WARNING\"/\"Offending\",\n+// so check it before generic auth.\n+function classifySshError(stderr) {\n+  const text = String(stderr || '')\n+\n+  if (\n+    /REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed|Offending (?:key|ECDSA|RSA|ED25519)/i.test(\n+      text\n+    )\n+  ) {\n+    return SSH_ERROR.HOST_KEY_CHANGED\n+  }\n+\n+  if (\n+    /Permission denied|Too many authentication failures|no matching host key|publickey|password|keyboard-interactive/i.test(\n+      text\n+    )\n+  ) {\n+    return SSH_ERROR.AUTH_FAILED\n+  }\n+\n+  if (\n+    /Could not resolve hostname|Connection refused|Connection timed out|No route to host|Network is unreachable|Operation timed out|port \\d+: Connection/i.test(\n+      text\n+    )\n+  ) {\n+    return SSH_ERROR.UNREACHABLE\n+  }\n+\n+  return SSH_ERROR.UNKNOWN\n+}\n+\n+function sshErrorMessage(kind, conn, stderr?) {\n+  const host = target(conn.user, conn.host)\n+\n+  switch (kind) {\n+    case SSH_ERROR.HOST_KEY_CHANGED:\n+      return (\n+        `The host key for ${host} has CHANGED since you last connected. ` +\n+        `This could be a man-in-the-middle attack, or the server was reinstalled. ` +\n+        `SSH refused to connect. Verify the change is expected, then remove the old key ` +\n+        `with \\`ssh-keygen -R ${conn.host}\\` and reconnect.\\n\\n${String(stderr || '').trim()}`\n+      )\n+\n+    case SSH_ERROR.AUTH_FAILED:\n+      return (\n+        `SSH authentication to ${host} failed. Desktop runs ssh non-interactively ` +\n+        `(BatchMode), so a key requiring a passphrase or 2FA must be loaded into your ` +\n+        `ssh-agent first (e.g. \\`ssh-add ~/.ssh/id_ed25519\\`), or set an IdentityFile in ` +\n+        `~/.ssh/config. Original error: ${String(stderr || '').trim()}`\n+      )\n+\n+    case SSH_ERROR.UNREACHABLE:\n+      return `Could not reach ${host} over SSH. Check the host, port, and your network. Original error: ${String(stderr || '').trim()}`\n+\n+    case SSH_ERROR.TIMEOUT:\n+      return `SSH operation to ${host} timed out. The connection may be half-open (e.g. after sleep); reconnecting.`\n+\n+    default:\n+      return `SSH error connecting to ${host}: ${String(stderr || '').trim() || 'unknown failure'}`\n+  }\n+}\n+\n+// Spawn helper — runs an ssh invocation, races it against a hard timeout\n+\n+// Resolves { code, stdout, stderr }. On timeout the child is SIGKILLed and the\n+// promise rejects with err.kind = TIMEOUT. `spawnFn` is injectable for tests.\n+function runSsh(args, { timeoutMs, spawnFn = spawn, stdin = 'ignore', stdinData }: any = {}) {\n+  return new Promise((resolve, reject) => {\n+    const useStdinPipe = stdinData != null || stdin !== 'ignore'\n+    let child\n+\n+    try {\n+      child = spawnFn('ssh', args, { stdio: [useStdinPipe ? 'pipe' : 'ignore', 'pipe', 'pipe'] })\n+    } catch (error) {\n+      reject(error)\n+\n+      return\n+    }\n+\n+    if (stdinData != null && child.stdin) {\n+      child.stdin.end(stdinData)\n+    }\n+\n+    let stdout = ''\n+    let stderr = ''\n+    let settled = false\n+\n+    const timer = setTimeout(() => {\n+      if (settled) {\n+        return\n+      }\n+\n+      settled = true\n+\n+      try {\n+        child.kill('SIGKILL')\n+      } catch {\n+        // already gone\n+      }\n+\n+      const err: any = new Error(`ssh timed out after ${timeoutMs}ms`)\n+      err.kind = SSH_ERROR.TIMEOUT\n+      reject(err)\n+    }, timeoutMs)\n+\n+    child.stdout?.on('data', d => {\n+      stdout += d.toString()\n+    })\n+    child.stderr?.on('data', d => {\n+      stderr += d.toString()\n+    })\n+    child.on('error', error => {\n+      if (settled) {\n+        return\n+      }\n+\n+      settled = true\n+      clearTimeout(timer)\n+      reject(error)\n+    })\n+    child.on('close', code => {\n+      if (settled) {\n+        return\n+      }\n+\n+      settled = true\n+      clearTimeout(timer)\n+      resolve({ code, stdout, stderr })\n+    })\n+  })\n+}\n+\n+function stopTunnelChild(child, timeoutMs = 5_000) {\n+  if (!child || child.exitCode != null || child.signalCode != null) {\n+    return Promise.resolve()\n+  }\n+\n+  return new Promise<void>((resolve, reject) => {\n+    let settled = false\n+\n+    const finish = (error?: unknown) => {\n+      if (settled) {\n+        return\n+      }\n+\n+      settled = true\n+      clearTimeout(timer)\n+      child.off?.('exit', onExit)\n+      child.off?.('error', onError)\n+      error ? reject(error) : resolve()\n+    }\n+\n+    const onExit = () => finish()\n+    const onError = error => finish(error)\n+    const timer = setTimeout(() => finish(new Error('SSH tunnel did not exit after termination.')), timeoutMs)\n+    child.once('exit', onExit)\n+    child.once('error', onError)\n+\n+    try {\n+      if (!child.kill()) {\n+        finish(new Error('SSH tunnel termination was refused.'))\n+      }\n+    } catch (error) {\n+      finish(error)\n+    }\n+  })\n+}\n+\n+// SshConnection — the public manager\n+\n+class SshConnection {\n+  host: string\n+  user: string\n+  port: number\n+  keyPath: string\n+  controlPath: string\n+  _spawnFn: any\n+  _log: (msg: string) => void\n+  _connectTimeoutMs: number\n+  _execTimeoutMs: number\n+  _forwardTimeoutMs: number\n+  _opened: boolean\n+  _mux: boolean\n+  _tunnels: Map<string, any>\n+\n+  constructor(cfg, opts: any = {}) {\n+    if (!cfg || !cfg.host) {\n+      throw new Error('SshConnection requires a host.')\n+    }\n+\n+    const port = cfg.port ? Number(cfg.port) : 22\n+    validateSshTarget(cfg.host, cfg.user || '', port)\n+\n+    if (cfg.keyPath) {\n+      validateKeyPath(cfg.keyPath)\n+    }\n+\n+    this.host = cfg.host\n+    this.user = cfg.user || ''\n+    this.port = port\n+    this.keyPath = cfg.keyPath || ''\n+    // Windows OpenSSH has no ControlMaster (mux sockets were never implemented\n+    // on Win32) — fall back to one ssh invocation per operation and a\n+    // persistent `ssh -N -L` child per tunnel. Empty controlPath routes the\n+    // pure builders onto their no-mux form.\n+    this._mux = opts.mux ?? process.platform !== 'win32'\n+    this.controlPath = this._mux\n+      ? controlSocketPath(this.user, this.host, this.port, opts.controlDir, {\n+          keyPath: this.keyPath,\n+          ownershipId: opts.ownershipId,\n+          scope: opts.scope,\n+          effectiveConfigFingerprint: opts.effectiveConfigFingerprint\n+        })\n+      : ''\n+    this._tunnels = new Map()\n+\n+    this._spawnFn = opts.spawnFn || spawn\n+\n+    this._log = typeof opts.rememberLog === 'function' ? opts.rememberLog : () => {}\n+    this._connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS\n+    this._execTimeoutMs = opts.execTimeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS\n+    this._forwardTimeoutMs = opts.forwardTimeoutMs ?? DEFAULT_FORWARD_TIMEOUT_MS\n+    this._opened = false\n+  }\n+\n+  // Lifecycle logging — ALWAYS through redaction.\n+  _logLine(msg) {\n+    this._log(redactSecrets(`[ssh] ${msg}`))\n+  }\n+\n+  _fail(stderrOrErr, fallbackKind = SSH_ERROR.UNKNOWN) {\n+    if (stderrOrErr && stderrOrErr.kind === SSH_ERROR.TIMEOUT) {\n+      const err: any = new Error(sshErrorMessage(SSH_ERROR.TIMEOUT, this))\n+      err.kind = SSH_ERROR.TIMEOUT\n+\n+      return err\n+    }\n+\n+    const stderr = typeof stderrOrErr === 'string' ? stderrOrErr : stderrOrErr?.message || ''\n+    const kind = stderr ? classifySshError(stderr) : fallbackKind\n+    const err: any = new Error(sshErrorMessage(kind, this, stderr))\n+    err.kind = kind\n+\n+    return err\n+  }\n+\n+  // Open the connection. Mux: start the persistent ControlMaster (idempotent —\n+  // a live master is a no-op). No-mux: there is no master; validate auth +\n+  // reachability with a one-shot `ssh true` so failures classify identically.\n+  async open() {\n+    if (await this.isAlive()) {\n+      // -O check passing is not proof the master works: a ControlPersist master\n+      // can survive a failed teardown with wedged channels (observed on macOS\n+      // after a mode switch — check succeeds, every exec times out). Verify with\n+      // a real exec before trusting it; on failure, evict and dial fresh.\n+      if (!this._mux || (await this._verifyMuxChannel())) {\n+        this._opened = true\n+\n+        return\n+      }\n+\n+      this._logLine('existing control master failed exec verification; evicting stale master')\n+      await this._evictStaleMaster()\n+    }\n+\n+    if (!this._mux) {\n+      this._logLine(`connecting (no-mux) to ${target(this.user, this.host)}:${this.port}`)\n+      let result\n+\n+      try {\n+        result = await runSsh(buildExecArgs(this, 'exit 0', this._connectTimeoutMs), {\n+          timeoutMs: this._connectTimeoutMs,\n+          spawnFn: this._spawnFn\n+        })\n+      } catch (error) {\n+        throw this._fail(error, SSH_ERROR.UNREACHABLE)\n+      }\n+\n+      if (result.code !== 0) {\n+        throw this._fail(result.stderr, SSH_ERROR.UNREACHABLE)\n+      }\n+\n+      this._opened = true\n+      this._logLine('connection verified (no-mux; per-operation ssh)')\n+\n+      return\n+    }\n+\n+    const controlDir = path.dirname(this.controlPath)\n+\n+    try {\n+      fs.mkdirSync(controlDir, { recursive: true, mode: 0o700 })\n+    } catch {\n+      void 0\n+    }\n+\n+    if (process.platform !== 'win32') {\n+      const st = fs.lstatSync(controlDir)\n+\n+      if (st.isSymbolicLink()) {\n+        throw new Error(`Unsafe SSH control dir: ${controlDir} is a symlink.`)\n+      }\n+\n+      if (!st.isDirectory()) {\n+        throw new Error(`Unsafe SSH control dir: ${controlDir} is not a directory.`)\n+      }\n+\n+      if (st.uid !== process.getuid!()) {\n+        throw new Error(`Unsafe SSH control dir: ${controlDir} is owned by uid ${st.uid}, not ${process.getuid!()}.`)\n+      }\n+\n+      if ((st.mode & 0o777) !== 0o700) {\n+        fs.chmodSync(controlDir, 0o700)\n+      }\n+    }\n+\n+    const args = buildMasterArgs(this, this._connectTimeoutMs)\n+    this._logLine(`opening control master to ${target(this.user, this.host)}:${this.port}`)\n+    let result\n+\n+    try {\n+      result = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })\n+    } catch (error) {\n+      throw this._fail(error, SSH_ERROR.UNREACHABLE)\n+    }\n+\n+    if (result.code !== 0) {\n+      throw this._fail(result.stderr, SSH_ERROR.UNREACHABLE)\n+    }\n+\n+    this._opened = true\n+    this._logLine('control master established')\n+  }\n+\n+  // Liveness. Mux: `-O check` against the master socket. No-mux: a cheap\n+  // one-shot exec — \"alive\" means \"we can still authenticate and run\".\n+  async isAlive() {\n+    if ([...this._tunnels.values()].some(tunnel => tunnel.alive === false)) {\n+      return false\n+    }\n+\n+    const args = this._mux\n+      ? buildControlArgs(this, 'check', [], this._connectTimeoutMs)\n+      : buildExecArgs(this, 'exit 0', this._connectTimeoutMs)\n+\n+    try {\n+      const result: any = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })\n+\n+      return result.code === 0\n+    } catch {\n+      return false\n+    }\n+  }\n+\n+  // A real exec through the master (`exit 0` works under POSIX shells and\n+  // cmd.exe); a wedged mux hangs to the timeout.\n+  async _verifyMuxChannel() {\n+    try {\n+      const result: any = await runSsh(buildExecArgs(this, 'exit 0', this._connectTimeoutMs), {\n+        timeoutMs: this._connectTimeoutMs,\n+        spawnFn: this._spawnFn\n+      })\n+\n+      return result.code === 0\n+    } catch {\n+      return false\n+    }\n+  }\n+\n+  // -O exit (best-effort) then drop the socket so ControlMaster=auto cannot\n+  // re-attach to the corpse. (The orphaned master process is left to\n+  // ControlPersist; a wedged channel can pin it, but without its socket it is\n+  // inert.)\n+  async _evictStaleMaster() {\n+    try {\n+      await runSsh(buildControlArgs(this, 'exit', [], this._connectTimeoutMs), {\n+        timeoutMs: this._connectTimeoutMs,\n+        spawnFn: this._spawnFn\n+      })\n+    } catch {\n+      void 0\n+    }\n+\n+    try {\n+      fs.unlinkSync(this.controlPath)\n+    } catch (error: any) {\n+      if (error?.code !== 'ENOENT') {\n+        this._logLine(`could not remove stale control socket (${error.code}); a fresh master may not dial`)\n+      }\n+    }\n+  }\n+\n+  // One-shot remote command over the control connection. Resolves stdout;\n+  // rejects with a classified error on non-zero exit or timeout.\n+  async exec(remoteCommand, { timeoutMs, stdinData }: any = {}) {\n+    const args = buildExecArgs(this, remoteCommand, this._connectTimeoutMs)\n+    let result\n+\n+    try {\n+      result = await runSsh(args, {\n+        timeoutMs: timeoutMs ?? this._execTimeoutMs,\n+        spawnFn: this._spawnFn,\n+        ...(stdinData != null ? { stdinData } : {})\n+      })\n+    } catch (error) {\n+      throw this._fail(error)\n+    }\n+\n+    if (result.code !== 0) {\n+      throw this._fail(result.stderr)\n+    }\n+\n+    return result.stdout\n+  }\n+\n+  // Establish a local→remote forward. Mux: `-O forward` against the master.\n+  // No-mux: spawn a persistent `ssh -N -L` child that IS the tunnel; ready when\n+  // the local port accepts. The child dying = tunnel down (isAlive of the\n+  // backend catches it upstream).\n+  async forward(localPort, remotePort, remoteHost = '127.0.0.1') {\n+    const spec = forwardSpec(localPort, remotePort, remoteHost)\n+    this._logLine(`forwarding 127.0.0.1:${localPort} -> ${remoteHost}:${remotePort}`)\n+\n+    if (!this._mux) {\n+      const args = [\n+        ...baseSshOptions('', this._connectTimeoutMs),\n+        ...hostArgs(this),\n+        '-v',\n+        '-N',\n+        '-L',\n+        spec,\n+        '--',\n+        target(this.user, this.host)\n+      ]\n+\n+      const child = this._spawnFn('ssh', args, { stdio: ['ignore', 'ignore', 'pipe'] })\n+      const tunnel = { child, alive: true }\n+      this._tunnels.set(spec, tunnel)\n+      let stderr = ''\n+      let readyConfirmed = false\n+      let readyResolve\n+      let readyReject\n+\n+      const ready = new Promise<void>((resolve, reject) => {\n+        readyResolve = resolve\n+        readyReject = reject\n+      })\n+\n+      const readyPattern = new RegExp(`Local forwarding listening on .* port ${localPort}\\\\b`)\n+      child.stderr?.on('data', d => {\n+        if (readyConfirmed) {\n+          return\n+        }\n+\n+        stderr = `${stderr}${String(d)}`.slice(-16_384)\n+\n+        if (readyPattern.test(stderr)) {\n+          readyConfirmed = true\n+          readyResolve()\n+        }\n+      })\n+      child.on('error', error => {\n+        tunnel.alive = false\n+        readyReject(error)\n+      })\n+      child.on('exit', code => {\n+        tunnel.alive = false\n+        readyReject(new Error(`tunnel process exited with code ${code}`))\n+      })\n+      child.on('close', code => {\n+        tunnel.alive = false\n+        readyReject(new Error(`tunnel process closed with code ${code}`))\n+      })\n+      let readyTimeout\n+\n+      try {\n+        await Promise.race([\n+          ready,\n+          new Promise((_, reject) => {\n+            readyTimeout = setTimeout(\n+              () => reject(new Error('tunnel did not confirm local forwarding')),\n+              this._forwardTimeoutMs\n+            )\n+          })\n+        ])\n+      } catch (error: any) {\n+        try {\n+          await stopTunnelChild(child)\n+          this._tunnels.delete(spec)\n+        } catch (stopError) {\n+          throw this._fail(stopError, SSH_ERROR.UNKNOWN)\n+        }\n+\n+        throw this._fail(stderr || error, SSH_ERROR.UNKNOWN)\n+      } finally {\n+        clearTimeout(readyTimeout)\n+      }\n+\n+      return\n+    }\n+\n+    const args = buildControlArgs(this, 'forward', ['-L', spec], this._connectTimeoutMs)\n+    let result\n+\n+    try {\n+      result = await runSsh(args, { timeoutMs: this._forwardTimeoutMs, spawnFn: this._spawnFn })\n+    } catch (error) {\n+      throw this._fail(error)\n+    }\n+\n+    if (result.code !== 0) {\n+      throw this._fail(result.stderr)\n+    }\n+  }\n+\n+  // Cancel a previously-established forward. Best-effort: a failure here is\n+  // logged but not thrown (close tears everything down anyway).\n+  async cancelForward(localPort, remotePort, remoteHost = '127.0.0.1') {\n+    const spec = forwardSpec(localPort, remotePort, remoteHost)\n+\n+    if (!this._mux) {\n+      const tunnel = this._tunnels.get(spec)\n+\n+      if (tunnel) {\n+        await stopTunnelChild(tunnel.child)\n+        this._tunnels.delete(spec)\n+        this._logLine(`cancelled forward 127.0.0.1:${localPort}`)\n+      }\n+\n+      return\n+    }\n+\n+    const args = buildControlArgs(this, 'cancel', ['-L', spec], this._connectTimeoutMs)\n+\n+    try {\n+      await runSsh(args, { timeoutMs: this._forwardTimeoutMs, spawnFn: this._spawnFn })\n+      this._logLine(`cancelled forward 127.0.0.1:${localPort}`)\n+    } catch (error: any) {\n+      this._logLine(`cancelForward failed (ignored): ${error.message}`)\n+    }\n+  }\n+\n+  // Tear down. Mux: exit the master (drops every forward with it). No-mux:\n+  // kill the tunnel children. Best-effort; never throws.\n+  async close() {\n+    if (!this._opened) {\n+      return\n+    }\n+\n+    if (!this._mux) {\n+      for (const [spec, tunnel] of this._tunnels) {\n+        await stopTunnelChild(tunnel.child)\n+        this._tunnels.delete(spec)\n+      }\n+\n+      this._opened = false\n+      this._logLine('connection closed (no-mux tunnels killed)')\n+\n+      return\n+    }\n+\n+    const args = buildControlArgs(this, 'exit', [], this._connectTimeoutMs)\n+\n+    try {\n+      const result: any = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })\n+\n+      if (result.code !== 0) {\n+        throw this._fail(result.stderr)\n+      }\n+\n+      this._logLine('control master closed')\n+    } catch (error: any) {\n+      // A master that refuses -O exit is the wedge that poisons re-attach;\n+      // disown it. (Without its socket the orphan is inert; ControlPersist may\n+      // not reap it if a wedged channel never idles.)\n+      this._logLine(`close failed; removing control socket: ${error.message}`)\n+\n+      try {\n+        fs.unlinkSync(this.controlPath)\n+      } catch {\n+        void 0\n+      }\n+    }\n+\n+    this._opened = false\n+  }\n+}\n+\n+// Free local port for the tunnel's local end. Bind 127.0.0.1:0, read the\n+// kernel-assigned port, release. The benign TOCTOU window (release → forward\n+// grabs it) is caught upstream and retried with a fresh port.\n+\n+function pickLocalPort() {\n+  return new Promise((resolve, reject) => {\n+    const server = net.createServer()\n+    server.unref()\n+    server.on('error', reject)\n+    server.listen(0, '127.0.0.1', () => {\n+      const { port } = server.address() as net.AddressInfo\n+      server.close(() => resolve(port))\n+    })\n+  })\n+}\n+\n+function createSshProbeConnection(config, options: any = {}) {\n+  return new SshConnection(config, { ...options, mux: false })\n+}\n+\n+export {\n+  baseSshOptions,\n+  buildControlArgs,\n+  buildExecArgs,\n+  buildInteractiveSshArgs,\n+  buildMasterArgs,\n+  classifySshError,\n+  CONTROL_PERSIST_SECONDS,\n+  controlSocketPath,\n+  createSshProbeConnection,\n+  DEFAULT_CONNECT_TIMEOUT_MS,\n+  DEFAULT_EXEC_TIMEOUT_MS,\n+  DEFAULT_FORWARD_TIMEOUT_MS,\n+  forwardSpec,\n+  hostArgs,\n+  pickLocalPort,\n+  redactSecrets,\n+  runSsh,\n+  SSH_ERROR,\n+  SshConnection,\n+  sshErrorMessage,\n+  stopTunnelChild,\n+  target,\n+  validateKeyPath,\n+  validateSshTarget\n+}"]},{"file":"apps/desktop/electron/windows-remote-lifecycle.test.ts","hunks":["@@ -0,0 +1,122 @@\n+import assert from 'node:assert/strict'\n+\n+import { test } from 'vitest'\n+\n+import {\n+  buildWindowsInteractiveCommand,\n+  detectRemotePlatform,\n+  encodedPowerShell,\n+  helperCommand,\n+  powerShellCommand,\n+  psLiteral,\n+  validLock\n+} from './windows-remote-lifecycle'\n+\n+const ownershipId = '0123456789abcdef0123456789abcdef'\n+\n+function sshWith(exec) {\n+  return { exec }\n+}\n+\n+test('PowerShell transport uses UTF-16LE encoded commands and literal escaping', () => {\n+  assert.equal(Buffer.from(encodedPowerShell(\"'ok'\"), 'base64').toString('utf16le'), \"'ok'\")\n+  assert.equal(psLiteral(\"a'b\"), \"'a''b'\")\n+  assert.match(powerShellCommand('Write-Output ok'), /^powershell\\.exe -NoProfile -NonInteractive .* -EncodedCommand /)\n+})\n+\n+test('platform detection preserves POSIX and falls back to Windows PowerShell', async () => {\n+  assert.deepEqual(await detectRemotePlatform(sshWith(async () => 'Linux\\nx86_64\\n')), { os: 'Linux', arch: 'x86_64' })\n+  const calls: string[] = []\n+\n+  const result = await detectRemotePlatform(\n+    sshWith(async command => {\n+      calls.push(command)\n+\n+      if (command.startsWith('uname ')) {\n+        throw new Error('PowerShell does not recognize uname')\n+      }\n+\n+      return JSON.stringify({\n+        os: 'Windows',\n+        arch: 'ARM64',\n+        hermesHome: 'C:\\\\h',\n+        hermesPath: 'C:\\\\h\\\\hermes.exe',\n+        python: 'C:\\\\h\\\\python.exe'\n+      })\n+    })\n+  )\n+\n+  assert.equal(result.os, 'Windows')\n+  assert.match(calls[1], /EncodedCommand/)\n+})\n+\n+test('platform detection surfaces transport failures as themselves, not unsupported-platform', async () => {\n+  // A dead/unauthorized host is a connectivity verdict; only a host that answers\n+  // neither probe is an unsupported platform.\n+  const transportErr: any = new Error('SSH connection timed out')\n+  transportErr.kind = 'timeout'\n+  await assert.rejects(\n+    detectRemotePlatform(\n+      sshWith(async () => {\n+        throw transportErr\n+      })\n+    ),\n+    (err: any) => err.kind === 'timeout'\n+  )\n+  // Probe genuinely failing on a reachable host still classifies unsupported,\n+  // and carries the probe detail for diagnosis.\n+  await assert.rejects(\n+    detectRemotePlatform(\n+      sshWith(async command => {\n+        if (command.startsWith('uname ')) {\n+          throw new Error('not recognized')\n+        }\n+\n+        throw new Error('Hermes is not installed on the remote Windows host.')\n+      })\n+    ),\n+    (err: any) => err.kind === 'unsupported-platform' && /Hermes is not installed/.test(err.message)\n+  )\n+})\n+\n+test('helper command uses the fixed remote Python entry point and quotes path data', () => {\n+  const command = helperCommand({ python: \"C:\\\\Program Files\\\\Hermes's\\\\python.exe\" }, 'inspect', [\n+    'C:\\\\x y\\\\hermes.exe'\n+  ])\n+\n+  const encoded = command.split(' ').pop()!\n+  const script = Buffer.from(encoded, 'base64').toString('utf16le')\n+  assert.match(script, /-m' 'hermes_cli\\.windows_ssh_runtime' 'inspect'/)\n+  assert.match(script, /Hermes''s/)\n+  assert.match(script, /C:\\\\x y\\\\hermes\\.exe/)\n+})\n+\n+test('Windows lock validation is scoped and exact', () => {\n+  const lock = {\n+    schemaVersion: 2,\n+    protocolVersion: 1,\n+    ownershipId,\n+    spawnNonce: '0123456789abcdef',\n+    pid: 10,\n+    creationTimeNs: '1784219690452757504',\n+    port: 1234,\n+    tokenFingerprint: 'a'.repeat(32),\n+    hermesPath: 'C:\\\\h\\\\hermes.exe',\n+    hermesHome: 'C:\\\\h'\n+  }\n+\n+  assert.equal(validLock(lock, ownershipId), true)\n+  assert.equal(validLock({ ...lock, ownershipId: 'b'.repeat(32) }, ownershipId), false)\n+  assert.equal(validLock({ ...lock, creationTimeNs: '0' }, ownershipId), false)\n+  // port 0 = spawn-in-progress record: valid ownership proof (cleanup can act\n+  // on it) but the reuse gate must reject it separately.\n+  assert.equal(validLock({ ...lock, port: 0 }, ownershipId), true)\n+  assert.equal(validLock({ ...lock, port: -1 }, ownershipId), false)\n+})\n+\n+test('Windows integrated terminal uses encoded PowerShell and preserves cwd as literal data', () => {\n+  const command = buildWindowsInteractiveCommand(\"C:\\\\Users\\\\O'Brien\\\\repo\")\n+  const script = Buffer.from(command.split(' ').pop()!, 'base64').toString('utf16le')\n+  assert.match(script, /Set-Location -LiteralPath 'C:\\\\Users\\\\O''Brien\\\\repo'/)\n+  assert.match(script, /powershell\\.exe -NoLogo/)\n+})"]},{"file":"apps/desktop/electron/windows-remote-lifecycle.ts","hunks":["@@ -0,0 +1,455 @@\n+import crypto from 'node:crypto'\n+\n+import { redactSecrets, SSH_ERROR } from './ssh-connection'\n+\n+const LOCKFILE_SCHEMA_VERSION = 2\n+const PROTOCOL_VERSION = 1\n+const READY_RE = /^HERMES_(?:BACKEND|DASHBOARD)_READY port=(\\d+)/gm\n+const READY_POLL_INTERVAL_MS = 750\n+\n+function psLiteral(value) {\n+  return `'${String(value).replace(/'/g, \"''\")}'`\n+}\n+\n+function encodedPowerShell(script) {\n+  return Buffer.from(script, 'utf16le').toString('base64')\n+}\n+\n+function powerShellCommand(script) {\n+  return `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodedPowerShell(script)}`\n+}\n+\n+async function probeWindowsRemote(ssh, explicitHermesPath = '') {\n+  const explicit = psLiteral(explicitHermesPath)\n+\n+  const script = [\n+    '$ErrorActionPreference=\"Stop\"',\n+    `$explicit=${explicit}`,\n+    '$hermesHome=$env:HERMES_HOME',\n+    'if(-not $hermesHome){$hermesHome=Join-Path $env:LOCALAPPDATA \"hermes\"}',\n+    '$candidates=@()',\n+    'if($explicit){$candidates+=$explicit}',\n+    '$cmd=Get-Command hermes.exe -ErrorAction SilentlyContinue',\n+    'if($cmd){$candidates+=$cmd.Source}',\n+    '$candidates+=(Join-Path $hermesHome \"hermes-agent\\\\venv\\\\Scripts\\\\hermes.exe\")',\n+    '$candidates+=(Join-Path $HOME \"hermes-agent\\\\.venv\\\\Scripts\\\\hermes.exe\")',\n+    '$hermes=$candidates|Where-Object{Test-Path -LiteralPath $_ -PathType Leaf}|Select-Object -First 1',\n+    'if(-not $hermes){throw \"Hermes is not installed on the remote Windows host.\"}',\n+    'if($explicit -and $hermes -ne $explicit){throw \"The configured Hermes path is not an executable file.\"}',\n+    '$python=Join-Path (Split-Path $hermes) \"python.exe\"',\n+    'if(-not (Test-Path -LiteralPath $python -PathType Leaf)){throw \"The remote Hermes Python runtime was not found.\"}',\n+    '[ordered]@{os=\"Windows\";arch=$env:PROCESSOR_ARCHITECTURE;hermesHome=$hermesHome;hermesPath=$hermes;python=$python}|ConvertTo-Json -Compress'\n+  ].join(';')\n+\n+  return JSON.parse((await ssh.exec(powerShellCommand(script))).trim())\n+}\n+\n+const TRANSPORT_KINDS = new Set([\n+  SSH_ERROR.AUTH_FAILED,\n+  SSH_ERROR.HOST_KEY_CHANGED,\n+  SSH_ERROR.TIMEOUT,\n+  SSH_ERROR.UNREACHABLE\n+])\n+\n+async function detectRemotePlatform(ssh, explicitHermesPath = '') {\n+  try {\n+    const output = (await ssh.exec('uname -s; uname -m')).trim().split('\\n')\n+\n+    if (output[0] === 'Linux' || output[0] === 'Darwin') {\n+      return { os: output[0], arch: output[1] || '' }\n+    }\n+  } catch (error: any) {\n+    // uname failing is the expected Windows fall-through; a TRANSPORT failure\n+    // (auth/host-key/timeout/unreachable) is not a platform verdict — surface it\n+    // as itself instead of letting the probe chain end in 'unsupported-platform'.\n+    if (TRANSPORT_KINDS.has(error?.kind)) {\n+      throw error\n+    }\n+  }\n+\n+  try {\n+    return await probeWindowsRemote(ssh, explicitHermesPath)\n+  } catch (cause: any) {\n+    if (TRANSPORT_KINDS.has(cause?.kind)) {\n+      throw cause\n+    }\n+\n+    // detail is remote-controlled output headed for the UI: redact + strip control chars.\n+    const detail = redactSecrets(String(cause?.message || cause || ''))\n+      // eslint-disable-next-line no-control-regex -- deliberately strip control chars from remote output\n+      .replace(/[\\x00-\\x1f\\x7f]/g, ' ')\n+      .trim()\n+\n+    const error: any = new Error(\n+      `The remote operating system is not supported by Desktop SSH.${detail ? ` (probe: ${detail.slice(0, 300)})` : ''}`\n+    )\n+\n+    error.kind = 'unsupported-platform'\n+    error.cause = cause\n+    throw error\n+  }\n+}\n+\n+function helperCommand(runtime, operation, args = []) {\n+  const argv = [runtime.python, '-m', 'hermes_cli.windows_ssh_runtime', operation, ...args]\n+\n+  const script = [\n+    '$ErrorActionPreference=\"Stop\"',\n+    `& ${argv.map(psLiteral).join(' ')}`,\n+    'if($LASTEXITCODE -ne 0){exit $LASTEXITCODE}'\n+  ].join(';')\n+\n+  return powerShellCommand(script)\n+}\n+\n+async function helper(ssh, runtime, operation, args = [], stdinData?) {\n+  const output = await ssh.exec(helperCommand(runtime, operation, args), stdinData == null ? {} : { stdinData })\n+\n+  const lines = String(output || '')\n+    .replace(/^\\uFEFF/, '')\n+    .trim()\n+    .split(/\\r?\\n/)\n+    .filter(Boolean)\n+\n+  const parsed = JSON.parse(lines[lines.length - 1] || 'null')\n+\n+  if (parsed?.error) {\n+    throw new Error(parsed.error)\n+  }\n+\n+  return parsed\n+}\n+\n+function fingerprintToken(token) {\n+  return crypto\n+    .createHash('sha256')\n+    .update(String(token || ''))\n+    .digest('hex')\n+    .slice(0, 32)\n+}\n+\n+function validLock(lock, ownershipId) {\n+  // port 0 = spawn-in-progress record (written before readiness); a valid\n+  // ownership proof for cleanup, but never reusable.\n+  return Boolean(\n+    lock &&\n+    lock.schemaVersion === LOCKFILE_SCHEMA_VERSION &&\n+    lock.protocolVersion === PROTOCOL_VERSION &&\n+    lock.ownershipId === ownershipId &&\n+    /^[0-9a-f]{16}$/.test(lock.spawnNonce || '') &&\n+    Number.isInteger(lock.pid) &&\n+    lock.pid > 0 &&\n+    /^[0-9]{10,20}$/.test(lock.creationTimeNs || '') &&\n+    Number.isInteger(lock.port) &&\n+    lock.port >= 0 &&\n+    lock.port <= 65535 &&\n+    /^[0-9a-f]{32}$/.test(lock.tokenFingerprint || '') &&\n+    typeof lock.hermesPath === 'string' &&\n+    typeof lock.hermesHome === 'string'\n+  )\n+}\n+\n+function assertCurrent(signal) {\n+  if (signal?.aborted) {\n+    const error: any = new Error('SSH bootstrap was cancelled.')\n+    error.kind = 'superseded'\n+    throw error\n+  }\n+}\n+\n+async function processState(ssh, runtime, lock) {\n+  return helper(ssh, runtime, 'process-state', [\n+    String(lock.pid),\n+    String(lock.creationTimeNs),\n+    lock.hermesPath,\n+    lock.spawnNonce\n+  ])\n+}\n+\n+async function cleanupOwned(ssh, runtime, ownershipId, lock) {\n+  const attempt = async fn => {\n+    try {\n+      await fn()\n+    } catch {\n+      void 0\n+    }\n+  }\n+\n+  if (lock) {\n+    const state = await processState(ssh, runtime, lock)\n+\n+    if (state.alive && state.owned) {\n+      // Deliberately not attempt()-wrapped: a thrown terminate must abort before\n+      // remove-lock, or a live backend is orphaned with no lock to reclaim it.\n+      await helper(ssh, runtime, 'terminate', [\n+        String(lock.pid),\n+        String(lock.creationTimeNs),\n+        lock.hermesPath,\n+        lock.spawnNonce\n+      ])\n+    }\n+\n+    if (lock.spawnNonce) {\n+      await attempt(() => helper(ssh, runtime, 'remove-token', [ownershipId, lock.spawnNonce]))\n+      await attempt(() => helper(ssh, runtime, 'remove-log', [ownershipId, lock.spawnNonce]))\n+    }\n+  }\n+\n+  await attempt(() => helper(ssh, runtime, 'remove-lock', [ownershipId]))\n+}\n+\n+async function waitReady(ssh, runtime, ownershipId, lock, timeoutMs, signal) {\n+  const deadline = Date.now() + timeoutMs\n+\n+  while (Date.now() < deadline) {\n+    assertCurrent(signal)\n+    let state\n+\n+    try {\n+      state = await processState(ssh, runtime, lock)\n+    } catch {\n+      await new Promise(resolve => setTimeout(resolve, READY_POLL_INTERVAL_MS))\n+\n+      continue\n+    }\n+\n+    if (!state.indeterminate && (!state.alive || !state.owned)) {\n+      let detail = ''\n+\n+      try {\n+        detail = (await helper(ssh, runtime, 'read-log', [ownershipId, lock.spawnNonce]))?.content || ''\n+      } catch {\n+        void 0\n+      }\n+\n+      const error: any = new Error(\n+        `Remote Windows backend exited before announcing its port. state=${JSON.stringify(state)} ${detail.slice(-2000)}`\n+      )\n+\n+      error.kind = 'spawn-failed'\n+      throw error\n+    }\n+\n+    let content = ''\n+\n+    try {\n+      content = (await helper(ssh, runtime, 'read-log', [ownershipId, lock.spawnNonce]))?.content || ''\n+    } catch {\n+      void 0\n+    }\n+\n+    let port\n+\n+    for (const match of content.matchAll(READY_RE)) {\n+      port = Number(match[1])\n+    }\n+\n+    if (port) {\n+      return port\n+    }\n+\n+    await new Promise(resolve => setTimeout(resolve, READY_POLL_INTERVAL_MS))\n+  }\n+\n+  const error: any = new Error(`Timed out waiting for the remote Windows backend (${timeoutMs}ms).`)\n+  error.kind = 'ready-timeout'\n+  throw error\n+}\n+\n+async function connectWindowsRemote(deps) {\n+  const {\n+    ssh,\n+    ownershipId,\n+    profile = '',\n+    remoteHermesPath = '',\n+    reuseToken = '',\n+    signal,\n+    pickLocalPort,\n+    forward,\n+    cancelForward,\n+    waitForHermes,\n+    probeReuseProof,\n+    rememberLog = () => {},\n+    readyTimeoutMs = 45_000\n+  } = deps\n+\n+  assertCurrent(signal)\n+  const runtime = await probeWindowsRemote(ssh, remoteHermesPath)\n+  const inspection = await helper(ssh, runtime, 'inspect', [runtime.hermesPath])\n+\n+  if (!inspection.supported) {\n+    const error: any = new Error('Update Hermes on the remote Windows host before connecting with Desktop SSH.')\n+    error.kind = 'update-required'\n+    throw error\n+  }\n+\n+  runtime.hermesPath = inspection.path\n+  const hermesVersion = inspection.version || ''\n+  rememberLog(`[ssh-lifecycle] remote platform Windows/${runtime.arch}`)\n+  rememberLog(`[ssh-lifecycle] located hermes at ${runtime.hermesPath}`)\n+\n+  const lock = await helper(ssh, runtime, 'read-lock', [ownershipId])\n+\n+  if (validLock(lock, ownershipId)) {\n+    const state = await processState(ssh, runtime, lock)\n+\n+    if (state.indeterminate) {\n+      const error: any = new Error('Could not determine the state of the existing remote backend.')\n+      error.kind = 'transient-transport-error'\n+      throw error\n+    }\n+\n+    const reusable =\n+      state.alive &&\n+      state.owned &&\n+      lock.port > 0 &&\n+      Boolean(reuseToken) &&\n+      lock.tokenFingerprint === fingerprintToken(reuseToken) &&\n+      lock.hermesPath === runtime.hermesPath &&\n+      lock.hermesHome === runtime.hermesHome\n+\n+    if (reusable) {\n+      const localPort = await pickLocalPort()\n+      await forward(localPort, lock.port)\n+\n+      try {\n+        const baseUrl = `http://127.0.0.1:${localPort}`\n+        const classification = await probeReuseProof(baseUrl, reuseToken, lock.spawnNonce)\n+\n+        if (classification === 'authenticated-ok') {\n+          return {\n+            baseUrl,\n+            token: [redacted:secret-assignment]\n+            remotePort: lock.port,\n+            localPort,\n+            pid: lock.pid,\n+            reused: true,\n+            platform: { os: 'Windows', arch: runtime.arch },\n+            hermesPath: runtime.hermesPath,\n+            hermesVersion,\n+            ownershipId,\n+            spawnNonce: lock.spawnNonce,\n+            creationTimeNs: lock.creationTimeNs\n+          }\n+        }\n+\n+        if (classification !== 'authenticated-stale') {\n+          throw new Error('Invalid SSH reuse classification.')\n+        }\n+\n+        await cancelForward(localPort, lock.port)\n+        await cleanupOwned(ssh, runtime, ownershipId, lock)\n+      } catch (error) {\n+        await cancelForward(localPort, lock.port)\n+        throw error\n+      }\n+    } else {\n+      await cleanupOwned(ssh, runtime, ownershipId, lock)\n+    }\n+  } else if (lock) {\n+    await helper(ssh, runtime, 'remove-lock', [ownershipId])\n+  }\n+\n+  assertCurrent(signal)\n+  const token = [redacted:secret-assignment]'hex')\n+  const spawnNonce = crypto.randomBytes(8).toString('hex')\n+  await helper(ssh, runtime, 'upload-token', [ownershipId, spawnNonce], token)\n+  let spawned\n+\n+  try {\n+    spawned = await helper(\n+      ssh,\n+      runtime,\n+      'spawn',\n+      [],\n+      JSON.stringify({ ownershipId, spawnNonce, profile, hermesPath: runtime.hermesPath })\n+    )\n+  } catch (error) {\n+    await helper(ssh, runtime, 'remove-token', [ownershipId, spawnNonce])\n+    throw error\n+  }\n+\n+  const owned = {\n+    schemaVersion: LOCKFILE_SCHEMA_VERSION,\n+    protocolVersion: PROTOCOL_VERSION,\n+    ownershipId,\n+    spawnNonce,\n+    pid: spawned.pid,\n+    creationTimeNs: spawned.creationTimeNs,\n+    port: 0,\n+    profile,\n+    hermesPath: runtime.hermesPath,\n+    hermesHome: runtime.hermesHome,\n+    tokenFingerprint: [redacted:secret-assignment]\n+    startedAt: new Date().toISOString()\n+  }\n+\n+  let localPort = 0\n+  let remotePort = 0\n+\n+  try {\n+    // Write the ownership record IMMEDIATELY (port=0): if this attempt is\n+    // superseded before readiness and cleanup cannot reach the box, the next\n+    // connect still finds the lock and reaps the process by exact ownership.\n+    // Inside the try: if this write itself fails, the catch still kills the\n+    // just-spawned process via the in-memory record.\n+    await helper(ssh, runtime, 'write-lock', [ownershipId], JSON.stringify(owned))\n+    remotePort = await waitReady(ssh, runtime, ownershipId, owned, readyTimeoutMs, signal)\n+    localPort = await pickLocalPort()\n+    await forward(localPort, remotePort)\n+    const baseUrl = `http://127.0.0.1:${localPort}`\n+    await waitForHermes(baseUrl, token)\n+    assertCurrent(signal)\n+    await helper(ssh, runtime, 'write-lock', [ownershipId], JSON.stringify({ ...owned, port: remotePort }))\n+\n+    return {\n+      baseUrl,\n+      token,\n+      remotePort,\n+      localPort,\n+      pid: spawned.pid,\n+      reused: false,\n+      platform: { os: 'Windows', arch: runtime.arch },\n+      hermesPath: runtime.hermesPath,\n+      hermesVersion,\n+      ownershipId,\n+      spawnNonce,\n+      creationTimeNs: spawned.creationTimeNs\n+    }\n+  } catch (error) {\n+    if (localPort && remotePort) {\n+      await cancelForward(localPort, remotePort)\n+    }\n+\n+    await cleanupOwned(ssh, runtime, ownershipId, owned)\n+    throw error\n+  }\n+}\n+\n+function buildWindowsInteractiveCommand(remoteCwd = '') {\n+  const cwd = String(remoteCwd || '').trim()\n+  const script = ['$ErrorActionPreference=\"Stop\"']\n+\n+  if (cwd) {\n+    script.push(\n+      `if(Test-Path -LiteralPath ${psLiteral(cwd)} -PathType Container){Set-Location -LiteralPath ${psLiteral(cwd)}}`\n+    )\n+  }\n+\n+  script.push('$host.UI.RawUI.WindowTitle=\"Hermes SSH\"', 'powershell.exe -NoLogo')\n+\n+  return powerShellCommand(script.join(';'))\n+}\n+\n+export {\n+  buildWindowsInteractiveCommand,\n+  connectWindowsRemote,\n+  detectRemotePlatform,\n+  encodedPowerShell,\n+  helper,\n+  helperCommand,\n+  powerShellCommand,\n+  probeWindowsRemote,\n+  psLiteral,\n+  validLock\n+}"]},{"file":"apps/desktop/electron/windows-remote-live.test.ts","hunks":["@@ -0,0 +1,106 @@\n+import assert from 'node:assert/strict'\n+\n+import { test } from 'vitest'\n+\n+import { pickLocalPort, SshConnection } from './ssh-connection'\n+import { connectWindowsRemote } from './windows-remote-lifecycle'\n+\n+// Live test against a real Windows host over SSH. Opt-in: set the env trio to\n+// your test rig; skipped everywhere else (CI, other machines).\n+//   HERMES_WIN_SSH_HOST   ssh alias/host of the Windows box\n+//   HERMES_WIN_SSH_USER   remote user\n+//   HERMES_WIN_SSH_HERMES absolute path to the remote hermes.exe under test\n+const liveHost = process.env.HERMES_WIN_SSH_HOST || ''\n+const liveUser = process.env.HERMES_WIN_SSH_USER || ''\n+const configuredHermes = process.env.HERMES_WIN_SSH_HERMES || ''\n+const ownershipId = '89abcdef0123456789abcdef01234567'\n+\n+function fetchJson(url, token, path) {\n+  return fetch(`${url}${path}`, { headers: { 'X-Hermes-Session-Token': token } }).then(async response => {\n+    if (!response.ok) {\n+      throw new Error(`${response.status}: ${await response.text()}`)\n+    }\n+\n+    return response.json()\n+  })\n+}\n+\n+test.skipIf(!liveHost || !liveUser || !configuredHermes)(\n+  'live Windows remote lifecycle spawns, authenticates, reuses, and cleans exact ownership',\n+  async () => {\n+    const ssh = new SshConnection({ host: liveHost, user: liveUser, port: 22, keyPath: '' }, { mux: true })\n+    await ssh.open()\n+\n+    const common = {\n+      ssh,\n+      ownershipId,\n+      profile: '',\n+      remoteHermesPath: configuredHermes,\n+      pickLocalPort,\n+      forward: (local, remote) => ssh.forward(local, remote),\n+      cancelForward: (local, remote) => ssh.cancelForward(local, remote),\n+      waitForHermes: async (baseUrl, token) => {\n+        for (let i = 0; i < 40; i++) {\n+          try {\n+            await fetchJson(baseUrl, token, '/api/status')\n+\n+            return\n+          } catch {\n+            void 0\n+          }\n+\n+          await new Promise(resolve => setTimeout(resolve, 250))\n+        }\n+\n+        throw new Error('status timeout')\n+      },\n+      probeReuseProof: async (baseUrl, token, nonce) => {\n+        const body: any = await fetchJson(baseUrl, token, '/api/ssh/ownership')\n+\n+        return body.sshOwnerNonce === nonce ? 'authenticated-ok' : 'authenticated-stale'\n+      },\n+      rememberLog: () => {}\n+    }\n+\n+    let first\n+    let second\n+\n+    try {\n+      first = await connectWindowsRemote(common)\n+      assert.equal(first.platform.os, 'Windows')\n+      assert.equal(first.reused, false)\n+      const status: any = await fetchJson(first.baseUrl, first.token, '/api/status')\n+      assert.ok(status)\n+      await ssh.cancelForward(first.localPort, first.remotePort)\n+      second = await connectWindowsRemote({ ...common, reuseToken: [redacted:secret-assignment] })\n+      assert.equal(second.reused, true)\n+      assert.equal(second.pid, first.pid)\n+      assert.equal(second.spawnNonce, first.spawnNonce)\n+    } finally {\n+      if (second) {\n+        await ssh.cancelForward(second.localPort, second.remotePort)\n+      }\n+\n+      const runtimeScript = `& '${configuredHermes.replace('hermes.exe', 'python.exe')}' -m hermes_cli.windows_ssh_runtime read-lock '${ownershipId}'`\n+\n+      const lock: any = JSON.parse(\n+        await ssh.exec(`powershell.exe -NoProfile -NonInteractive -Command \"${runtimeScript}\"`)\n+      )\n+\n+      if (lock) {\n+        const python = configuredHermes.replace('hermes.exe', 'python.exe')\n+        const terminate = `& '${python}' -m hermes_cli.windows_ssh_runtime terminate '${lock.pid}' '${lock.creationTimeNs}' '${lock.hermesPath}' '${lock.spawnNonce}'`\n+        await ssh.exec(`powershell.exe -NoProfile -NonInteractive -Command \"${terminate}\"`)\n+        await ssh.exec(\n+          `powershell.exe -NoProfile -NonInteractive -Command \"& '${python}' -m hermes_cli.windows_ssh_runtime remove-lock '${ownershipId}'\"`\n+        )\n+        await ssh.exec(\n+          `powershell.exe -NoProfile -NonInteractive -Command \"& '${python}' -m hermes_cli.windows_ssh_runtime remove-log '${ownershipId}' '${lock.spawnNonce}'\"`\n+        )\n+      }\n+\n+      await ssh.close()\n+    }\n+  },\n+  90_000\n+)"]},{"file":"apps/desktop/src/app/contrib/wiring.tsx","hunks":["@@ -71,7 +71,9 @@ import { ModelVisibilityOverlay } from '../model-visibility-overlay'\n import { PetGenerateOverlay } from '../pet-generate/pet-generate-overlay'\n import { FileActionDialogs } from '../right-sidebar/file-actions'\n import { RemoteFolderPicker } from '../right-sidebar/files/remote-picker'\n+import { resetProjectTreeState } from '../right-sidebar/files/use-project-tree'\n import { PersistentTerminal } from '../right-sidebar/terminal/persistent'\n+import { closeAllTerminals } from '../right-sidebar/terminal/terminals'\n import { CRON_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE, syncWorkspaceIsPage } from '../routes'\n import { SessionPickerOverlay } from '../session-picker-overlay'\n import { SessionSwitcher } from '../session-switcher'","@@ -173,6 +175,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {\n     openCommandCenterSection,\n     openStarmap,\n     profilesOpen,\n+    resetOverlayReturnRoute,\n     settingsOpen,\n     starmapOpen,\n     toggleCommandCenter","@@ -632,6 +635,12 @@ export function ContribWiring({ children }: { children: ReactNode }) {\n   )\n \n   useGatewayBoot({\n+    beforeConnectionSwitch: () => {\n+      startFreshSessionDraft({ preserveRoute: true, workspaceTarget: null })\n+      resetOverlayReturnRoute()\n+      resetProjectTreeState()\n+      closeAllTerminals()\n+    },\n     handleGatewayEvent: handleGatewayEventWithPlugins,\n     onConnectionReady: c => {\n       connectionRef.current = c"]},{"file":"apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx","hunks":["@@ -18,6 +18,7 @@ import { useGatewayBoot } from './use-gateway-boot'\n // post-boot reconnect loop.\n \n type Listener = (ev: unknown) => void\n+let connectionApplied: null | (() => void) = null\n \n // Minimal WebSocket stand-in implementing only what json-rpc-gateway.connect()\n // touches: readyState, add/removeEventListener('open'|'error'|'close'), close().","@@ -97,7 +98,13 @@ function fakeDesktop() {\n     })),\n     onBootProgress: vi.fn(() => () => undefined),\n     onBackendExit: vi.fn(() => () => undefined),\n-    onConnectionApplied: vi.fn(() => () => undefined),\n+    onConnectionApplied: vi.fn(callback => {\n+      connectionApplied = callback\n+\n+      return () => {\n+        connectionApplied = null\n+      }\n+    }),\n     onPowerResume: vi.fn(() => () => undefined),\n     onWindowStateChanged: vi.fn(() => () => undefined),\n     touchBackend: vi.fn(async () => undefined),","@@ -105,8 +112,12 @@ function fakeDesktop() {\n   }\n }\n \n-function Harness({ refreshSessions }: { refreshSessions?: () => Promise<void> } = {}) {\n+function Harness({\n+  beforeConnectionSwitch = () => undefined,\n+  refreshSessions\n+}: { beforeConnectionSwitch?: () => void; refreshSessions?: () => Promise<void> } = {}) {\n   useGatewayBoot({\n+    beforeConnectionSwitch,\n     handleGatewayEvent: () => undefined,\n     onConnectionReady: () => undefined,\n     onGatewayReady: () => undefined,","@@ -123,6 +134,7 @@ beforeEach(() => {\n   vi.useFakeTimers()\n   FakeWebSocket.mode = 'open'\n   FakeWebSocket.instances = []\n+  connectionApplied = null\n   ;(globalThis as { WebSocket: unknown }).WebSocket = FakeWebSocket\n   ;(window as { hermesDesktop?: unknown }).hermesDesktop = fakeDesktop()\n   $gatewayState.set('idle')","@@ -199,6 +211,18 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () =>\n     expect($desktopBoot.get().error).toBeTruthy()\n   })\n \n+  it('resets the old machine context before connecting an applied gateway', async () => {\n+    const beforeConnectionSwitch = vi.fn()\n+    render(<Harness beforeConnectionSwitch={beforeConnectionSwitch} />)\n+    await flushAsync()\n+    expect(connectionApplied).not.toBeNull()\n+\n+    act(() => connectionApplied?.())\n+    expect(beforeConnectionSwitch).toHaveBeenCalledTimes(1)\n+    await flushAsync()\n+    expect($gatewayState.get()).toBe('open')\n+  })\n+\n   it('a remote that drops post-boot keeps looping with NO boot.error (the dead-end CONNECTING combo)', async () => {\n     render(<Harness />)\n     await flushAsync()"]},{"file":"apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts","hunks":["@@ -48,6 +48,7 @@ import type { RpcEvent } from '@/types/hermes'\n const RECONNECT_ESCALATE_AFTER = 6\n \n interface GatewayBootOptions {\n+  beforeConnectionSwitch: () => void\n   handleGatewayEvent: (event: RpcEvent) => void\n   onConnectionReady: (\n     connection: Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null","@@ -58,6 +59,7 @@ interface GatewayBootOptions {\n }\n \n export function useGatewayBoot({\n+  beforeConnectionSwitch,\n   handleGatewayEvent,\n   onConnectionReady,\n   onGatewayReady,","@@ -65,6 +67,7 @@ export function useGatewayBoot({\n   refreshSessions\n }: GatewayBootOptions) {\n   const callbacksRef = useRef({\n+    beforeConnectionSwitch,\n     handleGatewayEvent,\n     onConnectionReady,\n     onGatewayReady,","@@ -73,6 +76,7 @@ export function useGatewayBoot({\n   })\n \n   callbacksRef.current = {\n+    beforeConnectionSwitch,\n     handleGatewayEvent,\n     onConnectionReady,\n     onGatewayReady,","@@ -265,6 +269,7 @@ export function useGatewayBoot({\n       reconnectAttempt = 0\n       escalated = false\n       reauthNotified = false\n+      callbacksRef.current.beforeConnectionSwitch()\n       wipeSessionListsForGatewaySwitch()\n \n       try {"]},{"file":"apps/desktop/src/app/session/hooks/use-session-actions.test.tsx","hunks":["@@ -90,9 +90,11 @@ function storedSession(overrides: Partial<SessionInfo> = {}): SessionInfo {\n }\n \n function Harness({\n+  navigate = vi.fn(),\n   onReady,\n   requestGateway\n }: {\n+  navigate?: ReturnType<typeof vi.fn>\n   onReady: (handle: HarnessHandle) => void\n   requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>\n }) {","@@ -106,7 +108,7 @@ function Harness({\n     ensureSessionState: () => ({}) as ClientSessionState,\n     getRouteToken: () => 'token',\n     getRoutedStoredSessionId: () => null,\n-    navigate: vi.fn() as never,\n+    navigate: navigate as never,\n     requestGateway,\n     resetViewSync: vi.fn(),\n     runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),","@@ -404,6 +406,25 @@ async function createWith(\n   return createParams\n }\n \n+describe('startFreshSessionDraft', () => {\n+  afterEach(() => cleanup())\n+\n+  it('can reset machine-bound session state without closing the current overlay route', async () => {\n+    const navigate = vi.fn()\n+    const requestGateway = vi.fn(async () => ({}) as never)\n+    let handle: HarnessHandle | null = null\n+\n+    render(<Harness navigate={navigate} onReady={value => (handle = value)} requestGateway={requestGateway} />)\n+    await waitFor(() => expect(handle).not.toBeNull())\n+\n+    act(() => handle!.startFreshSessionDraft({ preserveRoute: true, workspaceTarget: null }))\n+\n+    expect(navigate).not.toHaveBeenCalled()\n+    expect($currentCwd.get()).toBe('')\n+    expect($newChatWorkspaceTarget.get()).toBeNull()\n+  })\n+})\n+\n describe('createBackendSessionForSend profile routing', () => {\n   afterEach(() => {\n     cleanup()"]},{"file":"apps/desktop/src/app/session/hooks/use-session-actions/index.ts","hunks":["@@ -177,6 +177,7 @@ async function desktopSessionCreateParams(cwd: string): Promise<Record<string, u\n }\n \n interface FreshSessionDraftOptions {\n+  preserveRoute?: boolean\n   replaceRoute?: boolean\n   workspaceTarget?: NewChatWorkspaceTarget\n }","@@ -270,6 +271,7 @@ export function useSessionActions({\n   const startFreshSessionDraft = useCallback(\n     (options: boolean | FreshSessionDraftOptions = false) => {\n       const draftOptions = typeof options === 'boolean' ? { replaceRoute: options } : options\n+      const preserveRoute = draftOptions.preserveRoute ?? false\n       const replaceRoute = draftOptions.replaceRoute ?? false\n \n       const hasWorkspaceTarget =","@@ -290,7 +292,11 @@ export function useSessionActions({\n       // rebind race, so leaving the old id here could revive it on a very fast\n       // New Chat -> Enter sequence.\n       onFreshDraftRouteIntent?.()\n-      navigate(NEW_CHAT_ROUTE, { replace: replaceRoute })\n+\n+      if (!preserveRoute) {\n+        navigate(NEW_CHAT_ROUTE, { replace: replaceRoute })\n+      }\n+\n       setActiveSessionId(null)\n       activeSessionIdRef.current = null\n       setSelectedStoredSessionId(null)"]},{"file":"apps/desktop/src/app/settings/gateway-settings.tsx","hunks":["@@ -3,11 +3,24 @@ import { useEffect, useMemo, useRef, useState } from 'react'\n \n import { Button } from '@/components/ui/button'\n import { Input } from '@/components/ui/input'\n+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'\n import { Tip } from '@/components/ui/tooltip'\n import type { DesktopAuthProvider, DesktopCloudAgent, DesktopCloudOrg, DesktopConnectionProbeResult } from '@/global'\n import { useI18n } from '@/i18n'\n import { ExternalLink } from '@/lib/external-link'\n-import { AlertCircle, Check, Cloud, FileText, Globe, HelpCircle, Loader2, LogIn, Monitor, RefreshCw } from '@/lib/icons'\n+import {\n+  AlertCircle,\n+  Check,\n+  Cloud,\n+  FileText,\n+  Globe,\n+  HelpCircle,\n+  Loader2,\n+  LogIn,\n+  Monitor,\n+  RefreshCw,\n+  Terminal\n+} from '@/lib/icons'\n import { selectableCardClass } from '@/lib/selectable-card'\n import { cn } from '@/lib/utils'\n import { notify, notifyError } from '@/store/notifications'","@@ -15,8 +28,9 @@ import { $profiles, refreshActiveProfile } from '@/store/profile'\n \n import { CONTROL_TEXT } from './constants'\n import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'\n+import { enrichSelectedSshHost, selectSshHost } from './ssh-host-selection'\n \n-type Mode = 'local' | 'remote' | 'cloud'\n+type Mode = 'local' | 'remote' | 'cloud' | 'ssh'\n type AuthMode = 'oauth' | 'token'\n type ProbeStatus = 'idle' | 'probing' | 'done' | 'error'\n // Hermes Cloud discovery lifecycle for the cloud-mode panel.","@@ -31,8 +45,15 @@ interface GatewaySettingsState {\n   remoteTokenSet: boolean\n   remoteUrl: string\n   cloudOrg: string\n+  sshHost: string\n+  sshUser: string\n+  sshPort: number | null\n+  sshKeyPath: string\n+  sshRemoteHermesPath: string\n }\n \n+const SSH_HOST_CUSTOM = '__custom__'\n+\n const EMPTY_STATE: GatewaySettingsState = {\n   envOverride: false,\n   mode: 'local',","@@ -41,7 +62,12 @@ const EMPTY_STATE: GatewaySettingsState = {\n   remoteTokenPreview: null,\n   remoteTokenSet: [redacted:secret-assignment]\n   remoteUrl: '',\n-  cloudOrg: ''\n+  cloudOrg: '',\n+  sshHost: '',\n+  sshUser: '',\n+  sshPort: null,\n+  sshKeyPath: '',\n+  sshRemoteHermesPath: ''\n }\n \n export function savedCloudConnectionUrl(config: Pick<GatewaySettingsState, 'mode' | 'remoteUrl'>): string {","@@ -128,6 +154,14 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {\n   const [state, setState] = useState<GatewaySettingsState>(EMPTY_STATE)\n   const [remoteToken, setRemoteToken] = useState('')\n   const [lastTest, setLastTest] = useState<null | string>(null)\n+  const [sshHostSuggestions, setSshHostSuggestions] = useState<string[]>([])\n+  const [sshCustomHost, setSshCustomHost] = useState(false)\n+  const sshResolveSeq = useRef(0)\n+  const sshTestSeq = useRef(0)\n+  const saveSeq = useRef(0)\n+  const signingSeq = useRef(0)\n+  const cloudConnectSeq = useRef(0)\n+  const contextSeq = useRef(0)\n   const [connectedCloudUrl, setConnectedCloudUrl] = useState('')\n \n   const acceptSavedConfig = (config: GatewaySettingsState) => {","@@ -337,6 +371,48 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {\n   // per-profile scopes are the named, non-default profiles.\n   const namedProfiles = useMemo(() => profiles.filter(profile => profile.name !== 'default'), [profiles])\n \n+  useEffect(() => {\n+    // One-directional: a saved host that isn't in the suggestions must render\n+    // the free-text input (rehydration). Never force custom OFF here — that\n+    // instantly snapped the just-clicked-Custom (empty-host) input back to the\n+    // dropdown, making a raw-IP host impossible to type. The way back to the\n+    // dropdown is the input's onBlur (empty host + suggestions).\n+    if (state.sshHost && !sshHostSuggestions.includes(state.sshHost)) {\n+      setSshCustomHost(true)\n+    }\n+  }, [state.sshHost, sshHostSuggestions])\n+\n+  useEffect(() => {\n+    if (state.mode !== 'ssh' || !window.hermesDesktop?.sshConfigHosts) {\n+      return\n+    }\n+\n+    let cancelled = false\n+    void window.hermesDesktop\n+      .sshConfigHosts()\n+      .then(result => {\n+        if (!cancelled) {\n+          setSshHostSuggestions(result.hosts)\n+        }\n+      })\n+      .catch(() => {\n+        if (!cancelled) {\n+          setSshHostSuggestions([])\n+        }\n+      })\n+\n+    return () => void (cancelled = true)\n+  }, [state.mode])\n+\n+  useEffect(() => {\n+    contextSeq.current += 1\n+    sshTestSeq.current += 1\n+    saveSeq.current += 1\n+    signingSeq.current += 1\n+    cloudConnectSeq.current += 1\n+    setLastTest(null)\n+  }, [scope, state.mode, state.sshHost, state.sshUser, state.sshPort, state.sshKeyPath, state.sshRemoteHermesPath])\n+\n   const oauthConnected = state.remoteOauthConnected\n \n   const canUseRemote = useMemo(() => {","@@ -356,10 +432,17 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {\n     profile: scope ?? undefined,\n     remoteAuthMode: authMode,\n     remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,\n-    remoteUrl: trimmedUrl\n+    remoteUrl: trimmedUrl,\n+    sshHost: state.sshHost.trim(),\n+    sshUser: state.sshUser.trim() || undefined,\n+    sshPort: state.sshPort,\n+    sshKeyPath: state.sshKeyPath.trim() || undefined,\n+    sshRemoteHermesPath: state.sshRemoteHermesPath.trim()\n   })\n \n   const save = async (apply: boolean) => {\n+    const seq = ++saveSeq.current\n+\n     if (state.mode === 'remote' && !canUseRemote) {\n       notify({\n         kind: 'warning',","@@ -377,6 +460,10 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {\n         ? await window.hermesDesktop.applyConnectionConfig(payload())\n         : await window.hermesDesktop.saveConnectionConfig(payload())\n \n+      if (seq !== saveSeq.current) {\n+        return\n+      }\n+\n       acceptSavedConfig(next)\n       setRemoteToken('')\n       notify({","@@ -385,9 +472,35 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {\n         message: apply ? g.restartingMessage : g.savedMessage\n       })\n     } catch (err) {\n-      notifyError(err, apply ? g.applyFailed : g.saveFailed)\n+      if (seq !== saveSeq.current) {\n+        return\n+      }\n+\n+      const sshError = err && typeof err === 'object' && 'sshError' in err ? String(err.sshError) : ''\n+\n+      const errors = {\n+        'auth-failed': g.sshErrAuth,\n+        'hermes-not-found': g.sshErrNotInstalled,\n+        'host-key-changed': g.sshErrHostKey,\n+        timeout: g.sshErrTimeout,\n+        unreachable: g.sshErrUnreachable,\n+        'unsupported-platform': g.sshErrPlatform,\n+        'update-required': g.sshErrUpdateRequired\n+      }\n+\n+      if (state.mode === 'ssh' && sshError) {\n+        notify({\n+          kind: 'error',\n+          title: apply ? g.applyFailed : g.saveFailed,\n+          message: (errors as Record<string, string>)[sshError] || g.sshErrUnknown\n+        })\n+      } else {\n+        notifyError(err, apply ? g.applyFailed : g.saveFailed)\n+      }\n     } finally {\n-      setSaving(false)\n+      if (seq === saveSeq.current) {\n+        setSaving(false)\n+      }\n     }\n   }\n ","@@ -395,6 +508,8 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {\n   // the URL the login window needs), then open the gateway login window and\n   // refresh the connection status from the saved config once it completes.\n   const signIn = async () => {\n+    const seq = ++signingSeq.current\n+\n     if (!trimmedUrl) {\n       notify({ kind: 'warning', title: g.incompleteTitle, message: g.enterUrlFirst })\n ","@@ -413,10 +528,18 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {\n         remoteUrl: trimmedUrl\n       })\n \n+      if (seq !== signingSeq.current) {\n+        return\n+      }\n+\n       acceptSavedConfig(saved)\n \n       const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl)\n \n+      if (seq !== signingSeq.current) {\n+        return\n+      }\n+\n       if (result.connected) {\n         const refreshed = await window.hermesDesktop.getConnectionConfig(scope)\n         acceptSavedConfig(refreshed)","@@ -429,24 +552,38 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {\n         })\n       }\n     } catch (err) {\n-      notifyError(err, g.signInFailed)\n+      if (seq === signingSeq.current) {\n+        notifyError(err, g.signInFailed)\n+      }\n     } finally {\n-      setSigningIn(false)\n+      if (seq === signingSeq.current) {\n+        setSigningIn(false)\n+      }\n     }\n   }\n \n   const signOut = async () => {\n+    const seq = ++signingSeq.current\n     setSigningIn(true)\n \n     try {\n       await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined)\n       const refreshed = await window.hermesDesktop.getConnectionConfig(scope)\n+\n+      if (seq !== signingSeq.current) {\n+        return\n+      }\n+\n       acceptSavedConfig(refreshed)\n       notify({ kind: 'success', title: g.signedOutTitle, message: g.signedOutMessage })\n     } catch (err) {\n-      notifyError(err, g.signOutFailed)\n+      if (seq === signingSeq.current) {\n+        notifyError(err, g.signOutFailed)\n+      }\n     } finally {\n-      setSigningIn(false)\n+      if (seq === signingSeq.current) {\n+        setSigningIn(false)\n+      }\n     }\n   }\n ","@@ -458,6 +595,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {\n   // needsOrgSelection we surface the org list and show a picker instead.\n   const discoverCloud = async (org?: string) => {\n     const desktop = window.hermesDesktop\n+    const seq = contextSeq.current\n \n     if (!desktop?.cloud) {\n       return","@@ -468,6 +606,10 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {\n     try {\n       const result = await desktop.cloud.discover(org)\n \n+      if (seq !== contextSeq.current) {\n+        return\n+      }\n+\n       if ('needsOrgSelection' in result && result.needsOrgSelection) {\n         // Multi-org user with no org chosen yet: show the picker. Don't clear a\n         // previously-chosen org list on a refresh.","@@ -495,6 +637,10 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {\n \n       setCloudDiscover('done')\n     } catch (err) {\n+      if (seq !== contextSeq.current) {\n+        return\n+      }\n+\n       setCloudAgents([])\n       setCloudDiscover('error')\n ","@@ -579,6 +725,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {\n \n   const cloudSignIn = async () => {\n     const desktop = window.hermesDesktop\n+    const seq = ++signingSeq.current\n \n     if (!desktop?.cloud) {\n       return","@@ -588,20 +735,30 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {\n \n     try {\n       const result = await desktop.cloud.login()\n+\n+      if (seq !== signingSeq.current) {\n+        return\n+      }\n+\n       setCloudSignedIn(result.signedIn)\n \n       if (result.signedIn) {\n         await discoverCloud()\n       }\n     } catch (err) {\n-      notifyError(err, g.cloudSignInFailed)\n+      if (seq === signingSeq.current) {\n+        notifyError(err, g.cloudSignInFailed)\n+      }\n     } finally {\n-      setCloudSigningIn(false)\n+      if (seq === signingSeq.current) {\n+        setCloudSigningIn(false)\n+      }\n     }\n   }\n \n   const cloudSignOut = async () => {\n     const desktop = window.hermesDesktop\n+    const seq = ++signingSeq.current\n \n     if (!desktop?.cloud) {\n       return","@@ -611,6 +768,11 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {\n \n     try {\n       await desktop.cloud.logout()\n+\n+      if (seq !== signingSeq.current) {\n+        return\n+      }\n+\n       setCloudSignedIn(false)\n       setCloudAgents([])\n       setCloudOrgs([])","@@ -618,9 +780,13 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {\n       setCloudDiscover('idle')\n       notify({ kind: 'success', title: g.cloudSignedOutTitle, message: g.cloudSignedOutMessage })\n     } catch (err) {\n-      notifyError(err, g.signOutFailed)\n+      if (seq === signingSeq.current) {\n+        notifyError(err, g.signOutFailed)\n+      }\n     } finally {\n-      setCloudSigningIn(false)\n+      if (seq === signingSeq.current) {\n+        setCloudSigningIn(false)\n+      }\n     }\n   }\n ","@@ -628,6 +794,8 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {\n   // prompt — the shared portal session auto-approves), then persist a cloud-mode\n   // connection pointed at its dashboardUrl and apply it (soft-reconnects in place).\n   const connectCloudAgent = async (agent: DesktopCloudAgent) => {\n+    const seq = contextSeq.current\n+\n     if (!agent.dashboardUrl) {\n       return\n     }","@@ -643,6 +811,10 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {\n     try {\n       const result = await desktop.cloud.agentSignIn(agent.dashboardUrl)\n \n+      if (seq !== contextSeq.current) {\n+        return\n+      }\n+\n       if (!result.connected) {\n         notify({\n           kind: 'warning',","@@ -665,20 +837,113 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {\n         cloudOrg: cloudOrgRef.current ?? undefined\n       })\n \n+      if (seq !== contextSeq.current) {\n+        return\n+      }\n+\n       acceptSavedConfig(next)\n       notify({ kind: 'success', title: g.cloudConnectedTitle, message: g.cloudConnectedTo(agent.name) })\n     } catch (err) {\n+      if (seq !== contextSeq.current) {\n+        return\n+      }\n+\n       if (err && typeof err === 'object' && 'needsCloudLogin' in err) {\n         setCloudSignedIn(false)\n       }\n \n       notifyError(err, g.cloudConnectFailed)\n     } finally {\n-      setCloudConnectingId(null)\n+      if (seq === contextSeq.current) {\n+        setCloudConnectingId(null)\n+      }\n+    }\n+  }\n+\n+  const resolveSshHost = async (host: string) => {\n+    if (!host || !window.hermesDesktop?.sshResolveHost) {\n+      return\n+    }\n+\n+    const seq = ++sshResolveSeq.current\n+\n+    try {\n+      const resolved = await window.hermesDesktop.sshResolveHost(host)\n+\n+      if (seq !== sshResolveSeq.current) {\n+        return\n+      }\n+\n+      setState(current => enrichSelectedSshHost(current, host, resolved))\n+    } catch {\n+      return\n+    }\n+  }\n+\n+  const selectHost = (value: string) => {\n+    if (value === SSH_HOST_CUSTOM) {\n+      setSshCustomHost(true)\n+      setState(current => selectSshHost(current, ''))\n+\n+      return\n+    }\n+\n+    setSshCustomHost(false)\n+    setState(current => selectSshHost(current, value))\n+    void resolveSshHost(value)\n+  }\n+\n+  const testSsh = async () => {\n+    const seq = ++sshTestSeq.current\n+\n+    if (!state.sshHost.trim()) {\n+      notify({ kind: 'warning', title: g.incompleteTitle, message: g.sshIncompleteHost })\n+\n+      return\n+    }\n+\n+    setTesting(true)\n+    setLastTest(null)\n+\n+    try {\n+      const result = await window.hermesDesktop.testConnectionConfig(payload())\n+\n+      if (seq !== sshTestSeq.current) {\n+        return\n+      }\n+\n+      if (!result.reachable) {\n+        const errors = {\n+          'auth-failed': g.sshErrAuth,\n+          'hermes-not-found': g.sshErrNotInstalled,\n+          'host-key-changed': g.sshErrHostKey,\n+          timeout: g.sshErrTimeout,\n+          unreachable: g.sshErrUnreachable,\n+          'unsupported-platform': g.sshErrPlatform,\n+          'update-required': g.sshErrUpdateRequired,\n+          unknown: g.sshErrUnknown\n+        }\n+\n+        throw new Error(errors[result.sshError || 'unknown'] || result.error || g.sshErrUnknown)\n+      }\n+\n+      const message = g.sshReachable(result.host || state.sshHost, result.remotePlatform || '?')\n+      setLastTest(message)\n+      notify({ kind: 'success', title: g.reachableTitle, message })\n+    } catch (err) {\n+      if (seq === sshTestSeq.current) {\n+        notifyError(err, g.testFailed)\n+      }\n+    } finally {\n+      if (seq === sshTestSeq.current) {\n+        setTesting(false)\n+      }\n     }\n   }\n \n   const testRemote = async () => {\n+    const seq = ++sshTestSeq.current\n+\n     if (!canUseRemote) {\n       notify({\n         kind: 'warning',","@@ -701,13 +966,21 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {\n         remoteUrl: trimmedUrl\n       })\n \n-      const message = g.connectedTo(result.baseUrl, result.version ?? undefined)\n+      if (seq !== sshTestSeq.current) {\n+        return\n+      }\n+\n+      const message = g.connectedTo(result.baseUrl || trimmedUrl, result.version ?? undefined)\n       setLastTest(message)\n       notify({ kind: 'success', title: g.reachableTitle, message })\n     } catch (err) {\n-      notifyError(err, g.testFailed)\n+      if (seq === sshTestSeq.current) {\n+        notifyError(err, g.testFailed)\n+      }\n     } finally {\n-      setTesting(false)\n+      if (seq === sshTestSeq.current) {\n+        setTesting(false)\n+      }\n     }\n   }\n ","@@ -770,7 +1043,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {\n         <div className=\"text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)\">\n           {g.modeTitle}\n         </div>\n-        <div className=\"grid auto-rows-fr grid-cols-1 gap-2 min-[42rem]:grid-cols-3\">\n+        <div className=\"grid auto-rows-fr grid-cols-1 gap-2 sm:grid-cols-2 min-[72rem]:grid-cols-4\">\n           <ModeCard\n             active={state.mode === 'local'}\n             description={g.localDesc}","@@ -796,6 +1069,15 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {\n             onSelect={() => setState(current => ({ ...current, mode: 'remote' }))}\n             title={g.remoteTitle}\n           />\n+          <ModeCard\n+            active={state.mode === 'ssh'}\n+            description={g.sshDesc}\n+            disabled={state.envOverride}\n+            hint={g.sshTrustHint}\n+            icon={Terminal}\n+            onSelect={() => setState(current => ({ ...current, mode: 'ssh' }))}\n+            title={g.sshTitle}\n+          />\n         </div>\n       </div>\n ","@@ -1033,6 +1315,109 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {\n         </div>\n       ) : null}\n \n+      {state.mode === 'ssh' && !state.envOverride ? (\n+        <div className=\"mt-5 grid gap-1\">\n+          {sshHostSuggestions.length > 0 && !sshCustomHost ? (\n+            <ListRow\n+              action={\n+                <Select\n+                  onValueChange={selectHost}\n+                  value={sshHostSuggestions.includes(state.sshHost) ? state.sshHost : SSH_HOST_CUSTOM}\n+                >\n+                  <SelectTrigger className={cn('h-8', CONTROL_TEXT)}>\n+                    <SelectValue placeholder={g.sshHostPick} />\n+                  </SelectTrigger>\n+                  <SelectContent>\n+                    {sshHostSuggestions.map(host => (\n+                      <SelectItem key={host} value={host}>\n+                        {host}\n+                      </SelectItem>\n+                    ))}\n+                    <SelectItem value={SSH_HOST_CUSTOM}>{g.sshHostCustom}</SelectItem>\n+                  </SelectContent>\n+                </Select>\n+              }\n+              description={g.sshHostPickDesc}\n+              title={g.sshHostPickTitle}\n+            />\n+          ) : (\n+            <ListRow\n+              action={\n+                <Input\n+                  autoFocus={sshCustomHost}\n+                  className={cn('h-8', CONTROL_TEXT)}\n+                  onBlur={() => {\n+                    // Empty host on blur with suggestions available = the user backed\n+                    // out of Custom; return to the dropdown.\n+                    if (!state.sshHost.trim() && sshHostSuggestions.length > 0) {\n+                      setSshCustomHost(false)\n+\n+                      return\n+                    }\n+\n+                    void resolveSshHost(state.sshHost)\n+                  }}\n+                  onChange={event => setState(current => selectSshHost(current, event.target.value))}\n+                  value={state.sshHost}\n+                />\n+              }\n+              description={g.sshHostDesc}\n+              title={g.sshHostTitle}\n+            />\n+          )}\n+          <ListRow\n+            action={\n+              <Input\n+                className={cn('h-8', CONTROL_TEXT)}\n+                onChange={event => setState(current => ({ ...current, sshUser: event.target.value }))}\n+                placeholder={g.sshUserPlaceholder}\n+                value={state.sshUser}\n+              />\n+            }\n+            description={g.sshUserDesc}\n+            title={g.sshUserTitle}\n+          />\n+          <ListRow\n+            action={\n+              <Input\n+                className={cn('h-8', CONTROL_TEXT)}\n+                inputMode=\"numeric\"\n+                onChange={event =>\n+                  setState(current => ({ ...current, sshPort: event.target.value ? Number(event.target.value) : null }))\n+                }\n+                placeholder=\"22\"\n+                value={state.sshPort ?? ''}\n+              />\n+            }\n+            description={g.sshPortDesc}\n+            title={g.sshPortTitle}\n+          />\n+          <ListRow\n+            action={\n+              <Input\n+                className={cn('h-8 font-mono', CONTROL_TEXT)}\n+                onChange={event => setState(current => ({ ...current, sshKeyPath: event.target.value }))}\n+                value={state.sshKeyPath}\n+              />\n+            }\n+            description={g.sshKeyDesc}\n+            title={g.sshKeyTitle}\n+          />\n+          <ListRow\n+            action={\n+              <Input\n+                className={cn('h-8 font-mono', CONTROL_TEXT)}\n+                onChange={event => setState(current => ({ ...current, sshRemoteHermesPath: event.target.value }))}\n+                placeholder={g.sshHermesPathPlaceholder}\n+                value={state.sshRemoteHermesPath}\n+              />\n+            }\n+            description={g.sshHermesPathDesc}\n+            title={g.sshHermesPathTitle}\n+          />\n+        </div>\n+      ) : null}\n+\n       {lastTest ? <div className=\"mt-4 text-xs text-primary\">{lastTest}</div> : null}\n \n       {/* Test/Save apply to local + remote. Cloud connects via the agent picker","@@ -1051,6 +1436,17 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {\n               {testing ? <Loader2 className=\"animate-spin\" /> : null}\n               {g.testRemote}\n             </Button>\n+          ) : state.mode === 'ssh' ? (\n+            <Button\n+              className=\"mr-auto\"\n+              disabled={testing || !state.sshHost.trim()}\n+              onClick={() => void testSsh()}\n+              size=\"sm\"\n+              variant=\"text\"\n+            >\n+              {testing ? <Loader2 className=\"animate-spin\" /> : null}\n+              {g.sshTestConnection}\n+            </Button>\n           ) : null}\n           {embedded ? null : (\n             <Button"]},{"file":"apps/desktop/src/app/settings/ssh-host-selection.test.ts","hunks":["@@ -0,0 +1,46 @@\n+import { describe, expect, it } from 'vitest'\n+\n+import { enrichSelectedSshHost, selectSshHost } from './ssh-host-selection'\n+\n+const state = {\n+  mode: 'ssh',\n+  sshHost: 'linux-box',\n+  sshUser: 'operator',\n+  sshPort: 2222,\n+  sshKeyPath: '/keys/linux',\n+  sshRemoteHermesPath: '/opt/hermes'\n+}\n+\n+describe('selectSshHost', () => {\n+  it('clears host-specific fields when the selected host changes', () => {\n+    expect(selectSshHost(state, 'mac-box')).toEqual({\n+      mode: 'ssh',\n+      sshHost: 'mac-box',\n+      sshUser: '',\n+      sshPort: null,\n+      sshKeyPath: '',\n+      sshRemoteHermesPath: ''\n+    })\n+  })\n+\n+  it('preserves state when reselecting the same host', () => {\n+    expect(selectSshHost(state, state.sshHost)).toBe(state)\n+  })\n+\n+  it('enriches only the host that produced the ssh config result', () => {\n+    const selected = selectSshHost(state, 'mac-box')\n+    expect(\n+      enrichSelectedSshHost(selected, 'mac-box', {\n+        identityFile: '~/.ssh/id_ed25519',\n+        port: 22,\n+        user: 'hermes'\n+      })\n+    ).toMatchObject({\n+      sshHost: 'mac-box',\n+      sshUser: 'hermes',\n+      sshPort: null,\n+      sshKeyPath: '~/.ssh/id_ed25519'\n+    })\n+    expect(enrichSelectedSshHost(state, 'mac-box', { user: 'wrong' })).toBe(state)\n+  })\n+})"]},{"file":"apps/desktop/src/app/settings/ssh-host-selection.ts","hunks":["@@ -0,0 +1,43 @@\n+type SshHostState = {\n+  sshHost: string\n+  sshUser: string\n+  sshPort: number | null\n+  sshKeyPath: string\n+  sshRemoteHermesPath: string\n+}\n+\n+type ResolvedSshHost = {\n+  identityFile?: string | null\n+  port?: number | null\n+  user?: string | null\n+}\n+\n+function selectSshHost<T extends SshHostState>(state: T, host: string): T {\n+  if (host === state.sshHost) {\n+    return state\n+  }\n+\n+  return {\n+    ...state,\n+    sshHost: host,\n+    sshUser: '',\n+    sshPort: null,\n+    sshKeyPath: '',\n+    sshRemoteHermesPath: ''\n+  }\n+}\n+\n+function enrichSelectedSshHost<T extends SshHostState>(state: T, host: string, resolved: ResolvedSshHost): T {\n+  if (state.sshHost !== host) {\n+    return state\n+  }\n+\n+  return {\n+    ...state,\n+    sshUser: state.sshUser || resolved.user || '',\n+    sshPort: state.sshPort ?? (resolved.port === 22 ? null : (resolved.port ?? null)),\n+    sshKeyPath: state.sshKeyPath || resolved.identityFile || ''\n+  }\n+}\n+\n+export { enrichSelectedSshHost, selectSshHost }"]},{"file":"apps/desktop/src/app/shell/hooks/use-overlay-routing.ts","hunks":["@@ -47,6 +47,10 @@ export function useOverlayRouting() {\n     [navigate]\n   )\n \n+  const resetOverlayReturnRoute = useCallback(() => {\n+    returnPathRef.current = NEW_CHAT_ROUTE\n+  }, [])\n+\n   const closeOverlayToPreviousRoute = useCallback(\n     () => navigate(returnPathRef.current || NEW_CHAT_ROUTE, { replace: true }),\n     [navigate]","@@ -75,6 +79,7 @@ export function useOverlayRouting() {\n     openCommandCenterSection,\n     openStarmap,\n     profilesOpen,\n+    resetOverlayReturnRoute,\n     settingsOpen,\n     starmapOpen,\n     toggleCommandCenter"]},{"file":"apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx","hunks":["@@ -42,7 +42,7 @@ import {\n } from '@/store/updates'\n import type { StatusResponse } from '@/types/hermes'\n \n-import { CRON_ROUTE } from '../../routes'\n+import { CRON_ROUTE, SETTINGS_ROUTE } from '../../routes'\n import type { StatusbarItem } from '../statusbar-controls'\n \n const EMPTY_USAGE = { calls: 0, input: 0, output: 0, total: 0 } as const","@@ -289,8 +289,38 @@ export function useStatusbarItems({\n     copy\n   ])\n \n+  const connectionItem = useMemo<StatusbarItem | null>(() => {\n+    if (connection?.mode !== 'remote' || !connection.remoteHost) {\n+      return null\n+    }\n+\n+    const ssh = connection.remoteKind === 'ssh'\n+    const cloud = connection.remoteKind === 'cloud'\n+\n+    return {\n+      className: cn(\n+        'px-2 -ml-1 font-medium',\n+        ssh ? 'bg-primary text-primary-foreground' : 'bg-accent text-accent-foreground'\n+      ),\n+      icon: <Terminal className=\"size-3\" />,\n+      id: 'connection',\n+      label: ssh\n+        ? copy.connectionSsh(connection.remoteHost)\n+        : cloud\n+          ? copy.connectionCloud(connection.remoteHost)\n+          : copy.connectionRemote(connection.remoteHost),\n+      title: ssh\n+        ? copy.connectionSshTooltip(connection.remoteHost)\n+        : cloud\n+          ? copy.connectionCloudTooltip(connection.remoteHost)\n+          : copy.connectionRemoteTooltip(connection.remoteHost),\n+      to: `${SETTINGS_ROUTE}?tab=gateway`\n+    }\n+  }, [connection?.mode, connection?.remoteHost, connection?.remoteKind, copy])\n+\n   const coreLeftStatusbarItems = useMemo<readonly StatusbarItem[]>(\n     () => [\n+      ...(connectionItem ? [connectionItem] : []),\n       {\n         className: `w-7 justify-center px-0${commandCenterOpen ? ' bg-accent/55 text-foreground' : ''}`,\n         icon: <Command className=\"size-3.5\" />,","@@ -386,6 +416,7 @@ export function useStatusbarItems({\n     [\n       agentsOpen,\n       commandCenterOpen,\n+      connectionItem,\n       copy,\n       currentCwd,\n       fileMenu.copyPath,"]},{"file":"apps/desktop/src/components/boot-failure-overlay.tsx","hunks":["@@ -13,7 +13,13 @@ import { notify, notifyError } from '@/store/notifications'\n import { $desktopOnboarding } from '@/store/onboarding'\n \n import type { RemoteReauth } from './boot-failure-reauth'\n-import { deriveProviderShape, isRemoteConfig, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth'\n+import {\n+  deriveProviderShape,\n+  isRemoteConfig,\n+  isRemoteReauthFailure,\n+  signInLabel,\n+  sshFailureMessage\n+} from './boot-failure-reauth'\n \n // The recovery \"Gateway settings\" view embeds the real Settings → Gateway panel\n // (identical URL/auth/test/save controls — no parallel form to drift). Lazy so","@@ -44,6 +50,7 @@ export function BootFailureOverlay() {\n   const [logs, setLogs] = useState<string[]>([])\n   const [showLogs, setShowLogs] = useState(false)\n   const [remoteReauth, setRemoteReauth] = useState<RemoteReauth | null>(null)\n+  const [connectionConfig, setConnectionConfig] = useState<DesktopConnectionConfig | null>(null)\n   // A remote/cloud backend that failed to boot is fixable from gateway settings,\n   // so the escape hatch earns emphasis (local failures keep it as a quiet ghost).\n   const [remoteFailure, setRemoteFailure] = useState(false)","@@ -75,6 +82,7 @@ export function BootFailureOverlay() {\n   useEffect(() => {\n     if (!visible) {\n       setRemoteReauth(null)\n+      setConnectionConfig(null)\n       setRemoteFailure(false)\n       setView('recovery')\n ","@@ -102,6 +110,7 @@ export function BootFailureOverlay() {\n         return\n       }\n \n+      setConnectionConfig(config)\n       setRemoteFailure(isRemoteConfig(config))\n \n       if (!isRemoteReauthFailure(config, boot.error)) {","@@ -314,7 +323,7 @@ export function BootFailureOverlay() {\n \n         <div className=\"grid gap-4 p-5 pt-0\">\n           <div className=\"rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-xs text-destructive\">\n-            {boot.error}\n+            {sshFailureMessage(connectionConfig, boot.error, t.settings.gateway)}\n           </div>\n \n           <div className=\"grid gap-2\">"]},{"file":"apps/desktop/src/components/boot-failure-reauth.test.ts","hunks":["@@ -7,7 +7,8 @@ import {\n   isRemoteConfig,\n   isRemoteReauthError,\n   isRemoteReauthFailure,\n-  signInLabel\n+  signInLabel,\n+  sshFailureMessage\n } from './boot-failure-reauth'\n \n function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnectionConfig {","@@ -21,6 +22,11 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnec\n     remoteTokenSet: [redacted:secret-assignment]\n     remoteUrl: 'https://box:9119',\n     cloudOrg: '',\n+    sshHost: '',\n+    sshUser: '',\n+    sshPort: null,\n+    sshKeyPath: '',\n+    sshRemoteHermesPath: '',\n     ...overrides\n   }\n }","@@ -31,8 +37,20 @@ describe('isRemoteConfig', () => {\n     expect(isRemoteConfig(config({ mode: 'cloud', remoteOauthConnected: true }))).toBe(true)\n   })\n \n-  it('false for local, for a remote with no URL, and for nullish', () => {\n+  it('recognizes SSH as remote recovery without treating it as OAuth reauth', () => {\n+    const ssh = config({ mode: 'ssh' as never, remoteUrl: '', remoteAuthMode: 'token' }) as DesktopConnectionConfig & {\n+      sshHost: string\n+    }\n+\n+    ssh.sshHost = 'remote-box'\n+\n+    expect(isRemoteConfig(ssh)).toBe(true)\n+    expect(isRemoteReauthFailure(ssh, 'SSH authentication failed.')).toBe(false)\n+  })\n+\n+  it('false for local, incomplete SSH, a remote with no URL, and nullish', () => {\n     expect(isRemoteConfig(config({ mode: 'local' }))).toBe(false)\n+    expect(isRemoteConfig(config({ mode: 'ssh' as never, remoteUrl: '' }))).toBe(false)\n     expect(isRemoteConfig(config({ remoteUrl: '' }))).toBe(false)\n     expect(isRemoteConfig(null)).toBe(false)\n   })","@@ -93,6 +111,16 @@ describe('isRemoteReauthError', () => {\n   })\n })\n \n+describe('sshFailureMessage', () => {\n+  it('localizes SSH failures without changing non-SSH errors', () => {\n+    const copy = { sshErrAuth: 'localized auth', sshErrUnknown: 'localized unknown' }\n+    const ssh = config({ mode: 'ssh', sshHost: 'box', remoteUrl: '' })\n+    expect(sshFailureMessage(ssh, 'SSH authentication failed', copy)).toBe('localized auth')\n+    expect(sshFailureMessage(ssh, 'unexpected failure', copy)).toBe('localized unknown')\n+    expect(sshFailureMessage(config(), 'raw remote error', copy)).toBe('raw remote error')\n+  })\n+})\n+\n describe('deriveProviderShape', () => {\n   it('generic copy when there are no providers', () => {\n     expect(deriveProviderShape([])).toEqual({ isPassword: [redacted:secret-assignment] providerLabel: 'your identity provider' })"]},{"file":"apps/desktop/src/components/boot-failure-reauth.ts","hunks":["@@ -31,7 +31,16 @@ const DEFAULT_SIGN_IN_COPY: SignInCopy = {\n // Gateway (edit URL / token / sign in) — the local Retry/Repair buttons target\n // the bundled backend and can't help. Drives the escape-hatch emphasis.\n export function isRemoteConfig(config: DesktopConnectionConfig | null | undefined): boolean {\n-  return Boolean(config && (config.mode === 'remote' || config.mode === 'cloud') && config.remoteUrl)\n+  if (!config) {\n+    return false\n+  }\n+\n+  const ssh = config as DesktopConnectionConfig & { sshHost?: string }\n+\n+  return (\n+    ((config.mode === 'remote' || config.mode === 'cloud') && Boolean(config.remoteUrl)) ||\n+    ((config.mode as string) === 'ssh' && Boolean(ssh.sshHost))\n+  )\n }\n \n // 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 {\n // — see isRemoteReauthError). Only re-establishing the remote session fixes it;\n // the local Retry/Repair buttons can't. 'cloud' counts as remote (it resolves to\n // a remote oauth backend), so a lapsed cloud session is the same failure.\n+export function sshFailureMessage(\n+  config: DesktopConnectionConfig | null | undefined,\n+  error: string | null | undefined,\n+  copy: {\n+    sshErrAuth?: string\n+    sshErrHostKey?: string\n+    sshErrNotInstalled?: string\n+    sshErrPlatform?: string\n+    sshErrTimeout?: string\n+    sshErrUpdateRequired?: string\n+    sshErrUnreachable?: string\n+    sshErrUnknown?: string\n+  }\n+): string {\n+  const raw = String(error || '')\n+\n+  if (config?.mode !== 'ssh') {\n+    return raw\n+  }\n+\n+  const text = raw.toLowerCase()\n+\n+  if (text.includes('host key')) {\n+    return copy.sshErrHostKey || raw\n+  }\n+\n+  if (text.includes('auth')) {\n+    return copy.sshErrAuth || raw\n+  }\n+\n+  if (text.includes('not installed') || text.includes('not found')) {\n+    return copy.sshErrNotInstalled || raw\n+  }\n+\n+  if (text.includes('unsupported')) {\n+    return copy.sshErrPlatform || raw\n+  }\n+\n+  if (text.includes('timed out') || text.includes('timeout')) {\n+    return copy.sshErrTimeout || raw\n+  }\n+\n+  if (text.includes('update')) {\n+    return copy.sshErrUpdateRequired || raw\n+  }\n+\n+  if (text.includes('unreachable') || text.includes('could not reach')) {\n+    return copy.sshErrUnreachable || raw\n+  }\n+\n+  return copy.sshErrUnknown || raw\n+}\n+\n export function isRemoteReauthFailure(\n   config: DesktopConnectionConfig | null | undefined,\n   error?: string | null"]},{"file":"apps/desktop/src/global.d.ts","hunks":["@@ -60,6 +60,8 @@ declare global {\n       saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>\n       applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>\n       testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionTestResult>\n+      sshConfigHosts: () => Promise<DesktopSshHostsResult>\n+      sshResolveHost: (host: string) => Promise<DesktopSshResolveResult>\n       probeConnectionConfig: (remoteUrl: string) => Promise<DesktopConnectionProbeResult>\n       oauthLoginConnectionConfig: (remoteUrl: string) => Promise<DesktopOauthLoginResult>\n       oauthLogoutConnectionConfig: (remoteUrl?: string) => Promise<DesktopOauthLogoutResult>","@@ -395,6 +397,10 @@ export interface HermesConnection {\n   // (cloud-auto-discovery Q3/Q6), so this never carries 'cloud'.\n   mode?: 'local' | 'remote'\n   authMode?: 'oauth' | 'token'\n+  remoteHost?: string\n+  remoteIdentity?: string\n+  remoteKind?: 'cloud' | 'ssh' | 'url'\n+  remoteHermesVersion?: string\n   nativeOverlayWidth: number\n   source?: 'env' | 'local' | 'settings'\n   token: string","@@ -430,7 +436,7 @@ export interface DesktopConnectionConfig {\n   // remoteAuthMode 'oauth') but is remembered as cloud so settings reopens into\n   // the cloud picker. Resolution treats cloud exactly as remote\n   // (cloud-auto-discovery Q3/Q6).\n-  mode: 'local' | 'remote' | 'cloud'\n+  mode: 'local' | 'remote' | 'cloud' | 'ssh'\n   // The profile this config describes, or null for the global/default\n   // connection. Per-profile entries let a profile point at its own backend.\n   profile: null | string","@@ -443,10 +449,15 @@ export interface DesktopConnectionConfig {\n   // connected instance was discovered under, so Settings → Gateway can reopen\n   // into that org. Empty string for remote/local.\n   cloudOrg: string\n+  sshHost: string\n+  sshUser: string\n+  sshPort: number | null\n+  sshKeyPath: string\n+  sshRemoteHermesPath: string\n }\n \n export interface DesktopConnectionConfigInput {\n-  mode: 'local' | 'remote' | 'cloud'\n+  mode: 'local' | 'remote' | 'cloud' | 'ssh'\n   // When set, the save/apply/test targets this profile's per-profile remote\n   // override instead of the global connection.\n   profile?: null | string","@@ -456,12 +467,44 @@ export interface DesktopConnectionConfigInput {\n   // For a 'cloud' connection: the selected Hermes Cloud org (slug or id) to\n   // persist so Settings can reopen into it. Ignored for remote/local modes.\n   cloudOrg?: string\n+  sshHost?: string\n+  sshUser?: string\n+  sshPort?: number | null\n+  sshKeyPath?: string\n+  sshRemoteHermesPath?: string\n }\n \n export interface DesktopConnectionTestResult {\n-  baseUrl: string\n-  ok: boolean\n-  version: string | null\n+  baseUrl?: string\n+  ok?: boolean\n+  version?: string | null\n+  reachable?: boolean\n+  sshError?:\n+    | 'auth-failed'\n+    | 'hermes-not-found'\n+    | 'host-key-changed'\n+    | 'timeout'\n+    | 'unreachable'\n+    | 'unsupported-platform'\n+    | 'update-required'\n+    | 'unknown'\n+    | null\n+  error?: string | null\n+  host?: string\n+  remoteHermesPath?: string\n+  remoteHermesVersion?: string\n+  remotePlatform?: string\n+}\n+\n+export interface DesktopSshResolveResult {\n+  hostname: string | null\n+  identityFile: string | null\n+  port: number | null\n+  user: string | null\n+}\n+\n+export interface DesktopSshHostsResult {\n+  hosts: string[]\n }\n \n export interface DesktopAuthProvider {"]},{"file":"apps/desktop/src/i18n/en.ts","hunks":["@@ -645,7 +645,44 @@ export const en: Translations = {\n       signOutFailed: 'Sign-out failed',\n       testFailed: 'Remote gateway test failed',\n       applyFailed: 'Could not apply gateway settings',\n-      saveFailed: 'Could not save gateway settings'\n+      saveFailed: 'Could not save gateway settings',\n+      sshTitle: 'Connect via SSH',\n+      sshDesc:\n+        '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.',\n+      sshTrustHint: 'The first presented host key is trusted and pinned; later changes fail closed.',\n+      sshHostTitle: 'Host',\n+      sshHostDesc: 'user@host, or a Host alias from ~/.ssh/config.',\n+      sshHostPick: 'Select a host…',\n+      sshHostPickTitle: 'Host',\n+      sshHostPickDesc: 'A Host alias from ~/.ssh/config, or Custom to type one.',\n+      sshHostCustom: 'Custom (enter manually)…',\n+      sshUserTitle: 'User',\n+      sshUserDesc: 'Blank = ~/.ssh/config or your current user.',\n+      sshUserPlaceholder: 'from ~/.ssh/config',\n+      sshPortTitle: 'Port',\n+      sshPortDesc: 'Blank = 22 or the ~/.ssh/config port.',\n+      sshKeyTitle: 'Identity file',\n+      sshKeyDesc: 'Private key path. Blank = ssh-agent or ~/.ssh/config.',\n+      sshHermesPathTitle: 'Hermes path (optional)',\n+      sshHermesPathDesc: 'Full path to the remote hermes binary. Blank = auto-detect.',\n+      sshHermesPathPlaceholder: 'auto-detect',\n+      sshTestConnection: 'Test SSH',\n+      sshConnect: 'Connect',\n+      sshButtonsHint: 'Save applies on the next launch. Connect reconnects now.',\n+      sshReachable: (host, platform) => `Reachable: ${host} (${platform}) — Hermes found`,\n+      sshIncompleteHost: 'Enter an SSH host before connecting.',\n+      sshErrUnreachable: 'Could not reach that host over SSH. Check the host, port, and your network.',\n+      sshErrAuth:\n+        'SSH authentication failed. Load your key into the ssh-agent (ssh-add) or set an IdentityFile in ~/.ssh/config — Hermes runs ssh non-interactively.',\n+      sshErrHostKey:\n+        'The host key has CHANGED since you last connected. Verify this is expected, then run ssh-keygen -R <host> and reconnect.',\n+      sshErrNotInstalled:\n+        '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.',\n+      sshErrPlatform:\n+        'Unsupported remote platform. Hermes Desktop SSH mode supports Linux, macOS, and Windows remote hosts.',\n+      sshErrTimeout: 'SSH connection timed out. The host may be unreachable or asleep.',\n+      sshErrUpdateRequired: 'Update Hermes on the remote host before connecting with Desktop SSH.',\n+      sshErrUnknown: 'SSH connection failed.'\n     },\n     keys: {\n       loading: 'Loading API keys and credentials...',","@@ -2169,6 +2206,12 @@ export const en: Translations = {\n       desktopVersion: version => `Hermes Desktop v${version}`,\n       backendVersion: version => `Backend v${version}`,\n       clientLabel: version => `client v${version}`,\n+      connectionSsh: host => `SSH: ${host}`,\n+      connectionRemote: host => `Remote: ${host}`,\n+      connectionCloud: host => `Cloud: ${host}`,\n+      connectionCloudTooltip: host => `Connected to Hermes Cloud at ${host} · click to manage`,\n+      connectionSshTooltip: host => `Connected over SSH to ${host} · click to manage`,\n+      connectionRemoteTooltip: host => `Connected to remote backend ${host} · click to manage`,\n       backendLabel: version => `backend v${version}`,\n       commit: sha => `commit ${sha}`,\n       branch: branch => `branch ${branch}`,"]},{"file":"apps/desktop/src/i18n/ja.ts","hunks":["@@ -717,7 +717,44 @@ export const ja = defineLocale({\n       signOutFailed: 'サインアウトに失敗しました',\n       testFailed: 'リモートゲートウェイのテストに失敗しました',\n       applyFailed: 'ゲートウェイ設定を適用できませんでした',\n-      saveFailed: 'ゲートウェイ設定を保存できませんでした'\n+      saveFailed: 'ゲートウェイ設定を保存できませんでした',\n+      sshTitle: 'SSH で接続',\n+      sshDesc:\n+        'Hermes は SSH 経由でリモート上に起動され、このアプリにトンネルされます。リモート側で何かを起動・公開する必要はありません。ホストへの鍵ベースの SSH アクセスが前提です。',\n+      sshTrustHint: '初回に提示されたホスト鍵を信頼して固定し、以後の変更は拒否します。',\n+      sshHostTitle: 'ホスト',\n+      sshHostDesc: 'user@host、または ~/.ssh/config の Host エイリアス。',\n+      sshHostPick: 'ホストを選択…',\n+      sshHostPickTitle: 'ホスト',\n+      sshHostPickDesc: '~/.ssh/config の Host エイリアス、または「カスタム」で手入力。',\n+      sshHostCustom: 'カスタム（手入力）…',\n+      sshUserTitle: 'ユーザー',\n+      sshUserDesc: '空欄 = ~/.ssh/config または現在のユーザー。',\n+      sshUserPlaceholder: '~/.ssh/config から',\n+      sshPortTitle: 'ポート',\n+      sshPortDesc: '空欄 = 22 または ~/.ssh/config のポート。',\n+      sshKeyTitle: '鍵ファイル',\n+      sshKeyDesc: '秘密鍵のパス。空欄 = ssh-agent または ~/.ssh/config。',\n+      sshHermesPathTitle: 'Hermes パス（任意）',\n+      sshHermesPathDesc: 'リモートの hermes バイナリへのフルパス。空欄 = 自動検出。',\n+      sshHermesPathPlaceholder: '自動検出',\n+      sshTestConnection: 'SSH をテスト',\n+      sshConnect: '接続',\n+      sshButtonsHint: '「保存」は次回起動時に適用され、「接続」は今すぐ再接続します。',\n+      sshReachable: (host, platform) => `接続可能: ${host}（${platform}）— Hermes を検出`,\n+      sshIncompleteHost: '接続する前に SSH ホストを入力してください。',\n+      sshErrUnreachable: 'SSH でそのホストに到達できませんでした。ホスト、ポート、ネットワークを確認してください。',\n+      sshErrAuth:\n+        'SSH 認証に失敗しました。鍵を ssh-agent に読み込む（ssh-add）か、~/.ssh/config に IdentityFile を設定してください。Hermes は非対話的に ssh を実行します。',\n+      sshErrHostKey:\n+        '前回の接続以降、ホスト鍵が変更されています。想定どおりか確認し、ssh-keygen -R <host> を実行してから再接続してください。',\n+      sshErrNotInstalled:\n+        'リモートホストに Hermes がインストールされていません。リモートでインストールする（curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh）か、Hermes パスを設定してください。',\n+      sshErrPlatform:\n+        'サポートされていないリモートプラットフォームです。Hermes Desktop の SSH モードは Linux、macOS、Windows のリモートホストに対応しています。',\n+      sshErrTimeout: 'SSH 接続がタイムアウトしました。ホストが到達不能、またはスリープ中の可能性があります。',\n+      sshErrUpdateRequired: 'Desktop SSH で接続する前に、リモートホストの Hermes を更新してください。',\n+      sshErrUnknown: 'SSH 接続に失敗しました。'\n     },\n     keys: {\n       loading: 'API キーと認証情報を読み込み中...',","@@ -2095,6 +2132,12 @@ export const ja = defineLocale({\n       desktopVersion: version => `Hermes Desktop v${version}`,\n       backendVersion: version => `バックエンド v${version}`,\n       clientLabel: version => `クライアント v${version}`,\n+      connectionSsh: host => `SSH: ${host}`,\n+      connectionRemote: host => `リモート: ${host}`,\n+      connectionCloud: host => `クラウド: ${host}`,\n+      connectionCloudTooltip: host => `Hermes Cloud ${host} に接続中 · クリックして管理`,\n+      connectionSshTooltip: host => `SSH 経由で ${host} に接続中 · クリックして管理`,\n+      connectionRemoteTooltip: host => `リモートバックエンド ${host} に接続中 · クリックして管理`,\n       backendLabel: version => `バックエンド v${version}`,\n       commit: sha => `コミット ${sha}`,\n       branch: branch => `ブランチ ${branch}`,"]},{"file":"apps/desktop/src/i18n/types.ts","hunks":["@@ -549,6 +549,38 @@ export interface Translations {\n       testFailed: string\n       applyFailed: string\n       saveFailed: string\n+      sshTitle: string\n+      sshDesc: string\n+      sshTrustHint: string\n+      sshHostTitle: string\n+      sshHostDesc: string\n+      sshHostPick: string\n+      sshHostPickTitle: string\n+      sshHostPickDesc: string\n+      sshHostCustom: string\n+      sshUserTitle: string\n+      sshUserDesc: string\n+      sshUserPlaceholder: string\n+      sshPortTitle: string\n+      sshPortDesc: string\n+      sshKeyTitle: string\n+      sshKeyDesc: string\n+      sshHermesPathTitle: string\n+      sshHermesPathDesc: string\n+      sshHermesPathPlaceholder: string\n+      sshTestConnection: string\n+      sshConnect: string\n+      sshButtonsHint: string\n+      sshReachable: (host: string, platform: string) => string\n+      sshIncompleteHost: string\n+      sshErrUnreachable: string\n+      sshErrAuth: string\n+      sshErrHostKey: string\n+      sshErrNotInstalled: string\n+      sshErrPlatform: string\n+      sshErrTimeout: string\n+      sshErrUpdateRequired: string\n+      sshErrUnknown: string\n     }\n     keys: {\n       loading: string","@@ -1797,6 +1829,12 @@ export interface Translations {\n       desktopVersion: (version: string) => string\n       backendVersion: (version: string) => string\n       clientLabel: (version: string) => string\n+      connectionSsh: (host: string) => string\n+      connectionRemote: (host: string) => string\n+      connectionCloud: (host: string) => string\n+      connectionCloudTooltip: (host: string) => string\n+      connectionSshTooltip: (host: string) => string\n+      connectionRemoteTooltip: (host: string) => string\n       backendLabel: (version: string) => string\n       commit: (sha: string) => string\n       branch: (branch: string) => string"]},{"file":"apps/desktop/src/i18n/zh-hant.ts","hunks":["@@ -696,7 +696,42 @@ export const zhHant = defineLocale({\n       signOutFailed: '登出失敗',\n       testFailed: '遠端閘道測試失敗',\n       applyFailed: '無法套用閘道設定',\n-      saveFailed: '無法儲存閘道設定'\n+      saveFailed: '無法儲存閘道設定',\n+      sshTitle: '透過 SSH 連線',\n+      sshDesc:\n+        'Hermes 會透過 SSH 在遠端啟動並以通道連線到本應用程式——無需自行啟動或公開任何服務。前提：已具備到該主機的金鑰 SSH 存取。',\n+      sshTrustHint: '首次提供的主機金鑰會被信任並固定；後續變更將被拒絕。',\n+      sshHostTitle: '主機',\n+      sshHostDesc: 'user@host，或 ~/.ssh/config 中的 Host 別名。',\n+      sshHostPick: '選擇主機…',\n+      sshHostPickTitle: '主機',\n+      sshHostPickDesc: '~/.ssh/config 中的 Host 別名，或選擇「自訂」手動輸入。',\n+      sshHostCustom: '自訂（手動輸入）…',\n+      sshUserTitle: '使用者',\n+      sshUserDesc: '留空 = ~/.ssh/config 或目前使用者。',\n+      sshUserPlaceholder: '來自 ~/.ssh/config',\n+      sshPortTitle: '連接埠',\n+      sshPortDesc: '留空 = 22 或 ~/.ssh/config 中的連接埠。',\n+      sshKeyTitle: '金鑰檔案',\n+      sshKeyDesc: '私密金鑰路徑。留空 = ssh-agent 或 ~/.ssh/config。',\n+      sshHermesPathTitle: 'Hermes 路徑（選用）',\n+      sshHermesPathDesc: '遠端 hermes 執行檔的完整路徑。留空 = 自動偵測。',\n+      sshHermesPathPlaceholder: '自動偵測',\n+      sshTestConnection: '測試 SSH',\n+      sshConnect: '連線',\n+      sshButtonsHint: '「儲存」會在下次啟動時生效，「連線」則立即重新連線。',\n+      sshReachable: (host, platform) => `可連線：${host}（${platform}）——已找到 Hermes`,\n+      sshIncompleteHost: '連線前請輸入 SSH 主機。',\n+      sshErrUnreachable: '無法透過 SSH 連線到該主機。請檢查主機、連接埠和網路。',\n+      sshErrAuth:\n+        'SSH 驗證失敗。請將金鑰載入 ssh-agent（ssh-add），或在 ~/.ssh/config 中設定 IdentityFile——Hermes 以非互動方式執行 ssh。',\n+      sshErrHostKey: '自上次連線以來主機金鑰已變更。請確認這是預期的，然後執行 ssh-keygen -R <host> 並重新連線。',\n+      sshErrNotInstalled:\n+        '遠端主機上未安裝 Hermes。請在遠端安裝（curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh）或設定 Hermes 路徑。',\n+      sshErrPlatform: '不支援的遠端平台。Hermes Desktop 的 SSH 模式支援 Linux、macOS 和 Windows 遠端主機。',\n+      sshErrTimeout: 'SSH 連線逾時。主機可能無法存取或處於睡眠狀態。',\n+      sshErrUpdateRequired: '使用 Desktop SSH 連線前，請更新遠端主機上的 Hermes。',\n+      sshErrUnknown: 'SSH 連線失敗。'\n     },\n     keys: {\n       loading: '正在載入 API 金鑰和憑證...',","@@ -2028,6 +2063,12 @@ export const zhHant = defineLocale({\n       desktopVersion: version => `Hermes Desktop v${version}`,\n       backendVersion: version => `後端 v${version}`,\n       clientLabel: version => `用戶端 v${version}`,\n+      connectionSsh: host => `SSH: ${host}`,\n+      connectionRemote: host => `遠端: ${host}`,\n+      connectionCloud: host => `雲端: ${host}`,\n+      connectionCloudTooltip: host => `已連線到 Hermes Cloud ${host} · 點擊管理`,\n+      connectionSshTooltip: host => `已透過 SSH 連線到 ${host} · 點擊管理`,\n+      connectionRemoteTooltip: host => `已連線到遠端後端 ${host} · 點擊管理`,\n       backendLabel: version => `後端 v${version}`,\n       commit: sha => `提交 ${sha}`,\n       branch: branch => `分支 ${branch}`,"]},{"file":"apps/desktop/src/i18n/zh.ts","hunks":["@@ -839,7 +839,42 @@ export const zh: Translations = {\n       signOutFailed: '退出登录失败',\n       testFailed: '远程网关测试失败',\n       applyFailed: '无法应用网关设置',\n-      saveFailed: '无法保存网关设置'\n+      saveFailed: '无法保存网关设置',\n+      sshTitle: '通过 SSH 连接',\n+      sshDesc:\n+        'Hermes 会通过 SSH 在远程启动并以隧道连接到本应用——无需自行启动或暴露任何服务。前提：已具备到该主机的密钥 SSH 访问。',\n+      sshTrustHint: '首次提供的主机密钥会被信任并固定；后续变更将被拒绝。',\n+      sshHostTitle: '主机',\n+      sshHostDesc: 'user@host，或 ~/.ssh/config 中的 Host 别名。',\n+      sshHostPick: '选择主机…',\n+      sshHostPickTitle: '主机',\n+      sshHostPickDesc: '~/.ssh/config 中的 Host 别名，或选择\"自定义\"手动输入。',\n+      sshHostCustom: '自定义（手动输入）…',\n+      sshUserTitle: '用户',\n+      sshUserDesc: '留空 = ~/.ssh/config 或当前用户。',\n+      sshUserPlaceholder: '来自 ~/.ssh/config',\n+      sshPortTitle: '端口',\n+      sshPortDesc: '留空 = 22 或 ~/.ssh/config 中的端口。',\n+      sshKeyTitle: '密钥文件',\n+      sshKeyDesc: '私钥路径。留空 = ssh-agent 或 ~/.ssh/config。',\n+      sshHermesPathTitle: 'Hermes 路径（可选）',\n+      sshHermesPathDesc: '远程 hermes 可执行文件的完整路径。留空 = 自动检测。',\n+      sshHermesPathPlaceholder: '自动检测',\n+      sshTestConnection: '测试 SSH',\n+      sshConnect: '连接',\n+      sshButtonsHint: '“保存”将在下次启动时生效，“连接”则立即重新连接。',\n+      sshReachable: (host, platform) => `可连接：${host}（${platform}）——已找到 Hermes`,\n+      sshIncompleteHost: '连接前请输入 SSH 主机。',\n+      sshErrUnreachable: '无法通过 SSH 连接到该主机。请检查主机、端口和网络。',\n+      sshErrAuth:\n+        'SSH 认证失败。请将密钥加载到 ssh-agent（ssh-add），或在 ~/.ssh/config 中设置 IdentityFile——Hermes 以非交互方式运行 ssh。',\n+      sshErrHostKey: '自上次连接以来主机密钥已更改。请确认这是预期的，然后运行 ssh-keygen -R <host> 并重新连接。',\n+      sshErrNotInstalled:\n+        '远程主机上未安装 Hermes。请在远程安装（curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh）或设置 Hermes 路径。',\n+      sshErrPlatform: '不支持的远程平台。Hermes Desktop 的 SSH 模式支持 Linux、macOS 和 Windows 远程主机。',\n+      sshErrTimeout: 'SSH 连接超时。主机可能无法访问或处于休眠状态。',\n+      sshErrUpdateRequired: '使用 Desktop SSH 连接前，请更新远程主机上的 Hermes。',\n+      sshErrUnknown: 'SSH 连接失败。'\n     },\n     keys: {\n       loading: '正在加载 API 密钥和凭据...',","@@ -2339,6 +2374,12 @@ export const zh: Translations = {\n       desktopVersion: version => `Hermes Desktop v${version}`,\n       backendVersion: version => `后端 v${version}`,\n       clientLabel: version => `客户端 v${version}`,\n+      connectionSsh: host => `SSH: ${host}`,\n+      connectionRemote: host => `远程: ${host}`,\n+      connectionCloud: host => `云端: ${host}`,\n+      connectionCloudTooltip: host => `已连接到 Hermes Cloud ${host} · 点击管理`,\n+      connectionSshTooltip: host => `已通过 SSH 连接到 ${host} · 点击管理`,\n+      connectionRemoteTooltip: host => `已连接到远程后端 ${host} · 点击管理`,\n       backendLabel: version => `后端 v${version}`,\n       commit: sha => `提交 ${sha}`,\n       branch: branch => `分支 ${branch}`,"]},{"file":"apps/desktop/src/lib/desktop-fs.test.ts","hunks":["@@ -5,6 +5,7 @@ import { $connection } from '@/store/session'\n import {\n   desktopDefaultCwd,\n   desktopFileDiff,\n+  desktopFsCacheKey,\n   desktopGitRoot,\n   readDesktopDir,\n   readDesktopFileDataUrl,","@@ -122,6 +123,56 @@ describe('desktop filesystem facade', () => {\n     expect(api).toHaveBeenCalledWith({ path: '/api/fs/default-cwd', profile: 'remote-docker' })\n   })\n \n+  it('keys SSH filesystem caches by stable host identity instead of the forwarded port', () => {\n+    $connection.set({\n+      mode: 'remote',\n+      remoteKind: 'ssh',\n+      remoteHost: 'operator@remote-box',\n+      baseUrl: 'http://127.0.0.1:41001'\n+    } as never)\n+    const first = desktopFsCacheKey()\n+\n+    $connection.set({\n+      mode: 'remote',\n+      remoteKind: 'ssh',\n+      remoteHost: 'operator@remote-box',\n+      baseUrl: 'http://127.0.0.1:52002'\n+    } as never)\n+\n+    expect(desktopFsCacheKey()).toBe(first)\n+    expect(first).toContain('operator@remote-box')\n+    expect(first).not.toContain('41001')\n+  })\n+\n+  it('separates SSH filesystem caches by ownership and profile', () => {\n+    $connection.set({\n+      mode: 'remote',\n+      remoteKind: 'ssh',\n+      remoteHost: 'host-a',\n+      remoteIdentity: 'owner-a',\n+      profile: 'one'\n+    } as never)\n+    const first = desktopFsCacheKey()\n+    $connection.set({\n+      mode: 'remote',\n+      remoteKind: 'ssh',\n+      remoteHost: 'host-a',\n+      remoteIdentity: 'owner-b',\n+      profile: 'one'\n+    } as never)\n+    const otherOwner = desktopFsCacheKey()\n+    $connection.set({\n+      mode: 'remote',\n+      remoteKind: 'ssh',\n+      remoteHost: 'host-a',\n+      remoteIdentity: 'owner-a',\n+      profile: 'two'\n+    } as never)\n+\n+    expect(otherOwner).not.toBe(first)\n+    expect(desktopFsCacheKey()).not.toBe(first)\n+  })\n+\n   it('routes file diffs through backend git in remote mode', async () => {\n     $connection.set({ mode: 'remote' } as never)\n "]},{"file":"apps/desktop/src/lib/desktop-fs.ts","hunks":["@@ -21,7 +21,12 @@ function connectionCacheKey(connection: HermesConnection | null) {\n     return 'local:'\n   }\n \n-  return `${connection.mode || 'local'}:${connection.profile || ''}:${connection.baseUrl || ''}`\n+  const target =\n+    connection.remoteKind === 'ssh'\n+      ? connection.remoteIdentity || connection.remoteHost || ''\n+      : connection.baseUrl || ''\n+\n+  return `${connection.mode || 'local'}:${connection.remoteKind || ''}:${connection.profile || ''}:${target}`\n }\n \n export function desktopFsCacheKey() {"]},{"file":"hermes_cli/main.py","hunks":["@@ -391,6 +391,7 @@ def _try_termux_ultrafast_version() -> bool:\n import argparse\n import hashlib\n import json\n+import re\n import shlex\n import shutil\n import stat","@@ -12500,8 +12501,97 @@ def _maybe_setup_dashboard_auth_interactively(args) -> None:\n     print()\n \n \n+def _read_ssh_session_token_file(path: str) -> str:\n+    \"\"\"Read and unlink a Desktop SSH token from its private runtime directory.\"\"\"\n+    if sys.platform == \"win32\":\n+        from hermes_cli.windows_ssh_runtime import read_token\n+        return read_token(path)\n+\n+    import stat as _stat\n+    from pathlib import Path as _Path\n+    from hermes_constants import get_hermes_home as _get_hermes_home\n+\n+    if not os.path.isabs(path):\n+        raise SystemExit(\"--ssh-session-token-file must be absolute\")\n+\n+    token_path = [redacted:secret-assignment]\n+    token_root = [redacted:secret-assignment] / \"desktop-ssh\"\n+    try:\n+        relative = token_path.relative_to(token_root)\n+    except ValueError as exc:\n+        raise SystemExit(\"--ssh-session-token-file must be under the desktop-ssh directory\") from exc\n+    if len(relative.parts) != 2 or not re.fullmatch(r\"[0-9a-f]{32}\", relative.parts[0]):\n+        raise SystemExit(\"--ssh-session-token-file has an invalid runtime path\")\n+    if not re.fullmatch(r\"[0-9a-f]{16}\\.token\", relative.parts[1]):\n+        raise SystemExit(\"--ssh-session-token-file has an invalid filename\")\n+\n+    directory_flags = os.O_RDONLY | getattr(os, \"O_DIRECTORY\", 0) | getattr(os, \"O_NOFOLLOW\", 0)\n+    file_flags = os.O_RDONLY | getattr(os, \"O_NOFOLLOW\", 0)\n+    root_fd = -1\n+    directory_fd = -1\n+    file_fd = -1\n+    try:\n+        try:\n+            root_fd = os.open(token_root, directory_flags)\n+            root_stat = os.fstat(root_fd)\n+            if not _stat.S_ISDIR(root_stat.st_mode):\n+                raise SystemExit(\"--ssh-session-token-file has an unsafe runtime root\")\n+            if hasattr(os, \"getuid\") and root_stat.st_uid != os.getuid():\n+                raise SystemExit(\"--ssh-session-token-file runtime root has the wrong owner\")\n+            directory_fd = os.open(relative.parts[0], directory_flags, dir_fd=root_fd)\n+            directory_stat = os.fstat(directory_fd)\n+            if not _stat.S_ISDIR(directory_stat.st_mode):\n+                raise SystemExit(\"--ssh-session-token-file has an unsafe parent directory\")\n+            if hasattr(os, \"getuid\") and directory_stat.st_uid != os.getuid():\n+                raise SystemExit(\"--ssh-session-token-file parent has the wrong owner\")\n+            if (directory_stat.st_mode & 0o777) != 0o700:\n+                raise SystemExit(\"--ssh-session-token-file parent has unsafe permissions\")\n+            file_fd = os.open(relative.parts[1], file_flags, dir_fd=directory_fd)\n+        except SystemExit:\n+            raise\n+        except OSError as exc:\n+            if exc.errno == getattr(__import__(\"errno\"), \"ELOOP\", -1):\n+                raise SystemExit(\"--ssh-session-token-file is a symlink\") from exc\n+            raise SystemExit(\"--ssh-session-token-file is not accessible\") from exc\n+\n+        file_stat = os.fstat(file_fd)\n+        if not _stat.S_ISREG(file_stat.st_mode):\n+            raise SystemExit(\"--ssh-session-token-file is not a regular file\")\n+        if file_stat.st_size != 64:\n+            raise SystemExit(\"--ssh-session-token-file contains an invalid token\")\n+        if hasattr(os, \"getuid\") and file_stat.st_uid != os.getuid():\n+            raise SystemExit(\"--ssh-session-token-file has the wrong owner\")\n+        if hasattr(os, \"getuid\") and (file_stat.st_mode & 0o777) & ~0o600:\n+            raise SystemExit(\"--ssh-session-token-file has unsafe permissions\")\n+\n+        with os.fdopen(file_fd, \"r\") as token_stream:\n+            file_fd = -1\n+            token = [redacted:secret-assignment]\n+\n+        if not re.fullmatch(r\"[0-9a-f]{64}\", token):\n+            raise SystemExit(\"--ssh-session-token-file contains an invalid token\")\n+        return token\n+    finally:\n+        if file_fd >= 0:\n+            os.close(file_fd)\n+        if directory_fd >= 0:\n+            try:\n+                os.unlink(relative.parts[1], dir_fd=directory_fd)\n+            except OSError:\n+                pass\n+            os.close(directory_fd)\n+        if root_fd >= 0:\n+            os.close(root_fd)\n+\n+\n def cmd_dashboard(args):\n     \"\"\"Start the web UI server, or (with --stop/--status) manage running ones.\"\"\"\n+    _token_file = [redacted:secret-assignment] \"ssh_session_token_file\", None)\n+    if _token_file and (\n+        getattr(args, \"status\", False) or getattr(args, \"stop\", False)\n+    ):\n+        raise SystemExit(\"--ssh-session-token-file cannot be used with --status or --stop\")\n+\n     # --status: report running dashboards and exit, no deps needed.\n     if getattr(args, \"status\", False):\n         count = _report_dashboard_status()","@@ -12524,6 +12614,12 @@ def cmd_dashboard(args):\n     # ready sentinel. Resolved once and threaded through the re-exec, the\n     # build gate, and start_server.\n     _headless_backend = getattr(args, \"headless_backend\", False)\n+    _ssh_owner_nonce = getattr(args, \"ssh_owner_nonce\", None)\n+    if _ssh_owner_nonce and not re.fullmatch(r\"[0-9a-f]{16}\", _ssh_owner_nonce):\n+        raise SystemExit(\"--ssh-owner-nonce must be 16 lowercase hex characters\")\n+    _ssh_session_token = None\n+    if _token_file and not _headless_backend:\n+        raise SystemExit(\"--ssh-session-token-file is only valid with hermes serve\")\n \n     # ── Unified profile launch routing ────────────────────────────────\n     # The dashboard is a MACHINE management surface: it can read/write any","@@ -12577,6 +12673,10 @@ def cmd_dashboard(args):\n             \"--host\", args.host,\n             \"--open-profile\", _launch_profile,\n         ]\n+        if _ssh_owner_nonce:\n+            reexec_argv.extend([\"--ssh-owner-nonce\", _ssh_owner_nonce])\n+        if _token_file:\n+            reexec_argv.extend([\"--ssh-session-token-file\", _token_file])\n         if args.no_open:\n             reexec_argv.append(\"--no-open\")\n         if getattr(args, \"insecure\", False):","@@ -12613,6 +12713,9 @@ def cmd_dashboard(args):\n         else:\n             os.execvpe(sys.executable, reexec_argv, env)\n \n+    if _token_file:\n+        _ssh_session_token = [redacted:secret-assignment]\n+\n     # Attach gui.log early so dashboard startup/build failures are captured in\n     # the same logs directory as every other Hermes surface.\n     try:","@@ -12753,6 +12856,8 @@ def cmd_dashboard(args):\n         allow_public=getattr(args, \"insecure\", False),\n         initial_profile=getattr(args, \"open_profile\", \"\") or \"\",\n         headless=_headless_backend,\n+        ssh_session_token=[redacted:secret-assignment]\n+        ssh_owner_nonce=_ssh_owner_nonce,\n     )\n \n "]},{"file":"hermes_cli/subcommands/dashboard.py","hunks":["@@ -149,6 +149,20 @@ def build_dashboard_parser(\n     serve_parser.add_argument(\n         \"--no-open\", action=\"store_true\", help=argparse.SUPPRESS\n     )\n+    serve_parser.add_argument(\n+        \"--ssh-session-token-file\",\n+        dest=\"ssh_session_token_file\",\n+        metavar=\"PATH\",\n+        default=None,\n+        help=\"Read a one-shot Desktop SSH session token from PATH\",\n+    )\n+    serve_parser.add_argument(\n+        \"--ssh-owner-nonce\",\n+        dest=\"ssh_owner_nonce\",\n+        metavar=\"NONCE\",\n+        default=None,\n+        help=\"Identify a Desktop-owned SSH backend process\",\n+    )\n     # `headless_backend` marks the lean path: desktop/remote clients speak pure\n     # JSON-RPC/WS, so `serve` skips the web UI build AND never serves the SPA\n     # (cmd_dashboard exports HERMES_SERVE_HEADLESS=1). `dashboard` leaves it"]},{"file":"hermes_cli/web_server.py","hunks":["@@ -280,6 +280,18 @@ def _get_pty_active_session_files(app: \"FastAPI\") -> dict[str, Path]:\n # ---------------------------------------------------------------------------\n _SESSION_TOKEN = [redacted:secret-assignment]\"HERMES_DASHBOARD_SESSION_TOKEN\") or secrets.token_urlsafe(32)\n _SESSION_HEADER_NAME = \"X-Hermes-Session-Token\"\n+_SSH_OWNER_NONCE: Optional[str] = None\n+\n+\n+def _apply_ssh_session_token(token: str) -> None:\n+    global _SESSION_TOKEN\n+    if token:\n+        _SESSION_TOKEN = token\n+\n+\n+def _apply_ssh_owner_nonce(nonce: Optional[str]) -> None:\n+    global _SSH_OWNER_NONCE\n+    _SSH_OWNER_NONCE = nonce\n \n # In-browser Chat tab (/chat, /api/pty, /api/ws, …).  Always enabled: the\n # 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]:\n     return {\"profiles\": profile_names, \"gateway_mode\": mode, \"gateways\": gateways}\n \n \n[redacted:email](\"/api/ssh/ownership\")\n+async def get_ssh_ownership(request: Request):\n+    _require_token(request)\n+    if not _SSH_OWNER_NONCE:\n+        raise HTTPException(status_code=404, detail=\"SSH ownership is not active\")\n+    return {\"ok\": True, \"sshOwnerNonce\": _SSH_OWNER_NONCE, \"protocolVersion\": 1}\n+\n+\n @app.get(\"/api/status\")\n async def get_status(profile: Optional[str] = None):\n     status_scope = None","@@ -19145,6 +19165,8 @@ def start_server(\n     allow_public: bool = False,\n     initial_profile: str = \"\",\n     headless: bool = False,\n+    ssh_session_token: [redacted:secret-assignment] = None,\n+    ssh_owner_nonce: Optional[str] = None,\n ):\n     \"\"\"Start the web UI server.\n ","@@ -19156,7 +19178,13 @@ def start_server(\n     ``headless`` is the ``serve`` path: the JSON-RPC/WS backend with no UI\n     build and no SPA mount (mount_spa() honours ``HERMES_SERVE_HEADLESS``), so\n     the banner announces the bind rather than a browser URL.\n+\n+    ``ssh_session_token`` and ``ssh_owner_nonce`` are process-local Desktop SSH\n+    bootstrap state. Neither is persisted or exported to child processes.\n     \"\"\"\n+    _apply_ssh_session_token(ssh_session_token or \"\")\n+    _apply_ssh_owner_nonce(ssh_owner_nonce)\n+\n     import uvicorn\n \n     try:"]},{"file":"hermes_cli/windows_ssh_runtime.py","hunks":["@@ -0,0 +1,505 @@\n+\"\"\"Native Windows trust boundary for Desktop SSH backend lifecycle.\"\"\"\n+\n+from __future__ import annotations\n+\n+import argparse\n+import json\n+import os\n+import re\n+import subprocess\n+import sys\n+from pathlib import Path\n+from typing import Any\n+\n+from hermes_constants import get_hermes_home\n+\n+_HEX32 = re.compile(r\"[0-9a-f]{32}\\Z\")\n+_HEX16 = re.compile(r\"[0-9a-f]{16}\\Z\")\n+_HEX64 = re.compile(r\"[0-9a-f]{64}\\Z\")\n+_MAX_JSON = 1024 * 1024\n+_MAX_LOG = 512 * 1024\n+_OPEN_REPARSE_POINT = 0x00200000\n+_DELETE_ON_CLOSE = 0x04000000\n+_MOVE_REPLACE_EXISTING = 0x00000001\n+_MOVE_WRITE_THROUGH = 0x00000008\n+\n+\n+def _win32() -> tuple[Any, ...]:\n+    if sys.platform != \"win32\":\n+        raise RuntimeError(\"Windows SSH runtime is only available on Windows\")\n+    import ntsecuritycon\n+    import pywintypes\n+    import win32api\n+    import win32con\n+    import win32file\n+    import win32process\n+    import win32security\n+    return ntsecuritycon, pywintypes, win32api, win32con, win32file, win32process, win32security\n+\n+\n+def _ownership(value: str) -> str:\n+    if not _HEX32.fullmatch(value or \"\"):\n+        raise ValueError(\"invalid ownership ID\")\n+    return value\n+\n+\n+def _nonce(value: str) -> str:\n+    if not _HEX16.fullmatch(value or \"\"):\n+        raise ValueError(\"invalid spawn nonce\")\n+    return value\n+\n+\n+def _root() -> Path:\n+    return get_hermes_home() / \"desktop-ssh\"\n+\n+\n+def _directory(ownership_id: str) -> Path:\n+    return _root() / _ownership(ownership_id)\n+\n+\n+def _token_path(ownership_id: str, spawn_nonce: str) -> Path:\n+    return _directory(ownership_id) / f\"{_nonce(spawn_nonce)}.token\"\n+\n+\n+def _log_path(ownership_id: str, spawn_nonce: str) -> Path:\n+    return _directory(ownership_id) / f\"{_nonce(spawn_nonce)}.log\"\n+\n+\n+def _lock_path(ownership_id: str) -> Path:\n+    return _directory(ownership_id) / \"backend.lock.json\"\n+\n+\n+def _current_sid():\n+    _, _, win32api, win32con, _, _, win32security = _win32()\n+    token = [redacted:secret-assignment] win32con.TOKEN_QUERY)\n+    return win32security.GetTokenInformation(token, win32security.TokenUser)[0]\n+\n+\n+def _system_sid():\n+    return _win32()[6].ConvertStringSidToSid(\"S-1-5-18\")\n+\n+\n+def _security_attributes():\n+    ntsecuritycon, _, _, _, _, _, win32security = _win32()\n+    owner = _current_sid()\n+    acl = win32security.ACL()\n+    for sid in (owner, _system_sid()):\n+        acl.AddAccessAllowedAceEx(win32security.ACL_REVISION, 0, ntsecuritycon.FILE_ALL_ACCESS, sid)\n+    descriptor = win32security.SECURITY_DESCRIPTOR()\n+    descriptor.SetSecurityDescriptorOwner(owner, False)\n+    descriptor.SetSecurityDescriptorDacl(True, acl, False)\n+    # Protect the DACL so inheritable parent ACEs (%LOCALAPPDATA% grants) are not merged in.\n+    descriptor.SetSecurityDescriptorControl(win32security.SE_DACL_PROTECTED, win32security.SE_DACL_PROTECTED)\n+    attributes = win32security.SECURITY_ATTRIBUTES()\n+    attributes.SECURITY_DESCRIPTOR = descriptor\n+    return attributes\n+\n+\n+def _allowed_sids():\n+    win32security = _win32()[6]\n+    return {win32security.ConvertSidToStringSid(_current_sid()),\n+            win32security.ConvertSidToStringSid(_system_sid())}\n+\n+\n+def _verify_security(handle) -> None:\n+    _, _, _, _, _, _, win32security = _win32()\n+    info = win32security.OWNER_SECURITY_INFORMATION | win32security.DACL_SECURITY_INFORMATION\n+    descriptor = win32security.GetSecurityInfo(handle, win32security.SE_FILE_OBJECT, info)\n+    owner = descriptor.GetSecurityDescriptorOwner()\n+    owner_value = win32security.ConvertSidToStringSid(owner)\n+    if owner_value not in _allowed_sids():\n+        raise OSError(\"Windows SSH runtime object has the wrong owner\")\n+    dacl = descriptor.GetSecurityDescriptorDacl()\n+    if dacl is None:\n+        raise OSError(\"Windows SSH runtime object has a null DACL\")\n+    allowed = _allowed_sids()\n+    allow_types = {\n+        win32security.ACCESS_ALLOWED_ACE_TYPE,\n+        win32security.ACCESS_ALLOWED_OBJECT_ACE_TYPE,\n+        getattr(win32security, \"ACCESS_ALLOWED_CALLBACK_ACE_TYPE\", 9),\n+        getattr(win32security, \"ACCESS_ALLOWED_CALLBACK_OBJECT_ACE_TYPE\", 11),\n+    }\n+    for index in range(dacl.GetAceCount()):\n+        ace = dacl.GetAce(index)\n+        ace_type = ace[0][0]\n+        mask = ace[1]\n+        sid = ace[-1]\n+        if ace_type in allow_types and mask:\n+            if win32security.ConvertSidToStringSid(sid) not in allowed:\n+                raise OSError(\"Windows SSH runtime object has a permissive DACL\")\n+\n+\n+def _open(path: Path, access: int, creation: int, flags: int, share: int = 0):\n+    _, _, _, _, win32file, _, _ = _win32()\n+    handle = win32file.CreateFile(str(path), access, share, _security_attributes(), creation, flags, None)\n+    try:\n+        actual = win32file.GetFinalPathNameByHandle(handle, 0)\n+        if actual.startswith(\"\\\\\\\\?\\\\\"):\n+            actual = actual[4:]\n+        expected = os.path.abspath(str(path))\n+        if os.path.normcase(actual) != os.path.normcase(expected):\n+            raise OSError(\"Windows SSH runtime handle escaped its expected path\")\n+        attributes = win32file.GetFileInformationByHandle(handle)[0]\n+        if attributes & 0x400:\n+            raise OSError(\"Windows SSH runtime path contains a reparse point\")\n+        _verify_security(handle)\n+        return handle\n+    except BaseException:\n+        win32file.CloseHandle(handle)\n+        raise\n+\n+\n+def _ensure_directory(path: Path) -> None:\n+    _, pywintypes, _, win32con, win32file, _, _ = _win32()\n+    if path.parent != path:\n+        _ensure_directory(path.parent) if path.parent not in (Path(path.anchor), path) and not path.parent.exists() else None\n+    if not path.exists():\n+        try:\n+            win32file.CreateDirectory(str(path), _security_attributes())\n+        except pywintypes.error as exc:\n+            if exc.winerror != 183:\n+                raise\n+    handle = _open(path, win32con.GENERIC_READ | win32con.READ_CONTROL, win32con.OPEN_EXISTING,\n+                   win32con.FILE_FLAG_BACKUP_SEMANTICS | _OPEN_REPARSE_POINT,\n+                   win32con.FILE_SHARE_READ | win32con.FILE_SHARE_WRITE | win32con.FILE_SHARE_DELETE)\n+    win32file.CloseHandle(handle)\n+\n+\n+def _ensure_scope(ownership_id: str) -> Path:\n+    root = _root()\n+    _ensure_directory(root)\n+    directory = _directory(ownership_id)\n+    _ensure_directory(directory)\n+    return directory\n+\n+\n+def upload_token(ownership_id: str, spawn_nonce: str, token: [redacted:secret-assignment] -> dict[str, Any]:\n+    _, _, _, win32con, win32file, _, _ = _win32()\n+    if len(token) != 64 or not _HEX64.fullmatch(token.decode(\"ascii\", errors=\"ignore\")):\n+        raise ValueError(\"invalid session token\")\n+    _ensure_scope(ownership_id)\n+    path = _token_path(ownership_id, spawn_nonce)\n+    try:\n+        handle = _open(path, win32con.GENERIC_WRITE | win32con.READ_CONTROL, win32con.CREATE_NEW,\n+                       win32con.FILE_ATTRIBUTE_NORMAL | _OPEN_REPARSE_POINT)\n+        try:\n+            win32file.WriteFile(handle, token)\n+            win32file.FlushFileBuffers(handle)\n+        finally:\n+            win32file.CloseHandle(handle)\n+    except BaseException:\n+        path.unlink(missing_ok=True)\n+        raise\n+    return {\"path\": str(path)}\n+\n+\n+def read_token(path_value: str) -> str:\n+    _, _, _, win32con, win32file, _, _ = _win32()\n+    path = Path(path_value)\n+    root = _root()\n+    try:\n+        relative = path.relative_to(root)\n+    except ValueError as exc:\n+        raise SystemExit(\"--ssh-session-token-file must be under the desktop-ssh directory\") from exc\n+    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]):\n+        raise SystemExit(\"--ssh-session-token-file has an invalid runtime path\")\n+    flags = win32con.FILE_ATTRIBUTE_NORMAL | _OPEN_REPARSE_POINT | _DELETE_ON_CLOSE\n+    try:\n+        handle = _open(path, win32con.GENERIC_READ | win32con.READ_CONTROL | win32con.DELETE,\n+                       win32con.OPEN_EXISTING, flags)\n+    except Exception as exc:\n+        raise SystemExit(\"--ssh-session-token-file is not accessible\") from exc\n+    try:\n+        _, data = win32file.ReadFile(handle, 65)\n+    finally:\n+        win32file.CloseHandle(handle)\n+    token = [redacted:secret-assignment]\"ascii\", errors=\"ignore\")\n+    if len(token) != 64 or not _HEX64.fullmatch(token):\n+        raise SystemExit(\"--ssh-session-token-file contains an invalid token\")\n+    return token\n+\n+\n+def _read_json_stdin() -> dict[str, Any]:\n+    raw = sys.stdin.buffer.read(_MAX_JSON + 1)\n+    if len(raw) > _MAX_JSON:\n+        raise ValueError(\"runtime payload is too large\")\n+    parsed = json.loads(raw)\n+    if not isinstance(parsed, dict):\n+        raise ValueError(\"runtime payload must be an object\")\n+    return parsed\n+\n+\n+def read_lock(ownership_id: str) -> dict[str, Any] | None:\n+    _, pywintypes, _, win32con, win32file, _, _ = _win32()\n+    _ensure_scope(ownership_id)\n+    path = _lock_path(ownership_id)\n+    try:\n+        handle = _open(path, win32con.GENERIC_READ | win32con.READ_CONTROL, win32con.OPEN_EXISTING,\n+                       win32con.FILE_ATTRIBUTE_NORMAL | _OPEN_REPARSE_POINT,\n+                       win32con.FILE_SHARE_READ)\n+    except pywintypes.error as exc:\n+        if exc.winerror in (2, 3):\n+            return None\n+        raise\n+    try:\n+        _, data = win32file.ReadFile(handle, _MAX_JSON + 1)\n+    finally:\n+        win32file.CloseHandle(handle)\n+    if len(data) > _MAX_JSON:\n+        return None\n+    try:\n+        parsed = json.loads(data)\n+    except (UnicodeDecodeError, json.JSONDecodeError):\n+        return None\n+    return parsed if isinstance(parsed, dict) else None\n+\n+\n+def write_lock(ownership_id: str, payload: dict[str, Any]) -> None:\n+    _, _, _, win32con, win32file, _, _ = _win32()\n+    directory = _ensure_scope(ownership_id)\n+    data = json.dumps(payload, separators=(\",\", \":\")).encode()\n+    if len(data) > _MAX_JSON:\n+        raise ValueError(\"lock payload is too large\")\n+    temporary = directory / f\".{os.urandom(8).hex()}.lock.tmp\"\n+    handle = _open(temporary, win32con.GENERIC_WRITE | win32con.READ_CONTROL, win32con.CREATE_NEW,\n+                   win32con.FILE_ATTRIBUTE_NORMAL | _OPEN_REPARSE_POINT)\n+    try:\n+        win32file.WriteFile(handle, data)\n+        win32file.FlushFileBuffers(handle)\n+    finally:\n+        win32file.CloseHandle(handle)\n+    win32file.MoveFileEx(str(temporary), str(_lock_path(ownership_id)),\n+                         _MOVE_REPLACE_EXISTING | _MOVE_WRITE_THROUGH)\n+\n+\n+def remove_artifact(path: Path) -> bool:\n+    _, pywintypes, _, win32con, win32file, _, _ = _win32()\n+    try:\n+        handle = _open(path, win32con.DELETE | win32con.READ_CONTROL, win32con.OPEN_EXISTING,\n+                       win32con.FILE_ATTRIBUTE_NORMAL | _OPEN_REPARSE_POINT | _DELETE_ON_CLOSE)\n+    except pywintypes.error as exc:\n+        if exc.winerror in (2, 3):\n+            return False\n+        raise\n+    win32file.CloseHandle(handle)\n+    return True\n+\n+\n+def process_state(pid: int, creation_time_ns: int, hermes_path: str, spawn_nonce: str) -> dict[str, Any]:\n+    import psutil\n+    _nonce(spawn_nonce)\n+    try:\n+        process = psutil.Process(pid)\n+    except psutil.NoSuchProcess as exc:\n+        return {\"alive\": False, \"owned\": False, \"indeterminate\": False, \"reason\": type(exc).__name__}\n+    try:\n+        actual_creation = int(process.create_time() * 1_000_000_000)\n+        argv = process.cmdline()\n+    except psutil.NoSuchProcess as exc:\n+        return {\"alive\": False, \"owned\": False, \"indeterminate\": False, \"reason\": type(exc).__name__}\n+    except psutil.AccessDenied as exc:\n+        return {\"alive\": True, \"owned\": False, \"indeterminate\": True, \"reason\": type(exc).__name__}\n+    if actual_creation != creation_time_ns:\n+        return {\"alive\": False, \"owned\": False, \"indeterminate\": False, \"reason\": \"creation-time\",\n+                \"actualCreationTimeNs\": str(actual_creation), \"expectedCreationTimeNs\": str(creation_time_ns)}\n+    if not argv:\n+        return {\"alive\": True, \"owned\": False, \"indeterminate\": True, \"reason\": \"argv-unavailable\"}\n+    expected = os.path.normcase(os.path.abspath(hermes_path))\n+    arg0 = os.path.normcase(os.path.abspath(argv[0]))\n+    # argv[0] is either the hermes exe directly, or (normal case) the base Python\n+    # interpreter -- its exact path varies by venv/uv layout, so match on \"a python\n+    # running our module\". We launch via `-c` bootstrap, so it shows as\n+    # `-c <bootstrap that runs hermes_cli.main>`; also accept a plain `-m` launch.\n+    # Identity is anchored by the unforgeable creation-time + secret owner-nonce below.\n+    is_python = os.path.basename(arg0).startswith(\"python\")\n+    launches_module = (\n+        argv[1:3] == [\"-m\", \"hermes_cli.main\"]\n+        or (len(argv) > 2 and argv[1] == \"-c\" and \"hermes_cli.main\" in argv[2])\n+    )\n+    executable_match = arg0 == expected or (is_python and launches_module)\n+    try:\n+        serve = argv.index(\"serve\")\n+        owner = argv.index(\"--ssh-owner-nonce\", serve + 1)\n+        owned = executable_match and \"--isolated\" in argv[serve + 1:] and argv[owner + 1] == spawn_nonce\n+    except (ValueError, IndexError):\n+        owned = False\n+    return {\"alive\": process.is_running(), \"owned\": owned, \"indeterminate\": False,\n+            \"creationTimeNs\": str(actual_creation), \"reason\": \"owned\" if owned else \"argv\",\n+            \"argv\": argv[:20], \"expectedExecutable\": expected}\n+\n+\n+def terminate_owned(pid: int, creation_time_ns: int, hermes_path: str, spawn_nonce: str) -> bool:\n+    state = process_state(pid, creation_time_ns, hermes_path, spawn_nonce)\n+    if not state[\"alive\"] or not state[\"owned\"]:\n+        return False\n+    import psutil\n+    process = psutil.Process(pid)\n+    if int(process.create_time() * 1_000_000_000) != creation_time_ns:\n+        return False\n+    process.terminate()\n+    try:\n+        process.wait(5)\n+    except psutil.TimeoutExpired:\n+        process.kill()\n+        process.wait(5)\n+    return True\n+\n+\n+def _resolve_direct_interpreter(python_entry: str) -> tuple[str, list[str]]:\n+    \"\"\"Resolve the venv launcher to the base interpreter it would exec, plus the\n+    sys.path to reproduce. On Windows a venv Scripts\\\\python.exe is a launcher stub\n+    that spawns the real interpreter as a CHILD (two PIDs); spawning the base\n+    interpreter directly with the launcher's sys.path injected yields ONE process\n+    that both owns the port and is the process we lock.\n+\n+    Also resolves hermes_cli's own location and prepends its parent, because the\n+    launcher finds the module via cwd / an editable-install path hook that a bare\n+    PYTHONPATH does not reproduce.\"\"\"\n+    query = (\n+        \"import sys,json,os,importlib.util as u;\"\n+        \"s=u.find_spec('hermes_cli');\"\n+        \"root=os.path.dirname(os.path.dirname(s.origin)) if s and s.origin else '';\"\n+        \"print(json.dumps({'base':getattr(sys,'_base_executable','') or sys.executable,\"\n+        \"'path':[p for p in sys.path if p],'root':root}))\"\n+    )\n+    out = subprocess.run([python_entry, \"-c\", query], capture_output=True, text=True, timeout=30)\n+    if out.returncode != 0:\n+        raise ValueError(\"could not resolve the base Python interpreter\")\n+    info = json.loads(out.stdout.strip().splitlines()[-1])\n+    base = info[\"base\"]\n+    if not base or not os.path.isfile(base):\n+        raise ValueError(\"base Python interpreter was not found\")\n+    # drop editable-install finder markers ('__editable__.*.finder.__path_hook__')\n+    # that PYTHONPATH cannot reproduce; keep only real filesystem entries.\n+    py_path = [p for p in info.get(\"path\", []) if os.path.exists(p)]\n+    # the hermes_cli package parent must be explicit -- the launcher resolves it via\n+    # cwd or an editable path hook, neither of which survives a bare PYTHONPATH spawn.\n+    root = info.get(\"root\") or \"\"\n+    if root and os.path.isdir(root) and root not in py_path:\n+        py_path.insert(0, root)\n+    if not root or not os.path.isdir(root):\n+        raise ValueError(\"could not locate the hermes_cli package\")\n+    return base, py_path\n+\n+\n+def spawn_backend(payload: dict[str, Any]) -> dict[str, Any]:\n+    ownership_id = _ownership(str(payload[\"ownershipId\"]))\n+    spawn_nonce = _nonce(str(payload[\"spawnNonce\"]))\n+    configured_path = str(payload[\"hermesPath\"])\n+    if not os.path.isabs(configured_path):\n+        raise ValueError(\"Hermes path must be absolute\")\n+    hermes_path = os.path.abspath(configured_path)\n+    token_path = [redacted:secret-assignment] spawn_nonce))\n+    log_path = _log_path(ownership_id, spawn_nonce)\n+    profile = str(payload.get(\"profile\") or \"\")\n+    if len(profile) > 256 or any(ch in profile for ch in \"\\x00\\r\\n\"):\n+        raise ValueError(\"invalid profile\")\n+    venv_dir = os.path.dirname(hermes_path)\n+    python_entry = os.path.join(venv_dir, \"python.exe\")\n+    if not os.path.isfile(python_entry):\n+        raise ValueError(\"Hermes Python runtime was not found\")\n+    base_python, sys_path = _resolve_direct_interpreter(python_entry)\n+    # Seed sys.path IN-PROCESS via a -c bootstrap rather than exporting PYTHONPATH:\n+    # PYTHONPATH would be inherited by every subprocess the running backend spawns\n+    # (terminal tool, user scripts), shadowing their imports. This keeps the path\n+    # scoped to the backend process alone.\n+    bootstrap = (\n+        \"import sys,runpy;\"\n+        f\"sys.path[:0]={sys_path!r};\"\n+        \"runpy.run_module('hermes_cli.main',run_name='__main__',alter_sys=True)\"\n+    )\n+    args = [base_python, \"-c\", bootstrap]\n+    if profile:\n+        args.extend([\"--profile\", profile])\n+    args.extend([\"serve\", \"--isolated\", \"--host\", \"127.0.0.1\", \"--port\", \"0\",\n+                 \"--ssh-session-token-file\", token_path, \"--ssh-owner-nonce\", spawn_nonce])\n+    # VIRTUAL_ENV preserves venv identity; PYTHONPATH is deliberately NOT set (see above).\n+    env = dict(os.environ)\n+    env[\"VIRTUAL_ENV\"] = os.path.dirname(venv_dir)\n+    env.pop(\"PYTHONPATH\", None)\n+    _ensure_scope(ownership_id)\n+    creationflags = 0x00000008 | 0x00000200 | 0x01000000\n+    _, _, _, win32con, win32file, _, _ = _win32()\n+    log_handle = _open(log_path, win32con.GENERIC_WRITE | win32con.READ_CONTROL,\n+                       win32con.CREATE_NEW, win32con.FILE_ATTRIBUTE_NORMAL | _OPEN_REPARSE_POINT,\n+                       win32con.FILE_SHARE_READ | win32con.FILE_SHARE_WRITE)\n+    import msvcrt\n+    log_fd = msvcrt.open_osfhandle(int(log_handle), os.O_WRONLY)\n+    with os.fdopen(log_fd, \"wb\", buffering=0) as log_stream:\n+        process = subprocess.Popen(args, stdin=subprocess.DEVNULL, stdout=log_stream, stderr=log_stream,\n+                                   close_fds=True, creationflags=creationflags, env=env)\n+    creation_time_ns = int(__import__(\"psutil\").Process(process.pid).create_time() * 1_000_000_000)\n+    return {\"pid\": process.pid, \"creationTimeNs\": str(creation_time_ns),\n+            \"logPath\": str(log_path), \"tokenPath\": token_path}\n+\n+\n+def inspect_hermes(hermes_path: str) -> dict[str, Any]:\n+    path = os.path.abspath(hermes_path)\n+    if not os.path.isabs(hermes_path) or not os.path.isfile(path):\n+        raise ValueError(\"Hermes path is not an executable file\")\n+    version = subprocess.run([path, \"--version\"], capture_output=True, text=True, timeout=20)\n+    help_result = subprocess.run([path, \"serve\", \"--help\"], capture_output=True, text=True, timeout=20)\n+    help_text = help_result.stdout + help_result.stderr\n+    return {\n+        \"path\": path,\n+        \"version\": (version.stdout + version.stderr).splitlines()[0] if version.returncode == 0 else \"\",\n+        \"supported\": \"--ssh-session-token-file\" in help_text and \"--ssh-owner-nonce\" in help_text,\n+    }\n+\n+\n+def dispatch(argv: list[str]) -> Any:\n+    if not argv:\n+        raise ValueError(\"missing operation\")\n+    operation = argv[0]\n+    if operation == \"probe\":\n+        import platform\n+        return {\"os\": \"Windows\", \"arch\": platform.machine(), \"hermesHome\": str(get_hermes_home()), \"python\": sys.executable}\n+    if operation == \"upload-token\" and len(argv) == 3:\n+        return upload_token(argv[1], argv[2], sys.stdin.buffer.read(65))\n+    if operation == \"read-lock\" and len(argv) == 2:\n+        return read_lock(argv[1])\n+    if operation == \"write-lock\" and len(argv) == 2:\n+        write_lock(argv[1], _read_json_stdin()); return {\"ok\": True}\n+    if operation == \"remove-lock\" and len(argv) == 2:\n+        return {\"removed\": remove_artifact(_lock_path(argv[1]))}\n+    if operation == \"remove-token\" and len(argv) == 3:\n+        return {\"removed\": remove_artifact(_token_path(argv[1], argv[2]))}\n+    if operation == \"read-log\" and len(argv) == 3:\n+        path = _log_path(argv[1], argv[2])\n+        _, pywintypes, _, win32con, win32file, _, _ = _win32()\n+        try:\n+            handle = _open(path, win32con.GENERIC_READ | win32con.READ_CONTROL,\n+                           win32con.OPEN_EXISTING, win32con.FILE_ATTRIBUTE_NORMAL | _OPEN_REPARSE_POINT,\n+                           win32con.FILE_SHARE_READ | win32con.FILE_SHARE_WRITE)\n+        except pywintypes.error as exc:\n+            if exc.winerror in (2, 3):\n+                return {\"content\": \"\"}\n+            raise\n+        try:\n+            _, data = win32file.ReadFile(handle, _MAX_LOG)\n+        finally:\n+            win32file.CloseHandle(handle)\n+        return {\"content\": data.decode(errors=\"replace\")}\n+    if operation == \"remove-log\" and len(argv) == 3:\n+        return {\"removed\": remove_artifact(_log_path(argv[1], argv[2]))}\n+    if operation == \"spawn\":\n+        return spawn_backend(_read_json_stdin())\n+    if operation == \"inspect\" and len(argv) == 2:\n+        return inspect_hermes(argv[1])\n+    if operation == \"process-state\" and len(argv) == 5:\n+        return process_state(int(argv[1]), int(argv[2]), argv[3], argv[4])\n+    if operation == \"terminate\" and len(argv) == 5:\n+        return {\"terminated\": terminate_owned(int(argv[1]), int(argv[2]), argv[3], argv[4])}\n+    raise ValueError(\"invalid operation\")\n+\n+\n+def main() -> None:\n+    try:\n+        print(json.dumps(dispatch(sys.argv[1:]), separators=(\",\", \":\")))\n+    except Exception as exc:\n+        print(json.dumps({\"error\": str(exc)}, separators=(\",\", \":\")), file=sys.stderr)\n+        raise SystemExit(1)\n+\n+\n+if __name__ == \"__main__\":\n+    main()"]},{"file":"pyproject.toml","hunks":["@@ -116,6 +116,10 @@ dependencies = [\n   \"python-multipart>=0.0.9,<1\",\n   \"ptyprocess>=0.7.0,<1; sys_platform != 'win32'\",\n   \"pywinpty>=2.0.0,<3; sys_platform == 'win32'\",\n+  # Desktop SSH's Windows remote runtime (hermes_cli/windows_ssh_runtime.py)\n+  # imports win32security/win32file/etc. directly — declare pywin32 rather than\n+  # relying on the concurrent-log-handler → portalocker transitive chain.\n+  \"pywin32>=306,<312; sys_platform == 'win32'\",\n   # Image resize recovery for the vision tools. Pillow shrinks oversized images\n   # (>5 MB or >8000px) at embed time; without it the byte AND pixel-dimension\n   # shrink paths no-op, so an oversized image bakes into immutable history and"]},{"file":"tests/hermes_cli/test_ssh_ownership_endpoint.py","hunks":["@@ -0,0 +1,38 @@\n+from fastapi.testclient import TestClient\n+\n+from hermes_cli import web_server\n+\n+\n+def test_ssh_ownership_endpoint_requires_token_and_returns_exact_nonce(monkeypatch):\n+    token = \"t\" * 64\n+    nonce = \"0123456789abcdef\"\n+    monkeypatch.setattr(web_server, \"_SESSION_TOKEN\", token)\n+    monkeypatch.setattr(web_server, \"_SSH_OWNER_NONCE\", nonce)\n+    web_server.app.state.auth_required = False\n+    client = TestClient(web_server.app)\n+\n+    assert client.get(\"/api/ssh/ownership\").status_code == 401\n+    response = client.get(\n+        \"/api/ssh/ownership\",\n+        headers={\"X-Hermes-Session-Token\": token},\n+    )\n+    assert response.status_code == 200\n+    assert response.json() == {\n+        \"ok\": True,\n+        \"sshOwnerNonce\": nonce,\n+        \"protocolVersion\": 1,\n+    }\n+\n+\n+def test_ssh_ownership_endpoint_is_absent_without_owner_nonce(monkeypatch):\n+    token = \"t\" * 64\n+    monkeypatch.setattr(web_server, \"_SESSION_TOKEN\", token)\n+    monkeypatch.setattr(web_server, \"_SSH_OWNER_NONCE\", None)\n+    web_server.app.state.auth_required = False\n+    client = TestClient(web_server.app)\n+\n+    response = client.get(\n+        \"/api/ssh/ownership\",\n+        headers={\"X-Hermes-Session-Token\": token},\n+    )\n+    assert response.status_code == 404"]},{"file":"tests/hermes_cli/test_ssh_session_token_parser.py","hunks":["@@ -0,0 +1,94 @@\n+import argparse\n+import os\n+\n+import pytest\n+from hermes_constants import set_hermes_home_override, reset_hermes_home_override\n+\n+from hermes_cli.main import _read_ssh_session_token_file, cmd_dashboard\n+from hermes_cli.subcommands.dashboard import build_dashboard_parser\n+\n+\n+def dashboard_parser():\n+    parser = argparse.ArgumentParser()\n+    subparsers = parser.add_subparsers(dest=\"command\")\n+    build_dashboard_parser(\n+        subparsers,\n+        cmd_dashboard=lambda _args: None,\n+        cmd_dashboard_register=lambda _args: None,\n+    )\n+    return parser\n+\n+\n+def test_serve_help_advertises_secure_ssh_bootstrap_flags(capsys):\n+    with pytest.raises(SystemExit) as exit_info:\n+        dashboard_parser().parse_args([\"serve\", \"--help\"])\n+    assert exit_info.value.code == 0\n+    output = capsys.readouterr().out\n+    assert \"--ssh-session-token-file PATH\" in output\n+    assert \"--ssh-owner-nonce NONCE\" in output\n+\n+\n+def test_serve_accepts_owner_nonce():\n+    args = dashboard_parser().parse_args([\"serve\", \"--ssh-owner-nonce\", \"0123456789abcdef\"])\n+    assert args.ssh_owner_nonce == \"0123456789abcdef\"\n+\n+\n[redacted:email](\"operation\", [\"--status\", \"--stop\"])\n+def test_one_shot_token_file_rejects_non_starting_operations(operation):\n+    args = dashboard_parser().parse_args([\n+        \"serve\", operation, \"--ssh-session-token-file\", \"/tmp/token\",\n+    ])\n+    with pytest.raises(SystemExit, match=\"cannot be used\"):\n+        cmd_dashboard(args)\n+\n+\n+def test_token_file_is_read_and_unlinked_through_private_directory(tmp_path, monkeypatch):\n+    home = tmp_path / \"home\"\n+    hermes_home = home / \".hermes\"\n+    token_dir = [redacted:secret-assignment] / \"desktop-ssh\" / (\"a\" * 32)\n+    token_dir.mkdir(parents=True, mode=0o700)\n+    token_path = [redacted:secret-assignment] / \"0123456789abcdef.token\"\n+    token_path.write_text(\"b\" * 64)\n+    token_path.chmod(0o600)\n+    override = set_hermes_home_override(hermes_home)\n+    try:\n+        assert _read_ssh_session_token_file(str(token_path)) == \"b\" * 64\n+        assert not token_path.exists()\n+    finally:\n+        reset_hermes_home_override(override)\n+\n+\n[redacted:email](os.name == \"nt\", reason=\"POSIX symlink contract\")\n+def test_token_file_rejects_symlink(tmp_path, monkeypatch):\n+    home = tmp_path / \"home\"\n+    token_dir = home / \".hermes\" / \"desktop-ssh\" / (\"a\" * 32)\n+    token_dir.mkdir(parents=True, mode=0o700)\n+    target = tmp_path / \"token\"\n+    target.write_text(\"b\" * 64)\n+    target.chmod(0o600)\n+    token_path = [redacted:secret-assignment] / \"0123456789abcdef.token\"\n+    token_path.symlink_to(target)\n+    override = set_hermes_home_override(home / \".hermes\")\n+    try:\n+        with pytest.raises(SystemExit, match=\"symlink|not accessible\"):\n+            _read_ssh_session_token_file(str(token_path))\n+        assert not token_path.exists()\n+        assert target.read_text() == \"b\" * 64\n+    finally:\n+        reset_hermes_home_override(override)\n+\n+\n+def test_token_file_rejects_parent_escape(tmp_path, monkeypatch):\n+    home = tmp_path / \"home\"\n+    token_root = home / \".hermes\" / \"desktop-ssh\"\n+    token_root.mkdir(parents=True, mode=0o700)\n+    escaped = token_root.parent / \"0123456789abcdef.token\"\n+    escaped.write_text(\"b\" * 64)\n+    escaped.chmod(0o600)\n+    override = set_hermes_home_override(home / \".hermes\")\n+    try:\n+        with pytest.raises(SystemExit, match=\"invalid runtime path\"):\n+            _read_ssh_session_token_file(str(token_root / \"..\" / escaped.name))\n+        assert escaped.exists()\n+    finally:\n+        reset_hermes_home_override(override)"]},{"file":"tests/test_web_server.py","hunks":["@@ -69,6 +69,22 @@ async def shutdown(self, sockets=None):\n     return captured\n \n \n+def test_start_server_applies_process_local_ssh_bootstrap_state(monkeypatch):\n+    captured = _stub_uvicorn(monkeypatch)\n+\n+    web_server.start_server(\n+        host=\"127.0.0.1\",\n+        port=0,\n+        open_browser=False,\n+        ssh_session_token=\"s\" * 64,\n+        ssh_owner_nonce=\"0123456789abcdef\",\n+    )\n+\n+    assert web_server._SESSION_TOKEN == \"s\" * 64\n+    assert web_server._SSH_OWNER_NONCE == \"0123456789abcdef\"\n+    assert captured[\"port\"] == 0\n+\n+\n def test_start_server_disables_ws_ping_on_loopback(monkeypatch):\n     \"\"\"Loopback binds (the Desktop case) MUST disable uvicorn's protocol-level\n     keepalive ping so an event-loop stall can never trigger a false disconnect."]},{"file":"uv.lock","hunks":["@@ -1539,6 +1539,7 @@ dependencies = [\n     { name = \"pyjwt\", extra = [\"crypto\"] },\n     { name = \"python-dotenv\" },\n     { name = \"python-multipart\" },\n+    { name = \"pywin32\", marker = \"sys_platform == 'win32'\" },\n     { name = \"pywinpty\", marker = \"sys_platform == 'win32'\" },\n     { name = \"pyyaml\" },\n     { name = \"requests\" },","@@ -1823,6 +1824,7 @@ requires-dist = [\n     { name = \"python-multipart\", marker = \"extra == 'web'\", specifier = \"==0.0.27\" },\n     { name = \"python-telegram-bot\", extras = [\"webhooks\"], marker = \"extra == 'messaging'\", specifier = \"==22.6\" },\n     { name = \"python-telegram-bot\", extras = [\"webhooks\"], marker = \"extra == 'termux'\", specifier = \"==22.6\" },\n+    { name = \"pywin32\", marker = \"sys_platform == 'win32'\", specifier = \">=306,<312\" },\n     { name = \"pywinpty\", marker = \"sys_platform == 'win32'\", specifier = \">=2.0.0,<3\" },\n     { name = \"pyyaml\", specifier = \"==6.0.3\" },\n     { name = \"qrcode\", marker = \"extra == 'dingtalk'\", specifier = \"==7.4.2\" },\n"]}]},{"kind":"pr","number":68276,"title":"fix(desktop): load disk plugins from local Hermes home","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"eaecca4a71","number":0},{"kind":"pr","value":"#73926","number":73926},{"kind":"pr","value":"#66911","number":66911},{"kind":"pr","value":"#66152","number":66152}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":73926,"title":"fix(desktop): resolve local plugin root independent of remote backend (#66899)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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"]}],"trust":{"default_tier":"verified","includes_suggested":false}}