Merge latest skill metrics into client resource metrics

Signed-off-by: Alex Fournier <afournier@nvidia.com>
This commit is contained in:
Alex Fournier
2026-08-04 15:07:34 -07:00
58 changed files with 3807 additions and 212 deletions
+122
View File
@@ -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
+110
View File
@@ -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 }}
+3
View File
@@ -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
+22
View File
@@ -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
+38
View File
@@ -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
``/<id>``, 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
+16
View File
@@ -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
+1
View File
@@ -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),
@@ -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}
/>
<AddProfileButton label={p.newProfile} onClick={() => setCreateOpen(true)} />
<ImportProfileButton label={p.importProfile} />
</div>
) : (
<div
@@ -302,6 +304,7 @@ export function ProfileRail() {
)}
<AddProfileButton label={p.newProfile} onClick={() => setCreateOpen(true)} />
<ImportProfileButton label={p.importProfile} />
</div>
)}
@@ -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 (
<Tip label={label}>
<button
aria-label={label}
className="grid size-5 shrink-0 place-items-center rounded-[3px] text-(--ui-text-tertiary) opacity-55 transition hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100"
onClick={() => void runImportProfileFlow()}
type="button"
>
<Codicon name="cloud-download" size="0.75rem" />
</button>
</Tip>
)
}
// 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({
<Codicon name="edit" size="0.875rem" />
<span>{p.editSoul}</span>
</ContextMenuItem>
<ContextMenuItem onSelect={() => void runExportProfileFlow(label)}>
<Codicon name="package" size="0.875rem" />
<span>{p.exportProfile}</span>
</ContextMenuItem>
<ContextMenuItem
className="text-destructive focus:text-destructive"
onSelect={onDelete}
@@ -22,7 +22,14 @@ vi.mock('@/i18n', () => ({
t: {
common: { cancel: 'Cancel', close: 'Close', delete: 'Delete', save: 'Save' },
sidebar: {
projects: { menuAppearance: 'Appearance', noColor: 'No color' },
projects: {
menuAppearance: 'Appearance',
moveFailed: 'Could not move session',
moveNoProjects: 'No other projects',
movedTo: (name: string) => `Moved to ${name}`,
moveToProject: 'Move to project',
noColor: 'No color'
},
row: {
archive: 'Archive',
branchFrom: 'Branch from here',
@@ -50,6 +57,12 @@ vi.mock('@/lib/profile-color', () => ({ PROFILE_SWATCHES: [] }))
vi.mock('@/lib/session-export', () => ({ exportSession: vi.fn() }))
vi.mock('@/store/gateway', () => ({ activeGateway: vi.fn(() => null) }))
vi.mock('@/store/notifications', () => ({ notify: vi.fn(), notifyError: vi.fn() }))
vi.mock('@/store/projects', () => ({
$projectTree: atom<unknown[]>([]),
moveSessionToProject: vi.fn(),
projectIdForCwd: vi.fn(() => null),
projectRootCwd: vi.fn(() => '')
}))
vi.mock('@/store/session', () => ({
$activeSessionId: atom<null | string>(null),
$selectedStoredSessionId: atom<null | string>(null),
@@ -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 <kit.Item disabled>{p.moveNoProjects}</kit.Item>
}
return (
<>
{targets.map(node => (
<kit.Item
key={node.id}
onSelect={() => {
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}
</kit.Item>
))}
</>
)
}
function useSessionActions({
sessionId,
title,
@@ -355,6 +394,15 @@ function useSessionActions({
/>
<kit.Separator />
{workItems.map(item => renderActionItem(kit, item))}
<kit.Sub>
<kit.SubTrigger disabled={!sessionId}>
<Codicon name="folder" size="0.875rem" />
<span>{t.sidebar.projects.moveToProject}</span>
</kit.SubTrigger>
<kit.SubContent>
<MoveToProjectItems kit={kit} profile={profile} sessionId={sessionId} />
</kit.SubContent>
</kit.Sub>
{tabItems.length > 0 && (
<>
<kit.Separator />
+27 -1
View File
@@ -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
}
])
@@ -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<string>): 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 }
})
@@ -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()
})
})
@@ -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<HTMLElement>(`[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<HTMLElement>(`[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<HTMLElement>('[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()
}
})
@@ -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 = (
<PaneTab
@@ -444,11 +456,36 @@ export function TreeGroup({
key={paneId}
onClose={closeable ? () => 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 ? (
@@ -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')
}
}
@@ -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<string>
/** Range anchor: the last explicitly clicked tab (Chrome semantics). */
anchor: string
}
export const $tabSelection = atom<null | TabSelection>(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
}
@@ -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<HTMLDivElement, PaneTabProps>(function P
onPointerDown,
onPointerUp,
onClickCapture,
selected = false,
vertical = false,
side = 'left',
children,
@@ -81,9 +91,11 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(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
+6
View File
@@ -129,6 +129,12 @@ declare global {
}
readFileText: (filePath: string) => Promise<HermesReadFileTextResult>
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
/** Native save dialog; returns the chosen path or null on cancel. */
selectSavePath?: (options?: {
defaultPath?: string
filters?: Array<{ extensions: string[]; name: string }>
title?: string
}) => Promise<null | string>
writeClipboard: (text: string) => Promise<boolean>
readClipboard: () => Promise<string>
saveImageFromUrl: (url: string) => Promise<boolean>
+32
View File
@@ -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<ProfileSetupComman
})
}
/** Export a profile to a shareable .tar.gz on the backend's filesystem.
* `extraFiles` stages extra root-level files (desktop.json — the appearance/
* interface overlay) into the archive alongside the profile's own artifacts. */
export function exportProfileArchive(
name: string,
opts: { extraFiles?: Record<string, string>; 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<AnalyticsResponse> {
return window.hermesDesktop.api<AnalyticsResponse>({
...profileScoped(),
+8 -1
View File
@@ -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: {
+13 -1
View File
@@ -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: {
+8 -1
View File
@@ -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: {
+12
View File
@@ -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: {
+8 -1
View File
@@ -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: {
+13 -1
View File
@@ -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: {
@@ -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<unknown>(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')
})
})
+219
View File
@@ -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<string, unknown> = {}
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<string> {
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<string> {
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<null | string> {
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<null | string> {
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
}
}
+41 -1
View File
@@ -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<SidebarPr
}
}
interface WorkspaceMovePayload {
branch?: null | string
cwd?: string
git_repo_root?: null | string
}
// Re-home a stored session into another project's root folder — the fix for a
// chat created in the wrong directory. The backend replaces cwd + git identity
// (so the tree's grouping follows) and re-anchors any live agent bound to the
// row; here we mirror the move into the `$sessions` cache so both the flat list
// and the grouped tree reflect it before the next authoritative refresh.
export async function moveSessionToProject(
sessionId: string,
projectId: string,
profile?: null | string
): Promise<void> {
const cwd = projectRootCwd($projectTree.get().find(node => node.id === projectId))
if (!cwd) {
throw new Error(translateNow('sidebar.projects.moveNoFolder'))
}
const res = await gatewayRequest<WorkspaceMovePayload>('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[]
+20
View File
@@ -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<string, unknown>
/** 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().
+4
View File
@@ -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":
+2
View File
@@ -0,0 +1,2 @@
zyz619963502zyz
# PR #73449 salvage → #78815
+74
View File
@@ -363,6 +363,80 @@ class CLICommandsMixin:
print(f" Unknown subcommand: {subcmd}")
print(" Usage: /snapshot [list|create [label]|restore <id>|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 <profile> — export a named profile
/export [profile] -o <path> — 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 <archive>`.")
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 <archive.tar.gz> [--name <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 <archive.tar.gz> [--name <name>]")
return
name = parts[idx + 1]
parts = parts[:idx] + parts[idx + 2:]
if not parts:
print(" Usage: /import <archive.tar.gz> [--name <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.
+4
View File
@@ -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 <id>|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="<archive.tar.gz> [--name <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",
+3
View File
@@ -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:
+76
View File
@@ -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 ``/<name>`` — 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
+37 -5
View File
@@ -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 ``<base>.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)
+16
View File
@@ -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
+104
View File
@@ -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}")
+96 -5
View File
@@ -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
# =========================================================================
+1
View File
@@ -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)
);
+1 -4
View File
@@ -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
+5
View File
@@ -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
+124
View File
@@ -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} "$@"
'';
}
+24
View File
@@ -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.
+535 -142
View File
@@ -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] [--] <command...>
Usage: dev-sandbox.sh [options] [--] <command...>
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/<host>/<path>; 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 >/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 $?
+43
View File
@@ -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
+110
View File
@@ -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'
+237
View File
@@ -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:
* ``<root>/<host>/<path>`` 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 <fixture-root> <certs-dir> <real-ca-bundle>
"""
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()
+13
View File
@@ -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
+251
View File
@@ -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/<user> 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 "$@"
+33
View File
@@ -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) ──
+35
View File
@@ -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))
+50
View File
@@ -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"
)
@@ -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
+293
View File
@@ -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 </dev/null"
fi
if ! in_sandbox "cd $INSTALL_DIR && $update_cmd"; then
collect_sandbox_logs update
fail "hermes update failed ($update_cmd)"
fi
require_landed_on_target 'hermes update'
require_hermes_works 'after hermes update'
;;
installer)
step 'ROUTE: installer re-run over the existing checkout'
# No ref: serves this worktree's installer and points fake main at this
# checkout, which is what the re-run must land on.
install_in_sandbox 'installer re-run' '' reinstall
require_landed_on_target 'installer re-run'
require_hermes_works 'after installer re-run'
;;
esac
printf '\n\033[1;32m✓ install/update E2E passed (route: %s, from: %s)\033[0m\n' \
"$ROUTE" "$INSTALL_REF"
[ "$KEEP" = true ] && echo " sandbox kept at $SANDBOX_ROOT"
exit 0
+74
View File
@@ -747,6 +747,80 @@ def _(rid, params: dict) -> dict:
return _ok(rid, info)
@method("session.workspace.move")
def _(rid, params: dict) -> dict:
"""Re-home a STORED session's workspace into another folder/project.
Unlike ``session.cwd.set`` (which acts on a live runtime session by its UI
id), this targets a persisted row by ``session_key`` so the desktop can fix
a session that was created in the wrong directory — no live agent required.
The git branch/root columns are REPLACED (not merely enriched), because the
whole point of the move is to change which project claims the session; a
stale ``git_repo_root`` would keep it grouped under the project it left.
A live agent bound to the row follows through the runtime path too, so its
terminal/file tools re-anchor immediately; a mid-turn session refuses the
move rather than yanking the workspace out from under its tools.
"""
target = str(params.get("session_key") or "").strip()
if not target:
return _err(rid, 4007, "session_key required")
raw = str(params.get("cwd", "") or "").strip()
if not raw:
return _err(rid, 4016, "cwd required")
from hermes_constants import translate_cwd_for_wsl_backend
resolved = os.path.abspath(os.path.expanduser(translate_cwd_for_wsl_backend(raw)))
if not os.path.isdir(resolved):
return _err(rid, 4017, f"working directory does not exist: {raw}")
# Snapshot under the lock — concurrent RPCs mutate _sessions (same pattern
# as _cwd_for_session_key).
live = None
live_sid = ""
with _sessions_lock:
for sid, sess in list(_sessions.items()):
if sess.get("session_key") == target:
live, live_sid = sess, sid
break
if live is not None and live.get("running"):
return _err(rid, 4009, "session busy")
branch = _git_branch_for_cwd(resolved)
root = _git_common_repo_root_for_cwd(resolved)
with _profile_db(params) as db:
if db is None:
return _db_unavailable_error(rid, code=5007)
# A brand-new draft has no persisted row yet; the live re-home below
# still applies and the row inherits the cwd when it is first written.
row_exists = bool(db.get_session(target))
if not row_exists and live is None:
return _err(rid, 4007, "session not found")
if row_exists:
try:
db.update_session_cwd(
target, resolved, branch, root, replace_git_meta=True
)
except Exception as e:
return _err(rid, 5007, f"move failed: {e}")
if live is not None:
try:
_set_session_cwd(live, resolved)
except ValueError as e:
return _err(rid, 4017, str(e))
agent = live.get("agent")
info = _session_info(agent, live) if agent is not None else {
"cwd": resolved,
"branch": branch,
"project": _project_info_for_cwd(resolved),
"lazy": True,
}
_emit("session.info", live_sid, info)
return _ok(rid, {"cwd": resolved, "branch": branch, "git_repo_root": root})
@method("session.active_list")
def _(rid, params: dict) -> dict:
"""Return live TUI sessions in this gateway process.
+3
View File
@@ -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",