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 }} 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/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/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 2f5340d6e9..a082e2ba60 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} + ({ 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/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index da4ff828cb..b643a31c1b 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -39,7 +39,7 @@ import { useContributions } from '@/contrib/react/use-contributions' import { registry } from '@/contrib/registry' import { discoverRuntimePlugins } from '@/contrib/runtime-loader' import { sessionTitle as storedSessionTitle } from '@/lib/chat-runtime' -import { FileText, LayoutDashboard, PanelBottom, Terminal, Zap } from '@/lib/icons' +import { Download, FileText, LayoutDashboard, PanelBottom, Terminal, Upload, Zap } from '@/lib/icons' import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions' import { setYoloEnabled } from '@/lib/yolo-session' import { pruneComposerPopoutZones } from '@/store/composer-popout' @@ -56,6 +56,7 @@ import { SIDEBAR_MAX_WIDTH } from '@/store/layout' import { $previewOpenRequest, $previewTabs, closeRightRail } from '@/store/preview' +import { runExportProfileFlow, runImportProfileFlow } from '@/store/profile-share' import { $reviewOpen, closeReview, openReview, REVIEW_PANE_ID } from '@/store/review' import { $currentCwd, $selectedStoredSessionId, $sessions, $yoloActive, sessionMatchesStoredId } from '@/store/session' import { watchSessionPins } from '@/store/session-pin-sync' @@ -314,6 +315,31 @@ registry.registerMany([ keywords: ['keybinds', 'shortcuts', 'hotkeys', 'keyboard'], run: () => 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/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/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/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts index abfb76e34c..7392a04b05 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, @@ -1247,25 +1248,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' }) } } @@ -1277,11 +1314,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 +} 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/global.d.ts b/apps/desktop/src/global.d.ts index 548badf5e6..471b5c0dc6 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..79bb9aee36 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}`, @@ -2265,7 +2271,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 2ec60d2859..60a8d298ad 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1563,6 +1563,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}`, @@ -1876,6 +1882,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', @@ -2697,7 +2708,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 d1913d24d8..85444e123d 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1400,6 +1400,12 @@ export const ja = defineLocale({ search: 'ใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซใ‚’ๆคœ็ดข...', loading: 'ใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซใ‚’่ชญใฟ่พผใฟไธญ...', newProfile: 'ๆ–ฐใ—ใ„ใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซ', + importProfile: 'ใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซใ‚’ใ‚คใƒณใƒใƒผใƒˆโ€ฆ', + exportProfile: 'ใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซใ‚’ใ‚จใ‚ฏใ‚นใƒใƒผใƒˆโ€ฆ', + imported: 'ใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซใ‚’ใ‚คใƒณใƒใƒผใƒˆใ—ใพใ—ใŸ', + exported: 'ใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซใ‚’ใ‚จใ‚ฏใ‚นใƒใƒผใƒˆใ—ใพใ—ใŸ', + failedImport: 'ใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซใฎใ‚คใƒณใƒใƒผใƒˆใซๅคฑๆ•—ใ—ใพใ—ใŸ', + failedExport: 'ใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซใฎใ‚จใ‚ฏใ‚นใƒใƒผใƒˆใซๅคฑๆ•—ใ—ใพใ—ใŸ', allProfiles: 'ใ™ในใฆใฎใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซ', showAllProfiles: 'ใ™ในใฆใฎใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซใ‚’่กจ็คบ', switchToProfile: name => `${name} ใซๅˆ‡ใ‚Šๆ›ฟใˆ`, @@ -2523,7 +2529,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 e2a49f4234..e5d1fe4bce 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 @@ -1573,6 +1579,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 @@ -2293,6 +2304,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 bb71f3d094..452313c112 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1349,6 +1349,12 @@ export const zhHant = defineLocale({ search: 'ๆœๅฐ‹่จญๅฎšๆช”โ€ฆ', loading: 'ๆญฃๅœจ่ผ‰ๅ…ฅ่จญๅฎšๆช”โ€ฆ', newProfile: 'ๆ–ฐๅขž่จญๅฎšๆช”', + importProfile: 'ๅŒฏๅ…ฅ่จญๅฎšๆช”โ€ฆ', + exportProfile: 'ๅŒฏๅ‡บ่จญๅฎšๆช”โ€ฆ', + imported: '่จญๅฎšๆช”ๅทฒๅŒฏๅ…ฅ', + exported: '่จญๅฎšๆช”ๅทฒๅŒฏๅ‡บ', + failedImport: 'ๅŒฏๅ…ฅ่จญๅฎšๆช”ๅคฑๆ•—', + failedExport: 'ๅŒฏๅ‡บ่จญๅฎšๆช”ๅคฑๆ•—', allProfiles: 'ๅ…จ้ƒจ่จญๅฎšๆช”', showAllProfiles: '้กฏ็คบๅ…จ้ƒจ่จญๅฎšๆช”', switchToProfile: name => `ๅˆ‡ๆ›่‡ณ ${name}`, @@ -2443,7 +2449,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 79a9a1c143..e4f8cae934 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1757,6 +1757,12 @@ export const zh: Translations = { search: 'ๆœ็ดข้…็ฝฎๆกฃๆกˆโ€ฆ', loading: 'ๆญฃๅœจๅŠ ่ฝฝ้…็ฝฎๆกฃๆกˆโ€ฆ', newProfile: 'ๆ–ฐๅปบ้…็ฝฎๆกฃๆกˆ', + importProfile: 'ๅฏผๅ…ฅ้…็ฝฎๆกฃๆกˆโ€ฆ', + exportProfile: 'ๅฏผๅ‡บ้…็ฝฎๆกฃๆกˆโ€ฆ', + imported: '้…็ฝฎๆกฃๆกˆๅทฒๅฏผๅ…ฅ', + exported: '้…็ฝฎๆกฃๆกˆๅทฒๅฏผๅ‡บ', + failedImport: 'ๅฏผๅ…ฅ้…็ฝฎๆกฃๆกˆๅคฑ่ดฅ', + failedExport: 'ๅฏผๅ‡บ้…็ฝฎๆกฃๆกˆๅคฑ่ดฅ', allProfiles: 'ๅ…จ้ƒจ้…็ฝฎๆกฃๆกˆ', showAllProfiles: 'ๆ˜พ็คบๅ…จ้ƒจ้…็ฝฎๆกฃๆกˆ', switchToProfile: name => `ๅˆ‡ๆขๅˆฐ ${name}`, @@ -2070,6 +2076,11 @@ export const zh: Translations = { menuAddFolder: 'ๆทปๅŠ ๆ–‡ไปถๅคน', menuSetActive: '่ฎพไธบๆดปๅŠจ', menuDelete: 'ๅˆ ้™ค', + moveToProject: '็งปๅŠจๅˆฐ้กน็›ฎ', + movedTo: name => `ๅทฒ็งปๅŠจๅˆฐ ${name}`, + moveFailed: 'ๆ— ๆณ•็งปๅŠจไผš่ฏ', + moveNoFolder: '่ฏฅ้กน็›ฎๆฒกๆœ‰ๅฏ็งปๅ…ฅ็š„ๆ–‡ไปถๅคน', + moveNoProjects: 'ๆฒกๆœ‰ๅ…ถไป–้กน็›ฎ', reveal: 'ๅœจๆ–‡ไปถๅคนไธญๆ˜พ็คบ', copyPath: 'ๅคๅˆถ่ทฏๅพ„', removeFromSidebar: 'ไปŽไพง่พนๆ ็งป้™ค', @@ -2875,7 +2886,8 @@ export const zh: Translations = { layoutNamePlaceholder: fallback => `ๅธƒๅฑ€ๅ็งฐ๏ผˆ${fallback}๏ผ‰`, saveApply: 'ไฟๅญ˜ๅนถๅบ”็”จ', notExpressible: 'ๆญคๆŽ’ๅˆ—ไบ’็›ธๅ’ฌๅˆ๏ผˆ้ฃŽ่ฝฆๅฝข๏ผ‰โ€”โ€”ๆš‚ๆ— ๆณ•่กจ็คบไธบๅตŒๅฅ—ๆ‹†ๅˆ†', - zoneCount: count => `${count} ไธชๅŒบๅŸŸ` + zoneCount: count => `${count} ไธชๅŒบๅŸŸ`, + tabCount: count => `${count} ไธชๆ ‡็ญพ้กต` }, assistant: { 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/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[] 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(). 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/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/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", 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/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/hermes_cli/profiles.py b/hermes_cli/profiles.py index 4ed717668a..71acde626c 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,29 @@ def _default_export_ignore(root_dir: Path): return _ignore -def export_profile(name: str, output_path: str) -> Path: +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. + ``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 @@ -1912,9 +1935,16 @@ def export_profile(name: str, output_path: str) -> Path: 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: + 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,7 +1957,8 @@ def export_profile(name: str, output_path: str) -> Path: symlinks=True, ignore=_default_export_ignore(profile_dir), ) - result = shutil.make_archive(base, "gztar", tmpdir, "default") + _stage_extras(staged) + result = _make_profile_archive(base, tmpdir, "default") return Path(result) # Named profiles โ€” stage a filtered copy to exclude credentials @@ -1940,7 +1971,8 @@ def export_profile(name: str, output_path: str) -> Path: symlinks=True, ignore=lambda d, contents: _CREDENTIAL_FILES & set(contents), ) - result = shutil.make_archive(base, "gztar", tmpdir, canon) + _stage_extras(staged) + result = _make_profile_archive(base, 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}") diff --git a/hermes_state.py b/hermes_state.py index 9b8215390d..76f4ee2c33 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): @@ -5478,6 +5488,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 +6078,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/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/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/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 "$@" 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/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)) 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" + ) + 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 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 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",