From d1196750c0cc7a896affdf7743a7888a35d199e9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 4 Aug 2026 12:07:29 -0600 Subject: [PATCH 01/15] feat(profiles): REST export/import + extra_files overlay hook export_profile() accepts extra_files (root-relative filename -> text) so a caller can stage companion files into the archive; the desktop uses it for desktop.json, its appearance/interface overlay, now part of the default profile's export allow-list. New routes wrapping the existing hermes profile export/import machinery: - POST /api/profiles/{name}/export (extra_files + optional output path) - POST /api/profiles/import (returns the bundled desktop overlay) - GET /api/profiles/{name}/desktop-overlay Paths cross the API, not bytes - the desktop's native dialogs and its local/pooled backends share a filesystem. --- hermes_cli/profiles.py | 19 +++++- hermes_cli/web_models.py | 16 +++++ hermes_cli/web_routers/profiles.py | 104 +++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 2 deletions(-) diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 4ed717668a..10aa296d91 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -30,7 +30,7 @@ import sys import time from dataclasses import dataclass from pathlib import Path, PurePosixPath, PureWindowsPath -from typing import List, Optional, Tuple +from typing import Dict, List, Optional, Tuple from agent.skill_utils import is_excluded_skill_path @@ -237,6 +237,9 @@ _DEFAULT_EXPORT_INCLUDE_ROOT = frozenset({ # Configuration / persona "config.yaml", "SOUL.md", "MEMORY.md", "USER.md", "todo.json", "system_prompt.md", "AGENTS.md", "CLAUDE.md", ".cursorrules", + # Desktop appearance/interface overlay (written by the desktop app's + # profile export; applied by its import — see desktop.json handling). + "desktop.json", # User-facing skill, cron, and session artifacts "skills", "cron", "scripts", "sessions", # Plugin / memory surfaces (per-profile overrides live here) @@ -1898,9 +1901,12 @@ def _default_export_ignore(root_dir: Path): return _ignore -def export_profile(name: str, output_path: str) -> Path: +def export_profile(name: str, output_path: str, extra_files: Optional[Dict[str, str]] = None) -> Path: """Export a profile to a tar.gz archive. + ``extra_files`` maps root-relative filenames (e.g. ``desktop.json``) to + text content staged into the archive alongside the profile's own files — + the desktop app uses it to bundle its appearance/interface overlay. Returns the output file path. """ import tempfile @@ -1915,6 +1921,13 @@ def export_profile(name: str, output_path: str) -> Path: # shutil.make_archive wants the base name without extension base = str(output).removesuffix(".tar.gz").removesuffix(".tgz") + def _stage_extras(staged: Path) -> None: + for rel, content in (extra_files or {}).items(): + parts = _normalize_profile_archive_parts(rel) + target = staged.joinpath(*parts) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + if canon == "default": # The default profile IS ~/.hermes itself — its parent is ~/ and its # directory name is ".hermes", not "default". We stage a clean copy @@ -1927,6 +1940,7 @@ def export_profile(name: str, output_path: str) -> Path: symlinks=True, ignore=_default_export_ignore(profile_dir), ) + _stage_extras(staged) result = shutil.make_archive(base, "gztar", tmpdir, "default") return Path(result) @@ -1940,6 +1954,7 @@ def export_profile(name: str, output_path: str) -> Path: symlinks=True, ignore=lambda d, contents: _CREDENTIAL_FILES & set(contents), ) + _stage_extras(staged) result = shutil.make_archive(base, "gztar", tmpdir, canon) return Path(result) diff --git a/hermes_cli/web_models.py b/hermes_cli/web_models.py index 3ff438c638..03dd3a9736 100644 --- a/hermes_cli/web_models.py +++ b/hermes_cli/web_models.py @@ -578,6 +578,22 @@ class ProfileRename(BaseModel): new_name: str +class ProfileExport(BaseModel): + # Optional extra root-level files to stage into the archive, filename → + # text content (e.g. desktop.json — the desktop appearance overlay). + extra_files: Dict[str, str] = {} + # Where to write the archive. Empty → a staging path under HERMES_HOME. + output: str = "" + + +class ProfileImport(BaseModel): + # Path to a profile .tar.gz on the backend's filesystem (the desktop's + # local/pooled backends share the machine with the picker dialog). + archive: str + # Override the profile name inferred from the archive root. + name: Optional[str] = None + + class ProfileSoulUpdate(BaseModel): content: str diff --git a/hermes_cli/web_routers/profiles.py b/hermes_cli/web_routers/profiles.py index d7bc45c2d8..b3587ccd9e 100644 --- a/hermes_cli/web_routers/profiles.py +++ b/hermes_cli/web_routers/profiles.py @@ -26,6 +26,8 @@ from hermes_cli.web_deps import late from hermes_cli.web_models import ( ProfileCreate, ProfileActiveUpdate, + ProfileExport, + ProfileImport, ProfileRename, ProfileSoulUpdate, ProfileDescriptionUpdate, @@ -687,3 +689,105 @@ async def describe_profile_auto_endpoint(name: str, body: ProfileDescribeAuto): # auto-generated. "description_auto": bool(outcome.ok), } + + +# ── Export / Import ────────────────────────────────────────────────────────── +# Profile sharing for the desktop: wraps hermes_cli.profiles.export_profile / +# import_profile (the same machinery behind `hermes profile export|import`). +# Paths are exchanged, not bytes — the desktop's local and pooled backends +# share the filesystem with the native save/open dialogs that produce them. + + +@router.post("/api/profiles/{name}/export") +async def export_profile_endpoint(name: str, body: ProfileExport): + from hermes_cli import profiles as profiles_mod + + output = (body.output or "").strip() + if not output: + from hermes_constants import get_hermes_home + staging = get_hermes_home() / "profile-exports" + try: + staging.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not create export directory: {exc}") + stamp = time.strftime("%Y%m%d-%H%M%S") + output = str(staging / f"{profiles_mod.normalize_profile_name(name)}-{stamp}.tar.gz") + + loop = asyncio.get_running_loop() + try: + result = await loop.run_in_executor( + None, + lambda: profiles_mod.export_profile(name, output, extra_files=body.extra_files or None), + ) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + _log.exception("POST /api/profiles/%s/export failed", name) + raise HTTPException(status_code=500, detail=str(e)) + return {"ok": True, "archive": str(result)} + + +@router.post("/api/profiles/import") +async def import_profile_endpoint(body: ProfileImport): + from hermes_cli import profiles as profiles_mod + + archive = (body.archive or "").strip() + if not archive: + raise HTTPException(status_code=400, detail="archive path is required") + + loop = asyncio.get_running_loop() + try: + profile_dir = await loop.run_in_executor( + None, + lambda: profiles_mod.import_profile(archive, name=(body.name or "").strip() or None), + ) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except (ValueError, FileExistsError) as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + _log.exception("POST /api/profiles/import failed") + raise HTTPException(status_code=500, detail=str(e)) + + imported = profile_dir.name + # Match the CLI import flow: create the wrapper alias when it's safe. + try: + if not profiles_mod.check_alias_collision(imported): + profiles_mod.create_wrapper_script(imported) + except Exception: + _log.exception("Creating wrapper for imported profile %s failed", imported) + + # Surface the bundled desktop appearance overlay (if the archive carried + # one) so the desktop can apply theme/interface prefs without re-reading + # the file over another round-trip. + desktop_overlay = None + overlay_path = profile_dir / "desktop.json" + if overlay_path.is_file(): + try: + import json as _json + desktop_overlay = _json.loads(overlay_path.read_text(encoding="utf-8")) + except Exception: + _log.exception("Reading desktop.json from imported profile %s failed", imported) + + return { + "ok": True, + "name": imported, + "path": str(profile_dir), + "desktop": desktop_overlay, + } + + +@router.get("/api/profiles/{name}/desktop-overlay") +async def get_profile_desktop_overlay(name: str): + """The desktop appearance/interface overlay bundled with an imported + profile (``desktop.json`` at the profile root), or ``exists: false``.""" + overlay_path = _resolve_profile_dir(name) / "desktop.json" + if not overlay_path.is_file(): + return {"exists": False, "desktop": None} + try: + import json as _json + return {"exists": True, "desktop": _json.loads(overlay_path.read_text(encoding="utf-8"))} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Could not read desktop.json: {e}") From bde8c4e1083cabe65fc2868ea5e2321301418a96 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 4 Aug 2026 12:07:29 -0600 Subject: [PATCH 02/15] feat(cli): /export and /import slash commands for profile sharing /export [profile] [-o output.tar.gz] bundles a profile into the shareable archive; /import [--name ] adopts one as a new profile (wrapper alias created when safe). Registry-driven, cli_only, so the CLI and TUI both pick them up in autocomplete and help. --- cli.py | 4 ++ hermes_cli/cli_commands_mixin.py | 74 ++++++++++++++++++++++++++++++++ hermes_cli/commands.py | 4 ++ 3 files changed, 82 insertions(+) diff --git a/cli.py b/cli.py index aed3992922..9583383f61 100644 --- a/cli.py +++ b/cli.py @@ -10249,6 +10249,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self._handle_rollback_command(cmd_original) elif canonical == "snapshot": self._handle_snapshot_command(cmd_original) + elif canonical == "export": + self._handle_export_command(cmd_original) + elif canonical == "import": + self._handle_import_command(cmd_original) elif canonical == "stop": self._handle_stop_command() elif canonical == "agents": diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 5ec16a5fe4..9f9071ea94 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -363,6 +363,80 @@ class CLICommandsMixin: print(f" Unknown subcommand: {subcmd}") print(" Usage: /snapshot [list|create [label]|restore |prune [N]]") + def _handle_export_command(self, command: str): + """Handle /export — export a profile to a shareable .tar.gz archive. + + Syntax: + /export — export the active profile + /export — export a named profile + /export [profile] -o — choose the output path + """ + from hermes_cli.profiles import export_profile, get_active_profile_name + + parts = command.split()[1:] + output = None + if "-o" in parts: + idx = parts.index("-o") + if idx + 1 >= len(parts): + print(" Usage: /export [profile] [-o output.tar.gz]") + return + output = parts[idx + 1] + parts = parts[:idx] + parts[idx + 2:] + + name = parts[0] if parts else (get_active_profile_name() or "default") + if not output: + output = f"{name}.tar.gz" + + try: + result = export_profile(name, output) + print(f" ✓ Exported '{name}' to {result}") + print(" Share it: the other user runs /import or `hermes profile import `.") + except (ValueError, FileNotFoundError) as e: + print(f" Error: {e}") + + def _handle_import_command(self, command: str): + """Handle /import — import a shared profile archive as a new profile. + + Syntax: + /import [--name ] + """ + from hermes_cli.profiles import ( + check_alias_collision, create_wrapper_script, import_profile, + ) + + parts = command.split()[1:] + name = None + if "--name" in parts: + idx = parts.index("--name") + if idx + 1 >= len(parts): + print(" Usage: /import [--name ]") + return + name = parts[idx + 1] + parts = parts[:idx] + parts[idx + 2:] + + if not parts: + print(" Usage: /import [--name ]") + return + + archive = " ".join(parts) # paths may contain spaces + + try: + profile_dir = import_profile(archive, name=name) + except (ValueError, FileExistsError, FileNotFoundError) as e: + print(f" Error: {e}") + return + + imported = profile_dir.name + print(f" ✓ Imported profile '{imported}' at {profile_dir}") + try: + if not check_alias_collision(imported): + wrapper_path = create_wrapper_script(imported) + if wrapper_path: + print(f" Wrapper created: {wrapper_path}") + except Exception: + pass + print(f" Use it: hermes -p {imported}") + def _handle_stop_command(self): """Handle /stop — kill all running background processes and background (async) delegations. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 34280abc24..a803ee925f 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -133,6 +133,10 @@ COMMAND_REGISTRY: list[CommandDef] = [ args_hint="[number]"), CommandDef("snapshot", "Create or restore state snapshots of Hermes config/state", "Session", cli_only=True, aliases=("snap",), args_hint="[create|restore |prune]"), + CommandDef("export", "Export a profile (config, skills, theme) to a shareable archive", "Configuration", + cli_only=True, args_hint="[profile] [-o output.tar.gz]"), + CommandDef("import", "Import a shared profile archive as a new profile", "Configuration", + cli_only=True, args_hint=" [--name ]"), CommandDef("stop", "Kill all running background processes", "Session", busy_policy="interrupt_then_dispatch", busy_handler="stop"), CommandDef("approve", "Approve a pending dangerous command", "Session", From 6e7eafc7e84421dee860c61c38be02fedfecc005 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 4 Aug 2026 12:07:29 -0600 Subject: [PATCH 03/15] feat(desktop): share a profile as a portable bundle - theme, layout, skills Export stages desktop.json (skin + mode, bundled user-theme definitions, rail color, layout tree) into the CLI's own profile archive; import applies it, so the receiver gets the whole look as a ready-to-use profile. Doors: Export/Import profile... in Cmd-K, an import button beside the rail's +, and Export in each profile square's context menu. New selectSavePath IPC (native save dialog); credentials never leave the machine (CLI filter). --- apps/desktop/electron/main.ts | 16 ++ apps/desktop/electron/preload.ts | 1 + .../src/app/chat/sidebar/profile-switcher.tsx | 25 ++ apps/desktop/src/app/contrib/controller.tsx | 28 ++- apps/desktop/src/global.d.ts | 6 + apps/desktop/src/hermes.ts | 32 +++ apps/desktop/src/i18n/ar.ts | 6 + apps/desktop/src/i18n/en.ts | 6 + apps/desktop/src/i18n/ja.ts | 6 + apps/desktop/src/i18n/types.ts | 6 + apps/desktop/src/i18n/zh-hant.ts | 6 + apps/desktop/src/i18n/zh.ts | 6 + apps/desktop/src/store/profile-share.test.ts | 126 ++++++++++ apps/desktop/src/store/profile-share.ts | 219 ++++++++++++++++++ apps/desktop/src/types/hermes.ts | 20 ++ 15 files changed, 508 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/store/profile-share.test.ts create mode 100644 apps/desktop/src/store/profile-share.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 200ecb5fed..76fd414d66 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -10510,6 +10510,22 @@ ipcMain.handle('hermes:writeClipboard', (_event, text) => { return true }) +// Native save-location picker (profile export etc.) — the write itself happens +// elsewhere (the backend, for profile archives); this only picks the path. +ipcMain.handle('hermes:selectSavePath', async (_event, options: any = {}) => { + const result = await dialog.showSaveDialog(mainWindow, { + title: options?.title || 'Save', + defaultPath: options?.defaultPath ? String(options.defaultPath) : undefined, + filters: Array.isArray(options?.filters) ? options.filters : undefined + }) + + if (result.canceled || !result.filePath) { + return null + } + + return result.filePath +}) + // Paired reader for the GUI terminal's paste chord: the renderer's // navigator.clipboard.readText() throws "Document is not focused" whenever a // portaled overlay has focus, and there's no way to route a read through the diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 11483d9ce8..dd9537b26f 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -115,6 +115,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { }, readFileText: filePath => ipcRenderer.invoke('hermes:readFileText', filePath), selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options), + selectSavePath: options => ipcRenderer.invoke('hermes:selectSavePath', options), writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text), readClipboard: () => ipcRenderer.invoke('hermes:readClipboard'), saveImageFromUrl: url => ipcRenderer.invoke('hermes:saveImageFromUrl', url), diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index 1bd5ad3c1d..4a178f15c5 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -59,6 +59,7 @@ import { setShowAllProfiles, sortByProfileOrder } from '@/store/profile' +import { runExportProfileFlow, runImportProfileFlow } from '@/store/profile-share' import type { ProfileInfo } from '@/types/hermes' import { CreateProfileDialog } from '../../profiles/create-profile-dialog' @@ -264,6 +265,7 @@ export function ProfileRail() { profiles={named} /> setCreateOpen(true)} /> + ) : (
setCreateOpen(true)} /> +
)} @@ -435,6 +438,24 @@ function AddProfileButton({ label, onClick }: { label: string; onClick: () => vo ) } +// Import-archive door beside the "+": adopt a shared profile bundle (theme, +// skills, layout) as a new profile. Same chrome as AddProfileButton; the whole +// flow (picker → import → apply overlay → switch) lives in the store. +function ImportProfileButton({ label }: { label: string }) { + return ( + + + + ) +} + // The condensed rail: every named profile in one compact select. The trigger // shows the active profile (tinted initial + name); on default/all scope it // falls back to the placeholder since the left toggle pill carries that state. @@ -692,6 +713,10 @@ function ProfileSquare({ {p.editSoul} + void runExportProfileFlow(label)}> + + {p.exportProfile} + window.dispatchEvent(new CustomEvent('hermes:open-keybinds')) } satisfies PaletteContribution + }, + // Profile sharing: bundle the active profile (config, skills, theme, layout) + // into a portable archive, or adopt someone else's. Both open native dialogs, + // so the palette closing on select is correct. + { + id: 'profile.export', + area: PALETTE_AREA, + data: { + id: 'profile.export', + label: 'Export profile…', + icon: Upload, + keywords: ['profile', 'export', 'share', 'bundle', 'theme', 'settings', 'backup'], + run: () => void runExportProfileFlow() + } satisfies PaletteContribution + }, + { + id: 'profile.import', + area: PALETTE_AREA, + data: { + id: 'profile.import', + label: 'Import profile…', + icon: Download, + keywords: ['profile', 'import', 'share', 'bundle', 'archive', 'restore'], + run: () => void runImportProfileFlow() + } satisfies PaletteContribution } ]) diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index c2b677619e..efd7811876 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -129,6 +129,12 @@ declare global { } readFileText: (filePath: string) => Promise selectPaths: (options?: HermesSelectPathsOptions) => Promise + /** Native save dialog; returns the chosen path or null on cancel. */ + selectSavePath?: (options?: { + defaultPath?: string + filters?: Array<{ extensions: string[]; name: string }> + title?: string + }) => Promise writeClipboard: (text: string) => Promise readClipboard: () => Promise saveImageFromUrl: (url: string) => Promise diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index b3d5a45555..f33d05226c 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -47,6 +47,7 @@ import type { PairingResponse, PairingUser, ProfileCreatePayload, + ProfileDesktopOverlay, ProfileSetupCommand, ProfileSoul, ProfilesResponse, @@ -186,6 +187,7 @@ export type { PairingResponse, PairingUser, ProfileCreatePayload, + ProfileDesktopOverlay, ProfileInfo, ProfileSetupCommand, ProfileSoul, @@ -1431,6 +1433,36 @@ export function getProfileSetupCommand(name: string): Promise; output?: string } = {} +): Promise<{ archive: string; ok: boolean }> { + return window.hermesDesktop.api<{ archive: string; ok: boolean }>({ + path: `/api/profiles/${encodeURIComponent(name)}/export`, + method: 'POST', + body: { extra_files: opts.extraFiles ?? {}, output: opts.output ?? '' }, + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +/** Import a profile .tar.gz as a new profile. Returns the bundled desktop + * appearance overlay too (when the archive carried one) so the caller can + * apply theme/layout without another round-trip. */ +export function importProfileArchive( + archive: string, + name?: string +): Promise<{ desktop: null | ProfileDesktopOverlay; name: string; ok: boolean; path: string }> { + return window.hermesDesktop.api<{ desktop: null | ProfileDesktopOverlay; name: string; ok: boolean; path: string }>({ + path: '/api/profiles/import', + method: 'POST', + body: { archive, name: name || null }, + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + export function getUsageAnalytics(days = 30): Promise { return window.hermesDesktop.api({ ...profileScoped(), diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 6091c916c3..a5a63e258e 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -1293,6 +1293,12 @@ export const ar = defineLocale({ count: count => `${count} ملف شخصي`, loading: 'جار التحميل...', newProfile: 'ملف شخصي جديد', + importProfile: 'استيراد ملف شخصي…', + exportProfile: 'تصدير ملف شخصي…', + imported: 'تم استيراد الملف الشخصي', + exported: 'تم تصدير الملف الشخصي', + failedImport: 'فشل استيراد الملف الشخصي', + failedExport: 'فشل تصدير الملف الشخصي', allProfiles: 'كل الملفات الشخصية', showAllProfiles: 'إظهار كل الملفات الشخصية', switchToProfile: name => `التبديل إلى ${name}`, diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index fe764eb310..93ef3daa40 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1559,6 +1559,12 @@ export const en: Translations = { search: 'Search profiles...', loading: 'Loading profiles...', newProfile: 'New profile', + importProfile: 'Import profile…', + exportProfile: 'Export profile…', + imported: 'Profile imported', + exported: 'Profile exported', + failedImport: 'Failed to import profile', + failedExport: 'Failed to export profile', allProfiles: 'All profiles', showAllProfiles: 'Show all profiles', switchToProfile: name => `Switch to ${name}`, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 16d58834c6..f269e7d0d6 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1396,6 +1396,12 @@ export const ja = defineLocale({ search: 'プロファイルを検索...', loading: 'プロファイルを読み込み中...', newProfile: '新しいプロファイル', + importProfile: 'プロファイルをインポート…', + exportProfile: 'プロファイルをエクスポート…', + imported: 'プロファイルをインポートしました', + exported: 'プロファイルをエクスポートしました', + failedImport: 'プロファイルのインポートに失敗しました', + failedExport: 'プロファイルのエクスポートに失敗しました', allProfiles: 'すべてのプロファイル', showAllProfiles: 'すべてのプロファイルを表示', switchToProfile: name => `${name} に切り替え`, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index b82feb4001..59834d2136 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1305,6 +1305,12 @@ export interface Translations { search: string loading: string newProfile: string + importProfile: string + exportProfile: string + imported: string + exported: string + failedImport: string + failedExport: string allProfiles: string showAllProfiles: string switchToProfile: (name: string) => string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 31a0b62a53..f205fe2048 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1345,6 +1345,12 @@ export const zhHant = defineLocale({ search: '搜尋設定檔…', loading: '正在載入設定檔…', newProfile: '新增設定檔', + importProfile: '匯入設定檔…', + exportProfile: '匯出設定檔…', + imported: '設定檔已匯入', + exported: '設定檔已匯出', + failedImport: '匯入設定檔失敗', + failedExport: '匯出設定檔失敗', allProfiles: '全部設定檔', showAllProfiles: '顯示全部設定檔', switchToProfile: name => `切換至 ${name}`, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 10b4727245..3904e7c06c 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1753,6 +1753,12 @@ export const zh: Translations = { search: '搜索配置档案…', loading: '正在加载配置档案…', newProfile: '新建配置档案', + importProfile: '导入配置档案…', + exportProfile: '导出配置档案…', + imported: '配置档案已导入', + exported: '配置档案已导出', + failedImport: '导入配置档案失败', + failedExport: '导出配置档案失败', allProfiles: '全部配置档案', showAllProfiles: '显示全部配置档案', switchToProfile: name => `切换到 ${name}`, diff --git a/apps/desktop/src/store/profile-share.test.ts b/apps/desktop/src/store/profile-share.test.ts new file mode 100644 index 0000000000..985564e934 --- /dev/null +++ b/apps/desktop/src/store/profile-share.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { DesktopTheme } from '@/themes/types' +import type { ProfileDesktopOverlay } from '@/types/hermes' + +// Keep side-effecting transitive imports inert (gateway sockets, REST). +vi.mock('@/store/gateway', async () => { + const { atom } = await import('nanostores') + + return { + $gateway: atom(null), + ensureGatewayForProfile: vi.fn(async () => undefined), + openGatewayForProfile: vi.fn(async () => undefined) + } +}) +vi.mock('@/hermes', () => ({ + exportProfileArchive: vi.fn(async () => ({ archive: '/tmp/out.tar.gz', ok: true })), + getProfiles: vi.fn(async () => ({ profiles: [] })), + importProfileArchive: vi.fn(async () => ({ desktop: null, name: 'imported', ok: true, path: '/tmp/p' })), + setApiRequestProfile: vi.fn() +})) +vi.mock('@/lib/query-client', () => ({ invalidateProfileScopedQueries: vi.fn() })) +vi.mock('@/store/starmap', () => ({ resetStarmapGraph: vi.fn() })) + +const { applyDesktopOverlay, buildDesktopOverlay, exportProfileBundle } = await import('./profile-share') +const { $profileColors, setProfileColor } = await import('./profile') +const { modePref, skinPref } = await import('@/themes/context') +const { $userThemes } = await import('@/themes/user-themes') +const { $layoutTree } = await import('@/components/pane-shell/tree/store') +const { exportProfileArchive } = await import('@/hermes') + +// isValidTheme only requires background/foreground/primary at runtime; the +// static type wants the full palette, hence the cast. +const roseTheme = { + name: 'rose-quartz', + label: 'Rose Quartz', + description: 'test theme', + colors: { background: '#fff0f5', foreground: '#221122', primary: '#e91e63' } +} as unknown as DesktopTheme + +beforeEach(() => { + window.localStorage.clear() + $userThemes.set({}) + $profileColors.set({}) +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('buildDesktopOverlay', () => { + it('snapshots skin, mode, rail color, and the layout tree for the profile', () => { + skinPref.assign('glam', 'mono') + modePref.assign('glam', 'dark') + setProfileColor('glam', '#e91e63') + + const overlay = buildDesktopOverlay('glam') + + expect(overlay.version).toBe(1) + expect(overlay.skin).toBe('mono') + expect(overlay.mode).toBe('dark') + expect(overlay.profileColor).toBe('#e91e63') + // Built-in skin → no bundled theme definitions. + expect(overlay.themes).toBeUndefined() + }) + + it('bundles the full definition of a non-built-in skin', () => { + $userThemes.set({ 'rose-quartz': roseTheme }) + skinPref.assign('glam', 'rose-quartz') + + const overlay = buildDesktopOverlay('glam') + + expect(overlay.skin).toBe('rose-quartz') + expect(overlay.themes).toEqual({ 'rose-quartz': roseTheme }) + }) +}) + +describe('applyDesktopOverlay', () => { + it('installs bundled themes and assigns skin/mode/color to the new profile', () => { + applyDesktopOverlay('glam-copy', { + version: 1, + skin: 'rose-quartz', + mode: 'dark', + themes: { 'rose-quartz': roseTheme }, + profileColor: '#e91e63' + }) + + expect($userThemes.get()['rose-quartz']).toEqual(roseTheme) + expect(skinPref.resolve('glam-copy')).toBe('rose-quartz') + expect(modePref.resolve('glam-copy')).toBe('dark') + expect($profileColors.get()['glam-copy']).toBe('#e91e63') + }) + + it('ignores a skin that resolves to nothing and junk layout trees', () => { + const before = $layoutTree.get() + + applyDesktopOverlay('glam-copy', { + skin: 'no-such-skin', + layoutTree: { bogus: true } + } as ProfileDesktopOverlay) + + // Unresolvable skin → pref falls back to the default resolution. + expect(skinPref.resolve('glam-copy')).toBe(skinPref.resolve('some-unassigned')) + expect($layoutTree.get()).toBe(before) + }) + + it('is a no-op for a plain CLI archive (no overlay)', () => { + expect(() => applyDesktopOverlay('glam-copy', null)).not.toThrow() + expect(() => applyDesktopOverlay('glam-copy', undefined)).not.toThrow() + }) +}) + +describe('exportProfileBundle', () => { + it('stages desktop.json into the archive through extra_files', async () => { + skinPref.assign('glam', 'mono') + + const archive = await exportProfileBundle('glam', '/tmp/glam.tar.gz') + + expect(archive).toBe('/tmp/out.tar.gz') + const call = vi.mocked(exportProfileArchive).mock.calls[0] + expect(call[0]).toBe('glam') + const overlay = JSON.parse(call[1]?.extraFiles?.['desktop.json'] ?? '{}') as ProfileDesktopOverlay + expect(overlay.skin).toBe('mono') + expect(call[1]?.output).toBe('/tmp/glam.tar.gz') + }) +}) diff --git a/apps/desktop/src/store/profile-share.ts b/apps/desktop/src/store/profile-share.ts new file mode 100644 index 0000000000..ee5d052871 --- /dev/null +++ b/apps/desktop/src/store/profile-share.ts @@ -0,0 +1,219 @@ +/** + * Profile share: export/import a profile as a portable bundle. + * + * The archive is the CLI's own `hermes profile export` tar.gz (config, skills, + * SOUL.md, cron — credentials always excluded), plus one desktop-only file at + * the root: `desktop.json`, the appearance/interface overlay (skin + mode, + * any user-theme definitions the skin needs, the profile rail color, and the + * layout tree). A CLI import of the same archive simply carries the file + * along; the desktop import applies it so the receiving user gets the whole + * look — theme, layout, skills — as a ready-to-use profile. + * + * Paths, not bytes, cross the renderer↔backend boundary: the native save/open + * dialogs and the backend share the filesystem for local and pooled backends. + */ + +import { isLayoutNode, normalize } from '@/components/pane-shell/tree/model' +import { $layoutTree, markActivePreset, persistTree } from '@/components/pane-shell/tree/store' +import { exportProfileArchive, importProfileArchive } from '@/hermes' +import { translateNow } from '@/i18n' +import { modePref, skinPref, type ThemeMode } from '@/themes/context' +import { BUILTIN_THEMES } from '@/themes/presets' +import type { DesktopTheme } from '@/themes/types' +import { $userThemes, installUserTheme, resolveTheme } from '@/themes/user-themes' +import type { ProfileDesktopOverlay } from '@/types/hermes' + +import { notify, notifyError } from './notifications' +import { + $activeGatewayProfile, + $profileColors, + normalizeProfileKey, + refreshActiveProfile, + selectProfile, + setProfileColor +} from './profile' + +/** Filename of the overlay inside the archive (profile root). */ +export const DESKTOP_OVERLAY_FILENAME = 'desktop.json' + +const OVERLAY_VERSION = 1 + +/** + * Snapshot the desktop appearance/interface for `profile` into the overlay. + * The layout tree is global (one window layout, not per-profile) — it rides + * along so the receiver can opt into the sender's whole interface. + */ +export function buildDesktopOverlay(profile: string): ProfileDesktopOverlay { + const key = normalizeProfileKey(profile) + const skin = skinPref.resolve(key) + const mode = modePref.resolve(key) + + // Bundle the full definition of any non-built-in theme the skin points at, + // so the receiver's picker can resolve it. Built-ins resolve by name. + const themes: Record = {} + const userTheme = BUILTIN_THEMES[skin] ? undefined : $userThemes.get()[skin] + + if (userTheme) { + themes[userTheme.name] = userTheme + } + + return { + version: OVERLAY_VERSION, + skin, + mode, + ...(Object.keys(themes).length ? { themes } : {}), + profileColor: $profileColors.get()[key] ?? null, + layoutTree: $layoutTree.get() + } +} + +/** Export `profile` (backend archive + desktop overlay) to `output` (or the + * backend's staging dir when omitted). Returns the archive path. */ +export async function exportProfileBundle(profile: string, output?: string): Promise { + const overlay = buildDesktopOverlay(profile) + + const { archive } = await exportProfileArchive(profile, { + extraFiles: { [DESKTOP_OVERLAY_FILENAME]: JSON.stringify(overlay, null, 2) }, + output + }) + + return archive +} + +const isThemeMode = (value: unknown): value is ThemeMode => + value === 'light' || value === 'dark' || value === 'system' + +/** + * Apply an imported overlay: install bundled themes, assign the new profile's + * skin + mode + rail color, and (when present) adopt the sender's layout tree. + * Every step is independent and best-effort — a malformed half never blocks + * the rest, and a missing overlay is a plain CLI-exported archive (no-op). + */ +export function applyDesktopOverlay(profile: string, overlay: null | ProfileDesktopOverlay | undefined): void { + if (!overlay || typeof overlay !== 'object') { + return + } + + const key = normalizeProfileKey(profile) + + // 1. Bundled theme definitions. installUserTheme validates shape and refuses + // built-in collisions; a bad entry just doesn't install. + for (const theme of Object.values(overlay.themes ?? {})) { + try { + installUserTheme(theme as DesktopTheme) + } catch { + // Invalid/colliding theme — the skin assignment below falls back. + } + } + + // 2. Appearance assignment for the new profile. Only assign a skin that + // actually resolves so the pref never points at nothing. + if (typeof overlay.skin === 'string' && resolveTheme(overlay.skin)) { + skinPref.assign(key, overlay.skin) + } + + if (isThemeMode(overlay.mode)) { + modePref.assign(key, overlay.mode) + } + + // 3. Rail color. + if (typeof overlay.profileColor === 'string' && overlay.profileColor) { + setProfileColor(key, overlay.profileColor) + } + + // 4. Layout tree — global by design (one window layout). Normalize through + // the same canonicalizer the boot load uses; a null result means the + // tree was junk, so the current layout stays. + if (overlay.layoutTree != null && isLayoutNode(overlay.layoutTree)) { + const tree = normalize(overlay.layoutTree) + + if (tree) { + $layoutTree.set(tree) + persistTree() + markActivePreset('custom') + } + } +} + +/** Import an archive, apply its desktop overlay, return the new profile name. */ +export async function importProfileBundle(archive: string, name?: string): Promise { + const result = await importProfileArchive(archive, name) + applyDesktopOverlay(result.name, result.desktop) + + return result.name +} + +/** The profile the export pickers should default to — the active one. */ +export function activeProfileKey(): string { + return normalizeProfileKey($activeGatewayProfile.get()) +} + +// ── Dialog-driven flows ────────────────────────────────────────────────────── +// One store function per user verb (⌘K row, rail button, and any future menu +// item all funnel here). Toasts via the shared notification store; strings via +// translateNow so the flows stay callable from non-React surfaces. + +const ARCHIVE_FILTERS = [{ extensions: ['tar.gz', 'tgz'], name: 'Hermes profile' }] + +/** Pick a save location and export `profile` (default: the active one). + * Returns the archive path, or null when the user cancelled. */ +export async function runExportProfileFlow(profile?: string): Promise { + const target = normalizeProfileKey(profile ?? activeProfileKey()) + const pick = window.hermesDesktop?.selectSavePath + + if (!pick) { + return null + } + + const output = await pick({ + title: translateNow('profiles.exportProfile'), + defaultPath: `${target}.tar.gz`, + filters: ARCHIVE_FILTERS + }) + + if (!output) { + return null + } + + try { + const archive = await exportProfileBundle(target, output) + notify({ kind: 'success', title: translateNow('profiles.exported'), message: archive }) + + return archive + } catch (error) { + notifyError(error, translateNow('profiles.failedExport')) + + return null + } +} + +/** Pick an archive and import it as a new profile; lands the user in it on a + * fresh chat. Returns the new profile name, or null when cancelled/failed. */ +export async function runImportProfileFlow(): Promise { + const paths = await window.hermesDesktop?.selectPaths?.({ + title: translateNow('profiles.importProfile'), + multiple: false, + filters: ARCHIVE_FILTERS + }) + + const archive = paths?.[0] + + if (!archive) { + return null + } + + try { + const name = await importProfileBundle(archive) + notify({ kind: 'success', title: translateNow('profiles.imported'), message: name }) + // Same landing as CreateProfileDialog's onCreated: refresh the list, then + // switch into the new profile on a fresh chat. + await refreshActiveProfile() + selectProfile(name) + + return name + } catch (error) { + notifyError(error, translateNow('profiles.failedImport')) + + return null + } +} diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index bc1fb5c581..e468d21d12 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -871,6 +871,26 @@ export interface ProfileSetupCommand { command: string } +// The desktop appearance/interface overlay bundled into a profile export as +// `desktop.json`. Everything optional — an archive exported by an older (or +// non-desktop) Hermes simply carries none of it. See store/profile-share.ts. +export interface ProfileDesktopOverlay { + /** Overlay schema version (1). */ + version?: number + /** Skin name (built-in or bundled user theme). */ + skin?: string + /** Light/dark/system preference. */ + mode?: string + /** Full user-theme definitions the skin may reference (DesktopTheme JSON). */ + themes?: Record + /** Rail color override for this profile. */ + profileColor?: null | string + /** Layout tree (hermes.desktop.layoutTree.v2 shape). */ + layoutTree?: unknown + /** Active layout preset id. */ + layoutPreset?: string +} + // ── Projects ─────────────────────────────────────────────────────────────── // A first-class, per-profile, human-named workspace spanning one or more // folders. Mirrors hermes_cli/projects_db.Project.to_dict(). From b3e45a3d46ce1af52a267cc3aaa3cb6c4f52d1e8 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Tue, 4 Aug 2026 12:23:04 -0600 Subject: [PATCH 04/15] Discord drops an empty outbound message instead of sending it (#78815) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(discord): reject empty outbound messages * test(discord): cover empty final reply backfill state Missed-message backfill decides what to replay from discord_messages, so a dropped final reply must be recorded as failed by the new guard the same way the exception path records one — otherwise the reply is both never sent and never retried. Co-authored-by: Jony <619963502@qq.com> * chore: map 619963502@qq.com to zyz619963502zyz for PR #73449 salvage --------- Co-authored-by: Jony <619963502@qq.com> --- contributors/emails/619963502@qq.com | 2 ++ plugins/platforms/discord/adapter.py | 24 +++++++++++++++++++ tests/gateway/test_discord_send.py | 35 ++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 contributors/emails/619963502@qq.com diff --git a/contributors/emails/619963502@qq.com b/contributors/emails/619963502@qq.com new file mode 100644 index 0000000000..fed3604ab4 --- /dev/null +++ b/contributors/emails/619963502@qq.com @@ -0,0 +1,2 @@ +zyz619963502zyz +# PR #73449 salvage → #78815 diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index c7c3036f75..22d4c2af2d 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -23,6 +23,7 @@ import subprocess import tempfile import threading import time +import traceback from collections import defaultdict from contextlib import suppress from typing import Callable, Dict, List, Optional, Any, Tuple @@ -3040,6 +3041,29 @@ class DiscordAdapter(BasePlatformAdapter): """ if not self._client: return SendResult(success=False, error="Not connected") + if not (content or "").strip(): + logger.warning( + "[%s] Dropped empty message to chat=%s (caller bug). Call site:\n%s", + self.name, + chat_id, + "".join(traceback.format_stack(limit=12)[:-1]), + ) + result = SendResult( + success=False, + error="Refusing to send empty message", + ) + # Mirror the exception path's recovery bookkeeping. Missed-message + # backfill decides what to replay from this table, so a dropped + # final reply must be recorded as failed — otherwise the reply is + # both never sent and never retried. + await asyncio.to_thread( + self._record_discord_response, + reply_to=reply_to, + result=result, + content=content, + final=bool(metadata and metadata.get("notify")), + ) + return result try: # Determine target channel: thread_id in metadata takes precedence. diff --git a/tests/gateway/test_discord_send.py b/tests/gateway/test_discord_send.py index e26d55dbde..f12595e1ab 100644 --- a/tests/gateway/test_discord_send.py +++ b/tests/gateway/test_discord_send.py @@ -47,6 +47,41 @@ _ensure_discord_mock() from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402 +@pytest.mark.asyncio +async def test_send_rejects_whitespace_and_records_failed_final_reply( + caplog, monkeypatch, tmp_path +): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("DISCORD_MISSED_MESSAGE_BACKFILL", "true") + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + channel = SimpleNamespace(send=AsyncMock()) + get_channel = MagicMock(return_value=channel) + adapter._client = SimpleNamespace( + get_channel=get_channel, + fetch_channel=AsyncMock(), + ) + with caplog.at_level("WARNING"): + result = await adapter.send( + "555", + " \n\t ", + reply_to="123", + metadata={"notify": True}, + ) + + assert result.success is False + assert result.error == "Refusing to send empty message" + get_channel.assert_not_called() + channel.send.assert_not_awaited() + row = adapter._with_discord_recovery_db( + lambda conn: conn.execute( + "SELECT status, replied, outage_response, response_message_id " + "FROM discord_messages WHERE message_id='123'" + ).fetchone() + ) + assert tuple(row) == ("failed", 0, 0, None) + assert "Dropped empty message to chat=555" in caplog.text + + def _voice_adapter(reference_obj, *, native_result=None, native_error=None): adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) ref_msg = SimpleNamespace(id=99, to_reference=MagicMock(return_value=reference_obj)) From ec0c8d9c2064ad2fbc711d5fbef7ad0efba88768 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 4 Aug 2026 12:32:27 -0600 Subject: [PATCH 05/15] feat(state): sessions carry read/unread state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a last_read_at watermark to the sessions table so surfaces (CLI, TUI, desktop) can badge unread conversations. Read state derives from the watermark vs latest activity, so new messages flip a conversation back to unread with zero writes on the message path. NULL means never tracked, so shipping the column doesn't badge pre-existing history. set_session_read() stamps the whole compression lineage, matching the archive/pin semantics; list_sessions_rich() rows carry a derived `unread` key. DB layer only — no surface exposes it yet. --- hermes_state.py | 81 ++++++++++++++ hermes_state_common.py | 1 + tests/hermes_state/test_session_read_state.py | 105 ++++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 tests/hermes_state/test_session_read_state.py diff --git a/hermes_state.py b/hermes_state.py index 9b8215390d..38b90ed7e4 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -5478,6 +5478,80 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) rowcount = self._execute_write(_do) return rowcount > 0 + def set_session_read(self, session_id: str, read: bool = True) -> bool: + """Mark a session read or unread (and its whole compression lineage). + + Read state is a watermark, not a flag: ``last_read_at`` records when + the conversation was last read, and it counts as unread when activity + postdates that watermark (the derived ``unread`` key on + :meth:`list_sessions_rich` rows). New messages therefore flip a read + conversation back to unread without any write on the message path. + Three states: + + * NULL — never tracked (every pre-feature row): treated as read, so + shipping the column doesn't badge a user's entire history at once. + * 0 — explicitly marked unread: any activity postdates it. + * timestamp — read up to that moment. + + Like :meth:`set_session_archived` / :meth:`set_session_pinned`, the + whole compression chain is stamped as a unit, so reading the surfaced + tip clears the root (and vice-versa) no matter which id the caller + holds. Returns True when at least one row changed. + """ + def _do(conn): + cursor = conn.execute( + """ + WITH RECURSIVE + ancestors(id) AS ( + SELECT ? + UNION + SELECT parent.id + FROM ancestors a + JOIN sessions child ON child.id = a.id + JOIN sessions parent ON parent.id = child.parent_session_id + WHERE parent.end_reason = 'compression' + ), + descendants(id) AS ( + SELECT ? + UNION + SELECT child.id + FROM descendants d + JOIN sessions parent ON parent.id = d.id + JOIN sessions child ON child.parent_session_id = parent.id + WHERE parent.end_reason = 'compression' + ), + lineage(id) AS ( + SELECT id FROM ancestors + UNION + SELECT id FROM descendants + ) + UPDATE sessions + SET last_read_at = ? + WHERE id IN (SELECT id FROM lineage) + """, + (session_id, session_id, time.time() if read else 0.0), + ) + rowcount = cursor.rowcount + if rowcount is None or rowcount < 0: + rowcount = conn.execute("SELECT changes()").fetchone()[0] + return rowcount + rowcount = self._execute_write(_do) + return rowcount > 0 + + @staticmethod + def session_unread(session_row: Dict[str, Any]) -> bool: + """Derive unread from a session row's watermark and activity. + + Shared by ``list_sessions_rich`` and any future surface that holds a + row (or projected row) with ``last_read_at`` and ``last_active``. + NULL watermark = never tracked = read. + """ + last_read = session_row.get("last_read_at") + if last_read is None: + return False + last_active = session_row.get("last_active") or session_row.get("started_at") + return float(last_active or 0) > float(last_read) + def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]: """Look up a session by exact title. Returns session dict or None.""" with self._read_ctx() as conn: @@ -5994,6 +6068,13 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) projected.append(merged) sessions = projected + # Derive read state per surfaced conversation. ``last_read_at`` is + # lineage-stamped by set_session_read, so a projected row's root + # watermark and its tip's are the same value — comparing it against + # the tip's last_active is correct either way. + for s in sessions: + s["unread"] = self.session_unread(s) + return sessions # ========================================================================= diff --git a/hermes_state_common.py b/hermes_state_common.py index c520f1c51d..4e66115f81 100644 --- a/hermes_state_common.py +++ b/hermes_state_common.py @@ -245,6 +245,7 @@ CREATE TABLE IF NOT EXISTS sessions ( rewind_count INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0, + last_read_at REAL, FOREIGN KEY (parent_session_id) REFERENCES sessions(id), FOREIGN KEY (system_prompt_hash) REFERENCES system_prompts(hash) ); diff --git a/tests/hermes_state/test_session_read_state.py b/tests/hermes_state/test_session_read_state.py new file mode 100644 index 0000000000..94f229584c --- /dev/null +++ b/tests/hermes_state/test_session_read_state.py @@ -0,0 +1,105 @@ +import time + +import pytest + +from hermes_state import SessionDB + + +@pytest.fixture +def db(tmp_path): + database = SessionDB(tmp_path / "state.db") + try: + yield database + finally: + database.close() + + +def _last_read(db, sid): + row = db._conn.execute( + "SELECT last_read_at FROM sessions WHERE id = ?", (sid,) + ).fetchone() + return row["last_read_at"] if row is not None else None + + +def _row(db, sid): + rows = db.list_sessions_rich(include_archived=True) + return next(s for s in rows if s["id"] == sid) + + +def test_untracked_sessions_are_read(db): + """NULL watermark = never tracked = read, so shipping the column doesn't + badge a user's entire pre-feature history at once.""" + db.create_session(session_id="s1", source="cli") + db.append_message(session_id="s1", role="user", content="hi") + + assert _last_read(db, "s1") is None + assert _row(db, "s1")["unread"] is False + + +def test_mark_read_then_new_activity_flips_back_to_unread(db): + db.create_session(session_id="s1", source="cli") + db.append_message(session_id="s1", role="user", content="hi") + + assert db.set_session_read("s1") is True + assert _row(db, "s1")["unread"] is False + + # New activity postdating the watermark makes it unread again without + # any write on the message path. + time.sleep(0.01) + db.append_message(session_id="s1", role="assistant", content="reply") + assert _row(db, "s1")["unread"] is True + + +def test_mark_unread_explicitly(db): + db.create_session(session_id="s1", source="cli") + db.append_message(session_id="s1", role="user", content="hi") + db.set_session_read("s1") + + assert db.set_session_read("s1", read=False) is True + assert _last_read(db, "s1") == 0.0 + assert _row(db, "s1")["unread"] is True + + +def test_missing_session_returns_false(db): + assert db.set_session_read("nope") is False + + +def _compression_pair(db: SessionDB): + base = time.time() - 100 + db.create_session("root", source="cli") + db.create_session("tip", source="cli", parent_session_id="root") + db._conn.execute( + "UPDATE sessions SET started_at = ?, ended_at = ?, end_reason = 'compression', message_count = 1 WHERE id = 'root'", + (base, base + 10), + ) + db._conn.execute( + "UPDATE sessions SET started_at = ?, message_count = 1 WHERE id = 'tip'", + (base + 20,), + ) + db._conn.commit() + + +def test_reading_compression_tip_stamps_whole_lineage(db): + _compression_pair(db) + + assert db.set_session_read("tip") is True + + root_read = _last_read(db, "root") + assert root_read is not None and root_read > 0 + assert root_read == _last_read(db, "tip") + + # The projected conversation row (root surfaced as tip) derives read. + rows = db.list_sessions_rich(order_by_last_active=True) + assert [s["id"] for s in rows] == ["tip"] + assert rows[0]["unread"] is False + + +def test_marking_root_unread_marks_projected_conversation(db): + _compression_pair(db) + db.set_session_read("tip") + + assert db.set_session_read("root", read=False) is True + + rows = db.list_sessions_rich(order_by_last_active=True) + assert [s["id"] for s in rows] == ["tip"] + assert rows[0]["unread"] is True From cc8e97499caba1ae037ed554e1094258c6f072a8 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 4 Aug 2026 13:18:33 -0600 Subject: [PATCH 06/15] feat(desktop): the layout tree moves a tab block as one unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit movePanes/reorderPanesInGroup/mergeZonesWithPane now take a block of pane ids in strip order: the lead pane decides the drop geometry (slot, split, span-merge) and the rest stack in behind it, with the pressed tab fronting at the destination. The tab-selection store holds the block (Chrome grammar: toggle, anchor range, collapse on plain click), drag-session carries it — every dragged tab dims, the insertion slot skips the whole block, and a landed drop spends the selection while a deny-area release keeps it for a retry. --- .../src/components/pane-shell/tree/model.ts | 112 ++++++++++--- .../pane-shell/tree/multi-tab-drag.test.ts | 147 ++++++++++++++++++ .../pane-shell/tree/renderer/drag-session.ts | 83 +++++++--- .../src/components/pane-shell/tree/store.ts | 57 +++++-- .../pane-shell/tree/tab-selection.ts | 99 ++++++++++++ 5 files changed, 451 insertions(+), 47 deletions(-) create mode 100644 apps/desktop/src/components/pane-shell/tree/multi-tab-drag.test.ts create mode 100644 apps/desktop/src/components/pane-shell/tree/tab-selection.ts diff --git a/apps/desktop/src/components/pane-shell/tree/model.ts b/apps/desktop/src/components/pane-shell/tree/model.ts index 7f61143bab..06810cb4c5 100644 --- a/apps/desktop/src/components/pane-shell/tree/model.ts +++ b/apps/desktop/src/components/pane-shell/tree/model.ts @@ -317,6 +317,66 @@ export function movePane( return shapeSignature(next) === shapeSignature(root) ? root : next } +/** + * Move a SELECTION of panes together (multi-tab drag), preserving their strip + * order. The lead pane lands exactly like a single `movePane` (center joins at + * `before`, an edge opens the split); the rest stack in behind it. `activeId` + * (the pressed tab) fronts in the landing group. Same no-op guard as + * `movePane`: a drop that rebuilds the visible arrangement returns `root`. + */ +export function movePanes( + root: LayoutNode, + paneIds: readonly string[], + target: { groupId: string; pos: DropPosition; before?: null | string }, + activeId: string = paneIds[0] ?? '' +): LayoutNode { + if (paneIds.length <= 1) { + return paneIds.length === 1 ? movePane(root, paneIds[0], target) : root + } + + let without: LayoutNode | null = root + + for (const id of paneIds) { + without = without && removePane(without, id) + } + + // The selection was the whole tree, or removal dissolved the target zone + // (the selection was its only occupancy) — nowhere left to land. + if (!without || !findGroup(without, target.groupId)) { + return root + } + + // The lead insert decides geometry; the rest stack into the lead's group at + // the same slot (each lands before `before`, so the block keeps its order). + // Only the lead activates — `insertAtGroup(activate)` would otherwise front + // each follower in turn. + const lead = paneIds[0] + let next: LayoutNode | null = insertAtGroup(without, target.groupId, lead, target.pos, target.before) + + for (let i = 1; next && i < paneIds.length; i++) { + const leadGroup = findGroupOfPane(next, lead) + + if (!leadGroup) { + return root + } + + const before = target.pos === 'center' ? (target.before ?? null) : null + next = insertAtGroup(next, leadGroup.id, paneIds[i], 'center', before, false) + } + + if (!next) { + return root + } + + const landed = findGroupOfPane(next, lead) + + if (landed && landed.panes.includes(activeId)) { + next = setActivePane(next, landed.id, activeId) + } + + return shapeSignature(next) === shapeSignature(root) ? root : next +} + /** Group ids of every leaf under a node, in tree order. */ export function groupLeafIds(node: LayoutNode): string[] { return node.type === 'group' ? [node.id] : node.children.flatMap(groupLeafIds) @@ -347,26 +407,32 @@ function findCover(node: LayoutNode, set: Set): LayoutNode | null { } /** - * FancyZones span: merge the highlighted zones into ONE group holding - * `paneId`, absorbing any panes that lived in those zones as tabs. Only works - * when the highlighted set forms a rectangular subtree (it always does for a - * combined zone range on a guillotine tree); returns null otherwise so the - * caller can fall back to a single-zone drop. + * FancyZones span: merge the highlighted zones into ONE group holding the + * dragged pane block (one pane, or a multi-tab selection in strip order), + * absorbing any panes that lived in those zones as tabs. Only works when the + * highlighted set forms a rectangular subtree (it always does for a combined + * zone range on a guillotine tree); returns null otherwise so the caller can + * fall back to a single-zone drop. */ -export function mergeZonesWithPane(root: LayoutNode, groupIds: string[], paneId: string): LayoutNode | null { +export function mergeZonesWithPane( + root: LayoutNode, + groupIds: string[], + paneId: string | readonly string[] +): LayoutNode | null { + const paneIds = typeof paneId === 'string' ? [paneId] : [...paneId] const set = new Set(groupIds) if (set.size <= 1 || !findCover(root, set)) { return null } - // Panes from the merged zones (tree order), minus the dragged one. + // Panes from the merged zones (tree order), minus the dragged block. const panesInSet: string[] = [] const collect = (n: LayoutNode) => { if (n.type === 'group') { if (set.has(n.id)) { - panesInSet.push(...n.panes.filter(p => p !== paneId)) + panesInSet.push(...n.panes.filter(p => !paneIds.includes(p))) } } else { n.children.forEach(collect) @@ -375,16 +441,19 @@ export function mergeZonesWithPane(root: LayoutNode, groupIds: string[], paneId: collect(root) - // If the dragged pane lives OUTSIDE the merged set, pull it from its origin + // Any dragged pane living OUTSIDE the merged set is pulled from its origin // first (leaving that origin an empty zone). Inside the set it's absorbed. - const origin = findGroupOfPane(root, paneId) let working = root - if (origin && !set.has(origin.id)) { - working = removePane(root, paneId) ?? root + for (const id of paneIds) { + const origin = findGroupOfPane(working, id) + + if (origin && !set.has(origin.id)) { + working = removePane(working, id) ?? working + } } - const merged = group([paneId, ...panesInSet]) + const merged = group([...paneIds, ...panesInSet]) const replace = (n: LayoutNode): LayoutNode => { if (sameSet(groupLeafIds(n), set)) { @@ -409,16 +478,23 @@ export function setActivePane(root: LayoutNode, groupId: string, paneId: string) return mapGroups(root, g => (g.id === groupId && g.panes.includes(paneId) ? { ...g, active: paneId } : g)) } -/** Reorder a pane within its group's tab stack (browser-tab drag semantics). */ -export function reorderPaneInGroup(root: LayoutNode, groupId: string, paneId: string, toIndex: number): LayoutNode { +/** Reorder a block of panes within a group as one unit (browser-tab drag + * semantics; a single-tab drag is a one-id block): the block lands at + * `toIndex` among the remaining tabs, keeping its own order. */ +export function reorderPanesInGroup( + root: LayoutNode, + groupId: string, + paneIds: readonly string[], + toIndex: number +): LayoutNode { return mapGroups(root, g => { - if (g.id !== groupId || !g.panes.includes(paneId)) { + if (g.id !== groupId || !paneIds.every(p => g.panes.includes(p))) { return g } - const without = g.panes.filter(p => p !== paneId) + const without = g.panes.filter(p => !paneIds.includes(p)) const index = Math.max(0, Math.min(without.length, toIndex)) - const panes = [...without.slice(0, index), paneId, ...without.slice(index)] + const panes = [...without.slice(0, index), ...paneIds, ...without.slice(index)] return { ...g, panes } }) diff --git a/apps/desktop/src/components/pane-shell/tree/multi-tab-drag.test.ts b/apps/desktop/src/components/pane-shell/tree/multi-tab-drag.test.ts new file mode 100644 index 0000000000..1766e53fc7 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/multi-tab-drag.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' + +import { findGroup, findGroupOfPane, group, mergeZonesWithPane, movePanes, reorderPanesInGroup, split } from './model' +import { $tabSelection, clearTabSelection, selectionFor, selectTabRange, toggleTabSelected } from './tab-selection' + +describe('movePanes (multi-tab drag)', () => { + it('stacks the whole block into the target group at the divider slot, in strip order', () => { + const tree = split('row', [ + group(['a', 'b', 'c'], { active: 'a', id: 'left' }), + group(['x', 'y'], { active: 'x', id: 'right' }) + ]) + + const next = movePanes(tree, ['a', 'c'], { before: 'y', groupId: 'right', pos: 'center' }, 'c') + const right = findGroup(next, 'right') + + expect(right).toMatchObject({ panes: ['x', 'a', 'c', 'y'], active: 'c' }) + expect(findGroup(next, 'left')).toMatchObject({ panes: ['b'] }) + }) + + it('an edge drop opens ONE split holding the block as tabs, pressed tab fronted', () => { + const tree = split('row', [ + group(['a', 'b', 'c'], { active: 'a', id: 'left' }), + group(['x'], { active: 'x', id: 'right' }) + ]) + + const next = movePanes(tree, ['b', 'c'], { groupId: 'right', pos: 'bottom' }, 'b') + const landed = findGroupOfPane(next, 'b') + + expect(landed).toMatchObject({ panes: ['b', 'c'], active: 'b' }) + // One new zone, not one per pane: b and c share a group. + expect(findGroupOfPane(next, 'c')).toBe(landed) + expect(findGroup(next, 'left')).toMatchObject({ panes: ['a'] }) + }) + + it('dragging a whole zone into a sibling dissolves the source zone', () => { + const tree = split('row', [ + group(['a', 'b'], { active: 'a', id: 'left' }), + group(['x'], { active: 'x', id: 'right' }) + ]) + + const next = movePanes(tree, ['a', 'b'], { groupId: 'right', pos: 'center' }, 'a') + + expect(next).toMatchObject({ type: 'group', panes: ['x', 'a', 'b'], active: 'a' }) + }) + + it('is a no-op when removal dissolves the target zone itself', () => { + const tree = split('row', [ + group(['a', 'b'], { active: 'a', id: 'left' }), + group(['x'], { active: 'x', id: 'right' }) + ]) + + // Dropping right's only pane (as part of a block) "into right" — the + // target vanishes with the removal, so nothing moves. + expect(movePanes(tree, ['x', 'a'], { groupId: 'right', pos: 'center' }, 'x')).toBe(tree) + }) + + it('falls back to single-pane semantics for a one-id block', () => { + const tree = split('row', [ + group(['a', 'b'], { active: 'a', id: 'left' }), + group(['x'], { active: 'x', id: 'right' }) + ]) + + const next = movePanes(tree, ['b'], { groupId: 'right', pos: 'center' }) + + expect(findGroup(next, 'right')).toMatchObject({ panes: ['x', 'b'], active: 'b' }) + }) +}) + +describe('reorderPanesInGroup (block reorder)', () => { + it('moves a selection as one unit, preserving its internal order', () => { + const tree = group(['a', 'b', 'c', 'd'], { active: 'a', id: 'g' }) + + // [a, c] to the end: index 2 among the remaining [b, d]. + expect(reorderPanesInGroup(tree, 'g', ['a', 'c'], 2)).toMatchObject({ panes: ['b', 'd', 'a', 'c'] }) + }) + + it('leaves the group alone when any id is missing (stale selection)', () => { + const tree = group(['a', 'b'], { active: 'a', id: 'g' }) + + expect(reorderPanesInGroup(tree, 'g', ['a', 'ghost'], 0)).toBe(tree) + }) +}) + +describe('mergeZonesWithPane with a multi-tab block', () => { + it('merges the span into one group led by the block in strip order', () => { + const tree = split('row', [ + group(['a', 'b'], { active: 'a', id: 'left' }), + split('column', [group(['x'], { active: 'x', id: 'mid' }), group(['y'], { active: 'y', id: 'right' })]) + ]) + + const next = mergeZonesWithPane(tree, ['mid', 'right'], ['a', 'b']) + + expect(next).toMatchObject({ type: 'group', panes: ['a', 'b', 'x', 'y'] }) + }) + + it('returns null for a non-rectangular span (caller falls back to a single-zone drop)', () => { + const tree = split('row', [ + group(['a', 'b'], { active: 'a', id: 'left' }), + group(['x'], { active: 'x', id: 'mid' }), + group(['y'], { active: 'y', id: 'right' }) + ]) + + expect(mergeZonesWithPane(tree, ['mid', 'right'], ['a', 'b'])).toBeNull() + }) +}) + +describe('tab selection (Chrome grammar)', () => { + it('⌥-click seeds with the active tab, toggles, and dissolves at ≤1', () => { + clearTabSelection() + toggleTabSelected('g', 'c', 'a') + + expect([...$tabSelection.get()!.ids].sort()).toEqual(['a', 'c']) + + toggleTabSelected('g', 'c', 'a') + + expect($tabSelection.get()).toBeNull() + }) + + it('shift-click ranges from the anchor and re-ranges on the next shift-click', () => { + clearTabSelection() + + const order = ['a', 'b', 'c', 'd'] + selectTabRange('g', order, 'c', 'a') + + expect(selectionFor('g', order, 'b')).toEqual(['a', 'b', 'c']) + + // Anchor holds at a (Chrome): re-ranging to d replaces, not extends. + selectTabRange('g', order, 'd', 'a') + + expect(selectionFor('g', order, 'd')).toEqual(['a', 'b', 'c', 'd']) + }) + + it('selectionFor answers null for an unselected pressed tab and drops stale ids', () => { + clearTabSelection() + toggleTabSelected('g', 'b', 'a') + toggleTabSelected('g', 'c', 'a') + + // Pressed tab outside the selection = single-tab drag. + expect(selectionFor('g', ['a', 'b', 'c', 'd'], 'd')).toBeNull() + // 'a' closed since: it silently falls out, strip order preserved. + expect(selectionFor('g', ['b', 'c', 'd'], 'b')).toEqual(['b', 'c']) + // Another zone never sees it. + expect(selectionFor('other', ['b', 'c'], 'b')).toBeNull() + + clearTabSelection() + }) +}) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts b/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts index d37853f8e1..6e479c6795 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts +++ b/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts @@ -35,7 +35,8 @@ import { ESCAPE_PRIORITY, pushEscapeLayer } from '@/lib/escape-layers' import { reorderCommitHaptic, reorderStepHaptic } from '@/lib/reorder' import type { DropPosition } from '../model' -import { $dropHint, $treeDragging, type DropHint, mergeTreeZones, moveTreePane, reorderTreePane } from '../store' +import { $dropHint, $treeDragging, type DropHint, mergeTreeZones, moveTreePanes, reorderTreePanes } from '../store' +import { clearTabSelection } from '../tab-selection' import { type EngineZone, HighlightedZones, primaryZone, type ZoneRect } from '../zones-engine' const DRAG_THRESHOLD_PX = 4 @@ -96,10 +97,18 @@ const stripSlots = (strip: HTMLElement): StripSlot[] => }) /** Insertion slot from the pointer x against the OTHER tabs' midpoints: - * stack BEFORE the returned pane id (`null` = append). */ -export function slotBefore(slots: StripSlot[], x: number, excludePaneId = ''): { before: null | string } { + * stack BEFORE the returned pane id (`null` = append). `exclude` is the + * dragged tab — or the whole selection on a multi-tab drag, so the block + * can't target a slot inside itself. */ +export function slotBefore( + slots: StripSlot[], + x: number, + exclude: readonly string[] | string = '' +): { before: null | string } { + const excluded = typeof exclude === 'string' ? [exclude] : exclude + for (const slot of slots) { - if (slot.id === excludePaneId) { + if (excluded.includes(slot.id)) { continue } @@ -422,7 +431,11 @@ export function startPaneDrag( onTap?: () => void, reorder?: ReorderContext, double?: DoubleTapContext, - ghostLabel?: string + ghostLabel?: string, + /** Multi-tab selection riding this drag (strip order, includes `paneId`). + * The whole block moves/reorders together; `paneId` stays the pressed tab + * (it fronts at the destination). */ + selection?: readonly string[] ) { if (e.button !== 0) { return @@ -431,17 +444,28 @@ export function startPaneDrag( e.preventDefault() e.stopPropagation() + // The moving block: the selection when the pressed tab rides one, else just + // the pressed tab. Order is strip order (selectionFor guarantees it). + const moving: readonly string[] = selection && selection.length > 1 ? selection : [paneId] + const highlighted = new HighlightedZones() let zones: EngineZone[] = [] let strips: StripSnapshot[] = [] let mode: 'reorder' | 'zone' | null = null - let dimmed: HTMLElement | null = null + let dimmed: HTMLElement[] = [] const markSource = () => { - // The dragged tab dims for the drag's life — the divider says where it - // GOES, the dim says what MOVES. No live shuffle (placement-on-release). - dimmed ??= reorder?.strip.querySelector(`[data-tree-tab="${CSS.escape(paneId)}"]`) ?? null - dimmed?.style.setProperty('opacity', '0.45') + // Every dragged tab dims for the drag's life — the divider says where they + // GO, the dim says what MOVES. No live shuffle (placement-on-release). + if (dimmed.length === 0 && reorder) { + dimmed = moving + .map(id => reorder.strip.querySelector(`[data-tree-tab="${CSS.escape(id)}"]`)) + .filter((el): el is HTMLElement => el !== null) + } + + for (const el of dimmed) { + el.style.setProperty('opacity', '0.45') + } } const enterZoneMode = () => { @@ -494,7 +518,7 @@ export function startPaneDrag( groupId: reorder!.groupId, groupIds: [reorder!.groupId], pos: 'center', - stack: slotBefore(reorderStrip().slots, x, paneId) + stack: slotBefore(reorderStrip().slots, x, moving) } } @@ -525,7 +549,7 @@ export function startPaneDrag( const strip = groupIds.length === 1 && groupId ? strips.find(s => s.groupId === groupId && rectContains(s.rect, x, y)) : null - const stack = strip ? slotBefore(strip.slots, x, paneId) : undefined + const stack = strip ? slotBefore(strip.slots, x, moving) : undefined const pos: DropPosition = stack ? 'center' @@ -537,17 +561,26 @@ export function startPaneDrag( }, onCommit(hint) { + // A multi-tab selection is spent by a LANDED drop (reorder or zone) — + // a deny-area release keeps it, so a missed drop can just be retried. + const spendSelection = () => { + if (moving.length > 1) { + clearTabSelection() + } + } + if (mode === 'reorder' && reorder && hint?.stack !== undefined) { - // Slot -> index among the OTHER tabs (reorderPaneInGroup inserts there). + // Slot -> index among the OTHER tabs (the block re-inserts there). const others = [...reorder.strip.querySelectorAll('[data-tree-tab]')] .map(el => el.dataset.treeTab) - .filter((id): id is string => Boolean(id) && id !== paneId) + .filter((id): id is string => Boolean(id) && !moving.includes(id!)) const toIndex = hint.stack.before ? others.indexOf(hint.stack.before) : others.length if (toIndex >= 0) { - reorderTreePane(reorder.groupId, paneId, toIndex) + reorderTreePanes(reorder.groupId, moving, toIndex) reorderCommitHaptic() + spendSelection() } } @@ -559,18 +592,28 @@ export function startPaneDrag( const targets = hint?.groupIds ?? [] if (targets.length > 1) { - // Shift-span: merge the highlighted zones, dropping the pane across them. - mergeTreeZones([...targets], paneId, hint?.groupId ?? null) + // Shift-span: merge the highlighted zones, dropping the block across them. + mergeTreeZones([...targets], moving, hint?.groupId ?? null) + spendSelection() } else if (hint?.groupId) { // strip = stack at the divider slot; center = join the stack; - // an edge = split the zone and land there. - moveTreePane(paneId, { groupId: hint.groupId, pos: hint.pos ?? 'center', before: hint.stack?.before }) + // an edge = split the zone and land there. The whole selection + // rides — the pressed tab fronts at the destination. + moveTreePanes( + moving, + { groupId: hint.groupId, pos: hint.pos ?? 'center', before: hint.stack?.before }, + paneId + ) + spendSelection() } } }, onEnd() { - dimmed?.style.removeProperty('opacity') + for (const el of dimmed) { + el.style.removeProperty('opacity') + } + highlighted.reset() } }) diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts index 8399eb36ef..5601c666f4 100644 --- a/apps/desktop/src/components/pane-shell/tree/store.ts +++ b/apps/desktop/src/components/pane-shell/tree/store.ts @@ -28,9 +28,10 @@ import { mergeZonesWithPane as mergeZonesWithPaneOp, mirrorTreeHorizontal, movePane as movePaneOp, + movePanes as movePanesOp, normalize, removePane, - reorderPaneInGroup as reorderPaneInGroupOp, + reorderPanesInGroup as reorderPanesInGroupOp, setActivePane as setActivePaneOp, setGroupHeaderHidden as setGroupHeaderHiddenOp, setGroupMinimized, @@ -1244,25 +1245,61 @@ export function applyTree(tree: LayoutNode, presetId: string) { } /** - * Shift-drag span: merge the highlighted zones into one holding `paneId`. Falls - * back to a single-zone move at `fallbackGroupId` when the set can't merge - * (non-rectangular selection). + * Move a multi-tab SELECTION in one commit (drag any selected tab): the lead + * pane takes the drop geometry, the rest stack in behind it in strip order, + * and `activeId` (the pressed tab) fronts in the landing group. */ -export function mergeTreeZones(groupIds: string[], paneId: string, fallbackGroupId: string | null) { +export function moveTreePanes( + paneIds: readonly string[], + target: { groupId: string; pos: DropPosition; before?: null | string }, + activeId?: string +) { const tree = $layoutTree.get() if (!tree) { return } + const next = movePanesOp(tree, paneIds, target, activeId) + + if (next !== tree) { + commit(next) + markActivePreset('custom') + + for (const paneId of paneIds) { + markPaneUserPlaced(paneId) + } + } +} + +/** + * Shift-drag span: merge the highlighted zones into one holding `paneId`. Falls + * back to a single-zone move at `fallbackGroupId` when the set can't merge + * (non-rectangular selection). + */ +export function mergeTreeZones( + groupIds: string[], + paneId: string | readonly string[], + fallbackGroupId: null | string +) { + const tree = $layoutTree.get() + + if (!tree) { + return + } + + const paneIds = typeof paneId === 'string' ? [paneId] : paneId const merged = mergeZonesWithPaneOp(tree, groupIds, paneId) if (merged) { commit(merged) markActivePreset('custom') - markPaneUserPlaced(paneId) + + for (const id of paneIds) { + markPaneUserPlaced(id) + } } else if (fallbackGroupId) { - moveTreePane(paneId, { groupId: fallbackGroupId, pos: 'center' }) + moveTreePanes(paneIds, { groupId: fallbackGroupId, pos: 'center' }) } } @@ -1274,11 +1311,13 @@ export function activateTreePane(groupId: string, paneId: string) { } } -export function reorderTreePane(groupId: string, paneId: string, toIndex: number) { +/** Reorder a tab block (multi-tab selection, or a single tab) within its + * group's strip — the block keeps its own order. */ +export function reorderTreePanes(groupId: string, paneIds: readonly string[], toIndex: number) { const tree = $layoutTree.get() if (tree) { - commit(reorderPaneInGroupOp(tree, groupId, paneId, toIndex)) + commit(reorderPanesInGroupOp(tree, groupId, paneIds, toIndex)) markActivePreset('custom') } } diff --git a/apps/desktop/src/components/pane-shell/tree/tab-selection.ts b/apps/desktop/src/components/pane-shell/tree/tab-selection.ts new file mode 100644 index 0000000000..9653468dda --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/tab-selection.ts @@ -0,0 +1,99 @@ +/** + * Multi-tab selection on a zone's tab strip — Chrome's tab-selection grammar: + * + * - ⌥-click (Ctrl-click off-Mac) → toggle the tab in/out of the selection; + * - Shift-click → select the range from the anchor + * (the last explicitly clicked tab, else + * the active one) to the clicked tab; + * - plain click → collapse back to a single tab. + * + * ⌘-click stays CLOSE (middle-click.ts) and ⌃-click stays the macOS context + * menu, so the toggle chord is ⌥ on Mac / Ctrl elsewhere. One selection at a + * time, scoped to one zone — dragging any selected tab carries the whole set + * (drag-session resolves it), and ids are validated against the strip's + * current tabs at use time, so closed/moved panes fall out on their own. + */ + +import { atom } from 'nanostores' + +export interface TabSelection { + groupId: string + ids: ReadonlySet + /** Range anchor: the last explicitly clicked tab (Chrome semantics). */ + anchor: string +} + +export const $tabSelection = atom(null) + +const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform) + +/** The toggle-select chord: ⌥-click on Mac (⌘ closes, ⌃ is the context menu), + * Ctrl-click elsewhere — ⌥ is accepted everywhere for one muscle memory. */ +export const isToggleSelectClick = (event: { altKey: boolean; button: number; ctrlKey: boolean; metaKey: boolean }) => + event.button === 0 && !event.metaKey && (event.altKey || (!isMac && event.ctrlKey)) + +export function clearTabSelection() { + if ($tabSelection.get()) { + $tabSelection.set(null) + } +} + +/** ⌥/Ctrl-click: toggle `paneId`. A fresh selection seeds with the active tab + * (it is implicitly selected, as in Chrome); collapsing to ≤1 dissolves the + * selection entirely — a single "selected" tab is just a tab. */ +export function toggleTabSelected(groupId: string, paneId: string, activeId: string) { + const current = $tabSelection.get() + const ids = new Set(current?.groupId === groupId ? current.ids : [activeId]) + + if (ids.has(paneId)) { + ids.delete(paneId) + } else { + ids.add(paneId) + } + + if (ids.size <= 1) { + $tabSelection.set(null) + + return + } + + $tabSelection.set({ anchor: paneId, groupId, ids }) +} + +/** Shift-click: select the contiguous range anchor→`paneId` in strip order, + * replacing the previous range (the anchor holds, Chrome-style). */ +export function selectTabRange(groupId: string, orderedPanes: readonly string[], paneId: string, activeId: string) { + const current = $tabSelection.get() + const anchor = current?.groupId === groupId && orderedPanes.includes(current.anchor) ? current.anchor : activeId + const a = orderedPanes.indexOf(anchor) + const b = orderedPanes.indexOf(paneId) + + if (a === -1 || b === -1) { + return + } + + const ids = new Set(orderedPanes.slice(Math.min(a, b), Math.max(a, b) + 1)) + + if (ids.size <= 1) { + $tabSelection.set(null) + + return + } + + $tabSelection.set({ anchor, groupId, ids }) +} + +/** The selection as an ordered slice of `orderedPanes` — but only when the + * pressed tab rides it (dragging an unselected tab is a single-tab drag). + * Stale ids (closed panes) drop out here. */ +export function selectionFor(groupId: string, orderedPanes: readonly string[], paneId: string): null | string[] { + const current = $tabSelection.get() + + if (current?.groupId !== groupId || !current.ids.has(paneId)) { + return null + } + + const ids = orderedPanes.filter(id => current.ids.has(id)) + + return ids.length > 1 ? ids : null +} From 33c1d1f2669597feda55deed6b899f39ba312704 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 4 Aug 2026 13:18:40 -0600 Subject: [PATCH 07/15] feat(desktop): shift-click and opt-click select tabs to drag together MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chrome's grammar on every zone tab strip: Shift-click ranges from the anchor, ⌥-click (Ctrl-click off-Mac — ⌘ stays close, ⌃ stays the macOS context menu) toggles, plain click collapses back to one tab. Selected tabs wear an accent wash; dragging any of them carries the block — the ghost chip counts it — into a strip slot, a zone edge, or a Shift-span, so three tabs land in a new zone as one gesture. --- .../pane-shell/tree/renderer/tree-group.tsx | 60 ++++++++++++++++++- apps/desktop/src/components/ui/pane-tab.tsx | 12 ++++ apps/desktop/src/i18n/ar.ts | 3 +- apps/desktop/src/i18n/en.ts | 3 +- apps/desktop/src/i18n/ja.ts | 3 +- apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 3 +- apps/desktop/src/i18n/zh.ts | 3 +- 8 files changed, 82 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index 4f7d7ada11..0694c6c3af 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -50,6 +50,14 @@ import { setTreeGroupMinimized, treeTabCloseTargets } from '../store' +import { + $tabSelection, + clearTabSelection, + isToggleSelectClick, + selectionFor, + selectTabRange, + toggleTabSelected +} from '../tab-selection' import { type DoubleTapContext, startPaneDrag } from './drag-session' import { forceLoneHeaderForPanes } from './lone-header' @@ -186,6 +194,9 @@ export function TreeGroup({ const narrow = useStore($narrowViewport) const newSessionTabAction = useStore($newSessionTabAction) const panesWithCloser = useStore($panesWithCloser) + // Multi-tab selection (⌥/Ctrl-click, Shift-click) — null for every zone but + // the one holding it, so this subscription is quiet during normal use. + const tabSelection = useStore($tabSelection) // Reload epochs: only an explicit tab-menu Reload writes here, so this // subscription costs nothing on a normal render. const paneEpochs = useStore($treePaneEpochs) @@ -435,6 +446,7 @@ export function TreeGroup({ const chrome = paneChrome(paneFor(paneId)) const closeable = closeableTab(paneId) const title = paneFor(paneId)?.title ?? paneId + const isSelected = tabSelection?.groupId === node.id && tabSelection.ids.has(paneId) const tab = ( closeTab(paneId) : undefined} onPointerDown={e => { + // Chrome's tab-selection grammar, ahead of activate/drag: + // Shift-click ranges from the anchor, ⌥-click (Ctrl-click + // off-Mac) toggles. Neither activates nor starts a drag — + // the press IS the selection edit. ⌘-click stays close + // (PaneTab claims it first) and ⌃-click stays the macOS + // context menu. + if (e.button === 0 && e.shiftKey) { + e.preventDefault() + e.stopPropagation() + selectTabRange(node.id, shown, paneId, activeId) + + return + } + + if (isToggleSelectClick(e)) { + e.preventDefault() + e.stopPropagation() + toggleTabSelected(node.id, paneId, activeId) + + return + } + // Tabs ACTIVATE (restoring a collapsed group). Minimize // lives on the chevron / single-pane label — overloading // the active tab made double-click a minimize/restore/hide - // lottery. + // lottery. A plain click also collapses any multi-tab + // selection back to the one tab (Chrome semantics). const onTap = () => { + clearTabSelection() + if (node.minimized) { restoreTreePane(paneId) } @@ -465,6 +502,26 @@ export function TreeGroup({ e.stopPropagation() } + // Dragging a SELECTED tab carries the whole selection as + // one block through the generic pane move — a multi-tab + // drag outranks the pane's own tab drag (the session drop + // language is single-session). + const dragSelection = selectionFor(node.id, shown, paneId) + + if (dragSelection) { + startPaneDrag( + paneId, + e, + onTap, + stripRef.current ? { groupId: node.id, strip: stripRef.current } : undefined, + hideHeaderDoubleTap, + t.zones.tabCount(dragSelection.length), + dragSelection + ) + + return + } + // A pane may own its tab drag (a session tab speaks the // session drop language — link/stack/split); `false` defers // to the generic pane move (the workspace tab on a fresh @@ -481,6 +538,7 @@ export function TreeGroup({ } }} role="tab" + selected={isSelected} style={{ cursor: 'grab' }} > {chrome.tabLead ? ( diff --git a/apps/desktop/src/components/ui/pane-tab.tsx b/apps/desktop/src/components/ui/pane-tab.tsx index 47aad2614d..906b0d1f80 100644 --- a/apps/desktop/src/components/ui/pane-tab.tsx +++ b/apps/desktop/src/components/ui/pane-tab.tsx @@ -31,12 +31,21 @@ const TAB_ACTIVE_UNDERLINE = 'shadow-[inset_0_-2px_0_var(--pane-tab-active-accen const TAB_IDLE = 'text-(--ui-text-tertiary) [--tab-bg:var(--pane-tab-strip-bg,var(--ui-sidebar-surface-background))] hover:shadow-[inset_0_0_0_100vmax_color-mix(in_srgb,#000_var(--ui-tab-hover-darken),transparent)] hover:text-(--ui-text-secondary)' +// A tab riding a multi-tab selection: an accent wash over whatever surface the +// tab sits on. A background-image gradient (not a shadow) so it stacks cleanly +// over `--tab-bg` without fighting the active underline / hover shadows. +const TAB_SELECTED = + '[background-image:linear-gradient(color-mix(in_srgb,var(--ui-accent)_14%,transparent),color-mix(in_srgb,var(--ui-accent)_14%,transparent))] text-foreground' + interface PaneTabProps extends React.ComponentProps<'div'> { active?: boolean dirty?: boolean /** Close gesture, no hover X (too easy to hit on small tabs): middle-click, * or ⌘-click as the trackpad-friendly Mac equivalent. */ onClose?: () => void + /** Part of a multi-tab selection (⌥/Ctrl-click, Shift-click) — an accent + * wash marks every tab that a drag would carry, Chrome-style. */ + selected?: boolean /** Vertical rail form (collapsed sidebar zones). */ vertical?: boolean /** Content-facing edge of a vertical rail — the strip line the active tab cuts. */ @@ -59,6 +68,7 @@ export const PaneTab = React.forwardRef(function P onPointerDown, onPointerUp, onClickCapture, + selected = false, vertical = false, side = 'left', children, @@ -81,9 +91,11 @@ export const PaneTab = React.forwardRef(function P active ? cn(TAB_ACTIVE, !vertical && TAB_ACTIVE_UNDERLINE) : cn(TAB_IDLE, edge && `${edge}-(--ui-stroke-tertiary)`), + selected && TAB_SELECTED, className )} data-active={active} + data-selected={selected || undefined} data-vertical={vertical || undefined} onClickCapture={event => { // Sites whose tab activates on the label's own onClick (the preview diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 6091c916c3..20b5720ab8 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -2265,7 +2265,8 @@ export const ar = defineLocale({ layoutNamePlaceholder: fallback => `اسم التخطيط (${fallback})`, saveApply: 'حفظ وتطبيق', notExpressible: 'هذا الترتيب متشابك — لا يمكن تمثيله كتقسيمات متداخلة بعد', - zoneCount: count => `${count} مناطق` + zoneCount: count => `${count} مناطق`, + tabCount: count => `${count} تبويبات` }, assistant: { thread: { diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index fe764eb310..5d89178f75 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2693,7 +2693,8 @@ export const en: Translations = { layoutNamePlaceholder: fallback => `Layout name (${fallback})`, saveApply: 'Save & apply', notExpressible: 'this arrangement interlocks (pinwheel) — not expressible as nested splits yet', - zoneCount: count => `${count} zones` + zoneCount: count => `${count} zones`, + tabCount: count => `${count} tabs` }, assistant: { diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 16d58834c6..bf1bd1abd1 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -2519,7 +2519,8 @@ export const ja = defineLocale({ layoutNamePlaceholder: fallback => `レイアウト名(${fallback})`, saveApply: '保存して適用', notExpressible: 'この配置は互いに噛み合っています(風車型)— 入れ子の分割では表現できません', - zoneCount: count => `${count} ゾーン` + zoneCount: count => `${count} ゾーン`, + tabCount: count => `${count} 個のタブ` }, assistant: { diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index b82feb4001..2463286e1c 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -2293,6 +2293,7 @@ export interface Translations { saveApply: string notExpressible: string zoneCount: (count: number) => string + tabCount: (count: number) => string } assistant: { diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 31a0b62a53..56e9405a5d 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -2439,7 +2439,8 @@ export const zhHant = defineLocale({ layoutNamePlaceholder: fallback => `版面名稱(${fallback})`, saveApply: '儲存並套用', notExpressible: '此排列互相咬合(風車形)——暫時無法表示為巢狀分割', - zoneCount: count => `${count} 個區域` + zoneCount: count => `${count} 個區域`, + tabCount: count => `${count} 個分頁` }, assistant: { diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 10b4727245..9f9932d03f 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2871,7 +2871,8 @@ export const zh: Translations = { layoutNamePlaceholder: fallback => `布局名称(${fallback})`, saveApply: '保存并应用', notExpressible: '此排列互相咬合(风车形)——暂无法表示为嵌套拆分', - zoneCount: count => `${count} 个区域` + zoneCount: count => `${count} 个区域`, + tabCount: count => `${count} 个标签页` }, assistant: { From 28b3b0dd1c7bd42b22895b2594f19f0e8c860b2e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 4 Aug 2026 13:23:00 -0600 Subject: [PATCH 08/15] =?UTF-8?q?feat(gateway):=20session.workspace.move?= =?UTF-8?q?=20=E2=80=94=20re-home=20a=20stored=20session's=20workspace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session created in the wrong directory needs its cwd corrected after the fact. session.cwd.set only reaches live runtime sessions, so cold rows were stuck. The new RPC targets the persisted row by session_key, validates the folder, and REPLACES the git branch/root identity (update_session_cwd grows a replace_git_meta flag) so the project tree's grouping follows the move instead of pinning the session under the project it left via a stale git_repo_root. A live idle agent bound to the row is re-anchored through the runtime path; a mid-turn session refuses with 'session busy'. Runs on the RPC pool — the git probes are subprocesses. --- hermes_state.py | 20 ++++++--- tui_gateway/methods_session.py | 74 ++++++++++++++++++++++++++++++++++ tui_gateway/server.py | 3 ++ 3 files changed, 92 insertions(+), 5 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index 9b8215390d..7b8d8bfd90 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -3649,7 +3649,12 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) return False def update_session_cwd( - self, session_id: str, cwd: str, git_branch: str = None, git_repo_root: str = None + self, + session_id: str, + cwd: str, + git_branch: str = None, + git_repo_root: str = None, + replace_git_meta: bool = False, ) -> None: """Persist the session working directory when a frontend knows it. @@ -3664,6 +3669,11 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) every surface reads the same membership instead of re-probing git in the GUI over a partial page. Each field is only written when non-empty so a probe failure never clobbers a previously-captured value. + + ``replace_git_meta`` inverts that non-empty rule: a deliberate workspace + MOVE (re-homing a session into another project) must overwrite the old + repo identity even when the new cwd resolves to none — keeping the stale + root would leave the session grouped under the project it just left. """ if not session_id or not cwd: return @@ -3673,12 +3683,12 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) sets = ["cwd = ?"] params: List[Any] = [cwd] - if branch: + if branch or replace_git_meta: sets.append("git_branch = ?") - params.append(branch) - if repo_root: + params.append(branch or None) + if repo_root or replace_git_meta: sets.append("git_repo_root = ?") - params.append(repo_root) + params.append(repo_root or None) params.append(session_id) def _do(conn): diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 1a3e09f16e..bdbcd1cfaf 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -747,6 +747,80 @@ def _(rid, params: dict) -> dict: return _ok(rid, info) +@method("session.workspace.move") +def _(rid, params: dict) -> dict: + """Re-home a STORED session's workspace into another folder/project. + + Unlike ``session.cwd.set`` (which acts on a live runtime session by its UI + id), this targets a persisted row by ``session_key`` so the desktop can fix + a session that was created in the wrong directory — no live agent required. + The git branch/root columns are REPLACED (not merely enriched), because the + whole point of the move is to change which project claims the session; a + stale ``git_repo_root`` would keep it grouped under the project it left. + + A live agent bound to the row follows through the runtime path too, so its + terminal/file tools re-anchor immediately; a mid-turn session refuses the + move rather than yanking the workspace out from under its tools. + """ + target = str(params.get("session_key") or "").strip() + if not target: + return _err(rid, 4007, "session_key required") + raw = str(params.get("cwd", "") or "").strip() + if not raw: + return _err(rid, 4016, "cwd required") + from hermes_constants import translate_cwd_for_wsl_backend + + resolved = os.path.abspath(os.path.expanduser(translate_cwd_for_wsl_backend(raw))) + if not os.path.isdir(resolved): + return _err(rid, 4017, f"working directory does not exist: {raw}") + + # Snapshot under the lock — concurrent RPCs mutate _sessions (same pattern + # as _cwd_for_session_key). + live = None + live_sid = "" + with _sessions_lock: + for sid, sess in list(_sessions.items()): + if sess.get("session_key") == target: + live, live_sid = sess, sid + break + if live is not None and live.get("running"): + return _err(rid, 4009, "session busy") + + branch = _git_branch_for_cwd(resolved) + root = _git_common_repo_root_for_cwd(resolved) + with _profile_db(params) as db: + if db is None: + return _db_unavailable_error(rid, code=5007) + # A brand-new draft has no persisted row yet; the live re-home below + # still applies and the row inherits the cwd when it is first written. + row_exists = bool(db.get_session(target)) + if not row_exists and live is None: + return _err(rid, 4007, "session not found") + if row_exists: + try: + db.update_session_cwd( + target, resolved, branch, root, replace_git_meta=True + ) + except Exception as e: + return _err(rid, 5007, f"move failed: {e}") + + if live is not None: + try: + _set_session_cwd(live, resolved) + except ValueError as e: + return _err(rid, 4017, str(e)) + agent = live.get("agent") + info = _session_info(agent, live) if agent is not None else { + "cwd": resolved, + "branch": branch, + "project": _project_info_for_cwd(resolved), + "lazy": True, + } + _emit("session.info", live_sid, info) + + return _ok(rid, {"cwd": resolved, "branch": branch, "git_repo_root": root}) + + @method("session.active_list") def _(rid, params: dict) -> dict: """Return live TUI sessions in this gateway process. diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 9d5fd00ce7..a36a539408 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -274,6 +274,9 @@ _LONG_HANDLERS = frozenset( "session.compress", "session.list", "session.resume", + # Workspace re-home runs git branch/root subprocess probes against an + # arbitrary folder — inline they'd stall the reader on a slow mount. + "session.workspace.move", "shell.exec", "skills.manage", "slash.exec", From edae3eed10dcdc1ba498b8dae95315e88265d9c0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 4 Aug 2026 13:23:00 -0600 Subject: [PATCH 09/15] feat(desktop): move a session to another project from its row menu 'Move to project' submenu in the session actions menu (kebab and right-click, via the shared MenuKit) listing every project with a folder except the current owner. Picking one calls session.workspace.move at the project root, mirrors the new cwd/branch/root into the $sessions cache, and refreshes the tree so the row hops immediately. --- .../sidebar/session-actions-menu.test.tsx | 15 +++++- .../app/chat/sidebar/session-actions-menu.tsx | 48 +++++++++++++++++++ apps/desktop/src/i18n/en.ts | 5 ++ apps/desktop/src/i18n/types.ts | 5 ++ apps/desktop/src/i18n/zh.ts | 5 ++ apps/desktop/src/store/projects.ts | 42 +++++++++++++++- 6 files changed, 118 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.tsx index d3e197958f..559d648407 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.tsx @@ -22,7 +22,14 @@ vi.mock('@/i18n', () => ({ t: { common: { cancel: 'Cancel', close: 'Close', delete: 'Delete', save: 'Save' }, sidebar: { - projects: { menuAppearance: 'Appearance', noColor: 'No color' }, + projects: { + menuAppearance: 'Appearance', + moveFailed: 'Could not move session', + moveNoProjects: 'No other projects', + movedTo: (name: string) => `Moved to ${name}`, + moveToProject: 'Move to project', + noColor: 'No color' + }, row: { archive: 'Archive', branchFrom: 'Branch from here', @@ -50,6 +57,12 @@ vi.mock('@/lib/profile-color', () => ({ PROFILE_SWATCHES: [] })) vi.mock('@/lib/session-export', () => ({ exportSession: vi.fn() })) vi.mock('@/store/gateway', () => ({ activeGateway: vi.fn(() => null) })) vi.mock('@/store/notifications', () => ({ notify: vi.fn(), notifyError: vi.fn() })) +vi.mock('@/store/projects', () => ({ + $projectTree: atom([]), + moveSessionToProject: vi.fn(), + projectIdForCwd: vi.fn(() => null), + projectRootCwd: vi.fn(() => '') +})) vi.mock('@/store/session', () => ({ $activeSessionId: atom(null), $selectedStoredSessionId: atom(null), diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx index 57d8a9f962..0a32e1881f 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx @@ -30,6 +30,7 @@ import { PROFILE_SWATCHES } from '@/lib/profile-color' import { exportSession } from '@/lib/session-export' import { activeGateway } from '@/store/gateway' import { notify, notifyError } from '@/store/notifications' +import { $projectTree, moveSessionToProject, projectIdForCwd, projectRootCwd } from '@/store/projects' import { $activeSessionId, $selectedStoredSessionId, @@ -133,6 +134,44 @@ function SessionColorSwatches({ sessionId }: { sessionId: string }) { ) } +// The project list inside the session menu's "Move to project" submenu. Its own +// component so only an OPEN submenu subscribes to the stores (same reasoning as +// SessionColorSwatches). Re-homes the session's workspace at the target +// project's root — the fix for a chat created in the wrong folder. The current +// owner and folderless projects (the Home bucket) are excluded: there is +// nothing to move into. +function MoveToProjectItems({ kit, sessionId, profile }: { kit: MenuKit; sessionId: string; profile?: string }) { + const { t } = useI18n() + const p = t.sidebar.projects + const tree = useStore($projectTree) + const session = useStore($sessions).find(s => sessionMatchesStoredId(s, sessionId)) + const cwd = session?.cwd?.trim() || '' + const currentProjectId = cwd ? projectIdForCwd(cwd) : null + const targets = tree.filter(node => node.id !== currentProjectId && !node.isNoProject && projectRootCwd(node)) + + if (targets.length === 0) { + return {p.moveNoProjects} + } + + return ( + <> + {targets.map(node => ( + { + triggerHaptic('selection') + moveSessionToProject(sessionId, node.id, profile) + .then(() => notify({ durationMs: 2_000, kind: 'success', message: p.movedTo(node.label) })) + .catch(err => notifyError(err, p.moveFailed)) + }} + > + {node.label} + + ))} + + ) +} + function useSessionActions({ sessionId, title, @@ -355,6 +394,15 @@ function useSessionActions({ /> {workItems.map(item => renderActionItem(kit, item))} + + + + {t.sidebar.projects.moveToProject} + + + + + {tabItems.length > 0 && ( <> diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 2ec60d2859..a79e4d5b29 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1876,6 +1876,11 @@ export const en: Translations = { menuAddFolder: 'Add folder', menuSetActive: 'Set active', menuDelete: 'Delete', + moveToProject: 'Move to project', + movedTo: name => `Moved to ${name}`, + moveFailed: 'Could not move session', + moveNoFolder: 'That project has no folder to move into', + moveNoProjects: 'No other projects', reveal: 'Reveal in folder', copyPath: 'Copy path', removeFromSidebar: 'Hide from sidebar', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index e2a49f4234..9b0204c054 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1573,6 +1573,11 @@ export interface Translations { menuAddFolder: string menuSetActive: string menuDelete: string + moveToProject: string + movedTo: (name: string) => string + moveFailed: string + moveNoFolder: string + moveNoProjects: string reveal: string copyPath: string removeFromSidebar: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 79a9a1c143..c176ed0d91 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2070,6 +2070,11 @@ export const zh: Translations = { menuAddFolder: '添加文件夹', menuSetActive: '设为活动', menuDelete: '删除', + moveToProject: '移动到项目', + movedTo: name => `已移动到 ${name}`, + moveFailed: '无法移动会话', + moveNoFolder: '该项目没有可移入的文件夹', + moveNoProjects: '没有其他项目', reveal: '在文件夹中显示', copyPath: '复制路径', removeFromSidebar: '从侧边栏移除', diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index 2ba0d7e59b..6bb3451a88 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -22,6 +22,7 @@ import { $sessions, idsShareLineage, sessionMatchesStoredId, + setSessions, workspaceCwdForNewSession } from '@/store/session' import { $focusedSessionState, $focusedStoredSessionId } from '@/store/session-states' @@ -173,7 +174,7 @@ export function exitProjectScope(): void { // one. Empty for the path-less Home bucket. (The sidebar's `projectTreeCwd` is // the same rule over the same tree — this is the store-side copy so the store // doesn't reach into the sidebar's React module.) -const projectRootCwd = (project: SidebarProjectTree | undefined): string => +export const projectRootCwd = (project: SidebarProjectTree | undefined): string => (project?.path || project?.repos.find(repo => repo.path)?.path || '').trim() // ⌘K "go to project": flip the sidebar into grouped mode and enter the project @@ -520,6 +521,45 @@ export async function fetchProjectSessions(projectId: string): Promise { + const cwd = projectRootCwd($projectTree.get().find(node => node.id === projectId)) + + if (!cwd) { + throw new Error(translateNow('sidebar.projects.moveNoFolder')) + } + + const res = await gatewayRequest('session.workspace.move', { + cwd, + session_key: sessionId, + ...(profile ? { profile } : {}) + }) + + const moved = res.cwd || cwd + setSessions(prev => + prev.map(s => + sessionMatchesStoredId(s, sessionId) + ? { ...s, cwd: moved, git_branch: res.branch ?? null, git_repo_root: res.git_repo_root ?? null } + : s + ) + ) + void refreshProjectTree() +} + export interface RepoDiscoveryPolicy { enabled: boolean roots: string[] From 1d6606d2cff6e1db0a8bd1b86be25e1fb563b512 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 4 Aug 2026 13:23:33 -0600 Subject: [PATCH 10/15] fix(profiles): exported archives open in Finder (GNU tar, not PAX) shutil.make_archive writes PAX with fractional-mtime records, which macOS Archive Utility rejects ("Error 94 - Bad message") on double-click. Write the profile archive with tarfile in GNU format instead: integer mtimes, longlink for deep paths, extracts under Finder, bsdtar, and gnutar alike. Verified against /usr/bin/tar (bsdtar) with >100-char member paths. --- hermes_cli/profiles.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 10aa296d91..71acde626c 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -1901,6 +1901,23 @@ def _default_export_ignore(root_dir: Path): return _ignore +def _make_profile_archive(base: str, root_dir: str, base_dir: str) -> str: + """Create ``.tar.gz`` of ``root_dir/base_dir`` — GNU tar format. + + Not :func:`shutil.make_archive`: that writes PAX (Python's tarfile default + since 3.8), whose fractional-mtime records macOS Archive Utility rejects — + double-clicking an exported profile threw "Error 94 - Bad message." GNU + format keeps long paths working (longlink extensions) and stays integer- + mtime, so Finder, bsdtar, and gnutar all extract it. + """ + import tarfile + + archive_path = f"{base}.tar.gz" + with tarfile.open(archive_path, "w:gz", format=tarfile.GNU_FORMAT) as tf: + tf.add(str(Path(root_dir) / base_dir), arcname=base_dir) + return archive_path + + def export_profile(name: str, output_path: str, extra_files: Optional[Dict[str, str]] = None) -> Path: """Export a profile to a tar.gz archive. @@ -1918,7 +1935,7 @@ def export_profile(name: str, output_path: str, extra_files: Optional[Dict[str, raise FileNotFoundError(f"Profile '{canon}' does not exist.") output = Path(output_path) - # shutil.make_archive wants the base name without extension + # Archive base name without extension (.tar.gz appended by the writer). base = str(output).removesuffix(".tar.gz").removesuffix(".tgz") def _stage_extras(staged: Path) -> None: @@ -1941,7 +1958,7 @@ def export_profile(name: str, output_path: str, extra_files: Optional[Dict[str, ignore=_default_export_ignore(profile_dir), ) _stage_extras(staged) - result = shutil.make_archive(base, "gztar", tmpdir, "default") + result = _make_profile_archive(base, tmpdir, "default") return Path(result) # Named profiles — stage a filtered copy to exclude credentials @@ -1955,7 +1972,7 @@ def export_profile(name: str, output_path: str, extra_files: Optional[Dict[str, ignore=lambda d, contents: _CREDENTIAL_FILES & set(contents), ) _stage_extras(staged) - result = shutil.make_archive(base, "gztar", tmpdir, canon) + result = _make_profile_archive(base, tmpdir, canon) return Path(result) From 43717123ca1566a073270c5a61431e2e0e4a0211 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Tue, 4 Aug 2026 13:35:57 -0600 Subject: [PATCH 11/15] fix(models): a model id missing its vendor prefix says so instead of 404ing (#78856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting an NVIDIA NIM model whose id reached config without the nvidia/ prefix produced a bare "HTTP 404: 404 page not found" — retried three times, never naming the model. It reads exactly like an outage or an auth failure, which is where the Discord thread spent its time before the id was spotted. normalize_model_for_provider() had no branch for nvidia, so a bare id passed straight through to the API. Repair it from the provider's curated catalogue: a bare name that matches exactly one entry modulo the prefix gets it back. That's a lookup, not a guess — build.nvidia.com also fronts local NIM containers and third-party models, and anything absent from the catalogue is left alone. Because the repair runs on every runtime setup, an already-broken config self-heals on the next turn and prints what it changed. If a bare id still reaches the wire, the 404 now explains itself. The classifier consults the same catalogue: a prefix-less id the provider only serves as vendor/model is a deterministic failure, so it classifies as model_not_found instead of burning three retries on a retryable "unknown", and the error trace names the id to use. Fixes #78796 --- agent/conversation_loop.py | 22 +++++++ agent/error_classifier.py | 38 ++++++++++++ hermes_cli/model_normalize.py | 76 ++++++++++++++++++++++++ tests/agent/test_error_classifier.py | 33 ++++++++++ tests/hermes_cli/test_model_normalize.py | 50 ++++++++++++++++ 5 files changed, 219 insertions(+) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index aeae63f09f..0515a31331 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -4242,6 +4242,28 @@ def run_conversation( f" Check which providers support tools: https://openrouter.ai/models/{_model}" ) + # Actionable hint for a bare 404 on a provider whose catalogue + # uses ``vendor/model`` ids. A model id that lost its prefix + # (e.g. ``nemotron-…`` instead of ``nvidia/nemotron-…``) gets + # a content-free "404 page not found" from the provider that + # never names the model, so it reads like an outage or an auth + # failure. Name the real cause and the exact id to use (#78796). + if getattr(api_error, "status_code", None) == 404: + try: + from hermes_cli.model_normalize import suggest_prefixed_model_id + + _suggestion = suggest_prefixed_model_id(_provider, _model) + except Exception: + _suggestion = None + if _suggestion: + agent._buffer_vprint( + f" 💡 Model '{_model}' is not a valid id for provider {_provider} — " + f"it is missing its vendor prefix." + ) + agent._buffer_vprint( + f" Did you mean '{_suggestion}'? Re-pick it with `hermes model`." + ) + # Check for interrupt before deciding to retry if agent._interrupt_requested: # Preserve a pending redirect (mid-stream correction): the diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 8ac0b6c872..92d9fd43ef 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -339,6 +339,32 @@ _MODEL_NOT_FOUND_PATTERNS = [ "no endpoints found that support tool use", ] + +def _model_id_missing_known_prefix(model: str, provider: str) -> bool: + """True when a bare model id is only known to the provider as ``vendor/id``. + + Some providers answer a malformed model id with a naked 404 that names + nothing — NVIDIA NIM returns ``404 page not found`` for a bare + ``nemotron-3-ultra-550b-a55b``, indistinguishable from a bad endpoint + path. Consulting the curated catalogue tells the two apart: if the id + carries no ``/`` but the catalogue has exactly one entry ending in + ``/``, the prefix was dropped and the failure is deterministic. + + Never guesses — an id absent from the catalogue (a local NIM container, + a proxied model) returns False so genuine endpoint problems keep their + retryable ``unknown`` classification. + """ + name = (model or "").strip() + if not name or "/" in name: + return False + try: + from hermes_cli.model_normalize import suggest_prefixed_model_id + + return bool(suggest_prefixed_model_id((provider or "").strip(), name)) + except Exception: + return False + + # Malformed-message-array 400s. Deterministic request-shape rejections that # describe the *transcript* being invalid, not a parameter. The canonical # case: a stream dies mid-response and Hermes persists a content-less @@ -1061,6 +1087,18 @@ def _classify_by_status( retryable=False, should_fallback=True, ) + # A bare id that the provider's catalogue only knows in prefixed form + # is a malformed model id, not a routing glitch — NVIDIA NIM answers + # one with a naked ``404 page not found`` that names nothing, so the + # generic branch below burns three retries and reports what looks + # like an outage (#78796). Deterministic: don't retry, and let the + # model_not_found surface carry the real cause. + if _model_id_missing_known_prefix(model, provider): + return result_fn( + FailoverReason.model_not_found, + retryable=False, + should_fallback=True, + ) # Generic 404 with no "model not found" signal — could be a wrong # endpoint path (common with local llama.cpp / Ollama / vLLM when # the URL is slightly misconfigured), a proxy routing glitch, or diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py index 041aa47f0d..d2dfe132a8 100644 --- a/hermes_cli/model_normalize.py +++ b/hermes_cli/model_normalize.py @@ -109,6 +109,19 @@ _MATCHING_PREFIX_STRIP_PROVIDERS: frozenset[str] = frozenset({ "xai", }) +# Providers whose API serves ``vendor/model`` ids but whose endpoint can also +# front arbitrary self-hosted models, so a bare name cannot be prefixed +# blindly. A bare id is repaired only when the curated catalogue for that +# provider holds exactly one entry ending in ``/`` — a lookup, not a +# guess. NVIDIA NIM is the case in hand: build.nvidia.com serves +# ``nvidia/nemotron-…`` (and third-party ``z-ai/glm-…``), while the same +# provider id also points at local NIM containers with their own naming. +# Without this repair a bare ``nemotron-3-ultra-550b-a55b`` reaches the API +# and returns a bare ``404 page not found`` that never names the model (#78796). +_CATALOGUE_PREFIX_REPAIR_PROVIDERS: frozenset[str] = frozenset({ + "nvidia", +}) + # Providers whose APIs require lowercase model IDs. Xiaomi's # ``api.xiaomimimo.com`` rejects mixed-case names like ``MiMo-V2.5-Pro`` # that users might copy from marketing docs — it only accepts @@ -350,6 +363,63 @@ def _prepend_vendor(model_name: str) -> str: return model_name +def _repair_prefix_from_catalogue(model_name: str, provider: str) -> str: + """Restore a dropped ``vendor/`` prefix using the provider's catalogue. + + Unlike :func:`_prepend_vendor`, this never guesses from the model's name + shape — it only repairs a bare id that matches **exactly one** curated + entry for this provider modulo the prefix. That keeps self-hosted models + behind the same provider id (local NIM containers, proxies) untouched, + since they aren't in the catalogue. + + Examples:: + + >>> _repair_prefix_from_catalogue("nemotron-3-ultra-550b-a55b", "nvidia") + 'nvidia/nemotron-3-ultra-550b-a55b' + >>> _repair_prefix_from_catalogue("my-local-nim", "nvidia") + 'my-local-nim' + """ + if "/" in model_name: + return model_name + try: + from hermes_cli.models import _PROVIDER_MODELS + except Exception: + return model_name + + catalogue = _PROVIDER_MODELS.get(provider) or [] + # Compare against the catalogue's own suffix, tag included: a bare + # ``…:free`` id must resolve to the ``:free`` entry, not its paid sibling. + needle = model_name.strip().lower() + matches = { + entry + for entry in catalogue + if "/" in entry and entry.split("/", 1)[1].strip().lower() == needle + } + if len(matches) == 1: + return matches.pop() + return model_name + + +def suggest_prefixed_model_id(provider: str, model_name: str) -> Optional[str]: + """Return the prefixed catalogue id for a bare *model_name*, if unambiguous. + + The diagnostic counterpart to :func:`_repair_prefix_from_catalogue`: used + to explain a provider's content-free 404 when the configured id lost its + ``vendor/`` prefix. Returns ``None`` when the name already has a prefix, + the provider has no curated catalogue, or nothing matches — so callers can + stay silent rather than guess (#78796). + """ + name = (model_name or "").strip() + if not name or "/" in name: + return None + try: + canonical = _normalize_provider_alias(provider) + except Exception: + return None + repaired = _repair_prefix_from_catalogue(name, canonical) + return repaired if repaired != name else None + + # --------------------------------------------------------------------------- # Main normalisation entry point # --------------------------------------------------------------------------- @@ -492,6 +562,12 @@ def normalize_model_for_provider(model_input: str, target_provider: str) -> str: result = result.lower() return result + # --- Catalogue-backed prefix repair: restore a dropped ``vendor/`` on a + # bare id that matches exactly one curated entry. Unknown names (a + # local NIM container, a proxied model) pass through untouched. --- + if provider in _CATALOGUE_PREFIX_REPAIR_PROVIDERS: + return _repair_prefix_from_catalogue(name, provider) + # --- Authoritative native providers: preserve user-facing slugs as-is --- if provider in _AUTHORITATIVE_NATIVE_PROVIDERS: return name diff --git a/tests/agent/test_error_classifier.py b/tests/agent/test_error_classifier.py index be162f5aa3..37b498b251 100644 --- a/tests/agent/test_error_classifier.py +++ b/tests/agent/test_error_classifier.py @@ -371,6 +371,39 @@ class TestClassifyApiError: assert result.retryable is True assert result.should_fallback is False + def test_404_bare_model_id_missing_prefix_is_model_not_found(self): + """A bare id the provider only serves as ``vendor/id`` is malformed. + + Regression for #78796: NVIDIA NIM answers a prefix-less + ``nemotron-3-ultra-550b-a55b`` with a naked ``404 page not found``. + Without the catalogue check this fell into the generic branch and + burned three retries on a deterministic failure, reporting what + looked like an outage. + """ + e = MockAPIError("404 page not found", status_code=404) + result = classify_api_error( + e, provider="nvidia", model="nemotron-3-ultra-550b-a55b" + ) + assert result.reason == FailoverReason.model_not_found + assert result.retryable is False + + def test_404_correctly_prefixed_model_stays_generic(self): + """A properly prefixed id hitting a 404 is a real endpoint problem — + it must keep the retryable generic classification.""" + e = MockAPIError("404 page not found", status_code=404) + result = classify_api_error( + e, provider="nvidia", model="nvidia/nemotron-3-ultra-550b-a55b" + ) + assert result.reason == FailoverReason.unknown + assert result.retryable is True + + def test_404_unknown_bare_model_stays_generic(self): + """A local NIM container isn't in the catalogue — no verdict invented.""" + e = MockAPIError("404 page not found", status_code=404) + result = classify_api_error(e, provider="nvidia", model="my-local-nim") + assert result.reason == FailoverReason.unknown + assert result.retryable is True + # ── Provider policy-block (OpenRouter privacy/guardrail) ── diff --git a/tests/hermes_cli/test_model_normalize.py b/tests/hermes_cli/test_model_normalize.py index 767ed8ffd1..765525fb2b 100644 --- a/tests/hermes_cli/test_model_normalize.py +++ b/tests/hermes_cli/test_model_normalize.py @@ -137,3 +137,53 @@ class TestDeepseekCanonicalAndReasonerMapping: def test_reasoner_keywords_map_to_v4_flash(self, model): assert _normalize_for_deepseek(model) == "deepseek-v4-flash" + +# ── Regression: issue #78796 ─────────────────────────────────────────── + +class TestIssue78796NvidiaPrefixRepair: + """A bare NVIDIA model id must regain its ``vendor/`` prefix. + + build.nvidia.com serves ``nvidia/nemotron-…``; a bare + ``nemotron-3-ultra-550b-a55b`` returns a naked ``404 page not found`` + that never names the model, so the failure reads like an outage. + """ + + @pytest.mark.parametrize("model,expected", [ + ("nemotron-3-ultra-550b-a55b", "nvidia/nemotron-3-ultra-550b-a55b"), + ("nemotron-3-super-120b-a12b", "nvidia/nemotron-3-super-120b-a12b"), + ( + "nemotron-3-nano-omni-30b-a3b-reasoning", + "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", + ), + ]) + def test_bare_nemotron_regains_prefix(self, model, expected): + assert normalize_model_for_provider(model, "nvidia") == expected + + def test_third_party_model_gets_its_own_vendor(self): + """NIM also hosts third-party models — the prefix is the catalogue's, + not a hardcoded ``nvidia/``.""" + assert normalize_model_for_provider("glm-5.2", "nvidia") == "z-ai/glm-5.2" + + @pytest.mark.parametrize("model", [ + "nvidia/nemotron-3-ultra-550b-a55b", + "z-ai/glm-5.2", + ]) + def test_already_prefixed_is_untouched(self, model): + assert normalize_model_for_provider(model, "nvidia") == model + + @pytest.mark.parametrize("model", [ + "my-local-nim-container", + "some-finetune-v2", + ]) + def test_unknown_names_pass_through(self, model): + """The same provider id fronts local NIM containers. An id absent from + the catalogue is a lookup miss, not a guess — leave it alone.""" + assert normalize_model_for_provider(model, "nvidia") == model + + def test_other_providers_unaffected(self): + assert normalize_model_for_provider("my-model", "custom") == "my-model" + assert ( + normalize_model_for_provider("claude-sonnet-4.6", "openrouter") + == "anthropic/claude-sonnet-4.6" + ) + From fdc342c082c837847ca8de4a77d489f36a4af354 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Tue, 4 Aug 2026 15:05:53 -0600 Subject: [PATCH 12/15] fix(models): a model id missing its vendor prefix says so instead of 404ing (#78909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting an NVIDIA NIM model whose id reached config without the nvidia/ prefix produced a bare "HTTP 404: 404 page not found" — retried three times, never naming the model. It reads exactly like an outage or an auth failure, which is where the Discord thread spent its time before the id was spotted. normalize_model_for_provider() had no branch for nvidia, so a bare id passed straight through to the API. Repair it from the provider's curated catalogue: a bare name that matches exactly one entry modulo the prefix gets it back. That's a lookup, not a guess — build.nvidia.com also fronts local NIM containers and third-party models, and anything absent from the catalogue is left alone. Because the repair runs on every runtime setup, an already-broken config self-heals on the next turn and prints what it changed. If a bare id still reaches the wire, the 404 now explains itself. The classifier consults the same catalogue: a prefix-less id the provider only serves as vendor/model is a deterministic failure, so it classifies as model_not_found instead of burning three retries on a retryable "unknown", and the error trace names the id to use. Fixes #78796 From 84874c58a5f9fe8117ce4890629d44e7c11e3315 Mon Sep 17 00:00:00 2001 From: ethernet Date: Tue, 28 Jul 2026 13:17:59 -0400 Subject: [PATCH 13/15] feat(dev-sandbox): support fake installer / fake main / git clones allow you to simulate the whole official curl | bash installer, and subsequent hermes updates. Run development commands in a bubblewrap filesystem and network sandbox with a local HTTPS MITM fixture server and a fake github git-upload-pack transport. Package the sandbox command and expose it from the nix devShell. Stage the local installer at its canonical fake HTTPS URL and add a persistent installation/update test path. Route root installs through sandbox-owned filesystem locations and snapshot dirty source worktrees into temporary fake commits so update tests can fast-forward without changing the real checkout. Includes a --install-ref sandbox installer mode that fetches any commit (--from-main is a nice shorthand for local development) outside the sealed sandbox, installs from that snapshot, and then promotes the fake remote to the current worktree so update flows can be exercised with FF. Notes on non-root sandboxes: Giving a non-root sandbox a network is tricky. slirp4netns joins the target userns and setuids to root before configuring the netns, so the userns must map a uid 0; bwrap's --unshare-user maps exactly ONE uid, so --uid 1000 leaves no root to become and slirp diedswith `setns(CLONE_NEWNET): Operation not permitted`. Stage 1 builds the user+net namespaces with `unshare` and two one-id ranges: inner 0 -> a subuid, unused by the payload, present only so slirp can become root inner 1000 -> our real host uid Mapping the payload to the *host* uid (not a second subuid) keeps everything the sandbox writes owned by us, so `rm -rf` on a persistent sandbox still needs no privileges. Stage 2 execs bwrap WITHOUT --unshare-user -- it only adds mount/pid -- sidestepping bwrap's refusal to accept --uid outside a userns it created. Costs a /etc/subuid range for the invoking user (we error with the exact line to add) and util-linux `unshare`; `--root` needs neither. --- hermes_cli/main.py | 3 + nix/devShell.nix | 5 +- nix/packages.nix | 5 + nix/sandbox.nix | 124 +++++ scripts/dev-sandbox.sh | 677 +++++++++++++++++++++------ scripts/sandbox/openssl.cnf | 43 ++ scripts/sandbox/pick-release-tags.sh | 110 +++++ scripts/sandbox/proxy.py | 237 ++++++++++ scripts/sandbox/ssh-shim.sh | 13 + scripts/sandbox/stage2-run.sh | 251 ++++++++++ 10 files changed, 1322 insertions(+), 146 deletions(-) create mode 100644 nix/sandbox.nix create mode 100644 scripts/sandbox/openssl.cnf create mode 100755 scripts/sandbox/pick-release-tags.sh create mode 100644 scripts/sandbox/proxy.py create mode 100644 scripts/sandbox/ssh-shim.sh create mode 100755 scripts/sandbox/stage2-run.sh diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 624d0f1010..3d58833496 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -6876,6 +6876,9 @@ def _desktop_linux_needs_no_sandbox() -> bool: unprivileged desktop user on an AppArmor-restricted host. The root case should remain an explicit user choice. """ + if os.environ.get("ELECTRON_DISABLE_SANDBOX", 0) == "1": + return True + if sys.platform != "linux": return False if hasattr(os, "geteuid") and os.geteuid() == 0: diff --git a/nix/devShell.nix b/nix/devShell.nix index ac50beb0e6..d1c0875045 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -27,10 +27,7 @@ mkdir -p $out/bin install -Dm755 ${../hermes} $out/bin/hermes '') - (pkgs.runCommand "dev-sandbox" { } '' - mkdir -p $out/bin - install -Dm755 ${../scripts/dev-sandbox.sh} $out/bin/sandbox - '') + self'.packages.sandbox uv # Headless Wayland compositor for E2E tests (test:e2e:visual). # cage renders a single client with no window management, so diff --git a/nix/packages.nix b/nix/packages.nix index 5e4d32404f..a496f73a3e 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -9,6 +9,9 @@ ... }: let + + sandbox = pkgs.callPackage ./sandbox.nix { }; + minimal = pkgs.callPackage ./hermes-agent.nix { inherit (inputs) uv2nix pyproject-nix pyproject-build-systems; npm-lockfile-fix = inputs'.npm-lockfile-fix.packages.default; @@ -51,6 +54,8 @@ }).node-gyp; default = full; + inherit sandbox; + inherit minimal; # Ships discord.py + python-telegram-bot + slack-sdk so a plain diff --git a/nix/sandbox.nix b/nix/sandbox.nix new file mode 100644 index 0000000000..cec5dc9868 --- /dev/null +++ b/nix/sandbox.nix @@ -0,0 +1,124 @@ +{ + # electron deps + alsa-lib, + at-spi2-atk, + atk, + cairo, + cups, + dbus, + expat, + fontconfig, + freetype, + glib, + gtk3, + libdrm, + libgbm, + libxkbcommon, + mesa, + nspr, + nss, + pango, + systemd, + libX11, + libXcomposite, + libXdamage, + libXext, + libXfixes, + libXrandr, + libXrender, + libXtst, + libxcb, + + # sandbox deps + bash, + bubblewrap, + cacert, + coreutils, + curl, + gawk, + git, + glibc, + gnumake, + gnugrep, + gnused, + gzip, + nodejs_22, + openssl, + python3, + slirp4netns, + stdenv, + gnutar, + util-linux, + + # etc + writeShellApplication, + lib, +}: +let + electronRuntime = [ + alsa-lib + at-spi2-atk + atk + cairo + cups + dbus + expat + fontconfig + freetype + glib + gtk3 + libdrm + libgbm + libxkbcommon + mesa + nspr + nss + pango + systemd + libX11 + libXcomposite + libXdamage + libXext + libXfixes + libXrandr + libXrender + libXtst + libxcb + ]; +in +writeShellApplication { + name = "sandbox"; + runtimeInputs = [ + bash + bubblewrap + cacert + coreutils + curl + gawk + git + glibc.bin + gnumake + gnugrep + gnused + gzip + nodejs_22 + openssl + python3 + slirp4netns + stdenv.cc + gnutar + util-linux + ] + ++ electronRuntime; + text = '' + export DEV_SANDBOX_REAL_CA_CERT=${cacert}/etc/ssl/certs/ca-bundle.crt + export DEV_SANDBOX_DYNAMIC_LINKER=${stdenv.cc.bintools.dynamicLinker} + export DEV_SANDBOX_NODE_DIR=${nodejs_22} + export DEV_SANDBOX_ELECTRON_LD_LIBRARY_PATH=${lib.makeLibraryPath electronRuntime} + # The script is imported into the store as a single file, so its own + # directory has no scripts/sandbox/ beside it. Point it at the assets + # (fake-internet proxy, ssh shim) explicitly. + export DEV_SANDBOX_ASSETS=${../scripts/sandbox} + exec ${../scripts/dev-sandbox.sh} "$@" + ''; +} diff --git a/scripts/dev-sandbox.sh b/scripts/dev-sandbox.sh index 5ce459c94e..dca11a72f3 100755 --- a/scripts/dev-sandbox.sh +++ b/scripts/dev-sandbox.sh @@ -1,198 +1,591 @@ #!/usr/bin/env bash -# Run a Hermes instance in an isolated sandbox — separate HERMES_HOME, -# separate Electron userData, and a distinct Desktop app name so it doesn't compete -# with your main desktop instance's single-instance lock. +# Run a command in a disposable, network-isolated fake Internet. # -# By default the sandbox is throwaway: a temp dir is created and removed on -# exit. Use --persistent to keep the sandbox across restarts (stored under -# .hermes-sandbox/ in the worktree git root). -# -# Usage: -# scripts/dev-sandbox.sh python -m hermes_cli.main -# scripts/dev-sandbox.sh hermes desktop -# scripts/dev-sandbox.sh electron . -# scripts/dev-sandbox.sh -- npm run dev # from apps/desktop/ -# scripts/dev-sandbox.sh --persistent hermes desktop -# scripts/dev-sandbox.sh --persistent -- npm run dev -# -# Seed the sandbox HERMES_HOME from an existing directory (e.g. your main -# ~/.hermes) so config, sessions, skills, etc. are pre-populated: -# scripts/dev-sandbox.sh --from ~/.hermes hermes desktop -# -# Override the app name (default: HermesSandbox): -# HERMES_DEV_SANDBOX_NAME=Staging scripts/dev-sandbox.sh hermes desktop -# -# Override the persistent sandbox dir name (default: .hermes-sandbox): -# HERMES_DEV_SANDBOX_DIR=.staging-sandbox scripts/dev-sandbox.sh --persistent hermes desktop +# The command runs in private user, mount, PID, and network namespaces. This +# script is stage 1: it builds the sandbox tree, mints the fake CA, and creates +# the user+network namespaces with `unshare` (see the namespace plan further +# down), then re-execs into scripts/sandbox/stage2-run.sh, which adds the +# mount/pid namespaces with bubblewrap and runs the payload. Its only writable +# filesystem is SANDBOX_ROOT. HTTP(S) goes to a local static MITM proxy; +# github.com SSH uses a sandbox-local git-upload-pack shim; neither transport +# can reach the host network. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Helper files the sandbox needs: the stage-2 script it re-execs into, plus the +# files it copies in (the fake-internet proxy, the ssh shim, the openssl config). +# They sit next to this script in the repo, but the Nix wrapper installs the +# script into the store on its own, so it exports DEV_SANDBOX_ASSETS to point +# here. +SANDBOX_ASSETS="${DEV_SANDBOX_ASSETS:-$SCRIPT_DIR/sandbox}" +for asset in proxy.py ssh-shim.sh openssl.cnf stage2-run.sh; do + [ -f "$SANDBOX_ASSETS/$asset" ] || { + echo "error: missing sandbox asset: $SANDBOX_ASSETS/$asset" >&2 + exit 1 + } +done + print_help() { cat <<'EOF' -Usage: dev-sandbox.sh [--persistent] [--from DIR] [--] +Usage: dev-sandbox.sh [options] [--] + dev-sandbox.sh install [options] [--] [installer arguments...] -Run a Hermes instance in an isolated sandbox. +Run COMMAND in a throwaway chroot-like bubblewrap sandbox. The sandbox has no +writable host mounts: only its own root, mounted at /work, is writable. Options: - --persistent Keep the sandbox dir across restarts (under the worktree - git root, in .hermes-sandbox/). Without this flag the - sandbox is a temp dir that is removed on exit. - --from DIR Copy DIR into the sandbox HERMES_HOME as the starting - point (config, sessions, skills, etc.). - Ignored if the sandbox HERMES_HOME already has content - (e.g. reusing a --persistent sandbox) to avoid clobbering. - --delete Delete the existing persistent sandbox in .hermes-sandbox. - -h, --help Show this help message. + --persistent Keep the whole sandbox under .hermes-sandbox/. + --delete Delete the persistent sandbox (asks first). + --root Install as uid 0 with the root FHS layout: code in + /usr/local/lib/hermes-agent, command in + /usr/local/bin. Default is the user-level layout. + --from DIR One-time copy of DIR into the sandbox's $HOME. + Existing persistent sandboxes are never overwritten. + --http-root DIR Copy DIR into the fake web server root for this run. + Requests map to DIR//; no URL is forwarded. + --installer PATH With `install`, serve PATH at the canonical install.sh + URL. Default: scripts/install.sh in this worktree. + --from-main With `install`, fetch the real upstream main installer + and repository, then advance fake main to this folder + after a successful install for update testing. + Shorthand for --install-ref refs/heads/main. + --install-ref REF Like --from-main, but installs REF instead of main: + a branch, a tag (v2026.7.7), or a SHA reachable from main. + Use it to test updating from an older release, not just + from the tip. + -h, --help Show this help. + +Option order matters: every option above is consumed by THIS script, and +parsing stops at the first argument it does not recognize. Everything from +that point on is passed through to the command (or, with `install`, to the +installer). Put sandbox options first and separate installer arguments with +`--`, otherwise they arrive here and fail: + + # WRONG — --from-main reaches install.sh, which rejects it + scripts/dev-sandbox.sh install --skip-setup --from-main + + # RIGHT + scripts/dev-sandbox.sh install --from-main -- --skip-setup + +Install layout: `install.sh` picks its layout from `id -u` alone, so uid is what +separates the two real-world Linux installs. By default the sandbox runs as an +unprivileged `hermes` user, giving the layout most people have — +$HERMES_HOME/hermes-agent plus a ~/.local/bin launcher. Pass --root for the FHS +one. Both are worth testing; they differ in more than paths (root also relocates +uv's Python to /usr/local/share for world-readability). + +The fake web server signs certificates with a CA trusted only inside this +sandbox. HTTP_PROXY/HTTPS_PROXY send fixture URLs there first; other HTTP(S) +requests pass through the sandbox's rootless outbound network. SSH to github.com +runs a sandbox-local upload-pack shim, never your SSH config, agent, +known-hosts file, or authorized keys. + +Fake github main always comes from this folder. If it has staged, unstaged, or +non-ignored untracked changes, the sandbox warns and creates a temporary local +commit containing them; it never stages or commits the real worktree. Environment: - HERMES_DEV_SANDBOX_NAME Override the app name (default: HermesSandbox) - HERMES_DEV_SANDBOX_DIR Override the persistent dir name (default: .hermes-sandbox) + HERMES_DEV_SANDBOX_DIR Sandbox directory name, relative to the repo root + (default: .hermes-sandbox). Examples: - dev-sandbox.sh hermes desktop - dev-sandbox.sh --persistent hermes desktop - dev-sandbox.sh --from ~/.hermes hermes desktop - dev-sandbox.sh -- npm run dev + # create a sandbox, install this branch as `main`, and then drop to a shell, + # skipping `hermes setup` & the browser tools for speed. + scripts/dev-sandbox.sh install --persistent -- --skip-setup --skip-browser + + # Install the official upstream main. You're dropped into a shell where + # you can run `hermes update`. + scripts/dev-sandbox.sh install --persistent --from-main + EOF } PERSISTENT=false DELETE=false +RUN_AS_USER=true SEED_DIR="" +HTTP_ROOT="" +INSTALL_SHORTCUT=false +INSTALLER_PATH="" +# Which upstream commit the sandbox installs before the update routes run. +# Empty means "install this worktree's own installer" (no upstream fetch); set, +# it is anything git can resolve -- a branch, a tag (v2026.7.7), or a SHA +# reachable from main -- so "can a user two releases back still update?" is +# expressible. --from-main is shorthand for refs/heads/main. +INSTALL_REF="" +UPSTREAM_URL="${HERMES_DEV_SANDBOX_UPSTREAM:-https://github.com/NousResearch/hermes-agent.git}" + +if [ "${1:-}" = install ]; then + INSTALL_SHORTCUT=true + shift +fi while [ "$#" -gt 0 ]; do case "$1" in - --persistent) - PERSISTENT=true - shift - ;; + --persistent) PERSISTENT=true; shift ;; + --delete) DELETE=true; shift ;; + --root) RUN_AS_USER=false; shift ;; + --user) RUN_AS_USER=true; shift ;; # the default; accepted for symmetry --from) - if [ "$#" -lt 2 ] || [[ "$2" == -* ]]; then - echo "error: --from requires a directory argument" >&2 - exit 1 - fi - SEED_DIR="$2" - shift 2 - ;; - --from=*) - SEED_DIR="${1#--from=}" - if [ -z "$SEED_DIR" ]; then - echo "error: --from requires a directory argument" >&2 - exit 1 - fi - shift - ;; - --delete) - DELETE=true - shift - ;; - -h|--help) - print_help - exit 0 - ;; - --) - shift - break - ;; - *) - break - ;; + [ "$#" -ge 2 ] || { echo 'error: --from needs a directory' >&2; exit 1; } + SEED_DIR="$2"; shift 2 ;; + --http-root) + [ "$#" -ge 2 ] || { echo 'error: --http-root needs a directory' >&2; exit 1; } + HTTP_ROOT="$2"; shift 2 ;; + --installer) + [ "$#" -ge 2 ] || { echo 'error: --installer needs a file' >&2; exit 1; } + INSTALLER_PATH="$2"; shift 2 ;; + --from-main) INSTALL_REF="refs/heads/main"; shift ;; + --install-ref) + [ "$#" -ge 2 ] || { echo 'error: --install-ref needs a value' >&2; exit 1; } + INSTALL_REF="$2" + shift 2 ;; + --from=*|--http-root=*|--installer=*|--install-ref=*) + key="${1%%=*}"; value="${1#*=}" + [ -n "$value" ] || { echo "error: $key needs a value" >&2; exit 1; } + case "$key" in + --from) SEED_DIR="$value" ;; + --http-root) HTTP_ROOT="$value" ;; + --installer) INSTALLER_PATH="$value" ;; + --install-ref) INSTALL_REF="$value" ;; + esac + shift ;; + -h|--help) print_help; exit 0 ;; + --) shift; break ;; + *) break ;; esac done -if [ -n "$SEED_DIR" ]; then - if [ ! -d "$SEED_DIR" ]; then - echo "error: --from dir '$SEED_DIR' does not exist" >&2 - exit 1 - fi - # Resolve to absolute path so it's valid after we cd later. - SEED_DIR="$(cd "$SEED_DIR" && pwd)" -fi - -if [ "$#" -eq 0 ]; then +if [ "$INSTALL_SHORTCUT" = false ] && [ "$#" -eq 0 ]; then print_help >&2 exit 1 fi +if [ -n "$INSTALLER_PATH" ] && [ "$INSTALL_SHORTCUT" = false ]; then + echo 'error: --installer is only valid with the install shortcut' >&2 + exit 1 +fi +if [ -n "$INSTALL_REF" ] && [ "$INSTALL_SHORTCUT" = false ]; then + echo 'error: --from-main / --install-ref are only valid with the install shortcut' >&2 + exit 1 +fi +if [ -n "$INSTALL_REF" ] && [ -n "$INSTALLER_PATH" ]; then + echo 'error: --from-main / --install-ref cannot be combined with --installer' >&2 + exit 1 +fi -SANDBOX_DIR_NAME="${HERMES_DEV_SANDBOX_DIR:-.hermes-sandbox}" -GIT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR/..")" +for dir in "$SEED_DIR" "$HTTP_ROOT"; do + [ -z "$dir" ] || [ -d "$dir" ] || { echo "error: directory '$dir' does not exist" >&2; exit 1; } +done + +GIT_ROOT="${HERMES_SANDBOX_SOURCE_ROOT:-$(git rev-parse --show-toplevel)}" GIT_ROOT="$(cd "$GIT_ROOT" && pwd)" -PERSISTENT_SANDBOX_ROOT="$GIT_ROOT/$SANDBOX_DIR_NAME" +if [ "$INSTALL_SHORTCUT" = true ] && [ -z "$INSTALL_REF" ] && [ -z "$INSTALLER_PATH" ]; then + INSTALLER_PATH="$GIT_ROOT/scripts/install.sh" +fi +if [ -n "$INSTALLER_PATH" ] && [ ! -f "$INSTALLER_PATH" ]; then + echo "error: installer '$INSTALLER_PATH' does not exist" >&2 + exit 1 +fi +COMMIT="$(git -C "$GIT_ROOT" rev-parse --verify 'HEAD^{commit}')" || { + echo "error: current folder has no HEAD commit" >&2 + exit 1 +} +SANDBOX_DIR_NAME="${HERMES_DEV_SANDBOX_DIR:-.hermes-sandbox}" +PERSISTENT_ROOT="$GIT_ROOT/$SANDBOX_DIR_NAME" if [ "$DELETE" = true ]; then - if [ -d "$PERSISTENT_SANDBOX_ROOT" ]; then - read -r -p "[sandbox] delete $PERSISTENT_SANDBOX_ROOT? [y/N] " REPLY - case "$REPLY" in - [yY]|[yY][eE][sS]) - echo "[sandbox] deleting $PERSISTENT_SANDBOX_ROOT" >&2 - rm -rf -- "$PERSISTENT_SANDBOX_ROOT" - ;; - *) - echo "[sandbox] aborted" >&2 - exit 1 - ;; - esac - else - echo "[sandbox] nothing to delete at $PERSISTENT_SANDBOX_ROOT" >&2 + if [ ! -d "$PERSISTENT_ROOT" ]; then + echo "[sandbox] nothing to delete at $PERSISTENT_ROOT" >&2 + exit 0 fi + read -r -p "[sandbox] delete $PERSISTENT_ROOT? [y/N] " reply + case "$reply" in + y|Y|yes|YES) rm -rf -- "$PERSISTENT_ROOT" ;; + *) echo '[sandbox] aborted' >&2; exit 1 ;; + esac exit 0 fi -# Derive a per-worktree app name so multiple checkouts don't collide. -# Each worktree has its own toplevel path even though they share one repo, -# so we hash that path into a short, stable suffix. -WORKTREE_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR/..")" -WORKTREE_ROOT="$(cd "$WORKTREE_ROOT" && pwd)" -WORKTREE_HASH="$(printf '%s' "$WORKTREE_ROOT" | cksum | cut -d' ' -f1)" -WORKTREE_NAME="$(basename "$WORKTREE_ROOT")" -DEFAULT_SANDBOX_NAME="HermesSandbox-${WORKTREE_NAME}-${WORKTREE_HASH}" - -SANDBOX_NAME="${HERMES_DEV_SANDBOX_NAME:-$DEFAULT_SANDBOX_NAME}" - if [ "$PERSISTENT" = true ]; then - SANDBOX_ROOT="$PERSISTENT_SANDBOX_ROOT" + SANDBOX_ROOT="$PERSISTENT_ROOT" else SANDBOX_ROOT="$(mktemp -d -t hermes-sandbox.XXXXXX)" + cleanup() { chmod -R u+w "$SANDBOX_ROOT"; rm -rf -- "$SANDBOX_ROOT"; } + trap cleanup EXIT INT TERM fi -export HERMES_HOME="$SANDBOX_ROOT/hermes-home" -export HERMES_DESKTOP_USER_DATA_DIR="$SANDBOX_ROOT/user-data" -export HERMES_DESKTOP_APP_NAME="$SANDBOX_NAME" - -mkdir -p "$HERMES_HOME" "$HERMES_DESKTOP_USER_DATA_DIR" - -if [ -n "$SEED_DIR" ]; then - # Only seed when the sandbox HERMES_HOME is empty — avoids clobbering an - # existing persistent sandbox on re-run. - if [ -z "$(ls -A "$HERMES_HOME" 2>/dev/null)" ]; then - echo "[sandbox] seeding HERMES_HOME from $SEED_DIR" >&2 - cp -a "$SEED_DIR/." "$HERMES_HOME/" +mkdir -p "$SANDBOX_ROOT"/{root,home,etc} +UPSTREAM_REPO="" +UPSTREAM_COMMIT="" +if [ -n "$INSTALL_REF" ]; then + echo "[sandbox] fetching upstream $INSTALL_REF for installer/update test" >&2 + UPSTREAM_REPO="$(mktemp -d -t hermes-sandbox-upstream.XXXXXX)" + git -C "$UPSTREAM_REPO" init -q + # Fetch the ref as given. A branch or tag name resolves on its own; a raw SHA + # needs the remote to allow fetching it directly, so fall back to fetching + # main and resolving the SHA locally (which works for any commit that is an + # ancestor of main -- the interesting case for "update from N versions ago"). + # + # Peel to ^{commit} in both cases: an annotated tag fetches as a tag OBJECT, + # and using it directly fails later with "trying to write non-commit object + # ... to branch 'refs/heads/main'". + if git -C "$UPSTREAM_REPO" fetch -q "$UPSTREAM_URL" "$INSTALL_REF" 2>/dev/null; then + UPSTREAM_COMMIT="$(git -C "$UPSTREAM_REPO" rev-parse "FETCH_HEAD^{commit}")" + elif git -C "$UPSTREAM_REPO" fetch -q "$UPSTREAM_URL" refs/heads/main \ + && UPSTREAM_COMMIT="$(git -C "$UPSTREAM_REPO" rev-parse --verify -q "$INSTALL_REF^{commit}")"; then + : else - echo "[sandbox] --from ignored: $HERMES_HOME already has content" >&2 + rm -rf -- "$UPSTREAM_REPO" + echo "error: could not resolve upstream ref: $INSTALL_REF" >&2 + echo ' Use a branch (main), a tag (v2026.7.7), or a SHA reachable from main.' >&2 + exit 1 fi fi +if [ ! -e "$SANDBOX_ROOT/root/repo/.sandbox-source" ]; then + mkdir -p "$SANDBOX_ROOT/root/repo" + # Persistent roots live under the worktree, so copying with cp would recurse + # into the sandbox itself. tar also lets us exclude a worktree's .git file, + # which can point at the host's shared worktree metadata. + tar -C "$GIT_ROOT" --exclude='./.git' --exclude="./$SANDBOX_DIR_NAME" -cf - . \ + | tar -C "$SANDBOX_ROOT/root/repo" -xf - + : > "$SANDBOX_ROOT/root/repo/.sandbox-source" +fi -echo "[sandbox] HERMES_HOME=$HERMES_HOME" >&2 -echo "[sandbox] userData=$HERMES_DESKTOP_USER_DATA_DIR" >&2 -echo "[sandbox] appName=$HERMES_DESKTOP_APP_NAME" >&2 -if [ "$PERSISTENT" = true ]; then - echo "[sandbox] persistent: $SANDBOX_ROOT" >&2 +if [ -n "$SEED_DIR" ] && [ ! -e "$SANDBOX_ROOT/.seeded" ]; then + echo "[sandbox] seeding home from $SEED_DIR" >&2 + cp -a "$SEED_DIR/." "$SANDBOX_ROOT/home/" + : > "$SANDBOX_ROOT/.seeded" +fi + +rm -rf "$SANDBOX_ROOT/root/http" +mkdir -p "$SANDBOX_ROOT/root/http" +if [ -n "$HTTP_ROOT" ]; then + cp -a "$HTTP_ROOT/." "$SANDBOX_ROOT/root/http/" +fi +if [ "$INSTALL_SHORTCUT" = true ]; then + mkdir -p "$SANDBOX_ROOT/root/http/hermes-agent.nousresearch.com" + if [ -n "$INSTALL_REF" ]; then + git -C "$UPSTREAM_REPO" show "$UPSTREAM_COMMIT:scripts/install.sh" \ + > "$SANDBOX_ROOT/root/http/hermes-agent.nousresearch.com/install.sh" + else + cp -a "$INSTALLER_PATH" "$SANDBOX_ROOT/root/http/hermes-agent.nousresearch.com/install.sh" + fi + set -- bash -c ' + set +e + curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- "$@" + install_status=$? + if [ "$install_status" -eq 0 ] && [ -f /work/promote-main ]; then + next_main=$(cat /work/promote-main) + if git --git-dir=/work/repos/hermes-agent.git update-ref refs/heads/main "$next_main"; then + rm -f /work/promote-main + printf "[sandbox] fake main advanced to this folder for update testing\n" >&2 + else + printf "[sandbox] failed to advance fake main after install\n" >&2 + install_status=1 + fi + fi + if [ "$DEV_SANDBOX_INTERACTIVE" = true ]; then + printf "\n[sandbox] installer exited %s; entering sandbox shell\n" "$install_status" >&2 + exec /dev/tty 2>&1 + exec bash -i + fi + exit "$install_status" + ' sandbox-installer "$@" +fi + +mkdir -p "$SANDBOX_ROOT/root"/{bin,certs,lib64,logs,repos,ssh,usr/bin,usr/local} +REAL_CA_CERT="${DEV_SANDBOX_REAL_CA_CERT:-}" +if [ -z "$REAL_CA_CERT" ]; then + for candidate in /etc/ssl/certs/ca-certificates.crt /etc/ssl/cert.pem; do + if [ -f "$candidate" ]; then + REAL_CA_CERT="$candidate" + break + fi + done +fi +if [ ! -f "$REAL_CA_CERT" ]; then + echo 'error: no system CA bundle found for outbound sandbox HTTPS' >&2 + exit 1 +fi +if [ ! -f "$SANDBOX_ROOT/root/certs/real-ca.pem" ]; then + cp "$REAL_CA_CERT" "$SANDBOX_ROOT/root/certs/real-ca.pem" +fi +printf 'nameserver 10.0.2.3\n' > "$SANDBOX_ROOT/etc/resolv.conf" +SANDBOX_SHELL="$(command -v bash)" +DYNAMIC_LINKER="${DEV_SANDBOX_DYNAMIC_LINKER:-}" +if [ -z "$DYNAMIC_LINKER" ]; then + # Nix store first: NixOS also ships a /lib64/ld-linux-x86-64.so.2 compat stub, + # so probing FHS paths first would quietly switch which loader a bare script + # invocation uses on this host. Globs that match nothing expand to themselves, + # so every candidate is -f tested. The FHS paths cover Debian/Ubuntu (where + # the loader is under /lib64 or a multiarch /lib dir), which is what CI runs. + for candidate in \ + /nix/store/*-glibc-*/lib/ld-linux-*.so.* \ + /lib64/ld-linux-x86-64.so.2 \ + /lib/ld-linux-aarch64.so.1 \ + /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 \ + /lib/aarch64-linux-gnu/ld-linux-aarch64.so.1 + do + if [ -f "$candidate" ]; then + DYNAMIC_LINKER="$candidate" + break + fi + done +fi +if [ ! -f "$DYNAMIC_LINKER" ]; then + echo 'error: no glibc dynamic linker found for sandboxed release binaries' >&2 + echo ' Set DEV_SANDBOX_DYNAMIC_LINKER to its path.' >&2 + exit 1 +fi +ln -sf "$SANDBOX_SHELL" "$SANDBOX_ROOT/root/bin/sh" +ln -sf "$(command -v ls)" "$SANDBOX_ROOT/root/bin/ls" +ln -sf "$(command -v env)" "$SANDBOX_ROOT/root/usr/bin/env" +ln -sf "$DYNAMIC_LINKER" "$SANDBOX_ROOT/root/lib64/$(basename "$DYNAMIC_LINKER")" +# Identity inside the sandbox. install.sh chooses its layout from `id -u` +# alone (see resolve_install_layout), so the uid here is what decides between +# the root FHS install and a user-level one. +if [ "$RUN_AS_USER" = true ]; then + SANDBOX_UID=1000 + SANDBOX_GID=1000 + SANDBOX_USER=hermes + SANDBOX_HOME=/home/hermes else - echo "[sandbox] ephemeral (will be cleaned up on exit)" >&2 + SANDBOX_UID=0 + SANDBOX_GID=0 + SANDBOX_USER=root + SANDBOX_HOME=/root +fi +{ + printf 'root:x:0:0:Sandbox Root:/root:%s\n' "$SANDBOX_SHELL" + if [ "$RUN_AS_USER" = true ]; then + printf '%s:x:%s:%s:Sandbox User:%s:%s\n' \ + "$SANDBOX_USER" "$SANDBOX_UID" "$SANDBOX_GID" "$SANDBOX_HOME" "$SANDBOX_SHELL" + fi +} > "$SANDBOX_ROOT/etc/passwd" +{ + printf 'root:x:0:\n' + if [ "$RUN_AS_USER" = true ]; then + printf '%s:x:%s:\n' "$SANDBOX_USER" "$SANDBOX_GID" + fi +} > "$SANDBOX_ROOT/etc/group" +# A user-level install writes the `hermes` launcher to ~/.local/bin and the +# checkout to $HERMES_HOME; both live under the sandbox HOME, which is bound +# from $SANDBOX_ROOT/home. bwrap maps our real uid to $SANDBOX_UID, so the +# host-side ownership of that directory is what the sandbox sees as its own. +printf 'hosts: files dns\n' > "$SANDBOX_ROOT/etc/nsswitch.conf" +printf '127.0.0.1 localhost\n' > "$SANDBOX_ROOT/etc/hosts" + +SOURCE_REPO="$GIT_ROOT" +SOURCE_REF="$COMMIT" +SNAPSHOT_REPO="" +FAKE_REPO="$SANDBOX_ROOT/root/repos/hermes-agent.git" +git -C "$SANDBOX_ROOT/root/repos" init --bare -q hermes-agent.git +if [ -n "$INSTALL_REF" ]; then + git --git-dir="$FAKE_REPO" fetch -q --force "$UPSTREAM_REPO" \ + "$UPSTREAM_COMMIT:refs/heads/main" +fi +if [ -n "$(git -C "$GIT_ROOT" status --porcelain)" ]; then + echo '[sandbox] warning: current folder is dirty; creating a temporary fake commit for main' >&2 + SNAPSHOT_REPO="$(mktemp -d -t hermes-sandbox-snapshot.XXXXXX)" + git -C "$SNAPSHOT_REPO" init -q + git -C "$SNAPSHOT_REPO" fetch -q "$GIT_ROOT" "$COMMIT" + git -C "$SNAPSHOT_REPO" config user.name 'Hermes sandbox' + git -C "$SNAPSHOT_REPO" config user.email 'sandbox@invalid' + GIT_DIR="$SNAPSHOT_REPO/.git" GIT_WORK_TREE="$GIT_ROOT" git read-tree "$COMMIT" + GIT_DIR="$SNAPSHOT_REPO/.git" GIT_WORK_TREE="$GIT_ROOT" \ + git add -A -- . + SNAPSHOT_TREE="$(GIT_DIR="$SNAPSHOT_REPO/.git" git write-tree)" + SNAPSHOT_PARENT="$COMMIT" + if EXISTING_MAIN="$(git --git-dir="$FAKE_REPO" rev-parse --verify refs/heads/main 2>/dev/null)"; then + git -C "$SNAPSHOT_REPO" fetch -q "$FAKE_REPO" "$EXISTING_MAIN" + SNAPSHOT_PARENT="$EXISTING_MAIN" + fi + SOURCE_REF="$(GIT_DIR="$SNAPSHOT_REPO/.git" git commit-tree "$SNAPSHOT_TREE" -p "$SNAPSHOT_PARENT" \ + -m 'sandbox snapshot of dirty worktree')" + SOURCE_REPO="$SNAPSHOT_REPO" fi -if [ "$PERSISTENT" = false ]; then - cleanup() { - chmod -R u+w "$SANDBOX_ROOT" - rm -rf -- "$SANDBOX_ROOT" +if [ -n "$INSTALL_REF" ]; then + git --git-dir="$FAKE_REPO" fetch -q --force "$SOURCE_REPO" \ + "$SOURCE_REF:refs/hermes-sandbox/next" + printf '%s\n' "$SOURCE_REF" > "$SANDBOX_ROOT/root/promote-main" +else + git --git-dir="$FAKE_REPO" fetch -q --force "$SOURCE_REPO" \ + "$SOURCE_REF:refs/heads/main" +fi +git --git-dir="$FAKE_REPO" symbolic-ref HEAD refs/heads/main +if [ -n "$SNAPSHOT_REPO" ]; then + # Best-effort: it is a mktemp directory the OS will reap, and failing the whole + # run over a leftover object file would be worse than leaking it. Concurrent + # git activity in the worktree can still be writing here as we delete. + rm -rf -- "$SNAPSHOT_REPO" 2>/dev/null || true +fi +if [ -n "$UPSTREAM_REPO" ]; then + rm -rf -- "$UPSTREAM_REPO" +fi + +# openssl reads a config even for `req -addext`, and its compiled-in path is a +# symlink into /etc/ssl on Debian/Ubuntu -- which the sandbox replaces. Ship our +# own and point OPENSSL_CONF at it, both here and inside the sandbox. +cp "$SANDBOX_ASSETS/openssl.cnf" "$SANDBOX_ROOT/root/certs/openssl.cnf" + +if [ ! -f "$SANDBOX_ROOT/root/certs/ca.pem" ]; then + if ! ca_error="$(OPENSSL_CONF="$SANDBOX_ROOT/root/certs/openssl.cnf" \ + openssl req -x509 -newkey rsa:2048 -nodes -days 2 \ + -subj '/CN=Hermes dev sandbox CA' \ + -extensions sandbox_ca_ext \ + -keyout "$SANDBOX_ROOT/root/certs/ca.key" \ + -out "$SANDBOX_ROOT/root/certs/ca.pem" 2>&1 >/dev/null)"; then + echo 'error: could not create the sandbox CA:' >&2 + printf '%s\n' "$ca_error" >&2 + exit 1 + fi +fi +GIT_UPLOAD_PACK="$(command -v git-upload-pack)" +sed "s|@GIT_UPLOAD_PACK@|$GIT_UPLOAD_PACK|" "$SANDBOX_ASSETS/ssh-shim.sh" \ + > "$SANDBOX_ROOT/root/usr/bin/ssh" +chmod 700 "$SANDBOX_ROOT/root/usr/bin/ssh" + +# The fake-internet proxy and the ssh shim are real files under +# scripts/sandbox/ rather than heredocs, so they can be linted, syntax-checked +# and diffed like any other source. Copy them into the sandbox tree. +cp "$SANDBOX_ASSETS/proxy.py" "$SANDBOX_ROOT/root/proxy.py" + +if [ -n "$INSTALL_REF" ]; then + echo "[sandbox] fake main: upstream $INSTALL_REF ($UPSTREAM_COMMIT)" >&2 + echo "[sandbox] prepared update: current folder ($SOURCE_REF)" >&2 +else + echo "[sandbox] fake main: current folder ($SOURCE_REF)" >&2 +fi +echo "[sandbox] root: $SANDBOX_ROOT" >&2 +echo "[sandbox] http root: $SANDBOX_ROOT/root/http" >&2 +if [ "$RUN_AS_USER" = true ]; then + echo "[sandbox] identity: $SANDBOX_USER (uid $SANDBOX_UID) — installs are user-level under $SANDBOX_HOME" >&2 +else + echo '[sandbox] identity: root (uid 0) — installs use the /usr/local FHS layout' >&2 +fi +[ "$PERSISTENT" = true ] && echo '[sandbox] persistent' >&2 || echo '[sandbox] ephemeral' >&2 + +for command in awk bash bwrap curl git openssl python3 slirp4netns tar unshare; do + command -v "$command" >/dev/null || { + echo "error: missing required command: $command" >&2 + exit 1 } - trap cleanup EXIT - trap 'cleanup; exit 130' INT TERM +done + +INTERACTIVE=false +if [ -t 0 ] && [ -t 1 ]; then + INTERACTIVE=true +fi +NODE_DIR="${DEV_SANDBOX_NODE_DIR:-}" +if [ -z "$NODE_DIR" ] && command -v node >/dev/null; then + NODE_DIR="$(dirname "$(dirname "$(command -v node)")")" +fi +WAYLAND_SOCKET="" +if [ -n "${XDG_RUNTIME_DIR:-}" ] && [ -n "${WAYLAND_DISPLAY:-}" ] \ + && [ -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" ]; then + WAYLAND_SOCKET="$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" fi -"$@" -rc=$? -exit $rc +# Namespace plan (stage 1 -> stage 2). +# +# slirp4netns joins the target's userns and setuids to root before configuring +# the netns, so the userns MUST map a uid 0. bwrap's own --unshare-user maps +# exactly one uid, so it cannot both run the payload as uid 1000 and offer slirp +# a root to become: that combination fails with +# setns(CLONE_NEWNET): Operation not permitted. +# +# So stage 1 builds the namespaces here with two ranges: +# inner 0 <- a subuid, unused by the payload, present only so slirp can +# become root inside the namespace +# inner $SANDBOX_UID <- our real host uid, so everything the sandbox writes +# stays owned by us and `rm -rf` on a persistent sandbox needs +# no privileges or chown dance +# The payload then runs in stage 2, where bwrap adds the mount/pid namespaces +# without creating a userns at all. +# +# The root layout needs no subuid at all: inner 0 IS the host uid there. +netns_args=(--user --net) +if [ "$RUN_AS_USER" = true ]; then + host_user="$(id -un)" + subuid_base="$(awk -F: -v u="$host_user" '$1 == u {print $2; exit}' /etc/subuid)" + subgid_base="$(awk -F: -v u="$host_user" '$1 == u {print $2; exit}' /etc/subgid)" + if [ -z "$subuid_base" ] || [ -z "$subgid_base" ]; then + echo "error: no /etc/subuid or /etc/subgid range for $host_user" >&2 + echo ' A user-level sandbox needs one spare subordinate id to host' >&2 + echo " its internal root. Add e.g. '$host_user:100000:65536' to both," >&2 + echo ' or use --root.' >&2 + exit 1 + fi + netns_args+=( + --map-users="0:$subuid_base:1" --map-users="$SANDBOX_UID:$(id -u):1" + --map-groups="0:$subgid_base:1" --map-groups="$SANDBOX_GID:$(id -g):1" + ) +else + netns_args+=(--map-root-user) +fi + +sandbox_pid_file="$SANDBOX_ROOT/root/logs/sandbox.pid" +slirp_ready="$SANDBOX_ROOT/root/logs/slirp.ready" +slirp_log="$SANDBOX_ROOT/root/logs/slirp.log" +: > "$sandbox_pid_file" +: > "$slirp_ready" + +env \ + DEV_SANDBOX_ROOT="$SANDBOX_ROOT" \ + DEV_SANDBOX_BASH="$(command -v bash)" \ + DEV_SANDBOX_REAL_CA_CERT="$REAL_CA_CERT" \ + DEV_SANDBOX_INTERACTIVE="$INTERACTIVE" \ + DEV_SANDBOX_USER="$SANDBOX_USER" \ + DEV_SANDBOX_HOME="$SANDBOX_HOME" \ + DEV_SANDBOX_NODE_DIR="$NODE_DIR" \ + DEV_SANDBOX_ELECTRON_LD_LIBRARY_PATH="${DEV_SANDBOX_ELECTRON_LD_LIBRARY_PATH:-}" \ + DEV_SANDBOX_XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-}" \ + DEV_SANDBOX_WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-}" \ + DEV_SANDBOX_WAYLAND_SOCKET="$WAYLAND_SOCKET" \ + unshare "${netns_args[@]}" \ + "$SANDBOX_ASSETS/stage2-run.sh" "$@" & +sandbox_launcher=$! + +for _ in $(seq 1 200); do + [ -s "$sandbox_pid_file" ] && break + if ! kill -0 "$sandbox_launcher" 2>/dev/null; then + wait "$sandbox_launcher" + exit $? + fi + sleep 0.05 +done +sandbox_pid="$(tr -dc '0-9' < "$sandbox_pid_file")" +if [ -z "$sandbox_pid" ]; then + echo 'error: sandbox did not report its PID' >&2 + exit 1 +fi + +slirp4netns --configure --disable-host-loopback --ready-fd=3 \ + --userns-path="/proc/$sandbox_pid/ns/user" "$sandbox_pid" tap0 \ + 3>"$slirp_ready" >"$slirp_log" 2>&1 & +slirp_pid=$! +cleanup_slirp() { + kill "$slirp_pid" 2>/dev/null || true + wait "$slirp_pid" 2>/dev/null || true +} +trap cleanup_slirp EXIT INT TERM + +for _ in $(seq 1 200); do + [ -s "$slirp_ready" ] && break + if ! kill -0 "$slirp_pid" 2>/dev/null; then + cat "$slirp_log" >&2 || true + exit 1 + fi + sleep 0.05 +done +if [ ! -s "$slirp_ready" ]; then + echo 'error: timed out waiting for sandbox network setup' >&2 + exit 1 +fi + +wait "$sandbox_launcher" +exit $? \ No newline at end of file diff --git a/scripts/sandbox/openssl.cnf b/scripts/sandbox/openssl.cnf new file mode 100644 index 0000000000..04884355bd --- /dev/null +++ b/scripts/sandbox/openssl.cnf @@ -0,0 +1,43 @@ +# Minimal openssl config for the dev sandbox. +# +# The sandbox replaces /etc wholesale, and on Debian/Ubuntu +# /usr/lib/ssl/openssl.cnf (openssl's compiled-in OPENSSLDIR) is a symlink into +# /etc/ssl -- so the config openssl insists on reading disappears and every +# `openssl req` fails with: +# +# Can't open "/usr/lib/ssl/openssl.cnf" for reading +# +# which surfaces to the payload as a bare `curl: (35) Recv failure`. Rather than +# reconstruct each distro's /etc/ssl, point OPENSSL_CONF at this file: the proxy +# only needs enough config for `req -addext` and `x509 -copy_extensions`. + +[ req ] +distinguished_name = req_distinguished_name + +[ req_distinguished_name ] + +# Used by `req -x509` for the sandbox's own CA. Without an explicit +# basicConstraints the generated certificate is not a CA, and every leaf it +# signs is rejected by the client with "invalid CA certificate (79)". +[ sandbox_ca_ext ] +basicConstraints = critical,CA:true +keyUsage = critical,keyCertSign,cRLSign +subjectKeyIdentifier = hash + +[ ca ] +default_ca = sandbox_ca + +[ sandbox_ca ] +default_md = sha256 +policy = policy_anything +email_in_dn = no +preserve = no + +[ policy_anything ] +commonName = optional +countryName = optional +stateOrProvinceName = optional +localityName = optional +organizationName = optional +organizationalUnitName = optional +emailAddress = optional diff --git a/scripts/sandbox/pick-release-tags.sh b/scripts/sandbox/pick-release-tags.sh new file mode 100755 index 0000000000..1363eb14c5 --- /dev/null +++ b/scripts/sandbox/pick-release-tags.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Pick the release tags the install/update E2E should update FROM. +# +# Emits a JSON array of tag names on stdout, suitable for a GitHub Actions +# matrix (`fromJSON`). Choosing at runtime rather than hardcoding keeps the +# matrix honest as releases land: a pinned list silently stops covering the +# newest release the day after it ships, and pins the "oldest" forever even +# after it stops being a version anyone still runs. +# +# Selection: the newest tag, the oldest tag, and evenly spaced tags in between. +# Newest catches "did the last release break updating?", oldest is the longest +# upgrade jump anyone can still make, and the spread samples the migrations in +# between (config-schema bumps, venv layout changes, dependency floors). +# +# Usage: +# scripts/sandbox/pick-release-tags.sh [--count N] [--repo DIR] +# +# --count how many tags to emit (default 5, minimum 1). Fewer tags than +# requested emits all of them. +# --repo repository to read tags from (default: this checkout). +# +# Reads tags from the local checkout, so it needs one fetched with tags +# (actions/checkout with fetch-depth: 0, or `fetch-tags: true`). A shallow +# checkout has no tags and this exits non-zero rather than silently emitting an +# empty matrix. +# +# Only vYYYY.M.D[.N] release tags are considered; the repo also carries +# backup/* and one-off tags that are not releases. + +set -euo pipefail + +COUNT=5 +# Default to the repository containing this script, resolved through its real +# path so a symlinked or copied script still reads the checkout it lives in +# rather than whatever repo the caller happens to be standing in. +REPO="" +while [ "$#" -gt 0 ]; do + case "$1" in + --count) + [ "$#" -ge 2 ] || { echo 'error: --count needs a value' >&2; exit 1; } + COUNT="$2"; shift 2 ;; + --repo) + [ "$#" -ge 2 ] || { echo 'error: --repo needs a value' >&2; exit 1; } + REPO="$2"; shift 2 ;; + -h|--help) sed -n '2,30p' "$0"; exit 0 ;; + *) echo "error: unknown argument: $1" >&2; exit 1 ;; + esac +done +case "$COUNT" in + ''|*[!0-9]*) echo "error: --count must be a positive integer: $COUNT" >&2; exit 1 ;; +esac +[ "$COUNT" -ge 1 ] || { echo 'error: --count must be at least 1' >&2; exit 1; } + +# Resolve the script's own location through symlinks, then ask git which +# worktree that path belongs to. Deriving the repo from the script rather than +# from $PWD means a copied script cannot silently report a different checkout's +# tags, and --show-toplevel keeps it correct when invoked from a subdirectory. +if [ -z "$REPO" ]; then + script_path="${BASH_SOURCE[0]}" + if command -v readlink >/dev/null 2>&1; then + script_path="$(readlink -f "$script_path" 2>/dev/null || printf '%s' "$script_path")" + fi + script_dir="$(cd "$(dirname "$script_path")" && pwd)" + REPO="$(git -C "$script_dir" rev-parse --show-toplevel 2>/dev/null || printf '%s' "$script_dir")" +fi + +# sort -V orders v2026.4.8 before v2026.4.13 (numeric), which a plain +# lexicographic sort gets wrong. +mapfile -t tags < <( + git -C "$REPO" tag --list 'v*' \ + | grep -E '^v[0-9]{4}\.[0-9]+\.[0-9]+(\.[0-9]+)?$' \ + | sort -V +) + +total="${#tags[@]}" +if [ "$total" -eq 0 ]; then + echo "error: no release tags found in $REPO" >&2 + echo ' A shallow clone has no tags: fetch with tags (actions/checkout' >&2 + echo ' with fetch-depth: 0, or fetch-tags: true).' >&2 + exit 1 +fi + +if [ "$total" -le "$COUNT" ]; then + picked=("${tags[@]}") +elif [ "$COUNT" -eq 1 ]; then + # One slot means the newest release; there is no span to spread across. + picked=("${tags[$((total - 1))]}") +else + # Evenly spaced indices across [0, total-1], endpoints included, so the + # oldest and newest are always present and the rest are spread between them. + picked=() + for slot in $(seq 0 $((COUNT - 1))); do + # Round to nearest rather than truncate, so the spacing does not bunch + # toward the oldest end. + index=$(( (slot * (total - 1) * 2 + (COUNT - 1)) / ((COUNT - 1) * 2) )) + candidate="${tags[$index]}" + # Guard against a duplicate if rounding lands twice on the same tag. + case " ${picked[*]-} " in + *" $candidate "*) continue ;; + esac + picked+=("$candidate") + done +fi + +printf '[' +for i in "${!picked[@]}"; do + [ "$i" -eq 0 ] || printf ',' + printf '"%s"' "${picked[$i]}" +done +printf ']\n' diff --git a/scripts/sandbox/proxy.py b/scripts/sandbox/proxy.py new file mode 100644 index 0000000000..f34c9a843a --- /dev/null +++ b/scripts/sandbox/proxy.py @@ -0,0 +1,237 @@ +"""MITM proxy backing the dev sandbox's fake Internet. + +Listens on 127.0.0.1:8080 and is pointed at by http_proxy/https_proxy inside +the sandbox. For each request it either serves a fixture from the filesystem or +forwards to the real host: + +* ``//`` exists -> serve it. This is how the sandbox answers + the canonical install URL with the installer under test, so the payload can + run the true ``curl -fsSL https://…/install.sh | bash`` one-liner. +* otherwise -> forward upstream, verifying against the real CA bundle. The + sandbox is isolated from the *host*, not from the internet: a real install + still has to reach PyPI and npm. + +HTTPS is intercepted by minting a per-host certificate from the sandbox's own +throwaway CA, which the payload trusts via CURL_CA_BUNDLE / SSL_CERT_FILE. + +Usage: proxy.py +""" + +import os +import pathlib +import socket +import ssl +import subprocess +import sys +import threading +from urllib.parse import unquote, urlsplit + +ROOT, CERTS, REAL_CA = map(pathlib.Path, sys.argv[1:]) + +LISTEN_ADDRESS = ('127.0.0.1', 8080) +MAX_REQUEST_BYTES = 65536 +UPSTREAM_TIMEOUT_SECONDS = 30 +CERT_VALIDITY_DAYS = 2 + + +def read_request(conn): + data = b"" + while b"\r\n\r\n" not in data and len(data) < MAX_REQUEST_BYTES: + part = conn.recv(4096) + if not part: + return b"" + data += part + return data + + +def run_openssl(args): + """Run openssl, raising with its stderr when it fails. + + Discarding stderr here costs real debugging time: the caller sees only a + dropped connection (``curl: (35) Recv failure``) and the log holds nothing + but the argv, so an unwritable directory, a missing CA key, and an option + the host's openssl rejects all look identical. + """ + done = subprocess.run( + ['openssl', *args], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE + ) + if done.returncode != 0: + detail = done.stderr.decode('utf-8', 'replace').strip() + raise RuntimeError( + f'openssl {args[0]} failed (exit {done.returncode}): {detail}' + ) + + +_CERT_LOCK = threading.Lock() + + +def cert_for(host): + """Return a (cert, key) pair for host, minting it from the sandbox CA. + + Minting is serialized and published atomically. The proxy is threaded, so + two concurrent requests for the same host would otherwise both run openssl + into the same paths, and a reader could pick up a finished certificate + beside a key from the other writer -- which TLS rejects as + ``[X509: KEY_VALUES_MISMATCH] key values mismatch``. + """ + safe = ''.join(char if char.isalnum() or char in '.-' else '_' for char in host) + cert, key = CERTS / f'{safe}.pem', CERTS / f'{safe}.key' + if cert.exists() and key.exists(): + return cert, key + with _CERT_LOCK: + # Re-check: another thread may have finished while we waited. + if cert.exists() and key.exists(): + return cert, key + # Build under unique temp names, then rename into place. os.replace is + # atomic, so a reader sees either the old pair or the new one, never a + # half-written mix. The key lands first: the certificate's existence is + # what everything else keys off. + stamp = f'{os.getpid()}.{threading.get_ident()}' + tmp_key = CERTS / f'{safe}.key.{stamp}' + tmp_cert = CERTS / f'{safe}.pem.{stamp}' + csr = CERTS / f'{safe}.csr.{stamp}' + run_openssl([ + 'req', '-newkey', 'rsa:2048', '-nodes', + '-subj', f'/CN={host}', + '-addext', f'subjectAltName=DNS:{host}', + '-keyout', str(tmp_key), '-out', str(csr), + ]) + run_openssl([ + 'x509', '-req', '-days', str(CERT_VALIDITY_DAYS), '-in', str(csr), + '-CA', str(CERTS / 'ca.pem'), '-CAkey', str(CERTS / 'ca.key'), + '-CAcreateserial', '-copy_extensions', 'copy', '-out', str(tmp_cert), + ]) + csr.unlink(missing_ok=True) + os.replace(tmp_key, key) + os.replace(tmp_cert, cert) + return cert, key + + +def file_for(host, target): + """Resolve a request to a fixture file, or None to forward upstream.""" + path = urlsplit(target).path or '/' + parts = pathlib.PurePosixPath(unquote(path)).parts + if '..' in parts: + return None + candidate = ROOT / host / pathlib.PurePosixPath(*[p for p in parts if p != '/']) + if candidate.is_dir(): + candidate /= 'index.html' + return candidate if candidate.is_file() else None + + +def respond_fixture(conn, found): + body = found.read_bytes() + headers = ( + f'Content-Length: {len(body)}\r\nConnection: close\r\n\r\n'.encode() + ) + conn.sendall(b'HTTP/1.1 200 OK\r\n' + headers + body) + + +def close_request(request, target=None): + """Rewrite a proxied request for a direct upstream connection.""" + headers, separator, body = request.partition(b'\r\n\r\n') + lines = headers.split(b'\r\n') + if target is not None: + method, _, version = lines[0].split(b' ', 2) + lines[0] = b' '.join((method, target.encode(), version)) + lines = [ + line for line in lines + if not line.lower().startswith(b'proxy-connection:') + ] + lines.append(b'Connection: close') + return b'\r\n'.join(lines) + separator + body + + +def relay(source, destination): + while True: + chunk = source.recv(MAX_REQUEST_BYTES) + if not chunk: + return + destination.sendall(chunk) + + +def forward_https(conn, host, port, request): + context = ssl.create_default_context(cafile=str(REAL_CA)) + with socket.create_connection((host, port), timeout=UPSTREAM_TIMEOUT_SECONDS) as raw: + with context.wrap_socket(raw, server_hostname=host) as upstream: + upstream.sendall(close_request(request)) + relay(upstream, conn) + + +def forward_http(conn, host, port, request, target): + parsed = urlsplit(target) + path = parsed.path or '/' + if parsed.query: + path += f'?{parsed.query}' + with socket.create_connection((host, port), timeout=UPSTREAM_TIMEOUT_SECONDS) as upstream: + upstream.sendall(close_request(request, path)) + relay(upstream, conn) + + +def handle_connect(conn, target): + """Intercept a CONNECT tunnel, terminating TLS with a minted cert.""" + host, _, port_text = target.rpartition(':') + port = int(port_text or '443') + conn.sendall(b'HTTP/1.1 200 Connection Established\r\n\r\n') + cert, key = cert_for(host) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(cert, key) + with context.wrap_socket(conn, server_side=True) as tls: + nested = read_request(tls) + if not nested: + return + line = nested.split(b'\r\n', 1)[0].decode('iso-8859-1') + nested_target = line.split(' ', 2)[1] + found = file_for(host, nested_target) + if found is not None: + respond_fixture(tls, found) + else: + forward_https(tls, host, port, nested) + + +def host_from_headers(request): + for header in request.split(b'\r\n')[1:]: + if header.lower().startswith(b'host:'): + value = header.split(b':', 1)[1].strip().decode() + return value.split(':', 1)[0] + return None + + +def handle_request(conn): + with conn: + request = read_request(conn) + if not request: + return + line = request.split(b'\r\n', 1)[0].decode('iso-8859-1') + method, target, _ = line.split(' ', 2) + if method.upper() == 'CONNECT': + handle_connect(conn, target) + return + parsed = urlsplit(target) + host = parsed.hostname or host_from_headers(request) or 'unknown' + found = file_for(host, target) + if found is not None: + respond_fixture(conn, found) + else: + forward_http(conn, host, parsed.port or 80, request, target) + + +def handle(conn): + try: + handle_request(conn) + except Exception as error: + print(f'proxy request failed: {error!r}', file=sys.stderr, flush=True) + + +def main(): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(LISTEN_ADDRESS) + server.listen() + while True: + conn, _ = server.accept() + threading.Thread(target=handle, args=(conn,), daemon=True).start() + + +if __name__ == '__main__': + main() diff --git a/scripts/sandbox/ssh-shim.sh b/scripts/sandbox/ssh-shim.sh new file mode 100644 index 0000000000..1b90035eea --- /dev/null +++ b/scripts/sandbox/ssh-shim.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Stand-in for ssh inside the dev sandbox. +# +# install.sh and `hermes update` clone over ssh first (git@github.com:...), so +# the sandbox needs an `ssh` that answers. Rather than run a real sshd, this +# ignores the host, user, and command git asked for and speaks the +# upload-pack protocol directly against the sandbox's bare repo -- which is +# what makes the ssh-first code path exercisable with no keys, no known_hosts, +# and no network. +# +# GIT_UPLOAD_PACK is substituted by dev-sandbox.sh when it installs this shim, +# because the host's git-upload-pack is not necessarily on the sandbox PATH. +exec @GIT_UPLOAD_PACK@ /work/repos/hermes-agent.git diff --git a/scripts/sandbox/stage2-run.sh b/scripts/sandbox/stage2-run.sh new file mode 100755 index 0000000000..42d2800ec7 --- /dev/null +++ b/scripts/sandbox/stage2-run.sh @@ -0,0 +1,251 @@ +#!/usr/bin/env bash +# Stage 2 of the dev sandbox: build the mounts and run the payload. +# +# Not called directly. scripts/dev-sandbox.sh (stage 1) creates the user and +# network namespaces with `unshare` and re-execs into this script inside them, +# so by the time this runs we are already at the target uid with a private +# netns. bwrap therefore does NOT create a userns here -- it only adds the +# mount and pid namespaces. (`unshare --user` grants its creator full +# capabilities in the new userns regardless of which uid it maps, which is what +# lets bwrap mount as a non-root uid.) +# +# The whole interface with stage 1 is the DEV_SANDBOX_* environment, asserted +# below: there are no shared functions or variables between the two stages. +# Stage 1 locates this script alongside the other sandbox assets (see +# DEV_SANDBOX_ASSETS in dev-sandbox.sh), so the Nix wrapper's store copy and a +# plain repo checkout both work. + +set -euo pipefail + +: "${DEV_SANDBOX_ROOT:?missing DEV_SANDBOX_ROOT}" +: "${DEV_SANDBOX_BASH:?missing DEV_SANDBOX_BASH}" +: "${DEV_SANDBOX_INTERACTIVE:?missing DEV_SANDBOX_INTERACTIVE}" +: "${DEV_SANDBOX_USER:?missing DEV_SANDBOX_USER}" +: "${DEV_SANDBOX_HOME:?missing DEV_SANDBOX_HOME}" + +# Announce our pid so stage 1 can point slirp4netns at these namespaces, +# then hold until it reports the network is up. +slirp_ready="$DEV_SANDBOX_ROOT/root/logs/slirp.ready" +printf '%s\n' "$$" > "$DEV_SANDBOX_ROOT/root/logs/sandbox.pid" +for _ in $(seq 1 200); do + [ -s "$slirp_ready" ] && break + sleep 0.05 +done +if [ ! -s "$slirp_ready" ]; then + echo 'error: timed out waiting for sandbox network setup' >&2 + cat "$DEV_SANDBOX_ROOT/root/logs/slirp.log" >&2 || true + exit 1 +fi + +# The sandbox HOME is /root for a root install and /home/ for a +# user-level one. Only the latter needs its parent created first; --dir / +# is not a thing bwrap accepts. +home_mounts=() +home_parent="$(dirname "$DEV_SANDBOX_HOME")" +if [ "$home_parent" != / ]; then + home_mounts+=(--dir "$home_parent") +fi +home_mounts+=(--bind "$DEV_SANDBOX_ROOT/home" "$DEV_SANDBOX_HOME") + +node_env=() +if [ -n "${DEV_SANDBOX_NODE_DIR:-}" ]; then + node_env+=(--setenv npm_config_nodedir "$DEV_SANDBOX_NODE_DIR") +fi +electron_env=() +if [ -n "${DEV_SANDBOX_ELECTRON_LD_LIBRARY_PATH:-}" ]; then + electron_env+=( + --setenv LD_LIBRARY_PATH "$DEV_SANDBOX_ELECTRON_LD_LIBRARY_PATH" + --setenv HERMES_DESKTOP_DISABLE_GPU 1 + ) +fi +gui_mounts=() +if [ -n "${DEV_SANDBOX_WAYLAND_SOCKET:-}" ]; then + runtime_dir="${DEV_SANDBOX_XDG_RUNTIME_DIR:?missing DEV_SANDBOX_XDG_RUNTIME_DIR}" + runtime_parent="$(dirname "$runtime_dir")" + runtime_grandparent="$(dirname "$runtime_parent")" + gui_mounts+=( + --dir "$runtime_grandparent" + --dir "$runtime_parent" + --dir "$runtime_dir" + --bind "$DEV_SANDBOX_WAYLAND_SOCKET" "$DEV_SANDBOX_WAYLAND_SOCKET" + --setenv XDG_RUNTIME_DIR "$runtime_dir" + --setenv WAYLAND_DISPLAY "${DEV_SANDBOX_WAYLAND_DISPLAY:?missing DEV_SANDBOX_WAYLAND_DISPLAY}" + ) +fi + +# How the sandbox gets a usable runtime, and where its own shims go. +# +# On Nix, every binary lives under /nix/store, so the sandbox can own /bin, +# /lib64 and /usr/bin outright and fill them with symlinks into the store. +# +# Elsewhere the runtime IS /usr, /bin, /lib, /lib64 -- so binding the +# sandbox's near-empty versions over them hides the real thing, and bwrap +# dies with `execvp /usr/bin/bash: No such file or directory`. Keep the host +# directories read-only and override only the individual files we shim. +# +# The same answer decides how /etc is handled further down. +if [ -d /nix ] && [[ "$(readlink -f "$DEV_SANDBOX_BASH")" == /nix/* ]]; then + USE_HOST_RUNTIME=false +else + USE_HOST_RUNTIME=true +fi + +runtime_mounts=() +shim_mounts=() +if [ "$USE_HOST_RUNTIME" = false ]; then + runtime_mounts+=(--ro-bind /nix /nix) + shim_mounts+=( + --dir /usr + --dir /bin + --dir /lib64 + --bind "$DEV_SANDBOX_ROOT/root/bin" /bin + --bind "$DEV_SANDBOX_ROOT/root/lib64" /lib64 + --bind "$DEV_SANDBOX_ROOT/root/usr/bin" /usr/bin + ) +else + for path in /usr /bin /sbin /lib /lib64; do + [ -e "$path" ] && runtime_mounts+=(--ro-bind "$path" "$path") + done + # The git-upload-pack shim standing in for github.com is the only file that + # must beat the host's copy; sh/ls/env are already there for real. + shim_mounts+=(--bind "$DEV_SANDBOX_ROOT/root/usr/bin/ssh" /usr/bin/ssh) +fi + +# /etc: start from a copy of the host's and overwrite only the files we fake. +# +# Replacing the whole directory with a five-file one is the tempting shortcut +# and it is wrong: a distro puts things under /etc that binaries outside /etc +# depend on, so hiding all of it breaks tools that look fine on PATH. Two real +# examples, both Debian/Ubuntu: openssl's compiled-in openssl.cnf is a symlink +# into /etc/ssl, and /usr/bin/awk is a symlink to /etc/alternatives/awk -- with +# /etc replaced, openssl cannot mint a certificate and awk reports "not found". +# Those are two symptoms of one cause, and nothing says there are only two. +# +# Copying rather than mount-overlaying the individual files, because several of +# these are symlinks in the wild (resolv.conf -> ../run/systemd/... on Ubuntu, +# hosts and nsswitch.conf -> /etc/static/... on NixOS) and bwrap cannot bind a +# file onto a symlink whose target does not exist inside the sandbox. +# +# Symlinks are copied as symlinks, never dereferenced: on NixOS /etc/static +# points into the store and following it would copy gigabytes per sandbox. The +# store is already mounted at /nix on that path, and the host runtime dirs are +# mounted at their own paths, so absolute symlinks still resolve. +# +# The five we override, and why each must differ from the host's: +# passwd, group the sandbox identity, which does not exist on the host +# resolv.conf slirp4netns's DNS, not the host resolver +# nsswitch.conf files+dns only, so nothing consults host NSS modules +# hosts minimal, so no host entry leaks in +# +# os-release is removed rather than replaced. Installers branch on it to reach +# for a package manager -- `install.sh` reads ID from it and, on debian/ubuntu, +# offers to apt-get build tools, prompting on /dev/tty when sudo exists but is +# not passwordless. That prompt cannot be satisfied here (no terminal) and it is +# fatal under `set -e`. Inheriting the host's file would make the sandbox claim +# to be a distro whose package manager it cannot actually use; absent means +# DISTRO="unknown" and the apt path is skipped, which is the truth. +etc_mounts=() +if [ "$USE_HOST_RUNTIME" = true ] && [ -d /etc ]; then + sandbox_etc="$DEV_SANDBOX_ROOT/etc-merged" + rm -rf -- "$sandbox_etc" + mkdir -p "$sandbox_etc" + # -a keeps symlinks as symlinks; unreadable entries (shadow, sudoers) are + # skipped rather than failing the run. + cp -a /etc/. "$sandbox_etc/" 2>/dev/null || true + for etc_file in passwd group resolv.conf nsswitch.conf hosts; do + [ -f "$DEV_SANDBOX_ROOT/etc/$etc_file" ] || continue + rm -f "$sandbox_etc/$etc_file" + cp "$DEV_SANDBOX_ROOT/etc/$etc_file" "$sandbox_etc/$etc_file" + done + rm -f "$sandbox_etc/os-release" "$sandbox_etc/lsb-release" + etc_mounts+=(--ro-bind "$sandbox_etc" /etc) +else + etc_mounts+=(--bind "$DEV_SANDBOX_ROOT/etc" /etc) +fi + +# /dev without a tty, so a script guarding on `[ -e /dev/tty ]` takes its +# no-terminal path. +# +# bwrap's --dev creates a /dev/tty NODE, but nothing in here has a controlling +# terminal, so opening it fails with "No such device or address". That is the +# worst of both: the guard passes and the read then fails. Under `set -e` -- +# which install.sh uses -- a failed read inside a function aborts the whole +# installer, which is exactly how older releases died here while prompting for +# sudo to install ripgrep/ffmpeg. +# +# Making the tty real is not the fix: with an openable terminal that prompt +# blocks forever waiting for input nobody will type. Absent is what a headless +# machine looks like, and what every prompt in here should assume. +# +# --dev cannot be used with the node removed afterwards (bwrap refuses to mount +# a directory over a device node), so /dev is assembled explicitly. +dev_mounts=( + --tmpfs /dev + --dev-bind /dev/null /dev/null + --dev-bind /dev/zero /dev/zero + --dev-bind /dev/full /dev/full + --dev-bind /dev/random /dev/random + --dev-bind /dev/urandom /dev/urandom + --symlink /proc/self/fd /dev/fd + --symlink /proc/self/fd/0 /dev/stdin + --symlink /proc/self/fd/1 /dev/stdout + --symlink /proc/self/fd/2 /dev/stderr +) +if [ "$DEV_SANDBOX_INTERACTIVE" = true ]; then + # An interactive shell is deliberately given a terminal; keep bwrap's /dev. + dev_mounts=(--dev /dev) +fi + +exec bwrap \ + --unshare-pid \ + --die-with-parent --proc /proc --tmpfs /tmp \ + "${dev_mounts[@]}" \ + "${gui_mounts[@]}" \ + "${runtime_mounts[@]}" \ + --bind "$DEV_SANDBOX_ROOT/root" /work \ + "${shim_mounts[@]}" \ + --bind "$DEV_SANDBOX_ROOT/root/usr/local" /usr/local \ + "${home_mounts[@]}" \ + "${etc_mounts[@]}" \ + --chdir /work/repo \ + --clearenv \ + --setenv PATH "$DEV_SANDBOX_HOME/.local/bin:/usr/local/bin:/usr/bin:$PATH" \ + --setenv HOME "$DEV_SANDBOX_HOME" \ + --setenv USER "$DEV_SANDBOX_USER" \ + --setenv LOGNAME "$DEV_SANDBOX_USER" \ + --setenv CURL_CA_BUNDLE /work/certs/ca.pem \ + --setenv SSL_CERT_FILE /work/certs/ca.pem \ + --setenv GIT_SSL_CAINFO /work/certs/ca.pem \ + --setenv NODE_EXTRA_CA_CERTS /work/certs/real-ca.pem \ + --setenv OPENSSL_CONF /work/certs/openssl.cnf \ + --setenv HTTP_PROXY http://127.0.0.1:8080 \ + --setenv HTTPS_PROXY http://127.0.0.1:8080 \ + --setenv ALL_PROXY http://127.0.0.1:8080 \ + --setenv NO_PROXY '' \ + --setenv DEV_SANDBOX_INTERACTIVE "$DEV_SANDBOX_INTERACTIVE" \ + --setenv ELECTRON_DISABLE_SANDBOX 1 \ + "${node_env[@]}" \ + "${electron_env[@]}" \ + -- "$DEV_SANDBOX_BASH" -ceu ' + python3 /work/proxy.py /work/http /work/certs /work/certs/real-ca.pem >/work/logs/proxy.log 2>&1 & + proxy_pid=$! + cleanup() { + kill "$proxy_pid" 2>/dev/null || true + wait "$proxy_pid" 2>/dev/null || true + } + trap cleanup EXIT INT TERM + # Bash opens /dev/tcp itself, so the readiness probe needs no netcat -- + # one less binary the sandbox has to find on the host (GitHub runners + # ship no `nc`). + proxy_up() { (exec 3<>/dev/tcp/127.0.0.1/8080) 2>/dev/null; } + for _ in $(seq 1 100); do + proxy_up && break + sleep 0.05 + done + if ! proxy_up; then + echo "error: the sandbox fake-internet proxy never came up" >&2 + cat /work/logs/proxy.log >&2 || true + exit 1 + fi + "$@" + ' sandbox-command "$@" From 3d9ec4d62edcc0361edb8d9bddbe57ed0f398d02 Mon Sep 17 00:00:00 2001 From: ethernet Date: Tue, 4 Aug 2026 01:12:11 -0400 Subject: [PATCH 14/15] test(install): prove updating from a release reaches this commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing covered the update path, which is the worst thing to break: a broken updater strands users on the version that cannot fix itself. `hermes update` alone is ~2000 lines (hermes_cli/update_cmd.py) and had no end-to-end test. tests/install/install-update-e2e.sh installs a genuine earlier Hermes through the real one-liner (curl -fsSL https://…/install.sh | bash, served by dev-sandbox's MITM proxy at the canonical URL, cloning "github.com" through the upload-pack shim), which really installs uv, a managed Python, Node and the venv. It then applies ONE update route and requires the checkout to land on this commit with `hermes --version` still working -- so a pass means the venv and entry point survived, not merely that git moved. One route per run, each on a sandbox built from scratch. Sharing one install across routes -- or rewinding with `git reset --hard` between them -- leaves the second route running against a tree the first already updated (same venv, same console script, same __pycache__), which is not the state any real user is in: a route could pass only because its predecessor did the work, and a failure in the first left the second exercising something undefined. --install-ref chooses what to install first, so this covers "update from an older release", not just from the tip. Installer flags are probed against the target rather than assumed, because releases from months back predate flags current Hermes takes for granted: --skip-browser is read out of that ref's own install.sh, and `--yes` is asked of the installed `hermes update --help` (the update subcommand has lived in main.py, subcommands/update.py and update_cmd.py across the tags we sample, so a static parse rots silently -- and did). Without those probes, old releases die on "Unknown option: --skip-browser" and "unrecognized arguments: --yes" before doing any work. Installer output is streamed through tee rather than captured: a real install of uv, Python, Node and the venv IS the substance of this test, so it belongs in the job log, not only in an artifact. pipefail keeps the installer's exit status rather than tee's, so a failed install cannot look like a pass. The sandbox's own proxy log is printed in full on failure, since a rejected TLS handshake explains a failure that otherwise reads as a bare `curl: (35)`. Deliberately reuses dev-sandbox rather than adding a second harness. An earlier draft rewrote install.sh's hardcoded URLs with insteadOf and ran it against the host; that tested the installer LESS faithfully (bash install.sh instead of the real one-liner, host libs instead of a clean machine, ssh disabled to keep a failed rewrite from reaching real GitHub) while duplicating a fake Internet we already have. Shell, not pytest, so scripts/run_tests.sh and run_tests_parallel.py stay untouched: a pytest version needed an entry in the former's `env -i` credential allowlist and a _SKIP_PARTS exclusion in the latter, and every meaningful line was a command run inside the sandbox anyway. Two guards, both earned during bring-up. It prefers the `sandbox` wrapper and falls back to the raw script only when bwrap is on PATH (under Nix the wrapper supplies the PATH and DEV_SANDBOX_* vars, so the bare script exits 127). And it refuses to run on a dirty worktree: every dev-sandbox invocation re-derives fake main from the working copy, so uncommitted changes move the update target between the call that installs and the call that verifies -- a failure that looks like a broken updater but is a moving reference. --- .gitignore | 3 + tests/install/install-update-e2e.sh | 293 ++++++++++++++++++++++++++++ 2 files changed, 296 insertions(+) create mode 100755 tests/install/install-update-e2e.sh diff --git a/.gitignore b/.gitignore index 55a2ab8aad..283c8a9164 100644 --- a/.gitignore +++ b/.gitignore @@ -145,6 +145,9 @@ docs/superpowers/* # Persistent dev sandbox dir (scripts/dev-sandbox.sh --persistent) .hermes-sandbox/ +# Sandbox dirs used by the install/update E2E (tests/install/). The suffix is +# the route name, so each route gets its own tree and two can run at once. +.hermes-sandbox-e2e*/ # Interrupted-update breadcrumb + recovery lock written next to the shared venv # by `hermes update` / launch-time self-heal. Runtime state, never a code change diff --git a/tests/install/install-update-e2e.sh b/tests/install/install-update-e2e.sh new file mode 100755 index 0000000000..80f257907b --- /dev/null +++ b/tests/install/install-update-e2e.sh @@ -0,0 +1,293 @@ +#!/usr/bin/env bash +# Prove a user on some earlier commit can reach this one. +# +# Installs a real, earlier Hermes the way a user does, applies ONE update route, +# and requires the checkout to land on this commit with a working `hermes`. +# +# Nothing here is mocked. scripts/dev-sandbox.sh provides the fake Internet -- +# a bubblewrap sandbox with no writable host mounts, a MITM proxy serving the +# canonical install.sh URL, and a git-upload-pack shim standing in for +# github.com -- so `install.sh` really installs uv, a managed Python, Node and +# the venv, cloning "github.com" over the ssh-first path a user hits. +# +# One route per run, on a sandbox built from scratch, because the routes are only +# meaningful from a pristine install. Sharing one install across routes -- or +# rewinding the checkout with `git reset --hard` between them -- leaves the +# second route running against a tree the first already updated (same venv, same +# installed console script, same __pycache__), which is not the state any real +# user is in: a route can then pass only because its predecessor did the work, +# and a failure in the first leaves the second exercising something undefined. +# If you add a route, give it its own run. +# +# Usage: +# tests/install/install-update-e2e.sh --route update|installer +# [--install-ref REF] [--keep] +# +# --route which update path to exercise (required): +# update `hermes update` +# installer re-running the curl one-liner over the checkout +# --install-ref what to install first; anything git resolves (a branch, a +# tag like v2026.7.7, or a SHA reachable from main). +# Default: refs/heads/main. +# +# Requires a CLEAN worktree: every dev-sandbox invocation re-derives fake main +# from the working copy, so uncommitted changes move the update target between +# the call that installs and the call that verifies. + +set -euo pipefail + +ROUTE="" +INSTALL_REF="refs/heads/main" +KEEP=false +while [ "$#" -gt 0 ]; do + case "$1" in + --route) + [ "$#" -ge 2 ] || { echo 'error: --route needs a value' >&2; exit 1; } + ROUTE="$2"; shift 2 ;; + --install-ref) + [ "$#" -ge 2 ] || { echo 'error: --install-ref needs a value' >&2; exit 1; } + INSTALL_REF="$2"; shift 2 ;; + --keep) KEEP=true; shift ;; + -h|--help) sed -n '2,35p' "$0"; exit 0 ;; + *) echo "error: unknown argument: $1" >&2; exit 1 ;; + esac +done +case "$ROUTE" in + update|installer) ;; + '') echo 'error: --route is required (update or installer)' >&2; exit 1 ;; + *) echo "error: unknown route: $ROUTE (want update or installer)" >&2; exit 1 ;; +esac + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +# Keep sandbox state out of the default .hermes-sandbox so a run never clobbers +# a developer's own sandbox, and scope it per route so two routes can run +# concurrently (CI runs them as parallel matrix legs). dev-sandbox.sh joins this +# onto the worktree root and feeds it to `tar --exclude`, so it MUST be a +# relative directory name. +SANDBOX_DIR_NAME=".hermes-sandbox-e2e-$ROUTE" +export HERMES_DEV_SANDBOX_DIR="$SANDBOX_DIR_NAME" + +SANDBOX_ROOT="$REPO_ROOT/$SANDBOX_DIR_NAME" +INSTALL_DIR="/home/hermes/.hermes/hermes-agent" # user-level layout (sandbox default) +FAKE_REMOTE="/work/repos/hermes-agent.git" +# Only used to fetch an old install.sh for the flag probe below; the sandbox does +# its own fetching. Same override dev-sandbox.sh honours, so a fork can retarget +# both together. +UPSTREAM_URL="${HERMES_DEV_SANDBOX_UPSTREAM:-https://github.com/NousResearch/hermes-agent.git}" + +# Installer transcripts live outside the sandbox root: the sandbox is recreated +# and (unless --keep) deleted, and these logs are the most useful artifact when +# a real install breaks. Created after the dirty check below, so that a log dir +# pointed inside the repo cannot be the thing that makes the tree dirty. +LOG_DIR="${HERMES_E2E_LOG_DIR:-$(mktemp -d -t hermes-install-e2e-logs.XXXXXX)}" + +step() { printf '\n\033[1;36m▶ %s\033[0m\n' "$*"; } +ok() { printf '\033[1;32m ✓ %s\033[0m\n' "$*"; } +fail() { printf '\n\033[1;31m✗ %s\033[0m\n' "$*" >&2; exit 1; } + +# The sandbox's internal logs (fake-internet proxy, slirp) explain failures that +# happen BEFORE install.sh gets to say anything -- a TLS handshake the proxy +# rejected looks like a bare `curl: (35)` from outside. Copy them out where a CI +# artifact upload can find them, and echo the proxy log since it is the usual +# culprit. +collect_sandbox_logs() { + # Separate `local` statements on purpose: a single `local a=$1 b="$a"` does + # NOT see the earlier assignment, so under `set -u` the second expansion dies + # with "a: unbound variable". + local tag="$1" + local src="$SANDBOX_ROOT/root/logs" + local dest="$LOG_DIR/sandbox-$tag" + [ -d "$src" ] || return 0 + mkdir -p "$dest" + cp -a "$src/." "$dest/" 2>/dev/null || true + # Print it, not just archive it: a rejected TLS handshake here is the whole + # explanation for a failure that otherwise reads as a bare `curl: (35)`, and + # whoever is reading the job log should not have to download an artifact to + # see it. In full, not tailed -- the file is short, and the useful line is not + # reliably at the end. + if [ -s "$dest/proxy.log" ]; then + echo "--- sandbox proxy.log ---" >&2 + cat "$dest/proxy.log" >&2 + echo "--- end proxy.log ---" >&2 + fi +} + +# ── preflight ────────────────────────────────────────────────────────────── +# Prefer the `sandbox` wrapper from the Nix devShell: it supplies both the PATH +# (bwrap, slirp4netns, openssl, ...) and the DEV_SANDBOX_* variables the script +# needs -- notably DEV_SANDBOX_DYNAMIC_LINKER, without which it cannot find a +# glibc loader on NixOS. Off Nix, the script is the entry point and finds its +# dependencies on the system PATH. +if command -v sandbox >/dev/null 2>&1; then + SANDBOX=(sandbox) +elif command -v bwrap >/dev/null 2>&1; then + SANDBOX=("$REPO_ROOT/scripts/dev-sandbox.sh") +else + fail 'no usable sandbox: enter the Nix devShell (for `sandbox`) or install bubblewrap' +fi + +if [ -n "$(git status --porcelain)" ]; then + printf '\033[1;31m✗ working tree is dirty:\033[0m\n' >&2 + git status --porcelain | sed 's/^/ /' >&2 + fail 'Every sandbox invocation re-snapshots the working copy into a new + fake-main commit, so the update target would move mid-run. Commit or stash + first. (If a path above is build or log output, it needs gitignoring or to + live outside the repo.)' +fi + +mkdir -p "$LOG_DIR" + +if [ "$KEEP" = false ]; then + trap 'rm -rf -- "$SANDBOX_ROOT"' EXIT INT TERM +fi +rm -rf -- "$SANDBOX_ROOT" + +# ── helpers ──────────────────────────────────────────────────────────────── +# Does the INSTALLED hermes accept FLAG on `hermes update`? +# +# Asked of the installed binary rather than parsed out of a release's source: +# the update subcommand has lived in main.py, subcommands/update.py, and +# update_cmd.py across the releases we sample, so any static parse is a guess +# that silently rots. `hermes update --help` is the same surface a user meets, +# and argparse prints every option it accepts. +update_supports() { + local flag="$1" + in_sandbox "hermes update --help 2>&1" | grep -qF -- "$flag" +} + +# Does the installer at REF accept FLAG? Read it out of that ref's own +# install.sh rather than assuming this checkout's flag set: the point of the +# matrix is to install releases from months back, whose installers predate +# options we take for granted. (Unlike the updater, the installer runs before +# anything is installed, so there is no --help to ask yet.) +# +# The ref may not be local -- the sandbox does its own fetching -- so fall back +# to fetching just that blob. Unresolvable means "flag absent", which costs a +# more conservative invocation, never a wrong one. +installer_supports() { + local ref="$1" + local flag="$2" + local script="" + script="$(git show "$ref:scripts/install.sh" 2>/dev/null)" || { + git fetch -q --depth 1 "$UPSTREAM_URL" "$ref" 2>/dev/null || return 1 + script="$(git show FETCH_HEAD:scripts/install.sh 2>/dev/null)" || return 1 + } + printf '%s' "$script" | grep -qF -- "$flag" +} + +# Run the real install one-liner inside the sandbox. `ref` non-empty installs +# that upstream commit and promotes THIS checkout to fake main afterwards, +# leaving the state a user is in when an update is waiting; empty serves this +# worktree's own installer and points fake main here. +install_in_sandbox() { + local what="$1" + local ref="$2" + local tag="$3" + local log="$LOG_DIR/$tag.log" + local args=(install --persistent) + [ -n "$ref" ] && args+=(--install-ref "$ref") + + # Installer flags have to match the installer being run, not this checkout's. + # Older releases reject options added later ("Unknown option: --skip-browser"), + # and this test deliberately installs releases from months back. --skip-setup + # goes back further than any tag we sample; anything newer is probed for. + local installer_flags=(--skip-setup) + if [ -z "$ref" ] || installer_supports "$ref" --skip-browser; then + installer_flags+=(--skip-browser) + fi + # Sandbox flags must precede `--`; the rest goes to install.sh. + args+=(-- "${installer_flags[@]}") + + # Stream the installer's output to stdout AND keep a copy on disk. It is the + # substance of this test -- a real install of uv, a managed Python, Node and + # the venv -- so it belongs in the job log where anyone reading the run can + # see it, not only in an artifact they have to download. The file copy is what + # the artifact upload keeps and what the failure paths grep. + # + # `set -o pipefail` is load-bearing here: without it the pipeline reports + # tee's status and a failed install looks like a pass. + local status=0 + "${SANDBOX[@]}" "${args[@]}" 2>&1 | tee "$log" || status=$? + + if [ "$status" -ne 0 ]; then + collect_sandbox_logs "$tag" + fail "$what failed (exit $status)" + fi + grep -q 'Installation Complete' "$log" \ + || { collect_sandbox_logs "$tag"; \ + fail "$what did not report a completed install"; } + ok "$what completed (log: $log)" +} + +in_sandbox() { "${SANDBOX[@]}" --persistent bash -lc "$1"; } + +# fake main's SHA is read fresh whenever it is needed, never cached across a +# sandbox invocation: each invocation re-derives it from the worktree. +sandbox_target() { in_sandbox "git --git-dir=$FAKE_REMOTE rev-parse main" | tr -d '[:space:]'; } +sandbox_head() { in_sandbox "cd $INSTALL_DIR && git rev-parse HEAD" | tr -d '[:space:]'; } + +require_landed_on_target() { + local what="$1" head target + head="$(sandbox_head)" + target="$(sandbox_target)" + [ "$head" = "$target" ] || fail "$what left HEAD at $head, wanted $target" + ok "$what landed on ${head:0:12}" +} + +# The real smoke test: goes through the venv launcher and imports the app, so it +# fails if the venv, dependencies, or entry point are broken. +require_hermes_works() { + local when="$1" out + out="$(in_sandbox "hermes --version" 2>&1)" \ + || { printf '%s\n' "$out" >&2; fail "hermes --version failed $when"; } + printf '%s\n' "$out" | sed 's/^/ /' + ok "hermes runs $when" +} + +# ── install the earlier Hermes ───────────────────────────────────────────── +step "installing upstream $INSTALL_REF (real curl | install.sh: uv, Python, Node, venv)" +install_in_sandbox "install of upstream $INSTALL_REF" "$INSTALL_REF" install + +BASE="$(sandbox_head)" +TARGET="$(sandbox_target)" +[ -n "$BASE" ] || fail "could not read the installed commit" +[ "$BASE" != "$TARGET" ] \ + || fail "install landed on the update target ($BASE); base and target must differ" +ok "installed ${BASE:0:12}; update target is ${TARGET:0:12}" +require_hermes_works 'after install' + +# ── apply exactly one update route ───────────────────────────────────────── +case "$ROUTE" in + update) + step 'ROUTE: hermes update' + # `--yes` reaches the update subcommand only in later releases, and argparse + # rejects the whole invocation when it does not exist. Ask the installed + # hermes which it accepts; older ones read the prompt from stdin, so close it. + if update_supports --yes; then + update_cmd="hermes update --yes" + else + update_cmd="hermes update Date: Tue, 4 Aug 2026 01:12:36 -0400 Subject: [PATCH 15/15] ci: test updating from sampled release tags, on tag + every 12h Wires tests/install/install-update-e2e.sh into CI as a reusable workflow plus a caller that fans out over real releases, because that is the question users care about: can someone on a version they actually installed get to this commit? install-e2e-run.yml takes `route` and `install-ref`, so the combinations that matter are expressible without duplicating runner setup. Each leg is independent -- its own runner, its own sandbox, its own install, nothing shared or rewound. The starting versions are chosen at runtime by scripts/sandbox/pick-release-tags.sh: newest, oldest, and an evenly spaced spread between (5 by default). Choosing at runtime rather than hardcoding keeps the matrix honest -- a pinned list stops covering the newest release the day after it ships, and pins an "oldest" long after anyone still runs it. Newest catches "did the last release break updating?", oldest is the longest upgrade jump still possible, and the spread samples the migrations in between (config-schema bumps, venv layout changes, dependency floors). Tags are read from the checkout with `git tag --list`, not `git ls-remote`: the job has the repository already, so this needs no network, works offline and on a fork, and takes 8ms. The repo is derived from the script's own resolved path rather than $PWD, so a copy cannot silently report a different checkout's tags. The pick-releases job takes the checkout that suits it -- blob:none filter, sparse-checkout of just that script, and fetch-tags, since tags are the entire input and the default shallow checkout has none. Triggers match the shape of the work: * every 12 hours, so upstream drift (a new uv, a Node bump, a PyPI change) surfaces on a schedule instead of in someone's review cycle; * on release tags, the moment the set of versions users can update FROM changes and the moment a broken updater would strand them; * manually, with the route and the sample size as inputs. Not on pull_request: a leg is ~9 minutes of real toolchain installation and the matrix multiplies it. fail-fast is off so one broken release does not mask the others, and max-parallel caps the fan-out so a run does not hammer the runners or PyPI. The tag list is resolved once and shared by both route matrices, so the two routes cover the same versions. Artifact names include the sanitized install-ref, since a matrix runs the reusable workflow several times per route and same-named artifacts collide; that name is built in a step because Actions expressions have no string-replace function. The name step runs with `if: always()`, since a failing leg is exactly when its logs are wanted. .gitignore covers .hermes-sandbox-e2e*/ rather than the bare directory: the per-route sandbox trees (-update, -installer) fell outside it, so the sandbox made the worktree dirty and dev-sandbox reacted by snapshotting the working copy into a fresh fake-main commit on every invocation. --- .github/workflows/install-e2e-run.yml | 122 ++++++++++++++++++++++++++ .github/workflows/install-e2e.yml | 110 +++++++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 .github/workflows/install-e2e-run.yml create mode 100644 .github/workflows/install-e2e.yml diff --git a/.github/workflows/install-e2e-run.yml b/.github/workflows/install-e2e-run.yml new file mode 100644 index 0000000000..354ff6bd47 --- /dev/null +++ b/.github/workflows/install-e2e-run.yml @@ -0,0 +1,122 @@ +name: Install & Update E2E (reusable) + +# Runs ONE update route against ONE starting commit, in the dev sandbox, with a +# real install (uv, a managed Python, Node, the venv) behind it. +# +# Reusable so callers can fan out over the combinations that matter -- update +# from the tip vs. from an older release, `hermes update` vs. re-running the +# installer -- without duplicating the runner setup. Each leg is independent: +# its own sandbox, its own install, nothing rewound or shared. +# +# Call it: +# +# jobs: +# tip: +# uses: ./.github/workflows/install-e2e-run.yml +# with: +# route: update +# install-ref: refs/heads/main + +on: + workflow_call: + inputs: + route: + description: 'Update path to exercise: update (hermes update) or installer (re-run install.sh).' + required: true + type: string + install-ref: + description: 'What to install before updating: a branch, a tag (v2026.7.7), or a SHA reachable from main.' + required: false + type: string + default: refs/heads/main + runner: + description: 'Runner label.' + required: false + type: string + default: ubuntu-latest + timeout-minutes: + description: 'Job timeout. A cold run installs real toolchains twice.' + required: false + type: number + default: 45 + +permissions: + contents: read + +jobs: + e2e: + name: ${{ inputs.route }} from ${{ inputs.install-ref }} + runs-on: ${{ inputs.runner }} + timeout-minutes: ${{ inputs.timeout-minutes }} + + steps: + # Full history: the sandbox fetches the starting commit and the test + # compares against this commit, so a shallow clone is not enough. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + # bubblewrap + slirp4netns are what the sandbox is built on; util-linux + # supplies the `unshare` that builds the multi-uid userns for the + # user-level (non-root) install. + - name: Install sandbox dependencies + run: | + set -euo pipefail + sudo apt-get update -qq + sudo apt-get install -y -qq bubblewrap slirp4netns uidmap util-linux + + # Ubuntu 24.04 restricts unprivileged user namespaces through AppArmor, + # which is exactly what bwrap needs. Report the state before touching it + # so a future runner-image change is visible in the log rather than + # silently altering what this job proves. + - name: Permit unprivileged user namespaces + run: | + set -euo pipefail + echo "--- kernel userns settings (before)" + sysctl kernel.unprivileged_userns_clone 2>/dev/null || echo " (sysctl absent)" + sysctl kernel.apparmor_restrict_unprivileged_userns 2>/dev/null || echo " (sysctl absent)" + if sysctl -n kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + echo "--- subuid/subgid for $(id -un)" + grep "^$(id -un):" /etc/subuid /etc/subgid || echo " (none — sandbox will say so)" + + - name: Run install + update E2E + run: | + set -euo pipefail + tests/install/install-update-e2e.sh \ + --route '${{ inputs.route }}' \ + --install-ref '${{ inputs.install-ref }}' + env: + # Outside the workspace on purpose: the script creates this directory + # up front, and an untracked dir inside the repo makes the worktree + # dirty -- which dev-sandbox reacts to by snapshotting the working + # copy into a fresh fake-main commit on every invocation, moving the + # update target mid-run. + HERMES_E2E_LOG_DIR: ${{ runner.temp }}/e2e-logs + + # Artifact names cannot contain '/', and install-ref may be a full ref + # like refs/heads/main. GitHub Actions expressions have no string-replace + # function, so build the safe name here. Runs even on failure -- that is + # exactly when the logs are wanted. + - name: Build artifact name + if: always() + id: artifact + run: | + set -euo pipefail + safe_ref='${{ inputs.install-ref }}' + safe_ref="${safe_ref//\//-}" + echo "name=install-e2e-${{ inputs.route }}-${safe_ref}" >> "$GITHUB_OUTPUT" + + # The installer's own transcripts say far more than the assertion that + # tripped when a real install breaks. + - name: Upload installer logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + # Unique per leg: a matrix over releases runs this workflow several + # times per route, and same-named artifacts collide. + name: ${{ steps.artifact.outputs.name }}-${{ github.sha }} + path: ${{ runner.temp }}/e2e-logs + retention-days: 14 + if-no-files-found: ignore diff --git a/.github/workflows/install-e2e.yml b/.github/workflows/install-e2e.yml new file mode 100644 index 0000000000..03c9a1d0b8 --- /dev/null +++ b/.github/workflows/install-e2e.yml @@ -0,0 +1,110 @@ +name: Install & Update E2E + +# Can a user on a released version get to this commit? +# +# For each release we sample, a leg installs that release through the real +# `curl | install.sh` one-liner (uv, a managed Python, Node, the venv) inside +# scripts/dev-sandbox.sh, then applies one update route and requires the +# checkout to land on this commit with a working `hermes`. +# +# The starting versions are chosen at runtime from the repo's release tags +# (scripts/sandbox/pick-release-tags.sh): newest, oldest, and a spread between. +# A hardcoded list would stop covering the newest release the day after it +# ships, and would pin an "oldest" that nobody still runs. +# +# Triggers: +# * every 12 hours, so upstream drift (a new uv, a Node bump, a PyPI change) +# surfaces on a schedule rather than in someone's review cycle; +# * when a release tag is created -- the moment the set of versions users can +# update FROM changes, and the moment a broken updater would strand them; +# * manually, where you can pick the route and how many releases to sample. +# +# Deliberately NOT on pull_request: a leg takes ~11 minutes of real toolchain +# installation, and the matrix multiplies that. Updating is release-shaped work, +# so it is gated on releases and the clock instead. + +on: + workflow_dispatch: + inputs: + route: + description: 'Which update route to exercise.' + required: false + type: choice + default: both + options: [both, update, installer] + tag-count: + description: 'How many release tags to sample (newest, oldest, and a spread between).' + required: false + type: string + default: '5' + schedule: + # Every 12 hours, off the hour to avoid the top-of-hour runner crunch. + - cron: '20 7,19 * * *' + push: + tags: + # Release tags only: the repo also carries backup/* and one-off tags. + - 'v[0-9]+.[0-9]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+.[0-9]+' + +permissions: + contents: read + +concurrency: + group: install-e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Which released versions do we test updating FROM? Resolved once and shared + # by both route matrices, so the two routes cover the same set. + pick-releases: + name: Pick release tags + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + tags: ${{ steps.pick.outputs.tags }} + steps: + # This job only reads tag names and runs one script, so take the cheap + # checkout: no blobs (filter), no other files (sparse), but DO fetch tags + # -- they are the whole input, and the default shallow checkout has none. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + filter: blob:none + fetch-tags: true + sparse-checkout: scripts/sandbox/pick-release-tags.sh + sparse-checkout-cone-mode: false + - id: pick + run: | + set -euo pipefail + tags="$(scripts/sandbox/pick-release-tags.sh --count '${{ inputs.tag-count || 5 }}')" + echo "Testing updates from: $tags" + echo "tags=$tags" >> "$GITHUB_OUTPUT" + + # `hermes update` -- the route most users take. + update: + if: github.event_name != 'workflow_dispatch' || inputs.route != 'installer' + needs: pick-releases + strategy: + # One release breaking is worth knowing about even if another already + # failed, so let every leg report. + fail-fast: false + matrix: + install-ref: ${{ fromJSON(needs.pick-releases.outputs.tags) }} + uses: ./.github/workflows/install-e2e-run.yml + with: + route: update + install-ref: ${{ matrix.install-ref }} + + # Re-running the curl one-liner over an existing checkout: autostash + pull + # rather than the updater's own git handling. + installer: + if: github.event_name != 'workflow_dispatch' || inputs.route != 'update' + needs: pick-releases + strategy: + fail-fast: false + max-parallel: 3 + matrix: + install-ref: ${{ fromJSON(needs.pick-releases.outputs.tags) }} + uses: ./.github/workflows/install-e2e-run.yml + with: + route: installer + install-ref: ${{ matrix.install-ref }}