mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 04:08:28 +08:00
Compare commits
28 Commits
dashboard-
...
plugin-sdk
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44ef0150ab | ||
|
|
f019a9c491 | ||
|
|
46ea0a184d | ||
|
|
49f1b9e4b4 | ||
|
|
c77c470d27 | ||
|
|
e114b31eda | ||
|
|
fd1ec8033d | ||
|
|
28f1590b7a | ||
|
|
ada04573a9 | ||
|
|
a23728dfcc | ||
|
|
9b43ab8de5 | ||
|
|
188e52db91 | ||
|
|
5005b79bc3 | ||
|
|
d0ea4caf7f | ||
|
|
6a2909fe5a | ||
|
|
9272e4019a | ||
|
|
feb50eee70 | ||
|
|
e0a999aa8a | ||
|
|
55a76ec669 | ||
|
|
d9f7e7ac81 | ||
|
|
e618cbee44 | ||
|
|
2f0ee66467 | ||
|
|
cbc1d901ba | ||
|
|
84eb5f1f89 | ||
|
|
e5472da584 | ||
|
|
3ab783a7bb | ||
|
|
06aa140fa1 | ||
|
|
9bdf01852a |
17
Dockerfile
17
Dockerfile
@@ -243,6 +243,23 @@ COPY --chmod=0755 docker/cont-init.d/02-reconcile-profiles /etc/cont-init.d/02-r
|
||||
|
||||
# ---------- Runtime ----------
|
||||
ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
|
||||
# Point the TUI launcher at the prebuilt bundle baked at build time (Layer 8:
|
||||
# `ui-tui && npm run build`). This makes _make_tui_argv take the prebuilt-bundle
|
||||
# fast path (`node --expose-gc /opt/hermes/ui-tui/dist/entry.js`) and skip the
|
||||
# _tui_need_npm_install / runtime `npm install` branch entirely — exactly the
|
||||
# nix/packaged-release path the launcher was designed for.
|
||||
#
|
||||
# Why this is required (not just an optimization): the root package-lock.json
|
||||
# describes the WHOLE monorepo workspace set (root + web + ui-tui + apps/*),
|
||||
# but the image only installs root/web/ui-tui (apps/* — the desktop app — is
|
||||
# never `npm install`ed here). So the actualized node_modules permanently
|
||||
# disagrees with the canonical lock, _tui_need_npm_install() returns True on
|
||||
# every launch, and the runtime `npm install` it triggers (a) can never
|
||||
# converge against the partial monorepo and (b) races itself across concurrent
|
||||
# embedded-chat (/api/pty) connections → ENOTEMPTY → the chat tab dies with a
|
||||
# 502 / "[session ended]". Pointing at the prebuilt bundle sidesteps the whole
|
||||
# check. (A separate launcher hardening is tracked independently.)
|
||||
ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
|
||||
ENV HERMES_HOME=/opt/data
|
||||
|
||||
# `docker exec` privilege-drop shim. When operators run
|
||||
|
||||
@@ -32,8 +32,58 @@ function bundledRuntimeImportCheck(platform = process.platform) {
|
||||
return platform === 'win32' ? 'import fastapi, uvicorn, winpty' : 'import fastapi, uvicorn, ptyprocess'
|
||||
}
|
||||
|
||||
const GPU_OVERRIDE_ON = new Set(['1', 'true', 'yes', 'on'])
|
||||
const GPU_OVERRIDE_OFF = new Set(['0', 'false', 'no', 'off'])
|
||||
|
||||
/**
|
||||
* Decide whether the app is being shown over a remote/forwarded display, where
|
||||
* Chromium's GPU compositor produces an unstable, flickering surface (it can't
|
||||
* present accelerated layers cleanly over the wire). Native local Windows/macOS
|
||||
* sessions composite locally and never hit this, so we only fall back to
|
||||
* software rendering when a remote display is detected.
|
||||
*
|
||||
* Returns a short reason string when GPU acceleration should be disabled, or
|
||||
* null to keep it enabled. `HERMES_DESKTOP_DISABLE_GPU` overrides detection
|
||||
* both ways (1/true/yes/on → always disable, 0/false/no/off → never disable).
|
||||
*
|
||||
* Pure + dependency-free so it can be unit-tested and called before app ready.
|
||||
*/
|
||||
function detectRemoteDisplay(options = {}) {
|
||||
const env = options.env ?? process.env
|
||||
const platform = options.platform ?? process.platform
|
||||
|
||||
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '').trim().toLowerCase()
|
||||
if (GPU_OVERRIDE_ON.has(override)) return 'override (HERMES_DESKTOP_DISABLE_GPU)'
|
||||
if (GPU_OVERRIDE_OFF.has(override)) return null
|
||||
|
||||
// Launched from an SSH session → the display is X11-forwarded or otherwise
|
||||
// remote. Covers the common `ssh user@box` + GUI-forwarding case.
|
||||
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return 'ssh-session'
|
||||
|
||||
if (platform === 'linux') {
|
||||
// X11 forwarding sets DISPLAY to "<host>:N" (e.g. "localhost:10.0"); a
|
||||
// local X server is ":0"/":1" with no host part before the colon.
|
||||
// NB: WSLg deliberately isn't treated as remote — it reports
|
||||
// GPU-accelerated vGPU surfaces locally and doesn't show the flicker.
|
||||
const display = String(env.DISPLAY || '')
|
||||
if (display.includes(':') && display.split(':')[0]) {
|
||||
return `x11-forwarding (DISPLAY=${display})`
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
// RDP sessions report SESSIONNAME like "RDP-Tcp#7"; the local console is
|
||||
// "Console".
|
||||
const sessionName = String(env.SESSIONNAME || '')
|
||||
if (/^rdp-/i.test(sessionName)) return `rdp (SESSIONNAME=${sessionName})`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
bundledRuntimeImportCheck,
|
||||
detectRemoteDisplay,
|
||||
isWindowsBinaryPathInWsl,
|
||||
isWslEnvironment
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@ const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
|
||||
const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||
const {
|
||||
bundledRuntimeImportCheck,
|
||||
detectRemoteDisplay,
|
||||
isWindowsBinaryPathInWsl,
|
||||
isWslEnvironment
|
||||
} = require('./bootstrap-platform.cjs')
|
||||
|
||||
test('isWslEnvironment detects WSL2 env vars on linux', () => {
|
||||
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true)
|
||||
@@ -28,6 +33,53 @@ test('bundledRuntimeImportCheck selects platform-specific import checks', () =>
|
||||
assert.equal(bundledRuntimeImportCheck('linux'), 'import fastapi, uvicorn, ptyprocess')
|
||||
})
|
||||
|
||||
test('detectRemoteDisplay keeps GPU on for local sessions', () => {
|
||||
// Plain local X11, Wayland, native Windows, native macOS — no remote signal.
|
||||
assert.equal(detectRemoteDisplay({ env: { DISPLAY: ':0' }, platform: 'linux' }), null)
|
||||
assert.equal(detectRemoteDisplay({ env: { WAYLAND_DISPLAY: 'wayland-0' }, platform: 'linux' }), null)
|
||||
assert.equal(detectRemoteDisplay({ env: { SESSIONNAME: 'Console' }, platform: 'win32' }), null)
|
||||
assert.equal(detectRemoteDisplay({ env: {}, platform: 'darwin' }), null)
|
||||
})
|
||||
|
||||
test('detectRemoteDisplay does not treat WSLg as remote', () => {
|
||||
// WSLg renders locally via vGPU and doesn't show the flicker, so a WSL
|
||||
// session with a local DISPLAY keeps hardware acceleration on.
|
||||
assert.equal(detectRemoteDisplay({ env: { WSL_DISTRO_NAME: 'Ubuntu', DISPLAY: ':0' }, platform: 'linux' }), null)
|
||||
assert.equal(detectRemoteDisplay({ env: { WSL_INTEROP: '/run/WSL/1_interop', DISPLAY: ':0' }, platform: 'linux' }), null)
|
||||
})
|
||||
|
||||
test('detectRemoteDisplay flags SSH sessions on any platform', () => {
|
||||
assert.equal(detectRemoteDisplay({ env: { SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' }, platform: 'linux' }), 'ssh-session')
|
||||
assert.equal(detectRemoteDisplay({ env: { SSH_CLIENT: '1.2.3.4 5 22' }, platform: 'darwin' }), 'ssh-session')
|
||||
assert.equal(detectRemoteDisplay({ env: { SSH_TTY: '/dev/pts/0' }, platform: 'win32' }), 'ssh-session')
|
||||
})
|
||||
|
||||
test('detectRemoteDisplay flags forwarded X11 displays but not local ones', () => {
|
||||
assert.match(String(detectRemoteDisplay({ env: { DISPLAY: 'localhost:10.0' }, platform: 'linux' })), /x11-forwarding/)
|
||||
assert.match(String(detectRemoteDisplay({ env: { DISPLAY: '192.168.1.5:0' }, platform: 'linux' })), /x11-forwarding/)
|
||||
assert.equal(detectRemoteDisplay({ env: { DISPLAY: ':1' }, platform: 'linux' }), null)
|
||||
})
|
||||
|
||||
test('detectRemoteDisplay flags RDP sessions', () => {
|
||||
assert.match(String(detectRemoteDisplay({ env: { SESSIONNAME: 'RDP-Tcp#7' }, platform: 'win32' })), /^rdp/)
|
||||
})
|
||||
|
||||
test('detectRemoteDisplay honors the HERMES_DESKTOP_DISABLE_GPU override both ways', () => {
|
||||
// Force-on even on a local display.
|
||||
assert.match(
|
||||
String(detectRemoteDisplay({ env: { HERMES_DESKTOP_DISABLE_GPU: '1', DISPLAY: ':0' }, platform: 'linux' })),
|
||||
/override/
|
||||
)
|
||||
// Force-off even over SSH (escape hatch when a remote display has working accel).
|
||||
assert.equal(
|
||||
detectRemoteDisplay({
|
||||
env: { HERMES_DESKTOP_DISABLE_GPU: 'false', SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' },
|
||||
platform: 'linux'
|
||||
}),
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
test('packaged electron entrypoints do not require unpackaged npm modules', () => {
|
||||
const electronDir = __dirname
|
||||
const entrypoints = ['main.cjs', 'preload.cjs', 'bootstrap-platform.cjs']
|
||||
|
||||
@@ -23,7 +23,7 @@ const net = require('node:net')
|
||||
const path = require('node:path')
|
||||
const { fileURLToPath, pathToFileURL } = require('node:url')
|
||||
const { execFileSync, spawn } = require('node:child_process')
|
||||
const { isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
const {
|
||||
@@ -73,6 +73,26 @@ const IS_MAC = process.platform === 'darwin'
|
||||
const IS_WINDOWS = process.platform === 'win32'
|
||||
const IS_WSL = isWslEnvironment()
|
||||
const APP_ROOT = app.getAppPath()
|
||||
|
||||
// Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU
|
||||
// compositor flicker — accelerated layers can't be presented cleanly over the
|
||||
// wire, so the window flashes during scroll/streaming/animation. Local
|
||||
// Windows/macOS (and WSLg, which renders locally via vGPU) composite on the
|
||||
// GPU and never see it. Fall back to software rendering when a remote display
|
||||
// is detected; it's rock-steady over the wire and the CPU cost is negligible
|
||||
// next to the connection's latency. Must run before app `ready` — these
|
||||
// switches only apply pre-launch. Override with HERMES_DESKTOP_DISABLE_GPU
|
||||
// (1/true → always disable, 0/false → keep GPU on).
|
||||
const REMOTE_DISPLAY_REASON = detectRemoteDisplay()
|
||||
if (REMOTE_DISPLAY_REASON) {
|
||||
app.disableHardwareAcceleration()
|
||||
// Belt-and-suspenders for X11/VNC, where the Viz compositor can still glitch
|
||||
// with only --disable-gpu: force compositing onto the CPU too.
|
||||
app.commandLine.appendSwitch('disable-gpu-compositing')
|
||||
console.log(
|
||||
`[hermes] remote display detected (${REMOTE_DISPLAY_REASON}); disabling GPU hardware acceleration to prevent flicker`
|
||||
)
|
||||
}
|
||||
const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..')
|
||||
|
||||
// Build-time install stamp -- the git ref this .exe was built against.
|
||||
@@ -2731,9 +2751,31 @@ function buildApplicationMenu() {
|
||||
{ role: 'forceReload' },
|
||||
{ role: 'toggleDevTools' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'resetZoom' },
|
||||
{ role: 'zoomIn' },
|
||||
{ role: 'zoomOut' },
|
||||
{
|
||||
label: 'Actual Size',
|
||||
accelerator: 'CommandOrControl+0',
|
||||
click: () => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.setZoomLevel(0) }
|
||||
},
|
||||
{
|
||||
label: 'Zoom In',
|
||||
accelerator: 'CommandOrControl+Plus',
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
const next = Math.min(mainWindow.webContents.getZoomLevel() + 0.1, 9)
|
||||
mainWindow.webContents.setZoomLevel(next)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Zoom Out',
|
||||
accelerator: 'CommandOrControl+-',
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
const next = Math.max(mainWindow.webContents.getZoomLevel() - 0.1, -9)
|
||||
mainWindow.webContents.setZoomLevel(next)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'togglefullscreen' }
|
||||
]
|
||||
@@ -2792,6 +2834,32 @@ function installPreviewShortcut(window) {
|
||||
})
|
||||
}
|
||||
|
||||
function installZoomShortcuts(window) {
|
||||
// Override Ctrl/Cmd + +/-/0 with half the default zoom step (0.1 vs 0.2).
|
||||
// The menu items handle this on macOS (where the menu is always present),
|
||||
// but on Linux/Windows the menu is null and Chromium's default handler
|
||||
// would use the full 0.2 step, so we intercept here for consistency.
|
||||
const ZOOM_STEP = 0.1
|
||||
window.webContents.on('before-input-event', (event, input) => {
|
||||
const mod = IS_MAC ? input.meta : input.control
|
||||
if (!mod || input.alt || input.shift) return
|
||||
|
||||
const key = input.key
|
||||
if (key === '0') {
|
||||
event.preventDefault()
|
||||
window.webContents.setZoomLevel(0)
|
||||
} else if (key === '=' || key === '+') {
|
||||
event.preventDefault()
|
||||
const next = Math.min(window.webContents.getZoomLevel() + ZOOM_STEP, 9)
|
||||
window.webContents.setZoomLevel(next)
|
||||
} else if (key === '-') {
|
||||
event.preventDefault()
|
||||
const next = Math.max(window.webContents.getZoomLevel() - ZOOM_STEP, -9)
|
||||
window.webContents.setZoomLevel(next)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function installContextMenu(window) {
|
||||
window.webContents.on('context-menu', (_event, params) => {
|
||||
const template = []
|
||||
@@ -3378,6 +3446,7 @@ function createWindow() {
|
||||
|
||||
installPreviewShortcut(mainWindow)
|
||||
installDevToolsShortcut(mainWindow)
|
||||
installZoomShortcuts(mainWindow)
|
||||
installContextMenu(mainWindow)
|
||||
mainWindow.webContents.setWindowOpenHandler(details => {
|
||||
openExternalUrl(details.url)
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
enqueueQueuedPrompt,
|
||||
type QueuedPromptEntry,
|
||||
removeQueuedPrompt,
|
||||
shouldAutoDrainOnSettle,
|
||||
updateQueuedPrompt
|
||||
} from '@/store/composer-queue'
|
||||
import { $messages } from '@/store/session'
|
||||
@@ -124,6 +125,12 @@ export function ChatBar({
|
||||
const draftRef = useRef(draft)
|
||||
const previousBusyRef = useRef(busy)
|
||||
const drainingQueueRef = useRef(false)
|
||||
// Set when the user explicitly interrupts the running turn via the Stop
|
||||
// button (busy + empty composer). It suppresses the next busy→false
|
||||
// auto-drain so an explicit Stop actually halts instead of immediately
|
||||
// firing the head of the queue. The queue is preserved; the user resumes
|
||||
// it deliberately via Cmd/Ctrl+K, Enter, or the per-row "send now" arrow.
|
||||
const userInterruptedRef = useRef(false)
|
||||
const urlInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const [urlOpen, setUrlOpen] = useState(false)
|
||||
@@ -414,6 +421,14 @@ export function ChatBar({
|
||||
const [trigger, setTrigger] = useState<TriggerState | null>(null)
|
||||
const [triggerActive, setTriggerActive] = useState(0)
|
||||
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
||||
// Set synchronously in keydown when the open trigger popover consumes a
|
||||
// navigation/control key (Arrow/Enter/Tab/Escape). The subsequent keyup must
|
||||
// NOT run refreshTrigger for that keypress: it never edits text, and for
|
||||
// Escape the keydown has already set trigger=null, so a keyup refresh would
|
||||
// re-detect the still-present `/` and instantly reopen the menu. A ref is
|
||||
// used instead of reading `trigger` in keyup because by keyup time React has
|
||||
// re-rendered and the handler closure sees the post-keydown state.
|
||||
const triggerKeyConsumedRef = useRef(false)
|
||||
|
||||
const refreshTrigger = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
@@ -442,7 +457,14 @@ export function ChatBar({
|
||||
const detected = detectTrigger(before ?? composerPlainText(editor))
|
||||
|
||||
setTrigger(detected)
|
||||
setTriggerActive(0)
|
||||
|
||||
// Only reset the highlight when the trigger actually changed (opened, or
|
||||
// the query/kind differs). Re-detecting the *same* trigger — e.g. on a
|
||||
// caret move (mouseup) or a stray refresh — must preserve the user's
|
||||
// current selection instead of snapping back to the first item.
|
||||
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
|
||||
setTriggerActive(0)
|
||||
}
|
||||
}, [trigger])
|
||||
|
||||
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
@@ -558,6 +580,7 @@ export function ChatBar({
|
||||
if (trigger && triggerItems.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx + 1) % triggerItems.length)
|
||||
|
||||
return
|
||||
@@ -565,6 +588,7 @@ export function ChatBar({
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
|
||||
|
||||
return
|
||||
@@ -572,6 +596,7 @@ export function ChatBar({
|
||||
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
const item = triggerItems[triggerActive]
|
||||
|
||||
if (item) {
|
||||
@@ -583,6 +608,7 @@ export function ChatBar({
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
closeTrigger()
|
||||
|
||||
return
|
||||
@@ -603,6 +629,18 @@ export function ChatBar({
|
||||
}
|
||||
|
||||
const handleEditorKeyUp = () => {
|
||||
// If this keyup belongs to a key the open trigger popover already consumed
|
||||
// in keydown (Arrow/Enter/Tab/Escape), skip the refresh. Those keys never
|
||||
// edit text, and for Escape the keydown already closed the menu — a refresh
|
||||
// here would re-detect the still-present `/` and instantly reopen it. We
|
||||
// read a ref set during keydown rather than `trigger`, because by keyup
|
||||
// time React has re-rendered and `trigger` may already be null.
|
||||
if (triggerKeyConsumedRef.current) {
|
||||
triggerKeyConsumedRef.current = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
}
|
||||
|
||||
@@ -844,26 +882,42 @@ export function ChatBar({
|
||||
[queueEdit, runDrain]
|
||||
)
|
||||
|
||||
const interruptAndSendNextQueued = useCallback(async () => {
|
||||
if (queuedPrompts.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
await Promise.resolve(onCancel())
|
||||
|
||||
return drainNextQueued()
|
||||
}, [drainNextQueued, onCancel, queuedPrompts.length])
|
||||
|
||||
// Auto-drain on busy → false (turn settled).
|
||||
// Auto-drain on busy → false (turn settled). An explicit user interrupt
|
||||
// (Stop button) sets userInterruptedRef so we skip exactly one auto-drain:
|
||||
// the user asked to halt, so we must not immediately re-send the queue.
|
||||
// The queued turns stay intact and the user resumes them on demand.
|
||||
useEffect(() => {
|
||||
const wasBusy = previousBusyRef.current
|
||||
previousBusyRef.current = busy
|
||||
|
||||
if (busy || !wasBusy || queuedPrompts.length === 0) {
|
||||
// Clear the interrupt latch when a new turn starts (false → true). This
|
||||
// guards the sub-frame race where a Stop click lands after busy already
|
||||
// flipped false (button not yet unmounted): the stale latch can no longer
|
||||
// survive into the next turn and wrongly suppress its natural auto-drain.
|
||||
if (busy && !wasBusy) {
|
||||
userInterruptedRef.current = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
void drainNextQueued()
|
||||
const interrupted = userInterruptedRef.current
|
||||
|
||||
// Consume the interrupt latch on any settle so a later natural completion
|
||||
// is not wrongly suppressed.
|
||||
if (!busy && wasBusy && interrupted) {
|
||||
userInterruptedRef.current = false
|
||||
}
|
||||
|
||||
if (
|
||||
shouldAutoDrainOnSettle({
|
||||
isBusy: busy,
|
||||
queueLength: queuedPrompts.length,
|
||||
userInterrupted: interrupted,
|
||||
wasBusy
|
||||
})
|
||||
) {
|
||||
void drainNextQueued()
|
||||
}
|
||||
}, [busy, drainNextQueued, queuedPrompts.length])
|
||||
|
||||
// Clean up queue edit when its target disappears (session swap or external delete).
|
||||
@@ -886,9 +940,13 @@ export function ChatBar({
|
||||
} else if (busy) {
|
||||
if (hasComposerPayload) {
|
||||
queueCurrentDraft()
|
||||
} else if (queuedPrompts.length > 0) {
|
||||
void interruptAndSendNextQueued()
|
||||
} else {
|
||||
// Stop button: an explicit interrupt must actually halt the running
|
||||
// turn. Mark the interrupt so the busy→false auto-drain effect skips
|
||||
// re-sending the queue — otherwise a queued follow-up would fire the
|
||||
// instant we cancel and Stop would appear to "never work". Queued
|
||||
// turns are preserved; the user sends them on demand.
|
||||
userInterruptedRef.current = true
|
||||
triggerHaptic('cancel')
|
||||
void Promise.resolve(onCancel())
|
||||
}
|
||||
|
||||
183
apps/desktop/src/app/chat/composer/slash-nav-dom-repro.test.tsx
Normal file
183
apps/desktop/src/app/chat/composer/slash-nav-dom-repro.test.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { act, fireEvent, render } from '@testing-library/react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useLiveCompletionAdapter } from './hooks/use-live-completion-adapter'
|
||||
import { detectTrigger, type TriggerState } from './text-utils'
|
||||
|
||||
// Faithful mirror of index.tsx's trigger wiring, driven through REAL DOM
|
||||
// keydown+keyup events on a contentEditable. Exercises the parts a direct
|
||||
// reducer-call repro misses: the keyup -> refreshTrigger path, the
|
||||
// keydown-set "consumed" ref that guards it, and per-press keydown+keyup
|
||||
// ordering (critical for Escape, whose keydown nulls `trigger` before keyup).
|
||||
function Harness({
|
||||
onState
|
||||
}: {
|
||||
onState: (s: { active: number; items: readonly Unstable_TriggerItem[]; open: boolean }) => void
|
||||
}) {
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
const triggerKeyConsumedRef = useRef(false)
|
||||
const [trigger, setTrigger] = useState<TriggerState | null>(null)
|
||||
const [triggerActive, setTriggerActive] = useState(0)
|
||||
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
||||
|
||||
const { adapter } = useLiveCompletionAdapter({
|
||||
enabled: true,
|
||||
debounceMs: 0,
|
||||
fetcher: async (query: string) => ({
|
||||
query,
|
||||
items: Array.from({ length: 5 }, (_, i) => ({ text: `/cmd${i}`, display: `/cmd${i}`, meta: '' }))
|
||||
}),
|
||||
toItem: (entry, index) => ({ id: `${entry.text}|${index}`, type: 'slash', label: entry.text.slice(1) })
|
||||
})
|
||||
|
||||
const triggerAdapter: Unstable_TriggerAdapter | null = trigger?.kind === '/' ? adapter : null
|
||||
|
||||
const refreshTrigger = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor) {return}
|
||||
const raw = editor.textContent ?? ''
|
||||
|
||||
if (!raw.includes('@') && !raw.includes('/')) {
|
||||
if (trigger) {
|
||||
setTrigger(null)
|
||||
setTriggerActive(0)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const detected = detectTrigger(raw)
|
||||
setTrigger(detected)
|
||||
|
||||
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
|
||||
setTriggerActive(0)
|
||||
}
|
||||
}, [trigger])
|
||||
|
||||
useEffect(() => {
|
||||
if (!trigger || !triggerAdapter?.search) {
|
||||
setTriggerItems([])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setTriggerItems(triggerAdapter.search(trigger.query))
|
||||
}, [trigger, triggerAdapter])
|
||||
|
||||
useEffect(() => {
|
||||
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
|
||||
}, [triggerItems.length])
|
||||
|
||||
onState({ active: triggerActive, items: triggerItems, open: trigger !== null })
|
||||
|
||||
const closeTrigger = () => {
|
||||
setTrigger(null)
|
||||
setTriggerItems([])
|
||||
setTriggerActive(0)
|
||||
}
|
||||
|
||||
// Exact copies of index.tsx handlers, including the keydown-set "consumed"
|
||||
// ref that the keyup consults.
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (trigger && triggerItems.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx + 1) % triggerItems.length)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
closeTrigger()
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = () => {
|
||||
if (triggerKeyConsumedRef.current) {
|
||||
triggerKeyConsumedRef.current = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// index.tsx defers via setTimeout(refreshTrigger, 0); call synchronously
|
||||
// here so the test deterministically observes the keyup-driven refresh.
|
||||
refreshTrigger()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
contentEditable
|
||||
data-testid="editor"
|
||||
onInput={() => refreshTrigger()}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
ref={editorRef}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
})
|
||||
}
|
||||
|
||||
describe('slash menu navigation — real DOM keydown+keyup', () => {
|
||||
it('cycles through ALL items and Esc closes (and stays closed)', async () => {
|
||||
vi.useRealTimers()
|
||||
let latest = { active: 0, items: [] as readonly Unstable_TriggerItem[], open: false }
|
||||
const { getByTestId } = render(<Harness onState={s => (latest = s)} />)
|
||||
const editor = getByTestId('editor')
|
||||
|
||||
// Simulate typing '/'.
|
||||
await act(async () => {
|
||||
editor.textContent = '/'
|
||||
fireEvent.input(editor)
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(latest.open).toBe(true)
|
||||
expect(latest.items.length).toBe(5)
|
||||
|
||||
// ArrowDown 6x with REAL keydown+keyup pairs. Bug = stuck [0,1,0,1,...].
|
||||
const seen: number[] = [latest.active]
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(editor, { key: 'ArrowDown' })
|
||||
fireEvent.keyUp(editor, { key: 'ArrowDown' })
|
||||
await Promise.resolve()
|
||||
})
|
||||
seen.push(latest.active)
|
||||
}
|
||||
|
||||
expect(seen).toEqual([0, 1, 2, 3, 4, 0, 1])
|
||||
|
||||
// Escape: keydown closes; keyup must NOT reopen (the '/' is still in text).
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(editor, { key: 'Escape' })
|
||||
fireEvent.keyUp(editor, { key: 'Escape' })
|
||||
await Promise.resolve()
|
||||
})
|
||||
await flush()
|
||||
expect(latest.open).toBe(false)
|
||||
})
|
||||
})
|
||||
25
apps/desktop/src/app/chat/composer/text-utils.test.ts
Normal file
25
apps/desktop/src/app/chat/composer/text-utils.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { detectTrigger } from './text-utils'
|
||||
|
||||
describe('detectTrigger', () => {
|
||||
it('detects a bare slash trigger with an empty query', () => {
|
||||
expect(detectTrigger('/')).toEqual({ kind: '/', query: '', tokenLength: 1 })
|
||||
})
|
||||
|
||||
it('detects a slash command query', () => {
|
||||
expect(detectTrigger('/skill')).toEqual({ kind: '/', query: 'skill', tokenLength: 6 })
|
||||
})
|
||||
|
||||
it('detects a bare at-mention trigger with an empty query', () => {
|
||||
expect(detectTrigger('@')).toEqual({ kind: '@', query: '', tokenLength: 1 })
|
||||
})
|
||||
|
||||
it('detects an at-mention query', () => {
|
||||
expect(detectTrigger('@file')).toEqual({ kind: '@', query: 'file', tokenLength: 5 })
|
||||
})
|
||||
|
||||
it('returns null for plain text', () => {
|
||||
expect(detectTrigger('hello there')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
$gatewayState,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
$workingSessionIds,
|
||||
mergeWorkingSessions,
|
||||
sessionPinId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
@@ -206,7 +208,12 @@ export function DesktopController() {
|
||||
const result = await listSessions(limit, 1)
|
||||
|
||||
if (refreshSessionsRequestRef.current === requestId) {
|
||||
setSessions(result.sessions)
|
||||
// Don't hard-replace: a session whose first turn is still in flight has
|
||||
// message_count 0 in the DB, so min_messages=1 omits it. Since every
|
||||
// message.complete refreshes the list, a plain replace would drop the
|
||||
// other still-running new chats the moment one of them finishes. Keep
|
||||
// any working session the server hasn't surfaced yet.
|
||||
setSessions(prev => mergeWorkingSessions(prev, result.sessions, $workingSessionIds.get()))
|
||||
setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length)
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -65,7 +65,7 @@ interface PromptActionsOptions {
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
busyRef: MutableRefObject<boolean>
|
||||
branchCurrentSession: () => Promise<boolean>
|
||||
createBackendSessionForSend: () => Promise<string | null>
|
||||
createBackendSessionForSend: (preview?: string | null) => Promise<string | null>
|
||||
handleSkinCommand: (arg: string) => string
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
selectedStoredSessionIdRef: MutableRefObject<string | null>
|
||||
@@ -296,7 +296,7 @@ export function usePromptActions({
|
||||
|
||||
if (!sessionId) {
|
||||
try {
|
||||
sessionId = await createBackendSessionForSend()
|
||||
sessionId = await createBackendSessionForSend(visibleText)
|
||||
} catch (err) {
|
||||
dropOptimistic(null)
|
||||
releaseBusy()
|
||||
|
||||
@@ -303,7 +303,7 @@ export function useSessionActions({
|
||||
[activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef]
|
||||
)
|
||||
|
||||
const createBackendSessionForSend = useCallback(async (): Promise<string | null> => {
|
||||
const createBackendSessionForSend = useCallback(async (preview: string | null = null): Promise<string | null> => {
|
||||
const startingActiveSessionId = activeSessionIdRef.current
|
||||
const startingStoredSessionId = selectedStoredSessionIdRef.current
|
||||
const startingRouteToken = getRouteToken()
|
||||
@@ -330,7 +330,11 @@ export function useSessionActions({
|
||||
ensureSessionState(created.session_id, stored)
|
||||
|
||||
if (stored) {
|
||||
upsertOptimisticSession(created, stored)
|
||||
// Seed the sidebar preview with the user's first message so the row
|
||||
// reads meaningfully while the turn is in flight, instead of flashing
|
||||
// "Untitled session" until the turn persists and auto-title runs. The
|
||||
// server later returns its own preview/title and supersedes this.
|
||||
upsertOptimisticSession(created, stored, null, preview?.trim() || null)
|
||||
navigate(sessionRoute(stored), { replace: true })
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,19 @@ export function useSessionStateCache({
|
||||
|
||||
const syncSessionStateToView = useCallback(
|
||||
(sessionId: string, state: ClientSessionState) => {
|
||||
// Only the currently-viewed session may stage into the shared `$messages`
|
||||
// view. A background session (e.g. one still busy and emitting stream /
|
||||
// error updates after the user toggled away) must update its own cache
|
||||
// entry but never the view — otherwise its messages clobber the
|
||||
// foreground transcript and appear to "bleed" into every other session.
|
||||
// The flush below also re-checks the active id, but staging here is what
|
||||
// prevents a background write from overwriting an already-pending
|
||||
// foreground write within the same animation frame (only one RAF is
|
||||
// scheduled, so the last `pendingViewStateRef` writer would otherwise win).
|
||||
if (sessionId !== activeSessionIdRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingViewStateRef.current = { sessionId, state }
|
||||
|
||||
if (viewSyncRafRef.current !== null) {
|
||||
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons'
|
||||
@@ -637,7 +638,7 @@ function messageAttachmentRefs(value: unknown): string[] {
|
||||
function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="group/user-message sticky top-0 z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-2"
|
||||
className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-2"
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
@@ -685,6 +686,32 @@ const UserMessage: FC<{
|
||||
return messageAttachmentRefs(custom.attachmentRefs)
|
||||
})
|
||||
|
||||
// Sticky human bubbles clamp to ~2 lines with a soft fade so a long prompt
|
||||
// doesn't dominate the viewport while the response streams underneath; the
|
||||
// clamp lifts on hover / focus (see styles.css). We measure the *unclamped*
|
||||
// inner wrapper so the ResizeObserver only fires on real content / width
|
||||
// changes, not on every frame while the outer max-height animates open.
|
||||
const clampInnerRef = useRef<HTMLDivElement | null>(null)
|
||||
const [bodyClamped, setBodyClamped] = useState(false)
|
||||
|
||||
const measureClamp = useCallback(() => {
|
||||
const inner = clampInnerRef.current
|
||||
const outer = inner?.parentElement
|
||||
|
||||
if (!inner || !outer) {
|
||||
return
|
||||
}
|
||||
|
||||
const styles = getComputedStyle(inner)
|
||||
const lineHeight = parseFloat(styles.lineHeight) || 1.5 * parseFloat(styles.fontSize) || 20
|
||||
const fullHeight = inner.scrollHeight
|
||||
|
||||
outer.style.setProperty('--human-msg-full', `${fullHeight}px`)
|
||||
setBodyClamped(fullHeight > lineHeight * 2 + 1)
|
||||
}, [])
|
||||
|
||||
useResizeObserver(measureClamp, clampInnerRef)
|
||||
|
||||
const hasBody = messageText.trim().length > 0
|
||||
const isLatestUser = messageId === latestUserId
|
||||
const showStop = isLatestUser && threadRunning && Boolean(onCancel)
|
||||
@@ -707,7 +734,11 @@ const UserMessage: FC<{
|
||||
// Render the user's text through a minimal markdown pipeline:
|
||||
// backtick `code` and ``` fenced ``` blocks, with directive chips
|
||||
// (`@file:` etc.) still resolved inside the plain-text spans.
|
||||
<UserMessageText className="wrap-anywhere" text={messageText} />
|
||||
<div className="sticky-human-clamp" data-clamped={bodyClamped ? 'true' : undefined}>
|
||||
<div ref={clampInnerRef}>
|
||||
<UserMessageText className="wrap-anywhere" text={messageText} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
@@ -842,6 +873,10 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
const [trigger, setTrigger] = useState<TriggerState | null>(null)
|
||||
const [triggerActive, setTriggerActive] = useState(0)
|
||||
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
||||
// See index.tsx: set in keydown when the open popover consumes a nav/control
|
||||
// key so the matching keyup skips refreshTrigger (timing-immune vs reading
|
||||
// `trigger`, which keyup sees as already-null after Escape).
|
||||
const triggerKeyConsumedRef = useRef(false)
|
||||
const [triggerPlacement, setTriggerPlacement] = useState<'bottom' | 'top'>('top')
|
||||
const [focusRequestId, setFocusRequestId] = useState(0)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
@@ -966,8 +1001,15 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
}
|
||||
|
||||
setTrigger(detected)
|
||||
setTriggerActive(0)
|
||||
}, [])
|
||||
|
||||
// Only reset the highlight when the trigger actually changed (opened, or
|
||||
// the query/kind differs). Re-detecting the *same* trigger — e.g. on a
|
||||
// caret move (mouseup) or a stray refresh — must preserve the user's
|
||||
// current selection instead of snapping back to the first item.
|
||||
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
|
||||
setTriggerActive(0)
|
||||
}
|
||||
}, [trigger])
|
||||
|
||||
const closeTrigger = useCallback(() => {
|
||||
setTrigger(null)
|
||||
@@ -1200,6 +1242,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
if (trigger && triggerItems.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx + 1) % triggerItems.length)
|
||||
|
||||
return
|
||||
@@ -1207,6 +1250,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
|
||||
|
||||
return
|
||||
@@ -1214,6 +1258,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
const item = triggerItems[triggerActive]
|
||||
|
||||
if (item) {
|
||||
@@ -1225,6 +1270,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
closeTrigger()
|
||||
|
||||
return
|
||||
@@ -1244,6 +1290,22 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = () => {
|
||||
// If this keyup belongs to a key the open trigger popover already consumed
|
||||
// in keydown (Arrow/Enter/Tab/Escape), skip the refresh. Those keys never
|
||||
// edit text, and for Escape the keydown already closed the menu — a refresh
|
||||
// here would re-detect the still-present `/` and instantly reopen it. We
|
||||
// read a ref set during keydown rather than `trigger`, because by keyup
|
||||
// time React has re-rendered and `trigger` may already be null.
|
||||
if (triggerKeyConsumedRef.current) {
|
||||
triggerKeyConsumedRef.current = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Root className="contents" data-slot="aui_edit-composer-root">
|
||||
<StickyHumanMessageContainer>
|
||||
@@ -1294,7 +1356,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
onFocus={() => markActiveComposer('edit')}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={() => window.setTimeout(refreshTrigger, 0)}
|
||||
onKeyUp={handleKeyUp}
|
||||
onMouseUp={refreshTrigger}
|
||||
onPaste={handlePaste}
|
||||
ref={editorRef}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
enqueueQueuedPrompt,
|
||||
getQueuedPrompts,
|
||||
removeQueuedPrompt,
|
||||
shouldAutoDrainOnSettle,
|
||||
updateQueuedPrompt,
|
||||
updateQueuedPromptText
|
||||
} from './composer-queue'
|
||||
@@ -100,3 +101,37 @@ describe('composer queue store', () => {
|
||||
expect(parsed[SESSION_KEY]?.[0]?.text).toBe('persist me')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldAutoDrainOnSettle', () => {
|
||||
const base = { isBusy: false, queueLength: 1, userInterrupted: false, wasBusy: true }
|
||||
|
||||
it('drains the next queued prompt when a turn completes naturally', () => {
|
||||
expect(shouldAutoDrainOnSettle(base)).toBe(true)
|
||||
})
|
||||
|
||||
it('does NOT drain when the user explicitly interrupted (Stop button)', () => {
|
||||
// Regression: previously the Stop button "never worked" because cancelling
|
||||
// a turn flipped busy → false and the queue immediately re-fired its head.
|
||||
expect(shouldAutoDrainOnSettle({ ...base, userInterrupted: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('does not drain when the queue is empty', () => {
|
||||
expect(shouldAutoDrainOnSettle({ ...base, queueLength: 0 })).toBe(false)
|
||||
})
|
||||
|
||||
it('does not drain when interrupted even if the queue is also empty', () => {
|
||||
expect(shouldAutoDrainOnSettle({ ...base, queueLength: 0, userInterrupted: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores steady busy state (no true → false transition)', () => {
|
||||
expect(shouldAutoDrainOnSettle({ ...base, isBusy: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores busy entry (false → true, not a settle)', () => {
|
||||
expect(shouldAutoDrainOnSettle({ ...base, isBusy: true, wasBusy: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores steady idle state (was not busy)', () => {
|
||||
expect(shouldAutoDrainOnSettle({ ...base, wasBusy: false })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -188,3 +188,39 @@ export const clearQueuedPrompts = (key: string | null | undefined) => {
|
||||
|
||||
writeSession(sid, [])
|
||||
}
|
||||
|
||||
/** Inputs to {@link shouldAutoDrainOnSettle}, captured at a `busy` transition. */
|
||||
export interface AutoDrainSettleInput {
|
||||
wasBusy: boolean
|
||||
isBusy: boolean
|
||||
queueLength: number
|
||||
userInterrupted: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the composer should auto-drain the next queued prompt when a
|
||||
* turn settles (busy transitions true → false).
|
||||
*
|
||||
* The queue auto-advances when a turn *completes naturally*, but must NOT
|
||||
* advance when the user *explicitly interrupted* the turn via the Stop button.
|
||||
* Conflating the two made the Stop button appear to "never work": cancelling a
|
||||
* turn flipped busy → false, the queue immediately re-fired its head, and the
|
||||
* agent kept running. An explicit interrupt means stop — the queued turns are
|
||||
* preserved and the user resumes them deliberately (Cmd/Ctrl+K, Enter, or the
|
||||
* per-row "send now" arrow).
|
||||
*/
|
||||
export const shouldAutoDrainOnSettle = (params: AutoDrainSettleInput): boolean => {
|
||||
const { isBusy, queueLength, userInterrupted, wasBusy } = params
|
||||
|
||||
// Only react to a true → false transition; ignore steady state and entry.
|
||||
if (isBusy || !wasBusy) {
|
||||
return false
|
||||
}
|
||||
|
||||
// An explicit Stop suppresses exactly one auto-drain.
|
||||
if (userInterrupted) {
|
||||
return false
|
||||
}
|
||||
|
||||
return queueLength > 0
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { sessionPinId } from './session'
|
||||
import { mergeWorkingSessions, sessionPinId } from './session'
|
||||
|
||||
const session = (over: Partial<SessionInfo>): SessionInfo => ({
|
||||
archived: false,
|
||||
@@ -34,3 +34,46 @@ describe('sessionPinId', () => {
|
||||
expect(sessionPinId(session({ id: 'tip', _lineage_root_id: 'root' }))).toBe('root')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeWorkingSessions', () => {
|
||||
it('returns the server page untouched when nothing is working', () => {
|
||||
const previous = [session({ id: 'a' }), session({ id: 'b' })]
|
||||
const incoming = [session({ id: 'a' })]
|
||||
|
||||
expect(mergeWorkingSessions(previous, incoming, [])).toBe(incoming)
|
||||
})
|
||||
|
||||
it('keeps a still-working session the server omitted', () => {
|
||||
// Repro of the disappearing-sessions bug: A finished and is returned by the
|
||||
// server, but B and C are mid-first-response (message_count 0 in the DB) so
|
||||
// listSessions(min_messages=1) skips them. They must survive the refresh.
|
||||
const previous = [session({ id: 'c' }), session({ id: 'b' }), session({ id: 'a' })]
|
||||
const incoming = [session({ id: 'a', message_count: 2 })]
|
||||
|
||||
const merged = mergeWorkingSessions(previous, incoming, ['b', 'c'])
|
||||
|
||||
expect(merged.map(s => s.id)).toEqual(['c', 'b', 'a'])
|
||||
// The finished session comes from the fresh server payload, not the stale
|
||||
// optimistic copy.
|
||||
expect(merged.find(s => s.id === 'a')?.message_count).toBe(2)
|
||||
})
|
||||
|
||||
it('does not duplicate a working session the server already returned', () => {
|
||||
const previous = [session({ id: 'b' }), session({ id: 'a' })]
|
||||
const incoming = [session({ id: 'b', message_count: 4 }), session({ id: 'a' })]
|
||||
|
||||
const merged = mergeWorkingSessions(previous, incoming, ['b'])
|
||||
|
||||
expect(merged.map(s => s.id)).toEqual(['b', 'a'])
|
||||
expect(merged.find(s => s.id === 'b')?.message_count).toBe(4)
|
||||
})
|
||||
|
||||
it('never resurrects a non-working session the server dropped', () => {
|
||||
// A deleted/archived session is removed from `previous` optimistically and
|
||||
// is not in the working set, so it must stay gone after a refresh.
|
||||
const previous = [session({ id: 'b' }), session({ id: 'gone' })]
|
||||
const incoming = [session({ id: 'b' })]
|
||||
|
||||
expect(mergeWorkingSessions(previous, incoming, ['b']).map(s => s.id)).toEqual(['b'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,6 +27,33 @@ function updateAtom<T>(store: AppAtom<T>, next: Updater<T>) {
|
||||
export const sessionPinId = (session: Pick<SessionInfo, '_lineage_root_id' | 'id'>): string =>
|
||||
session._lineage_root_id ?? session.id
|
||||
|
||||
/** Merge a fresh server session page into the in-memory list, keeping any
|
||||
* still-"working" session the server omitted.
|
||||
*
|
||||
* A brand-new session's first user message isn't flushed to the SessionDB
|
||||
* until its turn is persisted, so `listSessions(min_messages=1)` skips
|
||||
* sessions that are mid-first-response. Because every `message.complete`
|
||||
* triggers a full refresh, a hard replace makes concurrent new chats vanish
|
||||
* the instant any one of them finishes. Preserving the working-but-absent
|
||||
* rows keeps them visible until their own turn persists and the server
|
||||
* starts returning them. Optimistic deletes/archives already drop the row
|
||||
* from `previous`, so a removed session can't be resurrected here. */
|
||||
export function mergeWorkingSessions(
|
||||
previous: SessionInfo[],
|
||||
incoming: SessionInfo[],
|
||||
workingIds: readonly string[]
|
||||
): SessionInfo[] {
|
||||
if (workingIds.length === 0) {
|
||||
return incoming
|
||||
}
|
||||
|
||||
const working = new Set(workingIds)
|
||||
const incomingIds = new Set(incoming.map(session => session.id))
|
||||
const survivors = previous.filter(session => working.has(session.id) && !incomingIds.has(session.id))
|
||||
|
||||
return survivors.length ? [...survivors, ...incoming] : incoming
|
||||
}
|
||||
|
||||
export const $connection = atom<HermesConnection | null>(null)
|
||||
export const $gatewayState = atom('idle')
|
||||
export const $sessions = atom<SessionInfo[]>([])
|
||||
|
||||
@@ -76,8 +76,7 @@
|
||||
--shadow-header:
|
||||
0 0.0625rem 0 color-mix(in srgb, var(--dt-foreground) 7%, transparent),
|
||||
0 0.625rem 1.5rem -1.25rem color-mix(in srgb, #000 16%, transparent);
|
||||
--shadow-composer:
|
||||
0 0.0625rem 0.125rem color-mix(in srgb, #000 5%, transparent);
|
||||
--shadow-composer: 0 0.0625rem 0.125rem color-mix(in srgb, #000 5%, transparent);
|
||||
--shadow-composer-focus:
|
||||
0 0 0 0.125rem color-mix(in srgb, var(--dt-composer-ring) calc(10% * var(--composer-ring-strength)), transparent),
|
||||
0 0 0 0.0625rem color-mix(in srgb, var(--dt-composer-ring) calc(22% * var(--composer-ring-strength)), transparent),
|
||||
@@ -133,15 +132,23 @@
|
||||
--ui-cyan: #4c7f8c;
|
||||
--ui-blue: #0053fd;
|
||||
--ui-purple: #9e94d5;
|
||||
--ui-bg-chrome: color-mix(in srgb, var(--theme-background-seed) var(--theme-mix-chrome), var(--theme-neutral-chrome));
|
||||
--ui-bg-sidebar: color-mix(in srgb, var(--theme-sidebar-seed) var(--theme-mix-sidebar), var(--theme-neutral-sidebar));
|
||||
--ui-bg-editor: color-mix(in srgb, var(--theme-card-seed) var(--theme-mix-card), var(--theme-neutral-card));
|
||||
--ui-bg-elevated: color-mix(in srgb, var(--theme-elevated-seed) var(--theme-mix-elevated), var(--theme-neutral-card));
|
||||
--ui-bg-card: color-mix(
|
||||
--ui-bg-chrome: color-mix(
|
||||
in srgb,
|
||||
var(--ui-accent) 4%,
|
||||
color-mix(in srgb, var(--ui-base) 4%, transparent)
|
||||
var(--theme-background-seed) var(--theme-mix-chrome),
|
||||
var(--theme-neutral-chrome)
|
||||
);
|
||||
--ui-bg-sidebar: color-mix(
|
||||
in srgb,
|
||||
var(--theme-sidebar-seed) var(--theme-mix-sidebar),
|
||||
var(--theme-neutral-sidebar)
|
||||
);
|
||||
--ui-bg-editor: color-mix(in srgb, var(--theme-card-seed) var(--theme-mix-card), var(--theme-neutral-card));
|
||||
--ui-bg-elevated: color-mix(
|
||||
in srgb,
|
||||
var(--theme-elevated-seed) var(--theme-mix-elevated),
|
||||
var(--theme-neutral-card)
|
||||
);
|
||||
--ui-bg-card: color-mix(in srgb, var(--ui-accent) 4%, color-mix(in srgb, var(--ui-base) 4%, transparent));
|
||||
--ui-bg-input: #fcfcfc;
|
||||
--ui-bg-primary: color-mix(
|
||||
in srgb,
|
||||
@@ -218,7 +225,11 @@
|
||||
--ui-sidebar-surface-background: var(--ui-bg-sidebar);
|
||||
--ui-chat-surface-background: var(--ui-bg-chrome);
|
||||
--ui-editor-surface-background: var(--ui-bg-chrome);
|
||||
--ui-chat-bubble-background: color-mix(in srgb, var(--theme-bubble-seed) var(--theme-mix-bubble), var(--theme-neutral-card));
|
||||
--ui-chat-bubble-background: color-mix(
|
||||
in srgb,
|
||||
var(--theme-bubble-seed) var(--theme-mix-bubble),
|
||||
var(--theme-neutral-card)
|
||||
);
|
||||
--ui-chat-bubble-opaque-background: var(--ui-bg-editor);
|
||||
--ui-inline-code-background: color-mix(in srgb, #141414 5%, transparent);
|
||||
--ui-inline-code-border: color-mix(in srgb, #141414 8%, transparent);
|
||||
@@ -272,6 +283,7 @@
|
||||
--conversation-line-height: 1.125rem;
|
||||
--conversation-caption-line-height: 1rem;
|
||||
--conversation-turn-gap: 0.375rem;
|
||||
--sticky-human-top: 0.23rem;
|
||||
--file-tree-row-height: 1.375rem;
|
||||
|
||||
--composer-width: 48.75rem;
|
||||
@@ -626,7 +638,7 @@ canvas {
|
||||
.scrollbar-dt::-webkit-scrollbar-thumb,
|
||||
.scrollbar-dt *::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--dt-midground) 18%, transparent);
|
||||
border-radius: 9999rem;
|
||||
border-radius: 9999rem;
|
||||
border: 0.125rem solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
@@ -704,11 +716,40 @@ canvas {
|
||||
padding-inline-start: var(--md-text-indent, 0.5rem);
|
||||
}
|
||||
|
||||
[data-slot='aui_user-message-root'] {
|
||||
top: var(--sticky-human-top);
|
||||
}
|
||||
|
||||
[data-slot='aui_user-message-root'],
|
||||
[data-slot='aui_edit-composer-root'] {
|
||||
font-size: var(--conversation-text-font-size);
|
||||
}
|
||||
|
||||
/* Sticky human bubbles clamp to ~2 lines with a soft bottom fade so a long
|
||||
prompt doesn't dominate the viewport while you read the response stuck
|
||||
beneath it. The clamp lifts on hover / focus (clicking the bubble opens the
|
||||
edit composer, which already shows the full text). --human-msg-full is the
|
||||
measured content height (set in UserMessage) so expand/collapse animates to
|
||||
the real height instead of overshooting the cap. */
|
||||
.sticky-human-clamp {
|
||||
max-height: calc(2 * var(--dt-line-height) * var(--conversation-text-font-size) + 0.15rem);
|
||||
overflow: hidden;
|
||||
transition: max-height 0.08s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.sticky-human-clamp[data-clamped='true'] {
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000 55%, transparent);
|
||||
mask-image: linear-gradient(to bottom, #000 55%, transparent);
|
||||
}
|
||||
|
||||
.composer-human-message:hover .sticky-human-clamp,
|
||||
.composer-human-message:focus-within .sticky-human-clamp {
|
||||
max-height: min(var(--human-msg-full, 24rem), 24rem);
|
||||
overflow-y: auto;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
/* The thread renders items in natural document flow (padding spacers, not
|
||||
transforms) and @tanstack/react-virtual already adjusts scrollTop itself
|
||||
when an off-screen turn is measured and its real height differs from the
|
||||
@@ -909,8 +950,7 @@ canvas {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content']
|
||||
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) {
|
||||
[data-slot='aui_assistant-message-content'] > :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) {
|
||||
opacity: 0.67;
|
||||
transition: opacity 120ms ease-out;
|
||||
}
|
||||
@@ -941,12 +981,8 @@ canvas {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content']
|
||||
[data-slot='aui_thinking-disclosure']
|
||||
+ [data-slot='tool-block'],
|
||||
[data-slot='aui_assistant-message-content']
|
||||
[data-slot='tool-block']
|
||||
+ [data-slot='aui_thinking-disclosure'] {
|
||||
[data-slot='aui_assistant-message-content'] [data-slot='aui_thinking-disclosure'] + [data-slot='tool-block'],
|
||||
[data-slot='aui_assistant-message-content'] [data-slot='tool-block'] + [data-slot='aui_thinking-disclosure'] {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -278,6 +278,38 @@ if [ ! -f "$HERMES_HOME/auth.json" ] && [ -n "${HERMES_AUTH_JSON_BOOTSTRAP:-}" ]
|
||||
chmod 600 "$HERMES_HOME/auth.json"
|
||||
fi
|
||||
|
||||
# gateway_state.json: declare the gateway's INITIAL supervised state on a
|
||||
# fresh volume. Same first-boot-only env-seed pattern as auth.json above.
|
||||
#
|
||||
# On a blank volume there is no gateway_state.json, so the boot reconciler
|
||||
# (cont-init.d/02-reconcile-profiles → container_boot.reconcile_profile_gateways)
|
||||
# registers the gateway-default s6 slot but leaves it DOWN — it only
|
||||
# auto-starts when the last recorded state was "running". That means a
|
||||
# freshly-provisioned container comes up with the gateway down until
|
||||
# someone starts it (e.g. from the dashboard). An orchestrator that
|
||||
# provisions a fresh volume and wants the gateway running from first boot
|
||||
# can set HERMES_GATEWAY_BOOTSTRAP_STATE=running; we seed the state file
|
||||
# here, BEFORE 02-reconcile-profiles runs (cont-init.d scripts run in
|
||||
# lexicographic order), so the reconciler sees prior_state=running and
|
||||
# brings the supervised slot up on the very first boot.
|
||||
#
|
||||
# This is a generic container contract, not specific to any host: it seeds
|
||||
# the SAME gateway_state.json the reconciler already consults, exactly as
|
||||
# HERMES_AUTH_JSON_BOOTSTRAP seeds auth.json. The [ ! -f ] guard is the
|
||||
# load-bearing part — on every subsequent boot the persisted state wins,
|
||||
# so a gateway the operator deliberately stopped stays stopped across
|
||||
# restarts and we never clobber real runtime state.
|
||||
#
|
||||
# Only a literal "running" is honoured (the sole value in the reconciler's
|
||||
# _AUTOSTART_STATES); any other value is ignored so a typo can't write a
|
||||
# bogus state the reconciler would treat as "no prior state" anyway.
|
||||
if [ ! -f "$HERMES_HOME/gateway_state.json" ] && \
|
||||
[ "${HERMES_GATEWAY_BOOTSTRAP_STATE:-}" = "running" ]; then
|
||||
printf '{"gateway_state":"running"}\n' > "$HERMES_HOME/gateway_state.json"
|
||||
chown hermes:hermes "$HERMES_HOME/gateway_state.json" 2>/dev/null || true
|
||||
chmod 644 "$HERMES_HOME/gateway_state.json"
|
||||
fi
|
||||
|
||||
# --- Sync bundled skills ---
|
||||
# Invoke the venv's python by absolute path so we don't need a `sh -c`
|
||||
# wrapper to source the activate script. This is safe because
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
"""Short-lived single-use tickets for WS-upgrade auth in gated mode.
|
||||
"""WS-upgrade auth credentials for gated mode.
|
||||
|
||||
Browsers cannot set ``Authorization`` on a WebSocket upgrade. In loopback
|
||||
mode the legacy ``?token=<_SESSION_TOKEN>`` query param works because the
|
||||
token is injected into the SPA bundle. In gated mode there is no injected
|
||||
token — the SPA gets a fresh ticket via the authenticated REST endpoint
|
||||
``POST /api/auth/ws-ticket`` and passes that as ``?ticket=`` on the
|
||||
WS upgrade.
|
||||
token — so this module provides two credential shapes:
|
||||
|
||||
Tickets are single-use, TTL = 30 seconds. In-memory; the dashboard is a
|
||||
single process so no distributed coordination is needed. The module
|
||||
exposes a small functional API rather than a class so tests can patch
|
||||
``time.time`` cleanly.
|
||||
1. **Single-use browser tickets** (``mint_ticket`` / ``consume_ticket``).
|
||||
The SPA gets a fresh ticket via the authenticated REST endpoint
|
||||
``POST /api/auth/ws-ticket`` and passes it as ``?ticket=`` on the WS
|
||||
upgrade. Single-use, TTL = 30 seconds — a leaked ticket is uninteresting.
|
||||
|
||||
2. **A process-lifetime internal credential** (``internal_ws_credential`` /
|
||||
``consume_internal_credential``). This authenticates *server-spawned*
|
||||
WS clients — specifically the embedded-TUI PTY child, which attaches to
|
||||
``/api/ws`` (JSON-RPC gateway) and ``/api/pub`` (event sidecar) over
|
||||
loopback. A single-use 30s ticket is the wrong shape for that link: the
|
||||
child reads its attach URL once at startup and **reuses it on every
|
||||
reconnect**, and on a slow cold boot the child may not dial within 30s.
|
||||
The internal credential is minted once per process, never expires, is
|
||||
multi-use, and — critically — is **never injected into any HTML/SPA**:
|
||||
it only ever leaves the process via the spawned child's environment, so
|
||||
browser-side XSS cannot read it. A leaked internal credential grants no
|
||||
more than a single-use ticket already does (the same two internal WS
|
||||
endpoints), and the same Origin / host guards still apply downstream.
|
||||
|
||||
In-memory; the dashboard is a single process so no distributed coordination
|
||||
is needed. The module exposes a small functional API rather than a class so
|
||||
tests can patch ``time.time`` cleanly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -18,7 +34,7 @@ from __future__ import annotations
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, Tuple
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
#: Time-to-live for newly-minted tickets in seconds. 30 s is long enough
|
||||
#: that the SPA can call ``getWsTicket()`` and immediately open the WS,
|
||||
@@ -28,6 +44,16 @@ TTL_SECONDS = 30
|
||||
_lock = threading.Lock()
|
||||
_tickets: Dict[str, Tuple[int, Dict[str, Any]]] = {} # ticket -> (expires_at, info)
|
||||
|
||||
#: The process-lifetime internal credential (see module docstring). Lazily
|
||||
#: minted on first ``internal_ws_credential()`` call and stable for the life
|
||||
#: of the process. Guarded by ``_lock``.
|
||||
_internal_credential: Optional[str] = None
|
||||
|
||||
#: Identity recorded for connections that authenticate via the internal
|
||||
#: credential, so audit logs distinguish them from browser-initiated tickets.
|
||||
INTERNAL_USER_ID = "server-internal"
|
||||
INTERNAL_PROVIDER = "server-internal"
|
||||
|
||||
|
||||
class TicketInvalid(Exception):
|
||||
"""Ticket missing, expired, or already consumed."""
|
||||
@@ -81,7 +107,55 @@ def _gc_expired_locked() -> None:
|
||||
_tickets.pop(t, None)
|
||||
|
||||
|
||||
def internal_ws_credential() -> str:
|
||||
"""Return the process-lifetime internal WS credential, minting it once.
|
||||
|
||||
Used by the server to authenticate WS clients it spawns itself (the
|
||||
embedded-TUI PTY child). The value is stable for the life of the process,
|
||||
multi-use, and never expires — so a server-spawned child can reconnect
|
||||
its ``/api/ws`` / ``/api/pub`` sockets indefinitely without re-minting.
|
||||
|
||||
The credential is never injected into the SPA HTML or returned over any
|
||||
REST endpoint; it is only ever passed to a child process via its
|
||||
environment. See the module docstring for the threat-model rationale.
|
||||
"""
|
||||
global _internal_credential
|
||||
with _lock:
|
||||
if _internal_credential is None:
|
||||
_internal_credential = secrets.token_urlsafe(32)
|
||||
return _internal_credential
|
||||
|
||||
|
||||
def consume_internal_credential(value: str) -> Dict[str, Any]:
|
||||
"""Validate an internal credential. Raises :class:`TicketInvalid` on mismatch.
|
||||
|
||||
Unlike :func:`consume_ticket` this is **not** single-use — the value is
|
||||
not removed on success, so a server-spawned child can present it on every
|
||||
(re)connect. Returns the fixed server-internal identity ``info`` dict
|
||||
(``{user_id, provider}``), mirroring the ``info`` shape ``consume_ticket``
|
||||
returns, so a caller that wants to record the connecting identity can; the
|
||||
current ``_ws_auth_ok`` caller validates for the boolean outcome only and
|
||||
discards the dict.
|
||||
|
||||
A constant-time compare against the (lazily-minted) credential avoids
|
||||
leaking length / prefix information on mismatch. If no internal
|
||||
credential has been minted yet, any value is rejected.
|
||||
"""
|
||||
with _lock:
|
||||
expected = _internal_credential
|
||||
if not value or expected is None:
|
||||
raise TicketInvalid("no internal credential")
|
||||
if not secrets.compare_digest(value.encode(), expected.encode()):
|
||||
raise TicketInvalid("internal credential mismatch")
|
||||
return {
|
||||
"user_id": INTERNAL_USER_ID,
|
||||
"provider": INTERNAL_PROVIDER,
|
||||
}
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
"""Test-only: drop all tickets."""
|
||||
"""Test-only: drop all tickets and the internal credential."""
|
||||
global _internal_credential
|
||||
with _lock:
|
||||
_tickets.clear()
|
||||
_internal_credential = None
|
||||
|
||||
@@ -6643,10 +6643,21 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
|
||||
Loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>`` query
|
||||
parameter, constant-time compared.
|
||||
|
||||
Gated (public bind, no ``--insecure``): ``?ticket=<single-use>`` query
|
||||
parameter consumed against the dashboard-auth ticket store. The legacy
|
||||
token path is unconditionally rejected in this mode (the SPA bundle
|
||||
isn't carrying the token any longer).
|
||||
Gated (public bind, no ``--insecure``): one of two credentials —
|
||||
|
||||
* ``?ticket=<single-use>`` — a browser-minted, single-use, 30s-TTL ticket
|
||||
consumed against the dashboard-auth ticket store. This is what the SPA
|
||||
(and native clients) use.
|
||||
* ``?internal=<process-credential>`` — the process-lifetime internal
|
||||
credential, used only by WS clients the server spawns itself (the
|
||||
embedded-TUI PTY child attaching to ``/api/ws`` and ``/api/pub``). It
|
||||
is multi-use and never expires so the child can reconnect, and is never
|
||||
injected into the SPA — see ``dashboard_auth.ws_tickets`` for the
|
||||
threat model.
|
||||
|
||||
The legacy ``?token=`` path is unconditionally rejected in gated mode
|
||||
(the SPA bundle isn't carrying the token any longer, and a leaked
|
||||
``_SESSION_TOKEN`` must not grant WS access once the gate is engaged).
|
||||
|
||||
Returns True if the WS should be accepted; callers close with the
|
||||
appropriate WS code (4401) on False. Audit-logs the rejection so
|
||||
@@ -6654,17 +6665,36 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
|
||||
"""
|
||||
auth_required = bool(getattr(app.state, "auth_required", False))
|
||||
if auth_required:
|
||||
ticket = ws.query_params.get("ticket", "")
|
||||
if not ticket:
|
||||
return False
|
||||
# Lazy import — keeps this function importable in test harnesses
|
||||
# that don't bring in the dashboard_auth layer.
|
||||
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
|
||||
from hermes_cli.dashboard_auth.ws_tickets import (
|
||||
TicketInvalid,
|
||||
consume_internal_credential,
|
||||
consume_ticket,
|
||||
)
|
||||
|
||||
# Server-spawned children (PTY child → /api/ws, /api/pub) present the
|
||||
# multi-use internal credential rather than a single-use ticket, so
|
||||
# they survive reconnects and slow cold boots.
|
||||
internal = ws.query_params.get("internal", "")
|
||||
if internal:
|
||||
try:
|
||||
consume_internal_credential(internal)
|
||||
return True
|
||||
except TicketInvalid as exc:
|
||||
audit_log(
|
||||
AuditEvent.WS_TICKET_REJECTED,
|
||||
reason=f"internal: {exc}",
|
||||
ip=(ws.client.host if ws.client else ""),
|
||||
path=ws.url.path,
|
||||
)
|
||||
return False
|
||||
|
||||
ticket = ws.query_params.get("ticket", "")
|
||||
if not ticket:
|
||||
return False
|
||||
|
||||
try:
|
||||
consume_ticket(ticket)
|
||||
return True
|
||||
@@ -6740,7 +6770,16 @@ def _resolve_chat_argv(
|
||||
|
||||
|
||||
def _build_gateway_ws_url() -> Optional[str]:
|
||||
"""ws:// URL the PTY child should attach to for JSON-RPC gateway traffic."""
|
||||
"""ws:// URL the PTY child should attach to for JSON-RPC gateway traffic.
|
||||
|
||||
Loopback / ``--insecure``: ``?token=<_SESSION_TOKEN>``.
|
||||
|
||||
Gated mode: the legacy token path is rejected by ``_ws_auth_ok``, so the
|
||||
server-spawned PTY child authenticates with the process-lifetime internal
|
||||
credential (``?internal=``). It must NOT use a single-use browser ticket:
|
||||
the child reads this URL once at startup and reuses it on every reconnect,
|
||||
and a 30s-TTL ticket can expire before a slow cold boot even dials.
|
||||
"""
|
||||
host = getattr(app.state, "bound_host", None)
|
||||
port = getattr(app.state, "bound_port", None)
|
||||
|
||||
@@ -6752,7 +6791,13 @@ def _build_gateway_ws_url() -> Optional[str]:
|
||||
if ":" in host and not host.startswith("[")
|
||||
else f"{host}:{port}"
|
||||
)
|
||||
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN})
|
||||
|
||||
if getattr(app.state, "auth_required", False):
|
||||
from hermes_cli.dashboard_auth.ws_tickets import internal_ws_credential
|
||||
|
||||
qs = urllib.parse.urlencode({"internal": internal_ws_credential()})
|
||||
else:
|
||||
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN})
|
||||
|
||||
return f"ws://{netloc}/api/ws?{qs}"
|
||||
|
||||
@@ -6762,16 +6807,14 @@ def _build_sidecar_url(channel: str) -> Optional[str]:
|
||||
|
||||
Loopback / ``--insecure``: uses ``?token=<_SESSION_TOKEN>``.
|
||||
|
||||
Gated mode: mints a single-use ticket via the dashboard-auth ticket
|
||||
store (server-side mint, no HTTP round trip — the PTY child is a
|
||||
server-spawned process and we trust it). The ticket binds to the
|
||||
pseudo-user ``"pty-sidecar"`` so audit logs can distinguish these from
|
||||
browser-initiated tickets.
|
||||
|
||||
The single-use lifetime means the PTY child cannot reconnect without a
|
||||
new sidecar URL. PTY children open ``/api/pub`` once at startup; if
|
||||
reconnect semantics ever become important, this should be upgraded to
|
||||
a long-lived process-scoped token.
|
||||
Gated mode: authenticates with the process-lifetime internal credential
|
||||
(``?internal=``), the same one ``_build_gateway_ws_url`` uses. The PTY
|
||||
child is a server-spawned process we trust; the credential is multi-use
|
||||
and never expires, so the child can reconnect ``/api/pub`` without a new
|
||||
URL. (This previously minted a single-use 30s ticket, which meant the
|
||||
child could not reconnect and could miss the window on a slow cold boot.)
|
||||
Connections authenticated this way are recorded under the
|
||||
``server-internal`` identity in the audit log.
|
||||
"""
|
||||
host = getattr(app.state, "bound_host", None)
|
||||
port = getattr(app.state, "bound_port", None)
|
||||
@@ -6782,11 +6825,13 @@ def _build_sidecar_url(channel: str) -> Optional[str]:
|
||||
netloc = f"[{host}]:{port}" if ":" in host and not host.startswith("[") else f"{host}:{port}"
|
||||
|
||||
if getattr(app.state, "auth_required", False):
|
||||
# Gated mode — mint a ticket so the WS upgrade survives _ws_auth_ok.
|
||||
from hermes_cli.dashboard_auth.ws_tickets import mint_ticket
|
||||
# Gated mode — use the internal credential so the WS upgrade survives
|
||||
# _ws_auth_ok and the child can reconnect.
|
||||
from hermes_cli.dashboard_auth.ws_tickets import internal_ws_credential
|
||||
|
||||
ticket = mint_ticket(user_id="pty-sidecar", provider="server-internal")
|
||||
qs = urllib.parse.urlencode({"ticket": ticket, "channel": channel})
|
||||
qs = urllib.parse.urlencode(
|
||||
{"internal": internal_ws_credential(), "channel": channel}
|
||||
)
|
||||
else:
|
||||
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN, "channel": channel})
|
||||
|
||||
|
||||
@@ -48,22 +48,16 @@
|
||||
return tier ? "ha-tier-" + tier.toLowerCase() : "ha-tier-pending";
|
||||
};
|
||||
|
||||
async function api(path, options) {
|
||||
function api(path, options) {
|
||||
// Delegate to the host SDK's fetchJSON so auth is handled correctly in
|
||||
// BOTH dashboard modes: loopback (X-Hermes-Session-Token header) and
|
||||
// gated OAuth (hermes_session_at cookie via credentials:'include').
|
||||
// Hand-rolling fetch + reading window.__HERMES_SESSION_TOKEN__ directly
|
||||
// 401s in gated mode (the token isn't injected there). fetchJSON throws
|
||||
// Error("<status>: <body>") on non-2xx — the call sites' .catch() relies
|
||||
// on that to surface errors, so we let it propagate (don't swallow).
|
||||
const url = "/api/plugins/hermes-achievements" + path;
|
||||
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
||||
const headers = { ...((options && options.headers) || {}) };
|
||||
if (token) headers["X-Hermes-Session-Token"] = token;
|
||||
const res = await fetch(url, { ...(options || {}), headers });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(function () { return res.statusText; });
|
||||
throw new Error(res.status + ": " + text);
|
||||
}
|
||||
const text = await res.text();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return SDK.fetchJSON(url, options);
|
||||
}
|
||||
|
||||
function AchievementIcon({ icon }) {
|
||||
|
||||
105
plugins/kanban/dashboard/dist/index.js
vendored
105
plugins/kanban/dashboard/dist/index.js
vendored
@@ -588,52 +588,62 @@
|
||||
wsClosedRef.current = false;
|
||||
function openWs() {
|
||||
if (wsClosedRef.current) return;
|
||||
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const qsParams = {
|
||||
since: String(cursorRef.current || 0),
|
||||
token: token,
|
||||
};
|
||||
// Build the WS URL via the host SDK so the correct auth param is used
|
||||
// in BOTH modes: single-use ?ticket= in gated OAuth mode, ?token= in
|
||||
// loopback. Reading window.__HERMES_SESSION_TOKEN__ directly (the old
|
||||
// path) sends an empty token and is rejected in gated mode. buildWsUrl
|
||||
// also applies the dashboard base-path prefix for reverse-proxied
|
||||
// deployments, which the old inline URL did not. It's async (gated
|
||||
// mode mints a fresh ticket per connect), so resolve then open.
|
||||
const wsParams = { since: String(cursorRef.current || 0) };
|
||||
// Pin the WS stream to the currently-selected board so events
|
||||
// from other boards don't bleed in. Includes "default" so the
|
||||
// dashboard's own board pin always wins over the server-side
|
||||
// ``current`` file — same rationale as ``withBoard()`` above.
|
||||
// Regression: #20879.
|
||||
if (board) qsParams.board = board;
|
||||
const qs = new URLSearchParams(qsParams);
|
||||
const url = `${proto}//${window.location.host}${API}/events?${qs}`;
|
||||
let ws;
|
||||
try { ws = new WebSocket(url); } catch (_e) { return; }
|
||||
wsRef.current = ws;
|
||||
ws.onopen = function () { wsBackoffRef.current = 1000; };
|
||||
ws.onmessage = function (ev) {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg && Array.isArray(msg.events) && msg.events.length > 0) {
|
||||
cursorRef.current = msg.cursor || cursorRef.current;
|
||||
// Stamp per-task signal so the TaskDrawer can reload itself.
|
||||
setTaskEventTick(function (prev) {
|
||||
const next = Object.assign({}, prev);
|
||||
for (const e of msg.events) {
|
||||
if (e && e.task_id) next[e.task_id] = (next[e.task_id] || 0) + 1;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
scheduleReload();
|
||||
}
|
||||
} catch (_e) { /* ignore */ }
|
||||
};
|
||||
ws.onclose = function (ev) {
|
||||
if (board) wsParams.board = board;
|
||||
SDK.buildWsUrl(`${API}/events`, wsParams).then(function (url) {
|
||||
if (wsClosedRef.current) return;
|
||||
let ws;
|
||||
try { ws = new WebSocket(url); } catch (_e) { return; }
|
||||
wsRef.current = ws;
|
||||
ws.onopen = function () { wsBackoffRef.current = 1000; };
|
||||
ws.onmessage = function (ev) {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg && Array.isArray(msg.events) && msg.events.length > 0) {
|
||||
cursorRef.current = msg.cursor || cursorRef.current;
|
||||
// Stamp per-task signal so the TaskDrawer can reload itself.
|
||||
setTaskEventTick(function (prev) {
|
||||
const next = Object.assign({}, prev);
|
||||
for (const e of msg.events) {
|
||||
if (e && e.task_id) next[e.task_id] = (next[e.task_id] || 0) + 1;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
scheduleReload();
|
||||
}
|
||||
} catch (_e) { /* ignore */ }
|
||||
};
|
||||
ws.onclose = function (ev) {
|
||||
if (wsClosedRef.current) return;
|
||||
if (ev && ev.code === 1008) {
|
||||
setError(tx(t, "wsAuthFailed",
|
||||
"WebSocket auth failed — reload the page to refresh the session token."));
|
||||
return;
|
||||
}
|
||||
const delay = Math.min(wsBackoffRef.current, 30000);
|
||||
wsBackoffRef.current = Math.min(wsBackoffRef.current * 2, 30000);
|
||||
setTimeout(openWs, delay);
|
||||
};
|
||||
}).catch(function () {
|
||||
// Ticket mint / URL build failed (e.g. session expired). Back off
|
||||
// and retry; a hard auth failure surfaces via the 1008 close path.
|
||||
if (wsClosedRef.current) return;
|
||||
if (ev && ev.code === 1008) {
|
||||
setError(tx(t, "wsAuthFailed",
|
||||
"WebSocket auth failed — reload the page to refresh the session token."));
|
||||
return;
|
||||
}
|
||||
const delay = Math.min(wsBackoffRef.current, 30000);
|
||||
wsBackoffRef.current = Math.min(wsBackoffRef.current * 2, 30000);
|
||||
setTimeout(openWs, delay);
|
||||
};
|
||||
});
|
||||
}
|
||||
openWs();
|
||||
return function () {
|
||||
@@ -2837,8 +2847,6 @@
|
||||
if (!files.length) return;
|
||||
setUploadBusy(true);
|
||||
setUploadErr(null);
|
||||
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
||||
const headers = token ? { Authorization: "Bearer " + token } : {};
|
||||
const url = withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/attachments`, boardSlug);
|
||||
// Upload sequentially so a partial failure leaves a clear state.
|
||||
let chain = Promise.resolve();
|
||||
@@ -2846,7 +2854,11 @@
|
||||
chain = chain.then(function () {
|
||||
const fd = new FormData();
|
||||
fd.append("file", f, f.name);
|
||||
return fetch(url, { method: "POST", headers: headers, credentials: "same-origin", body: fd })
|
||||
// SDK.authedFetch handles auth in BOTH modes (loopback token header /
|
||||
// gated cookie) and applies the dashboard base-path prefix. The old
|
||||
// hand-rolled Authorization:Bearer + credentials:'same-origin' sent
|
||||
// an empty token and 401'd in gated mode.
|
||||
return SDK.authedFetch(url, { method: "POST", body: fd })
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) {
|
||||
return resp.text().then(function (txt) {
|
||||
@@ -3073,15 +3085,16 @@
|
||||
const fileRef = useRef(null);
|
||||
const [dlErr, setDlErr] = useState(null);
|
||||
// Download via authenticated fetch → blob → synthetic anchor click.
|
||||
// A plain <a href> can't carry the session header/bearer the dashboard
|
||||
// auth middleware requires in loopback mode, so fetch with the token
|
||||
// and hand the browser a blob URL instead.
|
||||
// A plain <a href> can't carry the auth the dashboard middleware requires,
|
||||
// so fetch authenticated and hand the browser a blob URL instead.
|
||||
function downloadAttachment(a) {
|
||||
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
||||
const headers = token ? { Authorization: "Bearer " + token } : {};
|
||||
// SDK.authedFetch handles auth in BOTH modes (loopback token header /
|
||||
// gated cookie) and applies the dashboard base-path prefix. The old
|
||||
// hand-rolled Authorization:Bearer + credentials:'same-origin' sent an
|
||||
// empty token and 401'd in gated mode.
|
||||
const url = withBoard(`${API}/attachments/${a.id}`, props.boardSlug);
|
||||
setDlErr(null);
|
||||
fetch(url, { headers: headers, credentials: "same-origin" })
|
||||
SDK.authedFetch(url)
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) {
|
||||
return resp.text().then(function (txt) {
|
||||
|
||||
@@ -36,7 +36,6 @@ the port.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -63,15 +62,29 @@ router = APIRouter()
|
||||
# existing plugin-bypass; this is documented above).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _check_ws_token(provided: Optional[str]) -> bool:
|
||||
"""Constant-time compare against the dashboard session token.
|
||||
def _ws_upgrade_authorized(ws: "WebSocket") -> bool:
|
||||
"""Authorize a WebSocket upgrade by delegating to the dashboard's canonical
|
||||
WS auth gate (``hermes_cli.web_server._ws_auth_ok``).
|
||||
|
||||
Delegating (rather than re-implementing a ``_SESSION_TOKEN``-only check)
|
||||
means this endpoint transparently accepts whatever the core gate accepts
|
||||
in each mode:
|
||||
|
||||
* loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>``
|
||||
* gated OAuth: single-use ``?ticket=`` (the browser SDK's
|
||||
``buildWsUrl`` mints one per connect)
|
||||
* server-internal: the process-lifetime ``?internal=`` credential
|
||||
|
||||
The previous bespoke check only understood ``_SESSION_TOKEN``, so the
|
||||
kanban live-events WS was rejected on every OAuth-gated deployment even
|
||||
though the rest of the dashboard worked. Routing through the shared gate
|
||||
also means this can never drift from core auth again.
|
||||
|
||||
Imported lazily so the plugin still loads in test contexts where the
|
||||
dashboard web_server module isn't importable (e.g. the bare-FastAPI
|
||||
test harness).
|
||||
dashboard ``web_server`` module isn't importable (e.g. the bare-FastAPI
|
||||
test harness); there we accept so the tail loop stays testable, matching
|
||||
the prior behaviour.
|
||||
"""
|
||||
if not provided:
|
||||
return False
|
||||
try:
|
||||
from hermes_cli import web_server as _ws
|
||||
except Exception:
|
||||
@@ -79,10 +92,7 @@ def _check_ws_token(provided: Optional[str]) -> bool:
|
||||
# testable; in production the dashboard module always imports
|
||||
# cleanly because it's the caller.
|
||||
return True
|
||||
expected = getattr(_ws, "_SESSION_TOKEN", None)
|
||||
if not expected:
|
||||
return True
|
||||
return hmac.compare_digest(str(provided), str(expected))
|
||||
return bool(_ws._ws_auth_ok(ws))
|
||||
|
||||
|
||||
def _resolve_board(board: Optional[str]) -> Optional[str]:
|
||||
@@ -2375,11 +2385,12 @@ def set_orchestration_settings(payload: OrchestrationSettingsBody):
|
||||
|
||||
@router.websocket("/events")
|
||||
async def stream_events(ws: WebSocket):
|
||||
# Enforce the dashboard session token as a query param — browsers can't
|
||||
# set Authorization on a WS upgrade. This matches how the PTY bridge
|
||||
# authenticates in hermes_cli/web_server.py.
|
||||
token = ws.query_params.get("token")
|
||||
if not _check_ws_token(token):
|
||||
# Authorize the upgrade via the dashboard's canonical WS gate so the
|
||||
# correct credential is accepted in every mode (loopback token / gated
|
||||
# single-use ticket / server-internal credential). Browsers can't set
|
||||
# Authorization on a WS upgrade, so the credential rides in the query
|
||||
# string — the browser SDK's buildWsUrl() assembles it.
|
||||
if not _ws_upgrade_authorized(ws):
|
||||
await ws.close(code=http_status.WS_1008_POLICY_VIOLATION)
|
||||
return
|
||||
await ws.accept()
|
||||
|
||||
79
tests/docker/test_tui_prebuilt_bundle.py
Normal file
79
tests/docker/test_tui_prebuilt_bundle.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Harness: the image ships a prebuilt TUI bundle, not a runtime npm install.
|
||||
|
||||
Regression guard for the hosted-chat failure where the embedded dashboard
|
||||
Chat tab died with a 502 / "[session ended]". Root cause: the image installs
|
||||
only a subset of the npm monorepo workspaces (root/web/ui-tui, never apps/*),
|
||||
so the actualized node_modules permanently disagrees with the canonical
|
||||
package-lock.json. Without HERMES_TUI_DIR set, ``_make_tui_argv`` falls
|
||||
through to ``_tui_need_npm_install`` (which returns True forever) and tries a
|
||||
runtime ``npm install`` that can never converge and races itself across
|
||||
concurrent /api/pty connections → ENOTEMPTY.
|
||||
|
||||
The fix is ``ENV HERMES_TUI_DIR=/opt/hermes/ui-tui`` in the Dockerfile, which
|
||||
makes the launcher take the prebuilt-bundle fast path (``node --expose-gc
|
||||
.../dist/entry.js``) and skip the install check entirely. These tests assert
|
||||
that invariant holds in the built image.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
|
||||
def _exec_py(image: str, py: str) -> str:
|
||||
"""Run a Python snippet inside the image as the hermes user, return stdout."""
|
||||
inner = (
|
||||
"source /opt/hermes/.venv/bin/activate && "
|
||||
"cd /opt/hermes && "
|
||||
f"python3 -c {shlex.quote(py)}"
|
||||
)
|
||||
# Drop to the hermes user (UID 10000) so we exercise the same path the
|
||||
# dashboard PTY child runs as — not root.
|
||||
cmd = [
|
||||
"docker", "run", "--rm", "--entrypoint", "su", image,
|
||||
"hermes", "-s", "/bin/bash", "-c", inner,
|
||||
]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
assert r.returncode == 0, f"in-container python failed:\n{r.stderr[-2000:]}"
|
||||
return r.stdout.strip()
|
||||
|
||||
|
||||
def test_hermes_tui_dir_env_is_set(built_image: str) -> None:
|
||||
"""HERMES_TUI_DIR must point at the prebuilt bundle dir in the image."""
|
||||
r = subprocess.run(
|
||||
["docker", "run", "--rm", "--entrypoint", "sh", built_image,
|
||||
"-c", 'printf "%s" "$HERMES_TUI_DIR"'],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
assert r.returncode == 0, r.stderr[-2000:]
|
||||
assert r.stdout.strip() == "/opt/hermes/ui-tui", (
|
||||
f"HERMES_TUI_DIR={r.stdout.strip()!r} (expected /opt/hermes/ui-tui)"
|
||||
)
|
||||
|
||||
|
||||
def test_prebuilt_bundle_present_and_no_runtime_install(built_image: str) -> None:
|
||||
"""The launcher must (a) find the prebuilt bundle and (b) NOT want an
|
||||
npm install — i.e. it takes the same path as a nix/packaged release."""
|
||||
py = (
|
||||
"import json\n"
|
||||
"from pathlib import Path\n"
|
||||
"from hermes_cli.main import _tui_need_npm_install, _find_bundled_tui, _make_tui_argv\n"
|
||||
"ui = Path('/opt/hermes/ui-tui')\n"
|
||||
"argv, cwd = _make_tui_argv(ui, tui_dev=False)\n"
|
||||
"out = {\n"
|
||||
" 'dist_entry_exists': (ui / 'dist' / 'entry.js').is_file(),\n"
|
||||
" 'need_npm_install': _tui_need_npm_install(ui),\n"
|
||||
" 'argv': argv,\n"
|
||||
" 'uses_prebuilt': ('dist/entry.js' in ' '.join(argv)) and ('npm' not in argv[0].lower()),\n"
|
||||
"}\n"
|
||||
"print(json.dumps(out))\n"
|
||||
)
|
||||
out = json.loads(_exec_py(built_image, py))
|
||||
assert out["dist_entry_exists"], "prebuilt ui-tui/dist/entry.js missing from image"
|
||||
# With HERMES_TUI_DIR set, _make_tui_argv returns the prebuilt path BEFORE
|
||||
# ever reaching the install check — so the resolved argv is what matters.
|
||||
assert out["uses_prebuilt"], f"launcher did not take prebuilt path: argv={out['argv']!r}"
|
||||
assert "npm" not in out["argv"][0].lower(), (
|
||||
f"launcher resolved to an npm invocation, not the prebuilt bundle: {out['argv']!r}"
|
||||
)
|
||||
@@ -29,7 +29,8 @@ from hermes_cli import web_server
|
||||
from hermes_cli.dashboard_auth import clear_providers, register_provider
|
||||
from hermes_cli.dashboard_auth.ws_tickets import (
|
||||
_reset_for_tests,
|
||||
consume_ticket,
|
||||
consume_internal_credential,
|
||||
internal_ws_credential,
|
||||
mint_ticket,
|
||||
)
|
||||
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
|
||||
@@ -279,10 +280,33 @@ class TestWsAuthOkGated:
|
||||
content = log_file.read_text()
|
||||
assert "ws_ticket_rejected" in content
|
||||
|
||||
def test_internal_credential_accepted(self, gated_app):
|
||||
"""Server-spawned children present the process-lifetime internal
|
||||
credential via ?internal= and are accepted in gated mode."""
|
||||
cred = internal_ws_credential()
|
||||
ws = _fake_ws(query={"internal": cred})
|
||||
assert web_server._ws_auth_ok(ws) is True
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_sidecar_url — gated mode mints a server-internal ticket
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_internal_credential_is_multi_use(self, gated_app):
|
||||
"""Unlike single-use tickets, the internal credential survives
|
||||
repeated use so the child can reconnect."""
|
||||
cred = internal_ws_credential()
|
||||
for _ in range(3):
|
||||
ws = _fake_ws(query={"internal": cred})
|
||||
assert web_server._ws_auth_ok(ws) is True
|
||||
|
||||
def test_wrong_internal_credential_rejected(self, gated_app):
|
||||
# Mint the real one so the store is non-empty, then present a bogus value.
|
||||
internal_ws_credential()
|
||||
ws = _fake_ws(query={"internal": "not-the-internal-credential"})
|
||||
assert web_server._ws_auth_ok(ws) is False
|
||||
|
||||
def test_internal_credential_not_accepted_in_loopback(self, loopback_app):
|
||||
"""Outside gated mode, ?internal= is meaningless — only ?token= works.
|
||||
A naked internal credential must not authenticate."""
|
||||
cred = internal_ws_credential()
|
||||
ws = _fake_ws(query={"internal": cred})
|
||||
assert web_server._ws_auth_ok(ws) is False
|
||||
|
||||
|
||||
class TestWsRequestIsAllowedGated:
|
||||
@@ -477,18 +501,20 @@ class TestSidecarUrl:
|
||||
assert f"token={web_server._SESSION_TOKEN}" in url
|
||||
assert "ticket=" not in url
|
||||
|
||||
def test_gated_uses_ticket(self, gated_app):
|
||||
def test_gated_uses_internal_credential(self, gated_app):
|
||||
url = web_server._build_sidecar_url("ch-1")
|
||||
assert url is not None
|
||||
assert "token=" not in url
|
||||
assert "ticket=" in url
|
||||
# And the ticket should be live.
|
||||
ticket = url.split("ticket=")[1].split("&")[0]
|
||||
info = consume_ticket(ticket)
|
||||
# Sidecar tickets are bound to the pseudo-user so audit logs can
|
||||
# distinguish them from real browser tickets.
|
||||
assert info["user_id"] == "pty-sidecar"
|
||||
assert "ticket=" not in url
|
||||
assert "internal=" in url
|
||||
# The value should be the live process-lifetime internal credential,
|
||||
# multi-use so the child can reconnect /api/pub.
|
||||
cred = url.split("internal=")[1].split("&")[0]
|
||||
info = consume_internal_credential(cred)
|
||||
assert info["user_id"] == "server-internal"
|
||||
assert info["provider"] == "server-internal"
|
||||
# Multi-use: a second consume still succeeds (unlike a ticket).
|
||||
assert consume_internal_credential(cred)["provider"] == "server-internal"
|
||||
|
||||
def test_no_bound_host_returns_none(self, gated_app):
|
||||
web_server.app.state.bound_host = None
|
||||
@@ -496,3 +522,48 @@ class TestSidecarUrl:
|
||||
assert web_server._build_sidecar_url("ch") is None
|
||||
finally:
|
||||
web_server.app.state.bound_host = "fly-app.fly.dev"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_gateway_ws_url — the TUI child's primary JSON-RPC backend WS.
|
||||
# Loopback uses ?token=; gated mode uses the multi-use internal credential
|
||||
# (NOT a single-use ticket — the child reuses this URL across reconnects).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGatewayWsUrl:
|
||||
def test_loopback_uses_session_token(self, loopback_app):
|
||||
url = web_server._build_gateway_ws_url()
|
||||
assert url is not None
|
||||
assert "/api/ws?" in url
|
||||
assert f"token={web_server._SESSION_TOKEN}" in url
|
||||
assert "internal=" not in url
|
||||
|
||||
def test_gated_uses_internal_credential(self, gated_app):
|
||||
url = web_server._build_gateway_ws_url()
|
||||
assert url is not None
|
||||
assert "/api/ws?" in url
|
||||
assert "token=" not in url
|
||||
assert "ticket=" not in url
|
||||
assert "internal=" in url
|
||||
cred = url.split("internal=")[1].split("&")[0]
|
||||
# The credential authenticates against _ws_auth_ok in gated mode.
|
||||
ws = _fake_ws(query={"internal": cred})
|
||||
assert web_server._ws_auth_ok(ws) is True
|
||||
|
||||
def test_gated_credential_matches_sidecar(self, gated_app):
|
||||
"""Both server-internal builders share one process credential, so a
|
||||
single value authenticates /api/ws and /api/pub alike."""
|
||||
gw = web_server._build_gateway_ws_url()
|
||||
sc = web_server._build_sidecar_url("ch-1")
|
||||
assert gw is not None and sc is not None
|
||||
gw_cred = gw.split("internal=")[1].split("&")[0]
|
||||
sc_cred = sc.split("internal=")[1].split("&")[0]
|
||||
assert gw_cred == sc_cred
|
||||
|
||||
def test_no_bound_host_returns_none(self, gated_app):
|
||||
web_server.app.state.bound_host = None
|
||||
try:
|
||||
assert web_server._build_gateway_ws_url() is None
|
||||
finally:
|
||||
web_server.app.state.bound_host = "fly-app.fly.dev"
|
||||
|
||||
@@ -159,3 +159,73 @@ class TestConcurrency:
|
||||
assert len(results) == 20
|
||||
# Every consume returns a distinct user_id (no cross-thread bleed).
|
||||
assert {r["user_id"] for r in results} == {f"u{i}" for i in range(20)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Process-lifetime internal credential (server-spawned PTY child auth).
|
||||
# Direct unit coverage for internal_ws_credential / consume_internal_credential
|
||||
# — _ws_auth_ok exercises these indirectly, but the mint-once, unminted, and
|
||||
# empty-value branches are only reachable via direct calls.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInternalCredential:
|
||||
def test_minted_once_is_stable(self):
|
||||
"""Successive calls return the same process-lifetime value."""
|
||||
first = ws_tickets.internal_ws_credential()
|
||||
second = ws_tickets.internal_ws_credential()
|
||||
assert first == second
|
||||
assert len(first) >= 32 # token_urlsafe(32)
|
||||
|
||||
def test_round_trip_identity(self):
|
||||
cred = ws_tickets.internal_ws_credential()
|
||||
info = ws_tickets.consume_internal_credential(cred)
|
||||
assert info["user_id"] == ws_tickets.INTERNAL_USER_ID
|
||||
assert info["provider"] == ws_tickets.INTERNAL_PROVIDER
|
||||
|
||||
def test_multi_use(self):
|
||||
"""Unlike a single-use ticket, the credential survives repeated consume."""
|
||||
cred = ws_tickets.internal_ws_credential()
|
||||
for _ in range(5):
|
||||
assert (
|
||||
ws_tickets.consume_internal_credential(cred)["provider"]
|
||||
== ws_tickets.INTERNAL_PROVIDER
|
||||
)
|
||||
|
||||
def test_rejected_before_mint(self):
|
||||
"""With nothing minted yet, any value is rejected (expected is None)."""
|
||||
# autouse _reset leaves _internal_credential == None at test start.
|
||||
with pytest.raises(TicketInvalid):
|
||||
ws_tickets.consume_internal_credential("anything")
|
||||
|
||||
def test_empty_value_rejected(self):
|
||||
ws_tickets.internal_ws_credential() # mint so expected is non-None
|
||||
with pytest.raises(TicketInvalid):
|
||||
ws_tickets.consume_internal_credential("")
|
||||
|
||||
def test_wrong_value_rejected(self):
|
||||
ws_tickets.internal_ws_credential()
|
||||
with pytest.raises(TicketInvalid):
|
||||
ws_tickets.consume_internal_credential("not-the-credential")
|
||||
|
||||
def test_reset_clears_and_remints(self):
|
||||
first = ws_tickets.internal_ws_credential()
|
||||
_reset_for_tests()
|
||||
# The old value no longer validates after reset.
|
||||
with pytest.raises(TicketInvalid):
|
||||
ws_tickets.consume_internal_credential(first)
|
||||
# A fresh mint produces a different value.
|
||||
second = ws_tickets.internal_ws_credential()
|
||||
assert second != first
|
||||
assert ws_tickets.consume_internal_credential(second)["user_id"] == (
|
||||
ws_tickets.INTERNAL_USER_ID
|
||||
)
|
||||
|
||||
def test_independent_of_ticket_store(self):
|
||||
"""The internal credential is not a ticket — minting tickets doesn't
|
||||
touch it, and consuming the credential doesn't consume tickets."""
|
||||
cred = ws_tickets.internal_ws_credential()
|
||||
ticket = mint_ticket(user_id="u1", provider="nous")
|
||||
# Consuming the internal credential leaves the ticket intact.
|
||||
ws_tickets.consume_internal_credential(cred)
|
||||
assert consume_ticket(ticket)["user_id"] == "u1"
|
||||
|
||||
@@ -735,18 +735,29 @@ def test_board_auto_initializes_missing_db(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch):
|
||||
"""When _SESSION_TOKEN is set (normal dashboard context), a missing or
|
||||
wrong ?token= query param must be rejected with policy-violation."""
|
||||
"""Loopback mode: a missing or wrong ?token= must be rejected with
|
||||
policy-violation; the correct token is accepted. The kanban WS now
|
||||
delegates to web_server._ws_auth_ok, so we stub that with the real
|
||||
loopback-token semantics (auth_required False → constant-time token
|
||||
compare)."""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
kb.init_db()
|
||||
|
||||
# Stub web_server so _check_ws_token has a token to compare against.
|
||||
# Stub web_server with a loopback-mode _ws_auth_ok (auth_required False →
|
||||
# accept only the correct ?token=). Mirrors the real gate's loopback path.
|
||||
import hermes_cli
|
||||
import types
|
||||
stub = types.SimpleNamespace(_SESSION_TOKEN="secret-xyz")
|
||||
|
||||
def _fake_ws_auth_ok(ws):
|
||||
return ws.query_params.get("token", "") == "secret-xyz"
|
||||
|
||||
stub = types.SimpleNamespace(
|
||||
_SESSION_TOKEN="secret-xyz",
|
||||
_ws_auth_ok=_fake_ws_auth_ok,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub)
|
||||
monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False)
|
||||
|
||||
@@ -774,6 +785,51 @@ def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch):
|
||||
assert ws is not None # handshake succeeded
|
||||
|
||||
|
||||
def test_ws_events_accepts_gated_ticket(tmp_path, monkeypatch):
|
||||
"""Gated OAuth mode: the WS must accept a single-use ?ticket= (and reject
|
||||
a bare ?token=, even one matching _SESSION_TOKEN). This is the regression
|
||||
for the hosted-dashboard bug where the kanban live-events WS 1008'd on
|
||||
every gated deployment because its bespoke check only knew _SESSION_TOKEN.
|
||||
We stub _ws_auth_ok with the real gated semantics (ticket-only)."""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
kb.init_db()
|
||||
|
||||
import hermes_cli
|
||||
import types
|
||||
|
||||
def _fake_ws_auth_ok(ws):
|
||||
# Gated mode: only a known ticket is accepted; token path rejected.
|
||||
return ws.query_params.get("ticket", "") == "good-ticket"
|
||||
|
||||
stub = types.SimpleNamespace(
|
||||
_SESSION_TOKEN="secret-xyz",
|
||||
_ws_auth_ok=_fake_ws_auth_ok,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub)
|
||||
monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
|
||||
c = TestClient(app)
|
||||
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
# Legacy token is rejected in gated mode, even if it's the real one.
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with c.websocket_connect("/api/plugins/kanban/events?token=secret-xyz"):
|
||||
pass
|
||||
assert exc.value.code == 1008
|
||||
|
||||
# A valid ticket is accepted.
|
||||
with c.websocket_connect(
|
||||
"/api/plugins/kanban/events?ticket=good-ticket"
|
||||
) as ws:
|
||||
assert ws is not None
|
||||
|
||||
|
||||
def test_ws_events_board_query_param_default_overrides_current_board_pointer(tmp_path, monkeypatch):
|
||||
"""The event stream must honor ``board=default`` even when the global
|
||||
current-board pointer targets a different board.
|
||||
@@ -806,7 +862,10 @@ def test_ws_events_board_query_param_default_overrides_current_board_pointer(tmp
|
||||
import hermes_cli
|
||||
import types
|
||||
|
||||
stub = types.SimpleNamespace(_SESSION_TOKEN="secret-xyz")
|
||||
stub = types.SimpleNamespace(
|
||||
_SESSION_TOKEN="secret-xyz",
|
||||
_ws_auth_ok=lambda ws: ws.query_params.get("token", "") == "secret-xyz",
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub)
|
||||
monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False)
|
||||
|
||||
@@ -842,10 +901,10 @@ def test_ws_events_swallows_cancellation_on_shutdown(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
kb.init_db()
|
||||
|
||||
# Short-circuit the token check — this test is about the cancellation
|
||||
# Short-circuit the auth check — this test is about the cancellation
|
||||
# path, not auth.
|
||||
import plugins.kanban.dashboard.plugin_api as pa
|
||||
monkeypatch.setattr(pa, "_check_ws_token", lambda t: True)
|
||||
monkeypatch.setattr(pa, "_ws_upgrade_authorized", lambda ws: True)
|
||||
|
||||
class _FakeWS:
|
||||
def __init__(self):
|
||||
|
||||
95
tests/plugins/test_plugin_dashboard_auth_contract.py
Normal file
95
tests/plugins/test_plugin_dashboard_auth_contract.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Guardrail: dashboard plugins must NOT read the session token directly.
|
||||
|
||||
The dashboard host exposes a sanctioned, gated-mode-aware auth surface on the
|
||||
plugin SDK (``window.__HERMES_PLUGIN_SDK__``): ``fetchJSON`` (JSON REST),
|
||||
``authedFetch`` (uploads / blob downloads), and ``buildWsUrl`` /
|
||||
``buildWsAuthParam`` (WebSockets). These handle BOTH dashboard auth modes —
|
||||
loopback (``X-Hermes-Session-Token`` header) and gated OAuth
|
||||
(``hermes_session_at`` cookie / single-use ``?ticket=``).
|
||||
|
||||
Plugins that hand-roll ``fetch`` / ``WebSocket`` and read
|
||||
``window.__HERMES_SESSION_TOKEN__`` directly send an empty token in gated mode
|
||||
and 401/1008. That bug shipped in the kanban and achievements plugins and was
|
||||
invisible until the dashboard ran gated on hosted Fly agents.
|
||||
|
||||
This test fails if any bundled plugin's frontend reads the token global
|
||||
directly, forcing new/edited plugins through the SDK surface instead. It is
|
||||
the enforcement half of the "single sanctioned auth surface" design — the SDK
|
||||
helpers are the carrot, this test is the stick.
|
||||
|
||||
If you have a legitimate reason to reference the token name (e.g. a comment
|
||||
explaining why NOT to use it), add the file to ``_ALLOWED_FILES`` with a note.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Repo root: tests/plugins/<this file> → ../../
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_PLUGINS_DIR = _REPO_ROOT / "plugins"
|
||||
|
||||
# The forbidden global. Reading it directly bypasses the gated-mode auth path.
|
||||
_FORBIDDEN = "__HERMES_SESSION_TOKEN__"
|
||||
|
||||
# Files explicitly allowed to mention the token (none today). Map path →
|
||||
# reason so the allowance is self-documenting if one is ever needed.
|
||||
_ALLOWED_FILES: dict[str, str] = {}
|
||||
|
||||
|
||||
def _plugin_frontend_bundles() -> list[Path]:
|
||||
"""Every plugin-shipped JS bundle the dashboard loads into the browser."""
|
||||
if not _PLUGINS_DIR.is_dir():
|
||||
return []
|
||||
# Plugin dashboards live at plugins/<name>/dashboard/dist/*.js
|
||||
return sorted(_PLUGINS_DIR.glob("*/dashboard/dist/*.js"))
|
||||
|
||||
|
||||
def test_there_are_plugin_bundles_to_check() -> None:
|
||||
"""Sanity: the glob actually finds the bundles, so a future layout change
|
||||
doesn't silently turn this guard into a no-op."""
|
||||
bundles = _plugin_frontend_bundles()
|
||||
names = {b.parent.parent.parent.name for b in bundles}
|
||||
# kanban + hermes-achievements are bundled today; assert at least one is
|
||||
# found so the guard can't pass vacuously.
|
||||
assert bundles, "no plugin dashboard bundles found — glob/layout drift?"
|
||||
assert names, "could not resolve plugin names from bundle paths"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bundle",
|
||||
_plugin_frontend_bundles(),
|
||||
ids=lambda p: str(p.relative_to(_REPO_ROOT)),
|
||||
)
|
||||
def test_plugin_bundle_does_not_read_session_token(bundle: Path) -> None:
|
||||
rel = str(bundle.relative_to(_REPO_ROOT))
|
||||
text = bundle.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
if rel in _ALLOWED_FILES:
|
||||
return # explicitly allowed (with a documented reason)
|
||||
|
||||
# Only flag CODE reads of the token global, not mentions in ``//`` comments
|
||||
# (e.g. a comment explaining why the SDK helper is used instead). A line is
|
||||
# a code read if it contains the global and the global appears before any
|
||||
# ``//`` comment marker on that line.
|
||||
offending: list[str] = []
|
||||
for i, line in enumerate(text.splitlines(), start=1):
|
||||
idx = line.find(_FORBIDDEN)
|
||||
if idx == -1:
|
||||
continue
|
||||
comment_idx = line.find("//")
|
||||
in_comment = comment_idx != -1 and comment_idx < idx
|
||||
if not in_comment:
|
||||
offending.append(f" {i}: {line.strip()}")
|
||||
|
||||
if not offending:
|
||||
return
|
||||
|
||||
pytest.fail(
|
||||
f"{rel} reads {_FORBIDDEN} directly — this bypasses gated-mode auth "
|
||||
f"and 401/1008s on OAuth-gated dashboards. Use the plugin SDK instead: "
|
||||
f"SDK.fetchJSON (JSON), SDK.authedFetch (uploads/downloads), or "
|
||||
f"SDK.buildWsUrl (WebSockets). Offending lines:\n" + "\n".join(offending)
|
||||
)
|
||||
152
tests/tools/test_stage2_hook_gateway_bootstrap_state.py
Normal file
152
tests/tools/test_stage2_hook_gateway_bootstrap_state.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""Contract test: the s6-overlay stage2 hook seeds gateway_state.json from
|
||||
HERMES_GATEWAY_BOOTSTRAP_STATE on first boot, so a freshly-provisioned
|
||||
container can come up with the gateway already running.
|
||||
|
||||
Background. On a blank volume there is no gateway_state.json, so the boot
|
||||
reconciler (cont-init.d/02-reconcile-profiles ->
|
||||
container_boot.reconcile_profile_gateways) registers the gateway-default s6
|
||||
slot but leaves it DOWN — it only auto-starts when the last recorded state was
|
||||
"running". A container provisioned on a fresh volume therefore comes up with
|
||||
the gateway down until something starts it.
|
||||
|
||||
An orchestrator that wants the gateway running from first boot sets
|
||||
HERMES_GATEWAY_BOOTSTRAP_STATE=running; stage2-hook.sh (installed as
|
||||
/etc/cont-init.d/01-hermes-setup, which runs lexicographically BEFORE
|
||||
02-reconcile-profiles) seeds the state file so the reconciler sees
|
||||
prior_state=running and brings the slot up on the very first boot.
|
||||
|
||||
This mirrors the existing HERMES_AUTH_JSON_BOOTSTRAP env-seed pattern: it seeds
|
||||
the SAME gateway_state.json the reconciler already consults, guarded by
|
||||
``[ ! -f ]`` so persisted runtime state always wins on subsequent boots (a
|
||||
deliberately-stopped gateway must stay stopped across restarts).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
STAGE2_HOOK = REPO_ROOT / "docker" / "stage2-hook.sh"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def stage2_text() -> str:
|
||||
if not STAGE2_HOOK.exists():
|
||||
pytest.skip("docker/stage2-hook.sh not present in this checkout")
|
||||
return STAGE2_HOOK.read_text()
|
||||
|
||||
|
||||
def _seed_block(text: str) -> str:
|
||||
"""Extract the ``if [ ! -f "$HERMES_HOME/gateway_state.json" ] && … fi``
|
||||
block that seeds the gateway state file from the bootstrap env var."""
|
||||
m = re.search(
|
||||
r'(if \[ ! -f "\$HERMES_HOME/gateway_state\.json" \] && \\\n'
|
||||
r"(?:.*\n)*?fi)",
|
||||
text,
|
||||
)
|
||||
assert m, (
|
||||
"stage2-hook.sh must contain the gateway_state.json bootstrap-seed block "
|
||||
"guarded on HERMES_GATEWAY_BOOTSTRAP_STATE"
|
||||
)
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def test_seed_block_present_and_guarded(stage2_text: str) -> None:
|
||||
block = _seed_block(stage2_text)
|
||||
# Must be a first-boot-only seed (the [ ! -f ] guard) keyed on the env var.
|
||||
assert '[ ! -f "$HERMES_HOME/gateway_state.json" ]' in block, (
|
||||
"seed must be guarded by [ ! -f ] so persisted state wins on restart"
|
||||
)
|
||||
assert "HERMES_GATEWAY_BOOTSTRAP_STATE" in block
|
||||
assert "gateway_state" in block
|
||||
|
||||
|
||||
def _run_seed(
|
||||
text: str, *, env_value: str | None, preexisting: str | None
|
||||
) -> str | None:
|
||||
"""Run the extracted seed block in a sandbox $HERMES_HOME.
|
||||
|
||||
``env_value`` is the HERMES_GATEWAY_BOOTSTRAP_STATE value (None = unset).
|
||||
``preexisting`` is the contents of a gateway_state.json placed before the
|
||||
block runs (None = no file). Returns the file's contents afterwards, or
|
||||
None if it doesn't exist. ``chown``/``chmod`` are stubbed so the block
|
||||
runs without real root.
|
||||
"""
|
||||
bash = shutil.which("bash")
|
||||
if bash is None:
|
||||
pytest.skip("bash not available")
|
||||
block = _seed_block(text)
|
||||
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
dpath = Path(d)
|
||||
home = dpath / "home"
|
||||
home.mkdir()
|
||||
state_file = home / "gateway_state.json"
|
||||
if preexisting is not None:
|
||||
state_file.write_text(preexisting)
|
||||
|
||||
env_line = (
|
||||
f'export HERMES_GATEWAY_BOOTSTRAP_STATE="{env_value}"\n'
|
||||
if env_value is not None
|
||||
else "unset HERMES_GATEWAY_BOOTSTRAP_STATE\n"
|
||||
)
|
||||
script = (
|
||||
"set -e\n"
|
||||
f'HERMES_HOME="{home}"\n'
|
||||
# Stub privilege ops — the sandbox isn't root.
|
||||
"chown() { :; }\n"
|
||||
"chmod() { :; }\n"
|
||||
+ env_line
|
||||
+ block
|
||||
)
|
||||
script_path = dpath / "harness.sh"
|
||||
script_path.write_text(script)
|
||||
|
||||
proc = subprocess.run(
|
||||
[bash, str(script_path)], capture_output=True, text=True
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
|
||||
if not state_file.exists():
|
||||
return None
|
||||
return state_file.read_text()
|
||||
|
||||
|
||||
def test_seeds_running_state_on_blank_volume(stage2_text: str) -> None:
|
||||
"""env=running + no pre-existing file -> writes a valid running state."""
|
||||
out = _run_seed(stage2_text, env_value="running", preexisting=None)
|
||||
assert out is not None, "seed must create gateway_state.json"
|
||||
assert json.loads(out).get("gateway_state") == "running"
|
||||
|
||||
|
||||
def test_does_not_clobber_existing_state(stage2_text: str) -> None:
|
||||
"""The [ ! -f ] guard: an existing state file is never overwritten, even
|
||||
when the bootstrap env var says running. A deliberately-stopped gateway
|
||||
must stay stopped across restarts."""
|
||||
existing = json.dumps({"gateway_state": "stopped", "pid": 123})
|
||||
out = _run_seed(stage2_text, env_value="running", preexisting=existing)
|
||||
assert out == existing, "seed must not clobber a persisted state file"
|
||||
|
||||
|
||||
def test_no_seed_when_env_unset(stage2_text: str) -> None:
|
||||
"""No env var -> no file written (preserves the default down-on-first-boot
|
||||
behaviour for orchestrators that don't opt in)."""
|
||||
out = _run_seed(stage2_text, env_value=None, preexisting=None)
|
||||
assert out is None, "seed must not run when HERMES_GATEWAY_BOOTSTRAP_STATE is unset"
|
||||
|
||||
|
||||
def test_non_running_value_ignored(stage2_text: str) -> None:
|
||||
"""Only a literal "running" is honoured; any other value is ignored so a
|
||||
typo can't write a bogus state. (The reconciler's _AUTOSTART_STATES is
|
||||
exactly {"running"}.)"""
|
||||
for bogus in ("stopped", "Running", "1", "true", "starting"):
|
||||
out = _run_seed(stage2_text, env_value=bogus, preexisting=None)
|
||||
assert out is None, (
|
||||
f"only 'running' should seed a state file, not {bogus!r}"
|
||||
)
|
||||
@@ -192,6 +192,63 @@ export async function buildWsAuthParam(): Promise<[string, string]> {
|
||||
return ["token", token];
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated ``fetch`` for dashboard ``/api/...`` requests that aren't
|
||||
* plain JSON — file uploads (``FormData``), binary downloads (blobs), etc.
|
||||
* Mirrors ``fetchJSON``'s auth handling but returns the raw ``Response`` so
|
||||
* the caller can read ``.blob()`` / ``.formData()`` / stream it.
|
||||
*
|
||||
* Auth, in both modes, exactly as ``fetchJSON`` does it:
|
||||
* - loopback / ``--insecure``: attach the ``X-Hermes-Session-Token`` header.
|
||||
* - gated OAuth: no token header (it's absent by design); the
|
||||
* ``hermes_session_at`` cookie rides along via ``credentials: 'include'``.
|
||||
*
|
||||
* Unlike ``fetchJSON`` this does NOT parse the body, does NOT throw on
|
||||
* non-2xx (the caller decides — a 404 on a download is meaningful), and
|
||||
* does NOT run the global 401 → /login redirect (binary endpoints aren't
|
||||
* navigation targets). Callers that want the redirect behaviour should use
|
||||
* ``fetchJSON``.
|
||||
*/
|
||||
export async function authedFetch(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(init?.headers);
|
||||
const token = window.__HERMES_SESSION_TOKEN__;
|
||||
if (token) {
|
||||
setSessionHeader(headers, token);
|
||||
}
|
||||
return fetch(`${BASE}${url}`, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: init?.credentials ?? "include",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an absolute ``ws(s)://`` URL for a dashboard WebSocket endpoint,
|
||||
* with the correct auth query param appended for the active mode (fresh
|
||||
* single-use ``ticket`` in gated mode, ``token`` in loopback). Plugins and
|
||||
* the SPA should use this instead of hand-assembling a WS URL + reading
|
||||
* ``window.__HERMES_SESSION_TOKEN__`` directly, so the gated-mode ticket
|
||||
* path can never be forgotten.
|
||||
*
|
||||
* ``path`` is the dashboard-relative path (e.g.
|
||||
* ``"/api/plugins/kanban/events"``); the base-path prefix and host are
|
||||
* applied here. Extra query params can be supplied via ``params`` and are
|
||||
* merged before the auth param.
|
||||
*/
|
||||
export async function buildWsUrl(
|
||||
path: string,
|
||||
params?: Record<string, string>,
|
||||
): Promise<string> {
|
||||
const [authName, authValue] = await buildWsAuthParam();
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const qs = new URLSearchParams(params ?? {});
|
||||
qs.set(authName, authValue);
|
||||
return `${proto}//${window.location.host}${BASE}${path}?${qs}`;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
|
||||
/**
|
||||
@@ -361,15 +418,58 @@ export const api = {
|
||||
deleteCronJob: (id: string, profile = "default") =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${encodeURIComponent(id)}?profile=${encodeURIComponent(profile)}`, { method: "DELETE" }),
|
||||
|
||||
// Profiles (minimal)
|
||||
// Profiles
|
||||
getProfiles: () =>
|
||||
fetchJSON<{ profiles: ProfileInfo[] }>("/api/profiles"),
|
||||
createProfile: (body: { name: string; clone_from_default: boolean }) =>
|
||||
fetchJSON<{ ok: boolean; name: string; path: string }>("/api/profiles", {
|
||||
getActiveProfile: () =>
|
||||
fetchJSON<ActiveProfileInfo>("/api/profiles/active"),
|
||||
setActiveProfile: (name: string) =>
|
||||
fetchJSON<{ ok: boolean; active: string }>("/api/profiles/active", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
createProfile: (body: {
|
||||
name: string;
|
||||
clone_from_default: boolean;
|
||||
clone_all?: boolean;
|
||||
no_skills?: boolean;
|
||||
description?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
}) =>
|
||||
fetchJSON<{ ok: boolean; name: string; path: string; model_set?: boolean }>("/api/profiles", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
updateProfileDescription: (name: string, description: string) =>
|
||||
fetchJSON<{ ok: boolean; description: string; description_auto: boolean }>(
|
||||
`/api/profiles/${encodeURIComponent(name)}/description`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ description }),
|
||||
},
|
||||
),
|
||||
describeProfileAuto: (name: string, overwrite = true) =>
|
||||
fetchJSON<ProfileDescribeAutoResult>(
|
||||
`/api/profiles/${encodeURIComponent(name)}/describe-auto`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ overwrite }),
|
||||
},
|
||||
),
|
||||
setProfileModel: (name: string, provider: string, model: string) =>
|
||||
fetchJSON<{ ok: boolean; provider: string; model: string }>(
|
||||
`/api/profiles/${encodeURIComponent(name)}/model`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider, model }),
|
||||
},
|
||||
),
|
||||
renameProfile: (name: string, newName: string) =>
|
||||
fetchJSON<{ ok: boolean; name: string; path: string }>(
|
||||
`/api/profiles/${encodeURIComponent(name)}`,
|
||||
@@ -495,6 +595,10 @@ export const api = {
|
||||
fetchJSON<ActionResponse>("/api/gateway/restart", { method: "POST" }),
|
||||
updateHermes: () =>
|
||||
fetchJSON<ActionResponse>("/api/hermes/update", { method: "POST" }),
|
||||
checkHermesUpdate: (force = false) =>
|
||||
fetchJSON<UpdateCheckResponse>(
|
||||
`/api/hermes/update/check${force ? "?force=true" : ""}`,
|
||||
),
|
||||
getActionStatus: (name: string, lines = 200) =>
|
||||
fetchJSON<ActionStatusResponse>(
|
||||
`/api/actions/${encodeURIComponent(name)}/status?lines=${lines}`,
|
||||
@@ -1009,6 +1113,18 @@ export interface HookCreate {
|
||||
approve?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateCheckResponse {
|
||||
install_method: string;
|
||||
current_version: string;
|
||||
// commits behind: >=1 known count, 0 up to date, -1 behind by unknown
|
||||
// count (nix/pypi), or null when the check could not run.
|
||||
behind: number | null;
|
||||
update_available: boolean;
|
||||
can_apply: boolean;
|
||||
update_command: string;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface SystemStats {
|
||||
os: string;
|
||||
os_release: string;
|
||||
@@ -1154,6 +1270,8 @@ export interface EnvVarInfo {
|
||||
is_password: boolean;
|
||||
tools: string[];
|
||||
advanced: boolean;
|
||||
/** True when this var is a messaging-platform credential owned by the Channels page. */
|
||||
channel_managed?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionMessage {
|
||||
@@ -1234,6 +1352,18 @@ export interface AnalyticsResponse {
|
||||
};
|
||||
}
|
||||
|
||||
export interface ActiveProfileInfo {
|
||||
active: string;
|
||||
current: string;
|
||||
}
|
||||
|
||||
export interface ProfileDescribeAutoResult {
|
||||
ok: boolean;
|
||||
reason: string;
|
||||
description: string | null;
|
||||
description_auto: boolean;
|
||||
}
|
||||
|
||||
export interface ProfileInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
@@ -1242,6 +1372,13 @@ export interface ProfileInfo {
|
||||
provider: string | null;
|
||||
has_env: boolean;
|
||||
skill_count: number;
|
||||
gateway_running: boolean;
|
||||
description: string;
|
||||
description_auto: boolean;
|
||||
distribution_name: string | null;
|
||||
distribution_version: string | null;
|
||||
distribution_source: string | null;
|
||||
has_alias: boolean;
|
||||
}
|
||||
|
||||
export interface ModelsAnalyticsModelEntry {
|
||||
|
||||
@@ -17,7 +17,7 @@ import React, {
|
||||
useContext,
|
||||
createContext,
|
||||
} from "react";
|
||||
import { api, fetchJSON } from "@/lib/api";
|
||||
import { api, fetchJSON, authedFetch, buildWsUrl, buildWsAuthParam } from "@/lib/api";
|
||||
import { cn, timeAgo, isoTimeAgo } from "@/lib/utils";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
@@ -88,15 +88,18 @@ export function getRegisteredCount(): number {
|
||||
// Expose SDK + registry on window
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__HERMES_PLUGIN_SDK__: unknown;
|
||||
__HERMES_PLUGINS__: {
|
||||
register: typeof registerPlugin;
|
||||
registerSlot: typeof registerSlot;
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Version of the plugin SDK contract (see ``plugins/sdk.d.ts``). Bump the
|
||||
* major on any backwards-incompatible change to the exposed surface;
|
||||
* additive changes (new optional fields / helpers) don't require a bump.
|
||||
* Exposed at runtime as ``window.__HERMES_PLUGIN_SDK__.sdkVersion`` so a
|
||||
* plugin (or a future host-side compatibility gate) can read it.
|
||||
*/
|
||||
export const SDK_CONTRACT_VERSION = "1.1.0";
|
||||
|
||||
// Window globals for the plugin SDK are declared in ``plugins/sdk.d.ts`` —
|
||||
// the single source of truth for the public contract. Don't redeclare them
|
||||
// here (duplicate ambient declarations with differing modifiers conflict).
|
||||
|
||||
export function exposePluginSDK() {
|
||||
window.__HERMES_PLUGINS__ = {
|
||||
@@ -105,6 +108,9 @@ export function exposePluginSDK() {
|
||||
};
|
||||
|
||||
window.__HERMES_PLUGIN_SDK__ = {
|
||||
// Contract version of the plugin SDK surface (see plugins/sdk.d.ts).
|
||||
// Bump on backwards-incompatible changes; additive changes don't need it.
|
||||
sdkVersion: SDK_CONTRACT_VERSION,
|
||||
// React core — plugins use these instead of importing react
|
||||
React,
|
||||
hooks: {
|
||||
@@ -119,8 +125,19 @@ export function exposePluginSDK() {
|
||||
|
||||
// Hermes API client
|
||||
api,
|
||||
// Raw fetchJSON for plugin-specific endpoints
|
||||
// Raw fetchJSON for plugin-specific JSON endpoints
|
||||
fetchJSON,
|
||||
// Authenticated fetch for non-JSON endpoints (uploads / blob downloads).
|
||||
// Handles loopback-token vs gated-cookie auth so plugins never read
|
||||
// window.__HERMES_SESSION_TOKEN__ directly.
|
||||
authedFetch,
|
||||
// Build a ws(s):// URL with the correct auth param for the active mode
|
||||
// (single-use ticket in gated mode, token in loopback). Use this for any
|
||||
// plugin WebSocket instead of hand-assembling the URL.
|
||||
buildWsUrl,
|
||||
// Lower-level: resolve just the [authParamName, authParamValue] pair, for
|
||||
// plugins that need to build the WS URL themselves.
|
||||
buildWsAuthParam,
|
||||
|
||||
// UI components — Nous DS where available, shadcn/ui primitives elsewhere.
|
||||
components: {
|
||||
|
||||
160
web/src/plugins/sdk.d.ts
vendored
Normal file
160
web/src/plugins/sdk.d.ts
vendored
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Hermes Dashboard Plugin SDK — typed contract (SPIKE)
|
||||
* ====================================================
|
||||
*
|
||||
* This is the public type surface for ``window.__HERMES_PLUGIN_SDK__`` and
|
||||
* ``window.__HERMES_PLUGINS__``, the globals the dashboard host exposes to
|
||||
* plugin bundles (see ``web/src/plugins/registry.ts::exposePluginSDK``).
|
||||
*
|
||||
* STATUS: spike. This file documents the contract and gives plugin authors
|
||||
* (in-repo IIFEs and external bundles alike) editor types without bundling
|
||||
* their own copies of React / the API client. It is intentionally a
|
||||
* hand-authored ambient declaration rather than ``typeof
|
||||
* window.__HERMES_PLUGIN_SDK__`` because:
|
||||
* 1. The runtime object is assembled from many internal modules
|
||||
* (``@/lib/api``, ``@nous-research/ui``, …). Deriving the type would
|
||||
* leak those internal import paths into the public contract and couple
|
||||
* external plugins to the host's internal module layout.
|
||||
* 2. A hand-authored contract is the *versioned API boundary* — changing
|
||||
* it is a deliberate act, visible in review, not an accidental
|
||||
* consequence of refactoring an internal helper.
|
||||
*
|
||||
* Versioning: bump ``HermesPluginSDK["sdkVersion"]`` (and the
|
||||
* ``SDK_CONTRACT_VERSION`` const the host exposes) on any
|
||||
* backwards-incompatible change to this surface. Additive changes
|
||||
* (new optional fields, new helpers) don't require a major bump.
|
||||
*
|
||||
* OPEN QUESTIONS for productionising this spike (do not block the auth fix):
|
||||
* - Ship as a published ``@hermes/dashboard-plugin-sdk`` types package, or
|
||||
* keep in-repo and copy into external plugin repos?
|
||||
* - Should the host assert at runtime that a plugin's declared
|
||||
* ``manifest.sdk_version`` is compatible before executing it?
|
||||
* - The ``components`` map is typed loosely as ``Record<string,
|
||||
* ComponentType>`` here; do we want exact per-component prop types
|
||||
* (pulls @nous-research/ui types into the contract) or is the loose
|
||||
* shape the right boundary for external authors?
|
||||
*/
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth-relevant helpers (the surface this PR adds/sanctions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* JSON ``fetch`` for dashboard ``/api/...`` endpoints. Handles auth in both
|
||||
* modes (loopback session-token header / gated cookie), throws
|
||||
* ``Error("<status>: <body>")`` on non-2xx, and triggers the global
|
||||
* 401 → /login redirect in gated mode. Use for all JSON plugin endpoints.
|
||||
*/
|
||||
export type FetchJSON = <T = unknown>(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
options?: { allowUnauthorized?: boolean },
|
||||
) => Promise<T>;
|
||||
|
||||
/**
|
||||
* Authenticated ``fetch`` for NON-JSON endpoints (uploads via ``FormData``,
|
||||
* binary/blob downloads). Same auth handling as ``fetchJSON`` but returns
|
||||
* the raw ``Response``, does not parse, does not throw on non-2xx, and does
|
||||
* not run the 401 redirect. Plugins MUST use this (or ``fetchJSON``) instead
|
||||
* of calling ``fetch`` with a hand-read ``window.__HERMES_SESSION_TOKEN__``.
|
||||
*/
|
||||
export type AuthedFetch = (url: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* Build an absolute ``ws(s)://`` URL for a dashboard WebSocket endpoint with
|
||||
* the correct auth query param for the active mode (single-use ``ticket`` in
|
||||
* gated OAuth mode, ``token`` in loopback). Plugins MUST use this for any
|
||||
* WebSocket instead of hand-assembling the URL + reading the session token.
|
||||
*/
|
||||
export type BuildWsUrl = (
|
||||
path: string,
|
||||
params?: Record<string, string>,
|
||||
) => Promise<string>;
|
||||
|
||||
/** Lower-level: just the ``[authParamName, authParamValue]`` pair. */
|
||||
export type BuildWsAuthParam = () => Promise<[string, string]>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry surface (window.__HERMES_PLUGINS__)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PluginRegistry {
|
||||
/** Register the plugin's main tab component by manifest name. */
|
||||
register(name: string, component: ComponentType<Record<string, never>>): void;
|
||||
/** Register a component into a named host slot. */
|
||||
registerSlot(slot: string, name: string, component: ComponentType): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SDK surface (window.__HERMES_PLUGIN_SDK__)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface HermesPluginSDK {
|
||||
/** Contract version of this SDK surface (see SDK_CONTRACT_VERSION). */
|
||||
readonly sdkVersion: string;
|
||||
|
||||
/** React core — use instead of importing/bundling react. */
|
||||
React: typeof import("react").default;
|
||||
hooks: {
|
||||
useState: typeof import("react").useState;
|
||||
useEffect: typeof import("react").useEffect;
|
||||
useCallback: typeof import("react").useCallback;
|
||||
useMemo: typeof import("react").useMemo;
|
||||
useRef: typeof import("react").useRef;
|
||||
useContext: typeof import("react").useContext;
|
||||
createContext: typeof import("react").createContext;
|
||||
};
|
||||
|
||||
/**
|
||||
* Typed convenience client for core dashboard endpoints. Typed permissively
|
||||
* at the boundary (methods vary in arity and return type — most return
|
||||
* ``Promise<T>``, a few return a URL string synchronously); plugins call the
|
||||
* specific methods they need. See ``web/src/lib/api.ts`` for the concrete shape.
|
||||
*/
|
||||
api: Record<string, (...args: never[]) => unknown>;
|
||||
|
||||
/** JSON fetch with host auth handling. */
|
||||
fetchJSON: FetchJSON;
|
||||
/** Authenticated raw fetch for uploads / blob downloads. */
|
||||
authedFetch: AuthedFetch;
|
||||
/** Build an auth'd WebSocket URL for the active mode. */
|
||||
buildWsUrl: BuildWsUrl;
|
||||
/** Resolve just the WS auth query-param pair. */
|
||||
buildWsAuthParam: BuildWsAuthParam;
|
||||
|
||||
/**
|
||||
* Shared UI primitives (Nous DS / shadcn). Typed permissively at the
|
||||
* boundary: the host's concrete components (some of which require props like
|
||||
* ``active``/``value``/``name``) must be assignable here, and external plugin
|
||||
* authors render them dynamically without the host's internal prop types.
|
||||
* ``ComponentType<never>`` accepts any component regardless of its prop
|
||||
* requirements (props are contravariant).
|
||||
*/
|
||||
components: Record<string, ComponentType<never>>;
|
||||
|
||||
utils: {
|
||||
cn: (...classes: Array<string | false | null | undefined>) => string;
|
||||
/** Relative-time formatter. Accepts an epoch-ms number. */
|
||||
timeAgo: (ts: number) => string;
|
||||
/** Relative-time formatter for an ISO-8601 string. */
|
||||
isoTimeAgo: (iso: string) => string;
|
||||
};
|
||||
|
||||
/**
|
||||
* i18n hook. Returns the host's i18n context value; typed loosely at the
|
||||
* boundary so the contract doesn't couple to the host's internal
|
||||
* ``I18nContextValue`` shape. Plugins typically call ``useI18n().t(...)``.
|
||||
*/
|
||||
useI18n: () => unknown;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__HERMES_PLUGIN_SDK__?: HermesPluginSDK;
|
||||
__HERMES_PLUGINS__?: PluginRegistry;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -519,6 +519,7 @@ Advanced per-platform knobs for throttling the outbound message batcher. Most us
|
||||
| `HERMES_GATEWAY_BUSY_INPUT_MODE` | Default gateway busy-input behavior: `queue`, `steer`, or `interrupt`. Can be overridden per chat with `/busy`. |
|
||||
| `HERMES_GATEWAY_BUSY_ACK_ENABLED` | Whether the gateway sends an acknowledgment message (⚡/⏳/⏩) when a user sends input while the agent is busy (default: `true`). Set to `false` to suppress these messages entirely — the input is still queued/steered/interrupts as normal, only the chat reply is silenced. Bridged from `display.busy_ack_enabled` in `config.yaml`. |
|
||||
| `HERMES_GATEWAY_NO_SUPERVISE` | Inside the s6-overlay Docker image, opt out of auto-supervision when running `hermes gateway run` and use pre-s6 foreground semantics (no auto-restart, gateway is the container's main process). Truthy values: `1`, `true`, `yes`. Equivalent to the `--no-supervise` CLI flag. No-op outside the s6 image. |
|
||||
| `HERMES_GATEWAY_BOOTSTRAP_STATE` | Inside the s6-overlay Docker image, declare the gateway's **initial** supervised state on a fresh volume. On a blank volume there is no persisted `gateway_state.json`, so the boot reconciler registers the `gateway-default` slot but leaves it **down** (it only auto-starts when the last recorded state was `running`). Set this to `running` and the first-boot setup hook seeds `gateway_state.json` *before* the reconciler runs, so the gateway comes up on the very first boot. Only the literal value `running` is honoured. First-boot-only: an existing `gateway_state.json` is never overwritten, so a deliberately-stopped gateway stays stopped across restarts. No-op outside the s6 image. |
|
||||
| `HERMES_FILE_MUTATION_VERIFIER` | Enable the per-turn file-mutation verifier footer (default: `true`). When enabled, Hermes appends an advisory listing any `write_file` / `patch` calls that failed during the turn and were not superseded by a successful write. Set to `0`, `false`, `no`, or `off` to suppress. Mirrors `display.file_mutation_verifier` in `config.yaml`; the env var wins when set. |
|
||||
| `HERMES_CRON_TIMEOUT` | Inactivity timeout for cron job agent runs in seconds (default: `600`). The agent can run indefinitely while actively calling tools or receiving stream tokens — this only triggers when idle. Set to `0` for unlimited. |
|
||||
| `HERMES_CRON_SCRIPT_TIMEOUT` | Timeout for pre-run scripts attached to cron jobs in seconds (default: `120`). Override for scripts that need longer execution (e.g., randomized delays for anti-bot timing). Also configurable via `cron.script_timeout_seconds` in `config.yaml`. |
|
||||
|
||||
Reference in New Issue
Block a user