Merge origin/main into copilot-seams/integration

Conflict: hermes_cli/env_loader.py import block. Kept the branch's
left_core_migration.profile_env_names import and main's ConfigBackend
switch (load_yaml_file_readonly no longer imported; _load_secrets_config
reads through config_backend.read_config_doc_readonly).

No main commit since 0c771d7a5b touches Copilot code; nothing to port.
This commit is contained in:
Teknium committed 2026-10-25 21:02:41 -07:00
commit 2f644f3fed
203 files changed
+7254 -897

No files matched your search

+5
View File
@@ -252,6 +252,11 @@ jobs:
- name: Forbid config.yaml writers that bypass the comment-preserving writer
run: python scripts/check_config_yaml_writers.py
# Every read of a user config.yaml goes through hermes_cli.config_backend, so a non-file
# config backend (config-config design §4.1) sees every reader.
- name: Forbid config.yaml readers that bypass the config backend
run: python scripts/check_config_yaml_readers.py
# The OS lanes import only files carrying the matching marker, so a test that fakes
# macOS (is_macos -> True, sys.platform -> "darwin") without `platforms("macos")` is green on
# Linux over a faked branch and never runs on macOS (#111866, tests/AGENTS.md § Don't fake the host OS).
+1 -5
View File
@@ -653,11 +653,7 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
state.cancel_event.set()
try:
if state.agent:
request_hard_interrupt(state.agent)
# Background delegations are detached from the turn's fan-out; they end with the cancel.
from tools.async_delegation import interrupt_for_session
interrupt_for_session(parent_session_id=str(getattr(state.agent, "session_id", "") or ""),
reason="acp_cancel")
request_hard_interrupt(state.agent) # this turn only: background delegations keep running
except Exception:
logger.debug("Failed to interrupt ACP session %s", session_id, exc_info=True)
logger.info("Cancelled session %s", session_id)
+2 -5
View File
@@ -126,11 +126,8 @@ def _resolve_preset_cached(preset_name: str) -> tuple[dict[str, Any], Any]:
(skips resolve_moa_preset's full validation of the moa block on every create())."""
from hermes_cli.config import get_config_path, load_config
from hermes_cli.moa_config import resolve_moa_preset
from utils import file_signature
try:
cfg_stamp = file_signature(get_config_path().stat())
except OSError:
cfg_stamp = None
from hermes_cli.config_backend import config_version_or_none
cfg_stamp = config_version_or_none(get_config_path())
moa_raw = load_config().get("moa") or {}
key = (cfg_stamp, preset_name)
with _preset_cache_lock:
+10 -10
View File
@@ -209,23 +209,22 @@ def _raw_config_cache_clear() -> None:
def _config_cache_key(config_path: Path) -> Optional[tuple[str, int, int, int, int]]:
"""``(path, *file_signature)`` identity of config.yaml, or None when unreadable/absent."""
try:
from utils import file_signature
return (str(config_path), *file_signature(config_path.stat()))
except OSError:
return None
"""``(path, *backend version)`` identity of config.yaml, or None when unreadable/absent."""
from hermes_cli.config_backend import config_version_or_none
version = config_version_or_none(config_path)
return None if version is None else (str(config_path), *version)
def _load_raw_config() -> dict[str, Any]:
"""Read config.yaml with an mtime+size keyed cache (no hermes_cli.config import)."""
"""Read config.yaml with a version-keyed cache (no hermes_cli.config import)."""
from agent.safe_worker_policy import worker_config_snapshot
from hermes_cli.config_backend import config_exists, read_config_doc
snapshot = worker_config_snapshot()
if snapshot is not None:
return snapshot
config_path = get_config_path()
if not config_path.exists():
if not config_exists(config_path):
return {}
cache_key = _config_cache_key(config_path)
with _RAW_CONFIG_CACHE_LOCK:
@@ -233,7 +232,7 @@ def _load_raw_config() -> dict[str, Any]:
if cached is not None:
return cached
try:
parsed = yaml_load(config_path.read_text(encoding="utf-8-sig"))
parsed = read_config_doc(config_path)
except Exception as e:
logger.debug("Could not read skill config %s: %s", config_path, e)
return {}
@@ -337,8 +336,9 @@ def _config_str_list(raw) -> list[str]:
def get_external_skills_dirs() -> list[Path]:
"""Validated, deduplicated ``skills.external_dirs`` (existing dirs only). Entries
are ``~``/``${VAR}`` expanded, relative to HERMES_HOME; the local skills dir is skipped."""
from hermes_cli.config_backend import config_exists
config_path = get_config_path()
if not config_path.exists():
if not config_exists(config_path):
return []
full_key = _config_cache_key(config_path)
cache_key = full_key
+29
View File
@@ -809,3 +809,32 @@ export function sessionReadOwnerPin(id: string, profile?: ProfileScope): { conne
...(profile == null && ownerProfile ? { profile: ownerProfile } : {})
}
}
/** The backend a gateway FILE request naming session `id` must go to: the owner's
* backend-facing profile, plus its connection when that is not the ambient one.
* Null when no owner profile is known. A file route looks `session_id` up in
* the profile the request names, so the id must never travel with another
* profile: a caller that gets null names no session at all. */
export function sessionFileOwnerScope(id: string): { connectionId?: string; profile: string } | null {
const owner = _sessionOwnerResolver?.(id)
if (!owner) {
return null
}
if (typeof owner === 'string') {
const profile = owner.trim()
return profile ? { profile } : null
}
const profile = String(owner.targetProfile || owner.profile || '').trim()
if (!profile) {
return null
}
const connectionId = String(owner.connectionId ?? '').trim()
return connectionId && connectionId !== (ambientOwnerConnectionId() ?? 'local') ? { connectionId, profile } : { profile }
}
@@ -12,6 +12,7 @@ const copy: MicRecorderErrorCopy = {
microphoneAccessDenied: 'denied',
microphoneConstraintsUnsupported: 'constraints',
microphoneInUse: 'in use',
microphoneNeedsHttps: 'needs https',
microphonePermissionDenied: 'permission',
microphoneStartFailed: 'start failed',
microphoneUnsupported: 'unsupported',
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { closeMeterContext, meterContextsClosed } from '@/lib/mic-meter-context'
import { isBrowserHostedDesktop } from '@/lib/platform'
type BrowserAudioContext = typeof AudioContext
@@ -33,6 +34,7 @@ export interface MicRecorderErrorCopy {
microphoneAccessDenied: string
microphoneConstraintsUnsupported: string
microphoneInUse: string
microphoneNeedsHttps: string
microphonePermissionDenied: string
microphoneStartFailed: string
microphoneUnsupported: string
@@ -45,6 +47,15 @@ interface MicRecorderHandle {
cancel: () => void
}
/** "Microphone unsupported" copy. The Webapp opened over plain HTTP from another machine is an
* insecure context with no mediaDevices: it says HTTPS or a tunnel fixes that. Native Desktop
* keeps the plain copy. */
function unsupportedMicrophone(copy: MicRecorderErrorCopy): string {
return isBrowserHostedDesktop() && window.isSecureContext === false
? copy.microphoneNeedsHttps
: copy.microphoneUnsupported
}
/** Recorder + live-start mic failures → the same friendly copy: a DOMException
* name is mapped, an unrecognized DOMException falls back to the generic start
* copy, and anything else keeps its own message (non-mic failures must not be
@@ -68,6 +79,10 @@ export function micError(error: unknown, copy: MicRecorderErrorCopy): Error {
return new Error(copy.microphoneConstraintsUnsupported)
}
if (name === 'NotSupportedError') {
return new Error(unsupportedMicrophone(copy))
}
if (error instanceof DOMException) {
return new Error(copy.microphoneStartFailed)
}
@@ -268,7 +283,7 @@ export function useMicRecorder(copy: MicRecorderErrorCopy): {
}
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
throw new Error(copy.microphoneUnsupported)
throw new Error(unsupportedMicrophone(copy))
}
const permitted = await window.hermesDesktop?.requestMicrophoneAccess?.()
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { dropWarning } from './drop-warning'
const more = (count: number) => `and ${count} more`
const folder = "Folders can't be attached by dropping them. Use Attach → Folder… instead."
const limit = 'The file is larger than 16 MiB, the largest file the browser can attach.'
// Reasons are sentences; joined with '; ' their periods used to read '…instead.; Could not attach…'.
describe('dropWarning', () => {
it('drops the period of a reason another item follows, and keeps the last one', () => {
const { detail, message } = dropWarning(
[
{ name: 'somefolder', reason: folder },
{ name: 'huge.zip', reason: limit }
],
more
)
expect(message).toBe(
`Could not attach somefolder: ${folder.slice(0, -1)}; Could not attach huge.zip: ${limit}`
)
expect(message).not.toContain('.;')
expect(detail).toBeUndefined()
})
it('drops the period before the count of the rest, and keeps each reason whole in the detail', () => {
const failures = [
{ name: 'somefolder', reason: folder },
...Array.from({ length: 5 }, (_, index) => ({ name: `${'z'.repeat(135)}-${index}.zip`, reason: limit }))
]
const { detail, message } = dropWarning(failures, more)
expect(message).toMatch(/[^.]; and \d+ more$/)
expect(message).not.toContain('.;')
expect(detail?.split('\n')).toEqual(failures.map(({ name, reason }) => `Could not attach ${name}: ${reason}`))
})
it('keeps a lone reason as it is, period included, with no detail', () => {
expect(dropWarning([{ name: 'huge.zip', reason: limit }], more)).toEqual({
message: `Could not attach huge.zip: ${limit}`
})
})
})
@@ -0,0 +1,66 @@
/** A drop item the composer could not attach: what was dropped, and why when it is known. */
export interface DropFailure {
name: string
reason?: string
}
/** How many failed drop items the warning names before it counts the rest. */
const DROP_FAILURES_SHOWN = 3
/** The longest name the warning shows whole; a longer one keeps its start and its end. */
const DROP_FAILURE_NAME_CHARS = 48
/** The warning names failures until this many characters, then counts the rest. */
const DROP_WARNING_CHARS = 240
function middleEllipsis(text: string, max: number): string {
if (text.length <= max) {
return text
}
const head = Math.ceil((max - 1) / 2)
return `${text.slice(0, head)}…${text.slice(text.length - (max - 1 - head))}`
}
function failureText({ name, reason }: DropFailure, maxName = Infinity, joined = false): string {
const label = `Could not attach ${middleEllipsis(name, maxName)}`
// A reason ends with a period, which reads '….;' before the '; ' that joins the next item.
const text = joined ? reason?.replace(/\.$/, '') : reason
return text ? `${label}: ${text}` : label
}
/** The failures joined into one line, then the count of the rest when there is one. */
function summaryText(failures: DropFailure[], maxName: number, rest?: string): string {
const items = failures.map((failure, index) =>
failureText(failure, maxName, rest !== undefined || index < failures.length - 1)
)
return (rest === undefined ? items : [...items, rest]).join('; ')
}
/** One bounded warning for a drop's failures: shortened names, a few of them, and a count of the
* rest, so a large drop or long paths can't grow the toast off screen. Whenever anything is
* shortened or left out, the full list, each reason whole, rides in the collapsible detail. */
export function dropWarning(
failures: DropFailure[],
more: (count: number) => string
): { detail?: string; message: string } {
const shown: DropFailure[] = []
for (const failure of failures.slice(0, DROP_FAILURES_SHOWN)) {
if (shown.length && summaryText([...shown, failure], DROP_FAILURE_NAME_CHARS).length > DROP_WARNING_CHARS) {
break
}
shown.push(failure)
}
const hidden = failures.length - shown.length
const message = summaryText(shown, DROP_FAILURE_NAME_CHARS, hidden > 0 ? more(hidden) : undefined)
return message === summaryText(failures, Infinity)
? { message }
: { detail: failures.map(failure => failureText(failure)).join('\n'), message }
}
@@ -0,0 +1,153 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { $notifications } from '@/store/notifications'
import { useComposerActions } from './use-composer-actions'
// A drop item the composer cannot attach is reported in one bounded warning, never dropped silently.
describe('useComposerActions drop warnings', () => {
afterEach(() => {
Reflect.deleteProperty(window, 'hermesDesktop')
vi.unstubAllGlobals()
vi.clearAllMocks()
})
it('warns about a dropped folder it cannot attach instead of staging its File', async () => {
const stageFileForAttach = vi.fn(async () => '/srv/uploads/web-my-repo')
const value = { getPathForFile: () => '', stageFileForAttach }
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value })
const { result } = renderHook(() =>
useComposerActions({ activeSessionId: null, currentCwd: '/w', requestGateway: vi.fn() })
)
let attached = true
await act(async () => {
attached = await result.current.attachDroppedItems([{ file: new File([], 'shots.png'), isDirectory: true, path: '' }])
})
expect(attached).toBe(false)
expect(stageFileForAttach).not.toHaveBeenCalled()
expect($notifications.get().at(-1)).toMatchObject({ kind: 'warning' })
expect($notifications.get().at(-1)?.message).toContain("Could not attach shots.png: Folders can't be attached")
})
// A drop that attached some files must still name the ones it left out.
it('reports the items a mixed drop could not attach alongside the ones it did', async () => {
const stageFileForAttach = vi.fn(async (file: File) => {
if (file.name === 'big.zip') {
throw new Error('Upload request is too large')
}
return `/srv/uploads/web-${file.name}`
})
const value = { getPathForFile: () => '', stageFileForAttach }
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value })
const add = vi.fn()
const { result } = renderHook(() =>
useComposerActions({
activeSessionId: null,
currentCwd: '/w',
requestGateway: vi.fn(),
scope: { add, remove: vi.fn(() => null), target: 'main', update: vi.fn(() => true), updateIfCurrent: vi.fn(() => true) }
})
)
let attached = false
await act(async () => {
attached = await result.current.attachDroppedItems([
{ file: new File(['x'], 'notes.txt'), path: '' },
{ file: new File(['x'], 'big.zip'), path: '' },
{ file: new File([], 'my-repo'), isDirectory: true, path: '' }
])
})
expect(attached).toBe(true)
expect(add).toHaveBeenCalledOnce()
expect(add).toHaveBeenCalledWith(expect.objectContaining({ path: '/srv/uploads/web-notes.txt' }))
const warning = $notifications.get().find(notice => notice.message.includes('big.zip'))
expect(warning).toMatchObject({ kind: 'warning' })
expect(warning?.message).toContain('Could not attach big.zip: Upload request is too large')
expect(warning?.message).toContain("Could not attach my-repo: Folders can't be attached")
})
// 50 oversized files must not grow one toast off screen: name a few, count the rest.
it('bounds the warning for a large mixed drop and keeps the full list in its detail', async () => {
const stageFileForAttach = vi.fn(async (file: File) => {
if (file.name.startsWith('huge-')) {
throw new Error('Upload request is too large')
}
return `/srv/uploads/web-${file.name}`
})
const value = { getPathForFile: () => '', stageFileForAttach }
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value })
const add = vi.fn()
const { result } = renderHook(() =>
useComposerActions({
activeSessionId: null,
currentCwd: '/w',
requestGateway: vi.fn(),
scope: { add, remove: vi.fn(() => null), target: 'main', update: vi.fn(() => true), updateIfCurrent: vi.fn(() => true) }
})
)
const huge = Array.from({ length: 50 }, (_, index) => ({ file: new File(['x'], `huge-${index}.zip`), path: '' }))
await act(async () => {
await result.current.attachDroppedItems([{ file: new File(['x'], 'notes.txt'), path: '' }, ...huge])
})
expect(add).toHaveBeenCalledOnce()
const warning = $notifications.get().find(notice => notice.message.includes('huge-0.zip'))
expect(warning).toMatchObject({ kind: 'warning' })
expect(warning?.message).toContain('huge-2.zip')
expect(warning?.message).not.toContain('huge-3.zip')
expect(warning?.message).toMatch(/and 47 more$/)
expect(warning?.message.length).toBeLessThan(300)
expect(warning?.detail?.split('\n')).toHaveLength(50)
expect(warning?.detail).toContain('huge-49.zip')
})
// Three failures can still overflow when their names are long: shorten them, keep them whole in the detail.
it('bounds the warning for long names and keeps every full name in its detail', async () => {
const stageFileForAttach = vi.fn(async () => {
throw new Error('Upload request is too large')
})
const value = { getPathForFile: () => '', stageFileForAttach }
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value })
const { result } = renderHook(() =>
useComposerActions({ activeSessionId: null, currentCwd: '/w', requestGateway: vi.fn() })
)
const names = Array.from({ length: 3 }, (_, index) => `long-${index}-${'x'.repeat(200)}.zip`)
await act(async () => {
await result.current.attachDroppedItems(names.map(name => ({ file: new File(['x'], name), path: '' })))
})
const warning = $notifications.get().find(notice => notice.message.includes('long-0-'))
expect(warning).toMatchObject({ kind: 'warning' })
expect(warning?.message.length).toBeLessThan(300)
expect(warning?.message).toContain('…')
expect(warning?.message).toContain('Upload request is too large')
expect(warning?.message).toMatch(/and \d+ more$/)
expect(warning?.detail?.split('\n')).toEqual(names.map(name => `Could not attach ${name}: Upload request is too large`))
})
})
@@ -13,6 +13,7 @@ import {
HERMES_PATHS_MIME,
partitionDroppedFiles,
resolveImageAttachmentPreview,
stageDroppedFilePath,
useComposerActions
} from './use-composer-actions'
@@ -268,6 +269,18 @@ describe('extractDroppedFiles', () => {
expect(result[0]?.url).toBeUndefined()
})
// The browser Webapp has no host path for a dropped folder: report it, never drop it silently.
it('keeps a path-less dropped folder on the attach pipeline, where it is reported', async () => {
const transfer = stubTransfer([{ path: 'my-repo', isDirectory: true }])
stubBridge(transfer)
const result = extractDroppedFiles(transfer)
expect(result).toEqual([{ file: expect.any(File), isDirectory: true, path: '' }])
expect(partitionDroppedFiles(result).osDrops).toHaveLength(1)
await expect(stageDroppedFilePath(result[0]!)).rejects.toThrow("Folders can't be attached by dropping them")
})
it('does not duplicate a folder that appears in both items and files', () => {
// Chromium lists a dropped folder in transfer.files too (as a size-0 File);
// the items pass claims its path first so the files fallback skips it.
@@ -24,6 +24,8 @@ import { notify, notifyError, readableError } from '@/store/notifications'
import type { ImageDetachResponse } from '../../types'
import { type DropFailure, dropWarning } from './drop-warning'
const IMAGE_EXTENSION_PATTERN = /\.(png|jpe?g|gif|webp|bmp|tiff?|svg|ico)$/i
const BLOB_MIME_EXTENSION: Record<string, string> = {
@@ -47,6 +49,11 @@ export function isImagePath(filePath: string): boolean {
return IMAGE_EXTENSION_PATTERN.test(filePath)
}
/** A dropped File the composer sends as an image; never a folder, which has no bytes. */
function isDroppedImage(file: File, filePath: string, isDirectory?: boolean): boolean {
return !isDirectory && (file.type.startsWith('image/') || isImagePath(file.name) || isImagePath(filePath))
}
/**
* Read an attachment's thumbnail preview, local disk first. Paperclip picks,
* clipboard saves, and OS drops always hand us paths on THIS machine — the
@@ -119,6 +126,11 @@ export async function stageDroppedFilePath(candidate: DroppedFile): Promise<stri
return candidate.path
}
// A folder without a host path has no bytes to stage.
if (candidate.isDirectory) {
throw new Error("Folders can't be attached by dropping them. Use Attach → Folder… instead.")
}
if (!candidate.file || !window.hermesDesktop?.stageFileForAttach) {
return ''
}
@@ -128,15 +140,13 @@ export async function stageDroppedFilePath(candidate: DroppedFile): Promise<stri
/** Stage a drop for the main composer's chips. A rejected stage keeps its
* reason in the failure the drop warning shows, not a bare "Could not attach". */
async function stageDropForChip(candidate: DroppedFile, label: string): Promise<{ failure: string; path: string }> {
const failure = `Could not attach ${label}`
async function stageDropForChip(candidate: DroppedFile, name: string): Promise<{ failure: DropFailure; path: string }> {
try {
return { failure, path: await stageDroppedFilePath(candidate) }
return { failure: { name }, path: await stageDroppedFilePath(candidate) }
} catch (error) {
const reason = readableError(error, '').message
return { failure: reason ? `${failure}: ${reason}` : failure, path: '' }
return { failure: reason ? { name, reason } : { name }, path: '' }
}
}
@@ -240,10 +250,10 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
seenPaths.add(path)
}
// A folder with no host path (the browser Webapp) keeps its File, so the
// drop reaches the attach pipeline and is reported there, not dropped.
if (isDirectory) {
if (path) {
result.push({ isDirectory: true, path })
}
result.push(path ? { isDirectory: true, path } : { file, isDirectory: true, path })
return
}
@@ -503,11 +513,21 @@ export function useComposerActions({
const pickContextPaths = useCallback(
async (kind: 'file' | 'folder') => {
const paths = await selectDesktopPaths({
title: kind === 'file' ? 'Add files as context' : 'Add folders as context',
defaultPath: currentCwd || undefined,
directories: kind === 'folder'
})
let paths: string[]
try {
paths = await selectDesktopPaths({
title: kind === 'file' ? 'Add files as context' : 'Add folders as context',
defaultPath: currentCwd || undefined,
directories: kind === 'folder'
})
} catch (err) {
// As in pickImages: the browser stages a pick before returning its path, and a refused
// stage (a file over the upload limit) is reported here, not left as an unhandled rejection.
notifyError(err, copy.attachFailed)
return
}
if (!paths?.length) {
return
@@ -526,7 +546,7 @@ export function useComposerActions({
})
}
},
[attachToMain, currentCwd]
[attachToMain, copy.attachFailed, currentCwd]
)
const insertContextPathInlineRef = useCallback(
@@ -665,16 +685,26 @@ export function useComposerActions({
)
const pickImages = useCallback(async () => {
const paths = await selectDesktopPaths({
title: copy.attachImages,
defaultPath: currentCwd || undefined,
filters: [
{
name: t.composer.images,
extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff']
}
]
})
let paths: string[]
try {
paths = await selectDesktopPaths({
title: copy.attachImages,
defaultPath: currentCwd || undefined,
filters: [
{
name: t.composer.images,
extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff']
}
]
})
} catch (err) {
// The browser stages a pick before returning its path; a refused stage (an image over the
// upload limit) is reported here, not left as an unhandled rejection.
notifyError(err, copy.imageAttachFailed)
return
}
if (!paths?.length) {
return
@@ -683,7 +713,7 @@ export function useComposerActions({
for (const path of paths) {
await attachImagePath(path)
}
}, [attachImagePath, copy.attachImages, currentCwd, t.composer.images])
}, [attachImagePath, copy.attachImages, copy.imageAttachFailed, currentCwd, t.composer.images])
const pasteClipboardImage = useCallback(
async ({ silent = false }: { silent?: boolean } = {}) => {
@@ -788,7 +818,8 @@ export function useComposerActions({
}
let attached = false
let lastFailure: string | null = null
// Every item that failed, so a drop that attached some files still names the rest.
const failures: DropFailure[] = []
for (const candidate of candidates) {
const { file, isDirectory, path: knownPath } = candidate
@@ -802,7 +833,7 @@ export function useComposerActions({
continue
}
lastFailure = `Could not attach folder ${knownPath || ''}`
failures.push({ name: `folder ${knownPath || ''}` })
continue
}
@@ -814,7 +845,7 @@ export function useComposerActions({
continue
}
lastFailure = `Could not attach ${knownPath}`
failures.push({ name: knownPath })
continue
}
@@ -825,7 +856,7 @@ export function useComposerActions({
continue
}
lastFailure = `Could not attach ${knownPath || 'file'}`
failures.push({ name: knownPath || 'file' })
continue
}
@@ -834,7 +865,7 @@ export function useComposerActions({
!knownPath && window.hermesDesktop?.getPathForFile ? window.hermesDesktop.getPathForFile(file) : ''
const filePath = knownPath || fallbackPath || ''
const isImage = file.type.startsWith('image/') || isImagePath(file.name) || (filePath && isImagePath(filePath))
const isImage = isDroppedImage(file, filePath, isDirectory)
if (isImage) {
// Persist the File bytes into Desktop's durable composer-image cache
@@ -851,7 +882,7 @@ export function useComposerActions({
continue
}
lastFailure = `Could not attach ${file.name || 'image'}`
failures.push({ name: file.name || 'image' })
continue
}
@@ -865,16 +896,16 @@ export function useComposerActions({
continue
}
lastFailure = staged.failure
failures.push(staged.failure)
}
if (!attached && lastFailure) {
notify({ kind: 'warning', title: copy.dropFiles, message: lastFailure })
if (failures.length) {
notify({ kind: 'warning', title: copy.dropFiles, ...dropWarning(failures, copy.dropFilesMore) })
}
return attached
},
[attachContextFilePath, attachContextFolderPath, attachImageBlob, attachImagePath, copy.dropFiles]
[attachContextFilePath, attachContextFolderPath, attachImageBlob, attachImagePath, copy.dropFiles, copy.dropFilesMore]
)
const removeAttachment = useCallback(
@@ -282,9 +282,9 @@ function dataUrlToBlob(dataUrl: string) {
return new Blob([bytes], { type: 'application/pdf' })
}
async function readTextPreview(filePath: string) {
async function readTextPreview(filePath: string, sessionId?: string) {
try {
return await readDesktopFileText(filePath)
return await readDesktopFileText(filePath, { sessionId })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
@@ -744,6 +744,7 @@ export function LocalFilePreview({
onClose,
onSelectRendered,
reloadKey,
sessionId,
target
}: {
/** Closes the preview's tab; offered when the file can't be shown. */
@@ -752,6 +753,8 @@ export function LocalFilePreview({
* `rendered` mode to the switcher and routes its selection to the pane. */
onSelectRendered?: () => void
reloadKey: number
/** The session whose tab this is: a gateway read and Save resolve `/workspace` in its sandbox. */
sessionId?: string
target: PreviewTarget
}) {
const { t } = useI18n()
@@ -828,7 +831,7 @@ export function LocalFilePreview({
if (isImage || isPdf) {
// Prefer bytes the caller already handed us (a pasted/dropped
// screenshot) over re-reading a path that may be transient/unreadable.
const dataUrl = target.dataUrl || (await readDesktopFileDataUrl(filePath))
const dataUrl = target.dataUrl || (await readDesktopFileDataUrl(filePath, { sessionId }))
if (active) {
setState({ dataUrl, loading: false })
@@ -837,7 +840,7 @@ export function LocalFilePreview({
return
}
const result = await readTextPreview(filePath)
const result = await readTextPreview(filePath, sessionId)
if (active) {
const shouldBlock = !forcePreview && (result.binary || (result.byteSize ?? 0) > TEXT_PREVIEW_MAX_BYTES)
@@ -902,6 +905,7 @@ export function LocalFilePreview({
isText,
reloadKey,
selfReload,
sessionId,
target.dataUrl,
target.language,
target.url
@@ -1050,7 +1054,7 @@ export function LocalFilePreview({
// choice. `force` is the user picking "overwrite" from that banner.
if (!force) {
try {
const current = await readTextPreview(filePath)
const current = await readTextPreview(filePath, sessionId)
if (!current.binary && (current.text ?? '') !== baselineRef.current) {
setConflict(true)
@@ -1066,7 +1070,7 @@ export function LocalFilePreview({
// Also guards Overwrite: bypassing a content conflict never authorizes
// writing the same path on another connection/profile or this device.
requireEditorOwner()
await writeDesktopFileText(filePath, draftRef.current)
await writeDesktopFileText(filePath, draftRef.current, { sessionId })
baselineRef.current = draftRef.current
setDirty(false)
setConflict(false)
@@ -3,7 +3,7 @@
import './preview-mind'
import { useStore } from '@nanostores/react'
import type { PointerEvent as ReactPointerEvent } from 'react'
import type { ComponentProps, PointerEvent as ReactPointerEvent } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { requestComposerAttachImages, requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
@@ -154,6 +154,8 @@ interface PreviewPaneProps {
onClose?: () => void
onRestartServer?: (url: string, context?: string) => Promise<string>
reloadRequest?: number
/** The session that owns the tab; a file it names resolves in that session's workspace. */
sessionId?: string
/** The preview tab this pane renders. Keys the per-tab console store the
* browser bar's console toggle and the console panel both read. */
tabId?: string
@@ -268,11 +270,21 @@ function PreviewLoadError({
)
}
/** The body of a tab that is not a web page: a generated artifact or a file. */
function PreviewDocumentBody(props: ComponentProps<typeof LocalFilePreview>) {
return props.target.kind === 'artifact' ? (
<ArtifactPreview onClose={props.onClose} target={props.target} />
) : (
<LocalFilePreview {...props} />
)
}
export function PreviewPane({
embedded = false,
onClose,
onRestartServer,
reloadRequest = 0,
sessionId,
tabId,
target
}: PreviewPaneProps) {
@@ -1538,17 +1550,15 @@ export function PreviewPane({
title={target.label || copy.fallbackTitle}
/>
)}
{!isWebPreview &&
(target.kind === 'artifact' ? (
<ArtifactPreview onClose={onClose} target={target} />
) : (
<LocalFilePreview
onClose={onClose}
onSelectRendered={canRenderHtmlFile ? () => selectRenderMode('preview') : undefined}
reloadKey={localReloadKey}
target={target}
/>
))}
{!isWebPreview && (
<PreviewDocumentBody
onClose={onClose}
onSelectRendered={canRenderHtmlFile ? () => selectRenderMode('preview') : undefined}
reloadKey={localReloadKey}
sessionId={sessionId}
target={target}
/>
)}
{isBlankPage && (
<div className="absolute inset-0 grid bg-background">
<PanelEmpty description={copy.blankPageBody} icon="globe" />
@@ -39,7 +39,8 @@ export function PreviewTilePane({ onClose, tabId }: PreviewTilePaneProps) {
}
}, [previewTabs, tabId])
const target = previewTabs.find(tab => tab.id === tabId)?.target
const tab = previewTabs.find(candidate => candidate.id === tabId)
const target = tab?.target
// The tab closed while this pane was still mounted (the mirror disposes it a
// tick later).
@@ -53,6 +54,7 @@ export function PreviewTilePane({ onClose, tabId }: PreviewTilePaneProps) {
onClose={onClose}
onRestartServer={target.kind === 'url' ? (restartPreviewServer ?? undefined) : undefined}
reloadRequest={previewReloadRequest}
sessionId={tab?.sessionId}
tabId={tabId}
target={target}
/>
@@ -1,11 +1,13 @@
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HermesConnection } from '@/global'
import { installBrowserDesktopBridge } from '@/lib/browser-desktop-bridge'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $sidebarShowArchived } from '@/store/layout'
import { $changeEventsAvailable, $cronChangeTick, $sessionsChangeTick } from '@/store/live-sync'
import { $onBattery } from '@/store/power'
import { $activeSessionId } from '@/store/session'
import { $activeSessionId, $connection, $selectedStoredSessionId } from '@/store/session'
import {
$sessionStates,
$workingSessionIds,
@@ -15,6 +17,7 @@ import {
publishSessionState
} from '@/store/session-states'
import { loadArchivedSessions } from '@/store/sidebar-archive'
import { clearWebappSession, seedWebappSession } from '@/test/webapp-session'
import { useBackgroundSync } from './use-background-sync'
@@ -135,6 +138,130 @@ describe('useBackgroundSync profile-scoped session refresh', () => {
})
})
describe('useBackgroundSync live-status poll with no session open', () => {
// A profile picked on top of the shared primary socket (a Webapp tab's live pick, a global remote)
// is a view of that socket: a request that names no profile is answered for the socket's own
// profile, so the sidebar dot would show that profile's turns instead of the selected one's.
const sharedResearch = { profile: 'research', sharedPrimary: true } as HermesConnection
async function polledParams(connection: HermesConnection | null) {
$connection.set(connection)
const request = vi.fn(async (_method: string, _params?: Record<string, unknown>) => ({ sessions: [] }))
render('research', 'local', async () => undefined, request as typeof requestGateway)
await act(async () => undefined)
return request.mock.calls.filter(([method]) => method === 'session.active_list').map(([, params]) => params)
}
beforeEach(() => {
vi.useFakeTimers()
$activeSessionId.set(null)
$selectedStoredSessionId.set(null)
$changeEventsAvailable.set(false)
$sessionsChangeTick.set(0)
})
afterEach(() => {
cleanup()
$connection.set(null)
$selectedStoredSessionId.set(null)
vi.useRealTimers()
})
it('names the selected profile when it rides the shared primary socket', async () => {
expect(await polledParams(sharedResearch)).toEqual([{ profile: 'research' }])
})
it('asks what it asked before while a session is open (the session owner routes it)', async () => {
$selectedStoredSessionId.set('s-open')
expect(await polledParams(sharedResearch)).toEqual([{}])
})
it('names no profile on a socket of the selected profile’s own', async () => {
expect(await polledParams({ profile: 'research' } as HermesConnection)).toEqual([{}])
cleanup()
expect(await polledParams(null)).toEqual([{}])
})
// A reconnect re-resolves the socket's own route and publishes the primary's descriptor
// (sharedPrimary false) while the picked profile stays selected; the poll must still name it.
it('keeps naming the picked profile after a reconnect republishes the primary descriptor', async () => {
const win = window as unknown as Window & { __HERMES_DASHBOARD_PROFILE__?: string }
win.__HERMES_DASHBOARD_PROFILE__ = 'default'
seedWebappSession('served-token')
window.history.replaceState(null, '', '/?profile=research#/')
try {
expect(installBrowserDesktopBridge()).toBe(true)
const desktop = window.hermesDesktop!
const request = vi.fn(async (_method: string, _params?: Record<string, unknown>) => ({ sessions: [] }))
$connection.set(await desktop.getConnection('research'))
render('research', 'local', async () => undefined, request as typeof requestGateway)
await act(async () => undefined)
$connection.set(await desktop.getConnection())
expect($connection.get()?.sharedPrimary).toBeFalsy()
await act(async () => {
$sessionsChangeTick.set(1)
})
await act(async () => undefined)
expect(
request.mock.calls.filter(([method]) => method === 'session.active_list').map(([, params]) => params)
).toEqual([{ profile: 'research' }, { profile: 'research' }])
} finally {
delete win.__HERMES_DASHBOARD_PROFILE__
clearWebappSession()
Reflect.deleteProperty(window, 'hermesDesktop')
window.history.replaceState(null, '', '/#/')
}
})
describe('on a native local gateway', () => {
// Native local profiles ride the one host backend too (multiplex-only routing): main
// resolves 'research' to the primary socket with sharedPrimary, and the primary's own
// descriptor to an unflagged local one.
const getConnection = vi.fn(async (profile?: string) =>
profile === 'research'
? ({ mode: 'local', profile: 'research', sharedPrimary: true } as HermesConnection)
: ({ mode: 'local', profile: 'default' } as HermesConnection)
)
beforeEach(() => {
getConnection.mockClear()
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { getConnection } })
})
afterEach(() => {
Reflect.deleteProperty(window, 'hermesDesktop')
})
it('keeps naming the picked profile after a reconnect republishes the primary local descriptor', async () => {
const request = vi.fn(async (_method: string, _params?: Record<string, unknown>) => ({ sessions: [] }))
$connection.set(await getConnection('research'))
render('research', 'local', async () => undefined, request as typeof requestGateway)
await act(async () => undefined)
$connection.set(await getConnection())
await act(async () => {
$sessionsChangeTick.set(1)
})
await act(async () => undefined)
expect(
request.mock.calls.filter(([method]) => method === 'session.active_list').map(([, params]) => params)
).toEqual([{ profile: 'research' }, { profile: 'research' }])
})
it('names no profile and asks main nothing on the selected profile’s own local descriptor', async () => {
expect(await polledParams({ mode: 'local', profile: 'research' } as HermesConnection)).toEqual([{}])
expect(getConnection).not.toHaveBeenCalled()
})
})
})
describe('useBackgroundSync keeps a quiet working turn live', () => {
// A foreground tool emits nothing between tool.start and tool.complete; the
// backend is the only witness that the turn is still running.
@@ -19,15 +19,16 @@ import {
import { createClientSessionState } from '@/lib/chat-runtime'
import { sessionListFingerprint, sessionMessagesSignature } from '@/lib/session-signatures'
import { latestSessionTodos, latestSessionTodoSnapshot } from '@/lib/todos'
import { pendingSessionReplay } from '@/store/gateway'
import { pendingSessionReplay, sharedPrimaryRoute } from '@/store/gateway'
import { $sidebarShowArchived } from '@/store/layout'
import { $changeEventsAvailable, $cronChangeTick, $projectsChangeTick, $sessionsChangeTick } from '@/store/live-sync'
import { $onBattery, batteryPollInterval } from '@/store/power'
import { refreshActiveProfile } from '@/store/profile'
import { normalizeProfileKey, refreshActiveProfile } from '@/store/profile'
import { refreshProjects, refreshProjectTree } from '@/store/projects'
import {
$activeSessionId,
$busy,
$connection,
$currentCwd,
$messagingSessions,
$selectedStoredSessionId,
@@ -37,6 +38,7 @@ import {
sessionMatchesStoredId,
setCurrentCwd
} from '@/store/session'
import { $focusedStoredSessionId } from '@/store/session-focus'
import type { SessionOwnerRoute } from '@/store/session-request-router'
import {
$sessionStates,
@@ -907,6 +909,38 @@ export function rehydrateLiveSessionStatuses(
confirmReconnectSettlesExcept(workingStoredIds)
}
/** Params for the live-status poll. With a session open the session RPC
* dispatcher routes it to that session's owner, so it names nothing. With none
* open it rides the active socket, and a profile that shares the primary socket
* (a Webapp tab's live pick, a native local profile, a global remote) is only
* reached by naming it: the socket answers an unnamed request for its own
* profile, and the sidebar would paint that profile's turns instead of the
* selected one's.
*
* The published descriptor is not enough on its own: a reconnect republishes
* the primary's own descriptor (sharedPrimary false, local or remote) while the
* picked profile stays selected. So a descriptor that isn't the selected
* profile's own asks main for the selected profile's route, the check
* requestGatewayForProfile routes by. The selected profile's own descriptor
* asks nothing. */
async function liveStatusParams(activeGatewayProfile: string): Promise<Record<string, unknown>> {
const connection = $connection.get()
if ($focusedStoredSessionId.get() || !connection) {
return {}
}
if (connection.sharedPrimary === true) {
return { profile: activeGatewayProfile }
}
const ownSocket =
Boolean(connection.profile?.trim()) &&
normalizeProfileKey(connection.profile) === normalizeProfileKey(activeGatewayProfile)
return !ownSocket && (await sharedPrimaryRoute(activeGatewayProfile)) ? { profile: activeGatewayProfile } : {}
}
/** Forget every profile's live-runtime bookkeeping. A gateway wipe already
* drops the session states these ids point at, so a carried-over set would
* only reap runtimes that no longer exist. */
@@ -1278,7 +1312,10 @@ export function useBackgroundSync({
const stateAtRequest = $sessionStates.get()
try {
const response = await requestGateway<LiveSessionStatusResponse>('session.active_list', {})
const response = await requestGateway<LiveSessionStatusResponse>(
'session.active_list',
await liveStatusParams(activeGatewayProfile)
)
if (!cancelled) {
rehydrateLiveSessionStatuses(response, Date.now(), activeGatewayProfile, stateAtRequest)
@@ -366,11 +366,135 @@ function fishWords(text: string): string[] {
return words
}
// POSIX sh (and bash, zsh, ksh, dash): inside '...' every character is literal and nothing escapes
// a quote; outside quotes a backslash escapes the next character, ';' '&' '|' end a command, '#'
// starts a comment, and '$' or '`' starts an expansion.
function posixWords(text: string): string[] {
const words: string[] = []
let word = ''
let i = 0
const flush = () => {
if (word) {
words.push(word)
}
word = ''
}
while (i < text.length) {
const c = text[i]
if (c === "'") {
const end = text.indexOf("'", i + 1)
if (end === -1) {
return [...words, `${word}<unterminated>`]
}
word += text.slice(i + 1, end)
i = end + 1
} else if (c === '\\') {
word += text[i + 1] ?? ''
i += 2
} else if (';&|'.includes(c)) {
flush()
words.push(c)
i += 1
} else if (c === '#' && !word) {
words.push('#comment')
break
} else if (c === '$' || c === '`' || c === '"') {
words.push('<expansion>')
i += 1
} else if (/\s/.test(c)) {
flush()
i += 1
} else {
word += c
i += 1
}
}
flush()
return words
}
// csh and tcsh: as POSIX sh, except that '!' starts a history substitution everywhere, inside
// single quotes too, unless a backslash escapes it (csh(1), "History substitution").
function cshWords(text: string): string[] {
const words: string[] = []
let word = ''
let i = 0
const flush = () => {
if (word) {
words.push(word)
}
word = ''
}
while (i < text.length) {
const c = text[i]
if (c === "'") {
i += 1
while (i < text.length && text[i] !== "'") {
if (text[i] === '\\' && text[i + 1] === '!') {
i += 1
} else if (text[i] === '!') {
return [...words, '<history>']
}
word += text[i]
i += 1
}
if (i >= text.length) {
return [...words, `${word}<unterminated>`]
}
i += 1
} else if (c === '\\') {
word += text[i + 1] ?? ''
i += 2
} else if (c === '!') {
return [...words, '<history>']
} else if (';&|'.includes(c)) {
flush()
words.push(c)
i += 1
} else if (/\s/.test(c)) {
flush()
i += 1
} else {
word += c
i += 1
}
}
flush()
return words
}
// cmd.exe at its prompt first replaces %NAME% of every defined variable, inside double quotes too,
// and leaves an undefined one as typed; then a '"' toggles quoting, '^' outside quotes takes the
// next character literally and '&' outside quotes starts a new command.
function cmdWords(text: string, env: Record<string, string>): string[] {
const expanded = text.replace(/%([^%]+)%/g, (whole, name: string) => env[name.toUpperCase()] ?? whole)
// next character literally and '&' outside quotes starts a new command. With delayed expansion on
// (cmd /V:ON, or DelayedExpansion in the registry) a line that still holds a '!' is read once more:
// !NAME! is replaced by its value inside quotes too, a lone '!' is dropped, and '^' escapes.
function cmdWords(text: string, env: Record<string, string>, { delayed = false } = {}): string[] {
const percent = text.replace(/%([^%]+)%/g, (whole, name: string) => env[name.toUpperCase()] ?? whole)
const expanded =
delayed && percent.includes('!')
? percent.replace(/!([^!]*)!|!/g, (whole, name?: string) => (name ? (env[name.toUpperCase()] ?? '') : ''))
: percent
const words: string[] = []
let word = ''
let quoted = false
@@ -472,4 +596,107 @@ describe("terminal drop types each path as one literal word of the terminal's sh
expect(droppedText(shell, "/work/it's \\ a\u2019b!.txt")).toBe("'/work/it'\\''s \\ a\u2019b!.txt' ")
}
})
const HOSTILE_POSIX_PATHS = [
"/work/it's; touch PWNED; '.txt",
'/work/$(touch PWNED) `id` $HOME.txt',
'/work/a\\b \\\' & | # " !!.txt',
'/work/plain.txt'
]
it.each(['sh', 'ash', 'bash', 'dash', 'ksh', 'mksh', 'zsh', '/bin/bash', '-zsh', 'bash.exe'])(
'types every path as one literal POSIX word for %s',
shell => {
for (const path of HOSTILE_POSIX_PATHS) {
expect(posixWords(droppedText(shell, path))).toEqual([path])
}
}
)
it.each(['csh', 'tcsh', '/bin/tcsh'])('types every path as one literal word with no history substitution for %s', shell => {
for (const path of [...HOSTILE_POSIX_PATHS, '/home/me/x!!:s^e^;touch PWNED;^.txt', '/home/me/!-2']) {
expect(cshWords(droppedText(shell, path))).toEqual([path])
}
})
it('matches a shell by its program name, not by a directory or a longer name', () => {
// A directory named like cmd must not give bash the cmd quoting.
expect(posixWords(droppedText('/opt/cmd-tools/bin/bash', "/work/it's.txt"))).toEqual(["/work/it's.txt"])
expect(droppedText('C:\\Windows\\System32\\cmd.exe', 'C:\\work\\a b.txt')).toBe('"C:\\work\\a b.txt" ')
expect(droppedText('C:\\Program Files\\PowerShell\\7\\pwsh.exe', "C:\\it's.txt")).toBe("'C:\\it''s.txt' ")
})
})
describe('terminal drop in cmd with delayed expansion', () => {
const env = { HERMESPROBE: 'X"&calc&"Y', PATH: 'C:\\Windows\\system32' }
it('types the same words whether delayed expansion is on or off', () => {
for (const path of ['C:\\work\\a%HERMESPROBE%b.txt', 'C:\\work\\a^b & c.txt', 'C:\\work\\100%', 'C:\\work\\a b (1).txt']) {
const typed = droppedText('cmd.exe', path)
expect(cmdWords(typed, env)).toEqual([path])
expect(cmdWords(typed, env, { delayed: true })).toEqual([path])
}
})
// cmd can't be asked whether delayed expansion is on, and no quoting keeps a '!' literal in both
// modes: with it on, !HERMESPROBE! is replaced inside the quotes and a lone '!' is dropped.
it('never types a path holding "!" and names it in a warning', () => {
const { host, unbind, write } = bind('cmd.exe')
drop(host, transfer([], ['C:\\work\\a!HERMESPROBE!b.txt', 'C:\\work\\wow!.txt', 'C:\\work\\ok.txt']))
expect(typedText(write)).toBe('"C:\\work\\ok.txt" ')
const [warning] = $notifications.get()
expect(warning?.kind).toBe('warning')
expect(warning?.message).toContain('C:\\work\\a!HERMESPROBE!b.txt')
expect(warning?.message).toContain('C:\\work\\wow!.txt')
expect(warning?.message).not.toContain('ok.txt')
unbind()
})
})
// A shell the quoter does not name (nu, xonsh, elvish, an SSH session's remote shell) has quoting
// rules of its own, so POSIX quotes could end early there. Only a path every shell reads as one
// plain word is typed, as it is; any other is left out and named in a warning.
describe('terminal drop into a shell the quoter does not name', () => {
it.each(['nu', 'xonsh', 'elvish', 'ssh', 'C:\\tools\\nu.exe', '/usr/local/bin/xonsh'])('types only plain paths for %s', shell => {
const { host, unbind, write } = bind(shell)
drop(
host,
transfer([], ['/home/me/notes_v2.md', '/home/me/a b.txt', "/home/me/it's.txt", '/home/me/$HOME', '~/x', '-rf', 'C:\\x'])
)
expect(typedText(write)).toBe('/home/me/notes_v2.md ')
const [warning] = $notifications.get()
expect(warning?.kind).toBe('warning')
for (const path of ['/home/me/a b.txt', "/home/me/it's.txt", '/home/me/$HOME', '~/x', '-rf', 'C:\\x']) {
expect(warning?.message).toContain(path)
}
expect(warning?.message).not.toContain('notes_v2.md')
unbind()
})
it('types nothing and focuses nothing when no dropped path is plain', () => {
const { focus, host, unbind, write } = bind('nu')
drop(host, transfer([], ['/home/me/a b.txt']))
expect(write).not.toHaveBeenCalled()
expect(focus).not.toHaveBeenCalled()
expect($notifications.get()[0]?.message).toContain('nu')
unbind()
})
it('reads a plain path as the same one word in every named shell', () => {
const path = '/home/me/v1.2/notes_v2-final.md'
expect(posixWords(`${path} `)).toEqual([path])
expect(cshWords(`${path} `)).toEqual([path])
expect(fishWords(`${path} `)).toEqual([path])
expect(powershellWords(`${path} `)).toEqual([path])
expect(cmdWords(`${path} `, {}, { delayed: true })).toEqual([path])
})
})
@@ -142,19 +142,42 @@ async function stageDroppedEntries(entries: DroppedEntry[]): Promise<string[]> {
return paths.filter(Boolean)
}
// The path quoted so the terminal's shell reads it as one literal argument, whatever characters
// the name holds: a name must never end the quoting and add a command for the next Enter. Shells
// not named here get POSIX quoting.
function quotePathForShell(path: string, shellName: string): string {
const shell = shellName.toLowerCase()
const POSIX_SHELLS = new Set(['ash', 'bash', 'dash', 'ksh', 'mksh', 'sh', 'zsh'])
if (shell.includes('powershell') || shell.includes('pwsh')) {
// Every shell reads a path made only of these as one plain word, so a shell the quoter does not
// name still gets it as typed. A leading - or ~ would read as an option or a home directory.
const PLAIN_PATH = /^[\w./:][\w./:-]*$/
/** The shell's program name: `C:\...\pwsh.exe` is `pwsh`, a login shell's `-zsh` is `zsh`. */
function shellProgram(shellName: string): string {
return shellName
.toLowerCase()
.replace(/^.*[\\/]/, '')
.replace(/^-/, '')
.replace(/\.exe$/, '')
}
// The path quoted so the terminal's shell reads it as one literal argument, whatever characters
// the name holds: a name must never end the quoting and add a command for the next Enter. Null
// when this shell has no quoting that does that: a shell not named here (nu, xonsh, elvish, the
// remote shell of an SSH tab) gets only a plain path, typed as it is.
function quotePathForShell(path: string, shellName: string): null | string {
const name = shellProgram(shellName)
if (name.startsWith('pwsh') || name.startsWith('powershell')) {
// PowerShell reads the typographic quotes U+2018..U+201B as single quotes too; doubling any of
// them keeps it literal.
return `'${path.replace(/['\u2018\u2019\u201a\u201b]/g, '$&$&')}'`
}
if (shell.includes('cmd')) {
if (name === 'cmd') {
// With delayed expansion on (cmd /V:ON, or set in the registry) cmd replaces !NAME! inside
// double quotes too and drops a lone !. Nothing tells the terminal whether it is on, and no
// quoting keeps a ! literal both ways.
if (path.includes('!')) {
return null
}
// cmd expands %NAME% inside double quotes before it reads them, and a value holding a quote
// would end the quoting. Each % goes outside the quotes as ^%, which no variable name matches.
return path
@@ -163,8 +186,6 @@ function quotePathForShell(path: string, shellName: string): string {
.join('^%')
}
const name = shell.replace(/^.*[\\/]/, '').replace(/\.exe$/, '')
if (name === 'fish') {
// Inside fish's single quotes \' and \\ are escapes.
return `'${path.replace(/[\\']/g, '\\$&')}'`
@@ -172,8 +193,16 @@ function quotePathForShell(path: string, shellName: string): string {
const quoted = `'${path.replace(/'/g, "'\\''")}'`
// csh and tcsh expand !-history even inside single quotes; \! is the escape there.
return name === 'csh' || name === 'tcsh' ? quoted.replace(/!/g, '\\!') : quoted
if (name === 'csh' || name === 'tcsh') {
// csh and tcsh expand !-history even inside single quotes; \! is the escape there.
return quoted.replace(/!/g, '\\!')
}
if (POSIX_SHELLS.has(name)) {
return quoted
}
return PLAIN_PATH.test(path) ? path : null
}
// C0 controls, DEL and C1 controls. A line editor acts on them inside any quoting (^U erases the
@@ -233,7 +262,23 @@ export function bindTerminalDrop(
const typePaths = (paths: string[]) => {
const refused = paths.filter(hasControlCharacter)
const typed = paths.filter(path => !hasControlCharacter(path))
const shellName = getShellName()
const typed: string[] = []
const unquotable: string[] = []
for (const path of paths) {
if (hasControlCharacter(path)) {
continue
}
const quoted = quotePathForShell(path, shellName)
if (quoted === null) {
unquotable.push(path)
} else {
typed.push(quoted)
}
}
if (refused.length) {
notify({
@@ -243,12 +288,20 @@ export function bindTerminalDrop(
})
}
if (unquotable.length) {
notify({
kind: 'warning',
title: translateNow('rightSidebar.terminalDropControlTitle'),
message: translateNow('rightSidebar.terminalDropUnquotable', shellProgram(shellName), unquotable.join(', '))
})
}
if (!typed.length) {
return
}
markActivity()
term.paste(`${typed.map(p => quotePathForShell(p, getShellName())).join(' ')} `)
term.paste(`${typed.join(' ')} `)
term.focus()
triggerHaptic('selection')
}
@@ -243,10 +243,13 @@ export function createSessionAttemptController(
}
release()
const expired = error && typeof error === 'object' && 'signal' in error && error.signal === 'expired'
const signal = error && typeof error === 'object' && 'signal' in error ? error.signal : null
const message = error instanceof Error ? error.message : String(error)
if (expired) {
if (signal === 'exited') {
// The shell exited while no tab was attached: close the tab, as an attached exit does.
removeExitedTerminal(id)
} else if (signal === 'expired') {
resumeOnly = false
armRetry(TERMINAL_EXIT_MESSAGES.expired)
} else if (isBrowserHostedDesktop()) {
@@ -94,6 +94,8 @@ class Socket {
static OPEN = 1
static instances: Socket[] = []
static persistent = false
// Set: every new socket is closed with this instead of answering.
static refusal: [number, string] | null = null
url: URL
sent: string[] = []
readyState = 1
@@ -103,6 +105,12 @@ class Socket {
this.url = new URL(url)
Socket.instances.push(this)
queueMicrotask(() => {
if (Socket.refusal) {
this.close(...Socket.refusal)
return
}
const metadata = Socket.persistent
? { shell: 'fish', cwd: '/work', terminalId: this.url.searchParams.get('attach') || `shell-${Socket.instances.length}`, closed: this.url.searchParams.get('action') === 'close' }
: { shell: 'fish' }
@@ -169,6 +177,7 @@ afterEach(() => {
$terminals.set([])
Socket.instances = []
Socket.persistent = false
Socket.refusal = null
emulator.output = ''
vi.unstubAllGlobals()
vi.restoreAllMocks()
@@ -299,6 +308,38 @@ it('replays server history once, suppresses parser replies, and detaches on remo
expect(Socket.instances[2].url.searchParams.has('attach')).toBe(false)
})
// Typing `exit` ends the host shell: the tab closes as it does in native Desktop, instead of
// staying open as an expired terminal.
it('closes a persistent browser tab when its host shell exits', async () => {
seedWebappSession('test-token')
Socket.persistent = true
vi.stubGlobal('WebSocket', Socket)
installBrowserDesktopBridge()
const id = createTerminal('/work')
render(<Harness id={id} />)
await waitFor(() => expect(screen.getByText('open')).toBeTruthy())
act(() => Socket.instances[0].close(4410, 'Process exited'))
expect($terminals.get().some(tab => tab.id === id)).toBe(false)
expect(emulator.output).not.toContain('Press Enter to create a new shell')
})
// The shell exited while no view was attached (`exit`, then the tab was hidden or the page reloaded).
it('closes a persistent browser tab whose shell exited while it was detached', async () => {
seedWebappSession('test-token')
Socket.persistent = true
vi.stubGlobal('WebSocket', Socket)
installBrowserDesktopBridge()
const id = createTerminal('/work')
const firstView = render(<Harness id={id} />)
await waitFor(() => expect(screen.getByText('open')).toBeTruthy())
firstView.unmount()
Socket.refusal = [4410, 'Process exited']
render(<Harness id={id} />)
await waitFor(() => expect($terminals.get().some(tab => tab.id === id)).toBe(false))
expect(Socket.instances[1].url.searchParams.get('attach')).toBe('shell-1')
expect(emulator.output).not.toContain('Press Enter to create a new shell')
})
it('close others and close all terminate mounted and unmounted persisted shells', async () => {
seedWebappSession('test-token')
Socket.persistent = true
@@ -0,0 +1,141 @@
import { cleanup } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getLatestSessionMessages } from '@/hermes'
import { textPart } from '@/lib/chat-messages'
import { $backgroundStatusBySession, reconcileBackgroundProcesses } from '@/store/composer-status'
import { $gateway } from '@/store/gateway'
import { clearNotifications } from '@/store/notifications'
import { $busy, setMessages } from '@/store/session'
import { $subagentsBySession, upsertSubagent } from '@/store/subagents'
import { actRender, Harness, type HarnessHandle, RUNTIME_SESSION_ID } from './index-test-utils'
import { clearSingleFlightSessionResumeState } from './single-flight-resume'
beforeEach(() => {
clearSingleFlightSessionResumeState()
vi.mocked(getLatestSessionMessages).mockReset()
vi.mocked(getLatestSessionMessages).mockImplementation(async () => ({ messages: [], session_id: 'session' }))
})
vi.mock('@/hermes', () => ({
getLatestSessionMessages: vi.fn(async () => ({ messages: [], session_id: 'session' })),
getProfiles: vi.fn(async () => ({ profiles: [] })),
getSession: vi.fn(),
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS: 1_800_000,
setApiRequestProfile: vi.fn(),
transcribeAudio: vi.fn()
}))
vi.mock('@/store/gateway', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
requestGatewayForAgent: vi.fn()
}))
// Plain interrupts must not kill background work; rewinds must. cancelRun is
// Stop and "send now" on a queued message while busy — the timeline survives,
// and the gateway's session.interrupt leaves terminal(background=true) running.
// reload / restore / edit truncate history, so processes from the discarded
// turns get reaped.
describe('usePromptActions background processes: interrupt vs rewind', () => {
const seed = [
{ id: 'u1', parts: [textPart('first')], role: 'user' as const, timestamp: 0 },
{ id: 'a1', parts: [textPart('reply')], role: 'assistant' as const, timestamp: 1 }
]
let gatewayCalls: Array<{ method: string; params?: Record<string, unknown> }>
// resetSessionBackground remembers dismissed ids per session at module
// level, so each test seeds a fresh process id.
let procSeq = 0
let procId = ''
beforeEach(() => {
procId = `proc_live_${++procSeq}`
$busy.set(false)
$backgroundStatusBySession.set({})
setMessages(seed as never)
gatewayCalls = []
$gateway.set({
request: async (method: string, params?: Record<string, unknown>) => {
gatewayCalls.push({ method, params })
return {} as never
}
} as never)
reconcileBackgroundProcesses(RUNTIME_SESSION_ID, [
{ command: 'npm run dev', session_id: procId, status: 'running' }
] as never)
})
afterEach(() => {
cleanup()
clearNotifications()
setMessages([])
$busy.set(false)
$backgroundStatusBySession.set({})
$gateway.set(null)
vi.restoreAllMocks()
})
const killed = () => gatewayCalls.some(call => call.method === 'process.kill' && call.params?.process_id === procId)
async function mount(requestGateway: ReturnType<typeof vi.fn>) {
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway as never}
seedMessages={seed}
/>
)
return handle!
}
it('cancelRun interrupts the turn but leaves running background processes and subagents alone', async () => {
const requestGateway = vi.fn(async () => ({}) as never)
const handle = await mount(requestGateway)
upsertSubagent(RUNTIME_SESSION_ID, { goal: 'review', status: 'running', subagent_id: 'bg-review', task_index: 0 })
upsertSubagent(RUNTIME_SESSION_ID, { goal: 'old', status: 'completed', subagent_id: 'done-1', task_index: 1 })
await handle.cancelRun()
expect(($subagentsBySession.get()[RUNTIME_SESSION_ID] ?? []).map(row => [row.id, row.status])).toEqual([
['bg-review', 'running']
])
expect(requestGateway).toHaveBeenCalledWith('session.interrupt', { session_id: RUNTIME_SESSION_ID })
expect(requestGateway).not.toHaveBeenCalledWith('process.kill', expect.anything())
expect(killed()).toBe(false)
const rows = $backgroundStatusBySession.get()[RUNTIME_SESSION_ID] ?? []
expect(rows.map(row => [row.id, row.state])).toEqual([[procId, 'running']])
})
it('reloadFromMessage (regenerate) kills processes from the truncated turns', async () => {
const requestGateway = vi.fn(async () => ({}) as never)
const handle = await mount(requestGateway)
await handle.reloadFromMessage('u1')
expect(requestGateway).toHaveBeenCalledWith(
'prompt.submit',
expect.objectContaining({ confirm_truncate: true, truncate_before_message_id: 'u1' }),
expect.anything()
)
expect(killed()).toBe(true)
expect($backgroundStatusBySession.get()[RUNTIME_SESSION_ID] ?? []).toEqual([])
})
it('restoreToMessage still kills processes from the discarded turns', async () => {
const requestGateway = vi.fn(async () => ({}) as never)
const handle = await mount(requestGateway)
await handle.restoreToMessage('u1')
expect(killed()).toBe(true)
expect($backgroundStatusBySession.get()[RUNTIME_SESSION_ID] ?? []).toEqual([])
})
})
@@ -42,7 +42,7 @@ import {
} from '@/store/session'
import { $sessionStates, isLiveTurnAwaitingEvents, isSessionRemote } from '@/store/session-states'
import { flushStreamDeltas } from '@/store/stream-delta-flush'
import { clearSessionSubagents } from '@/store/subagents'
import { pruneFinishedSessionSubagents } from '@/store/subagents'
import { runGatewayRestart } from '@/store/system-actions'
import { clearSessionTodos } from '@/store/todos'
import { setSessionDraftingTool } from '@/store/tool-drafting'
@@ -800,8 +800,14 @@ export function usePromptActions({
})
clearSessionTodos(sessionId)
clearSessionSubagents(sessionId)
resetSessionBackground(sessionId)
// Stop ends this turn only: background subagents keep running and keep their rows; the
// stopped turn's own children drop out of the next roster snapshot.
pruneFinishedSessionSubagents(sessionId)
// No resetSessionBackground() here. A plain interrupt (Stop, or "send now"
// on a queued message while busy) keeps the timeline, and the gateway's
// session.interrupt deliberately leaves terminal(background=true) processes
// running. Killing them belongs only to the rewind paths (reload / restore /
// edit), where the spawning turns are discarded.
setSessionDraftingTool(sessionId, '')
// Auto-compaction sets a per-session flag that only clears on message.start
// / message.complete / error. A hung compaction emits none of those, so the
@@ -1106,6 +1112,13 @@ export function usePromptActions({
return
}
// Regenerate truncates history (prompt.submit truncate_before_*) just like
// restore/edit, so the discarded turns' todos and background processes
// belong to an abandoned timeline — drop them before the re-run.
clearSessionTodos(sessionId)
resetSessionBackground(sessionId)
clearPreviewArtifacts(sessionId)
clearNotifications()
updateSessionState(sessionId, state => applyReloadOptimistic(state, plan))
@@ -51,6 +51,7 @@ import { detectTrigger, openDirectiveScope, textBeforeCaret, type TriggerState }
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
import { isRedoShortcut, isUndoShortcut } from '@/app/chat/composer/undo-history'
import { chipTypedUrlOnSpace, linkifyUrls } from '@/app/chat/composer/url-refs'
import { type DropFailure, dropWarning } from '@/app/chat/hooks/drop-warning'
import {
extractDroppedFiles,
HERMES_PATHS_MIME,
@@ -78,7 +79,7 @@ import { Loader2Icon } from '@/lib/icons'
import { isMacPlatform } from '@/lib/platform'
import { cn } from '@/lib/utils'
import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
import { notify, notifyError, readableError } from '@/store/notifications'
import { $terminalBackend } from '@/store/session'
import { ambientRequestFor } from '@/store/session-gone-latch'
import { isSessionRemote, requestForOwnedSession } from '@/store/session-states'
@@ -474,16 +475,30 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
requestForOwnedSession<T>(sessionId, ambient, method, params)
const refs: InlineRefInput[] = []
// The staging reason leaves out the name (the main composer's drop warning adds it): name
// each rejected file here too, in the same bounded warning, so several aren't told apart.
const failures: DropFailure[] = []
for (const candidate of osDrops) {
let path: string
try {
// A rejected stage carries the server's or a proxy's reason (an nginx 413).
const path = await stageDroppedFilePath(candidate)
path = await stageDroppedFilePath(candidate)
} catch (err) {
const name = candidate.file?.name || candidate.path || 'file'
const reason = readableError(err, '').message
if (!path) {
continue
}
failures.push(reason ? { name, reason } : { name })
continue
}
if (!path) {
continue
}
try {
const kind: ComposerAttachment['kind'] =
candidate.file?.type.startsWith('image/') || isImagePath(candidate.file?.name || path) ? 'image' : 'file'
@@ -502,9 +517,14 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
}
}
// A warning, as in the main composer, so the same drop looks the same wherever it lands.
if (failures.length) {
notify({ kind: 'warning', title: t.desktop.dropFiles, ...dropWarning(failures, t.desktop.dropFilesMore) })
}
return refs
},
[cwd, gateway, sessionId, t.desktop.dropFiles]
[cwd, gateway, sessionId, t.desktop.dropFiles, t.desktop.dropFilesMore]
)
const resetDragState = useCallback(() => {
@@ -50,16 +50,23 @@ function Harness({ gateway }: { gateway: HermesGateway }) {
)
}
// A browser drop: a File with no native path (the Webapp shell has no getPathForFile).
function fileDrop(file: File) {
// A browser drop: Files with no native path (the Webapp shell has no getPathForFile).
function fileDrop(...files: File[]) {
return {
files: { item: (index: number) => (index === 0 ? file : null), length: 1 },
files: { item: (index: number) => files[index] ?? null, length: files.length },
getData: () => '',
items: [{ getAsFile: () => file, kind: 'file', webkitGetAsEntry: () => null }],
items: files.map(file => ({ getAsFile: () => file, kind: 'file', webkitGetAsEntry: () => null })),
types: ['Files']
}
}
async function openEditor(gateway: HermesGateway) {
render(<Harness gateway={gateway} />)
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
return screen.findByRole('textbox', { name: 'Edit message' })
}
describe('edit composer drop staging failures', () => {
it('reports why a dropped file could not be staged', async () => {
const stageFileForAttach = vi.fn(async () => {
@@ -68,10 +75,7 @@ describe('edit composer drop staging failures', () => {
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { stageFileForAttach } })
const request = vi.fn(async () => ({}))
render(<Harness gateway={{ request } as unknown as HermesGateway} />)
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
const editor = await screen.findByRole('textbox', { name: 'Edit message' })
const editor = await openEditor({ request } as unknown as HermesGateway)
await act(async () => {
fireEvent.drop(editor, { dataTransfer: fileDrop(new File(['payload'], 'notes.txt', { type: 'text/plain' })) })
@@ -79,8 +83,55 @@ describe('edit composer drop staging failures', () => {
await waitFor(() => expect(stageFileForAttach).toHaveBeenCalledOnce())
await waitFor(() =>
expect($notifications.get()).toEqual([expect.objectContaining({ kind: 'error', message: UPLOAD_413 })])
expect($notifications.get()).toEqual([
expect.objectContaining({ kind: 'warning', message: `Could not attach notes.txt: ${UPLOAD_413}` })
])
)
expect(request).not.toHaveBeenCalledWith('file.attach', expect.anything())
})
// The oversize reason leaves out the name, so the edit composer must add it, or several
// oversized files in one drop give the same error. A long name is shortened; the limit stays.
it('names each oversized file of a mixed drop, bounded, and keeps the limit', async () => {
const limit = 'The file is larger than 16 MiB, the largest file the browser can attach.'
const longName = `${'a'.repeat(150)}.zip`
const stageFileForAttach = vi.fn(async (file: File) => {
if (file.name === 'notes.txt') {
return '/srv/uploads/web-notes.txt'
}
throw new Error(limit)
})
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { stageFileForAttach } })
const editor = await openEditor({ request: vi.fn(async () => ({})) } as unknown as HermesGateway)
await act(async () => {
fireEvent.drop(editor, {
dataTransfer: fileDrop(
new File(['payload'], 'notes.txt', { type: 'text/plain' }),
new File(['x'], longName, { type: 'application/zip' }),
new File(['x'], 'big.zip', { type: 'application/zip' })
)
})
})
await waitFor(() => expect(stageFileForAttach).toHaveBeenCalledTimes(3))
const warning = await waitFor(() => {
const found = $notifications.get().find(notice => notice.message.includes('big.zip'))
expect(found).toBeDefined()
return found!
})
expect(warning.kind).toBe('warning')
// A reason another item follows loses its period; the detail keeps it whole.
expect(warning.message).toContain(`Could not attach ${'a'.repeat(24)}…${'a'.repeat(19)}.zip: ${limit.slice(0, -1)}; `)
expect(warning.message).toContain(`Could not attach big.zip: ${limit}`)
expect(warning.message).not.toContain(longName)
expect(warning.message).not.toContain('notes.txt')
expect(warning.detail).toContain(`Could not attach ${longName}: ${limit}`)
})
})
@@ -167,10 +167,10 @@ const ToolMediaLeaf: FC<{
// a split-view tile can belong to another connection than the foreground.
resolveUrl: () =>
['audio', 'video'].includes(mediaKind(path))
? resolveMediaPlaybackSrc(path)
? resolveMediaPlaybackSrc(path, owner, sessionId ?? undefined)
: resolveMediaDisplaySrc(path, owner)
}),
[owner, path]
[owner, path, sessionId]
)
const args = useMemo(
@@ -161,7 +161,7 @@ export function PreviewAttachment({ target }: { target: string }) {
setOpening(true)
try {
const preview = await normalizeOrLocalPreviewTarget(requestTarget, requestCwd || undefined)
const preview = await normalizeOrLocalPreviewTarget(requestTarget, requestCwd || undefined, { sessionId })
if (
!mountedRef.current ||
@@ -360,7 +360,7 @@ function NotificationDetail({ detail }: { detail: string }) {
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">{copy.details}</summary>
<div className="mt-1 rounded-md bg-background/65 p-2">
<pre
className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed"
className="max-h-32 overflow-y-auto overscroll-contain whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed"
data-selectable-text="true"
>
{detail}
+2
View File
@@ -350,6 +350,8 @@ declare global {
connectionId?: null | string
path: string
profile?: null | string
/** The chat the clip belongs to: its `/workspace` is that session's sandbox. */
sessionId?: string
}) => Promise<string>
saveImageFromUrl: (url: string) => Promise<boolean>
/** Edit verb against the window's focused element (the custom context
@@ -1,6 +1,8 @@
import { useStore } from '@nanostores/react'
import { type SyntheticEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useComposerScope } from '@/app/chat/composer/scope'
import { useSessionView } from '@/app/chat/session-view'
import { mediaKind, resolveMediaPlaybackSrc } from '@/lib/media'
type PlaybackEvent = SyntheticEvent<HTMLAudioElement | HTMLVideoElement>
@@ -31,6 +33,12 @@ export function useMediaPlaybackSrc(path: string) {
[scope.connectionId, scope.profile]
)
// The transcript's own session: a Docker agent's /workspace clip lives in its sandbox.
const view = useSessionView()
const storedId = useStore(view.$storedId)
const runtimeId = useStore(view.$runtimeId)
const sessionId = storedId || runtimeId || undefined
const [media, setMedia] = useState({ failed: false, path, src: '' })
const loadedSrc = useRef('')
// Minted for a source that failed before it ever loaded; it gets no retry of its own.
@@ -47,7 +55,7 @@ export function useMediaPlaybackSrc(path: string) {
return
}
void resolveMediaPlaybackSrc(path, owner)
void resolveMediaPlaybackSrc(path, owner, sessionId)
.then(value => {
if (value.startsWith('blob:')) {
objectUrl = value
@@ -72,7 +80,7 @@ export function useMediaPlaybackSrc(path: string) {
URL.revokeObjectURL(objectUrl)
}
}
}, [kind, owner, path])
}, [kind, owner, path, sessionId])
const onLoadedMetadata = useCallback((event: PlaybackEvent) => {
const element = event.currentTarget
@@ -115,7 +123,7 @@ export function useMediaPlaybackSrc(path: string) {
const point = { element, paused: element.paused, time: element.currentTime }
void resolveMediaPlaybackSrc(path, owner)
void resolveMediaPlaybackSrc(path, owner, sessionId)
.then(fresh => {
if (fresh === failing || fresh.startsWith('blob:')) {
if (fresh !== failing) {
@@ -136,7 +144,7 @@ export function useMediaPlaybackSrc(path: string) {
})
.catch(fail)
},
[owner, path]
[owner, path, sessionId]
)
// An element with preload="none" fetches nothing for a new source until asked, so one
+2
View File
@@ -465,6 +465,7 @@ export const arChat = {
imageAttach: 'إرفاق الصورة',
imageWriteFailed: 'فشل كتابة الصورة',
imageAttachFailed: 'فشل إرفاق الصورة',
attachFailed: 'فشل الإرفاق',
pastedContent: 'محتوى ملصق',
pasteAttachFailed: 'تعذر إرفاق النص الملصق',
attachImages: 'إرفاق الصور',
@@ -472,6 +473,7 @@ export const arChat = {
noClipboardImage: 'لا توجد صورة في الحافظة',
clipboardPasteFailed: 'فشل اللصق من الحافظة',
dropFiles: 'أفلت الملفات هنا',
dropFilesMore: (count: number) => `و${count} أخرى`,
handoff: {
pickPlatform: 'اختر وجهة',
success: platform => `تم التسليم إلى ${platform}. استأنف هنا في أي وقت.`,
+1
View File
@@ -475,6 +475,7 @@ export const arChrome = {
}
},
rightSidebar: {
terminalDropUnquotable: (shell, paths) => `لا يمكن كتابة هذه الأسماء بأمان في ${shell}: ${paths}`,
terminalReadOnly: 'مخرجات للقراءة فقط',
terminalReadOnlyHelp:
'للرد على المطالبات، أوقف الأمر الذي يعمل في الخلفية وشغّله في طرفية جديدة. تفتح الطرفية الجديدة صدفة منفصلة ولا تتصل بهذه العملية.',
@@ -397,6 +397,7 @@ export const arCommandCenter = {
exported: 'تم تصدير الملف الشخصي',
failedImport: 'فشل استيراد الملف الشخصي',
failedExport: 'فشل تصدير الملف الشخصي',
exportNeedsHost: name => `لا يمكن للمتصفح حفظ أرشيف الملف الشخصي. على مضيف Hermes، شغّل: hermes profile export ${name}`,
allProfiles: 'كل الملفات الشخصية',
showAllProfiles: 'إظهار كل الملفات الشخصية',
switchToProfile: name => `التبديل إلى ${name}`,
+1
View File
@@ -73,6 +73,7 @@ export const arDiagnostics = {
microphoneConstraintsUnsupported: 'قيود الميكروفون غير مدعومة على هذا الجهاز.',
microphoneFailed: 'فشل الميكروفون',
microphoneInUse: 'الميكروفون مستخدم من تطبيق آخر.',
microphoneNeedsHttps: 'يحتاج الميكروفون إلى اتصال آمن. افتح Webapp عبر HTTPS، أو عبر نفق SSH إلى 127.0.0.1، لاستخدامه.',
microphonePermissionDenied: 'تم رفض إذن الميكروفون.',
microphoneStartFailed: 'تعذر بدء تسجيل الميكروفون.',
microphoneUnsupported: 'هذا المتصفح لا يدعم تسجيل الميكروفون.',
+5
View File
@@ -464,6 +464,7 @@ export const deOverrides = {
microphoneConstraintsUnsupported: 'Mikrofon-Einschränkungen werden von diesem Gerät nicht unterstützt.',
microphoneFailed: 'Mikrofon fehlgeschlagen',
microphoneInUse: 'Das Mikrofon wird bereits von einer anderen App verwendet.',
microphoneNeedsHttps: 'Das Mikrofon braucht eine sichere Verbindung. Öffne die Webapp über HTTPS oder über einen SSH-Tunnel zu 127.0.0.1, um es zu nutzen.',
microphonePermissionDenied: 'Die Mikrofonberechtigung wurde verweigert.',
microphoneStartFailed: 'Mikrofonaufnahme konnte nicht gestartet werden.',
microphoneUnsupported: 'Diese Laufzeitumgebung unterstützt keine Mikrofonaufnahme.',
@@ -3204,6 +3205,7 @@ export const deOverrides = {
exported: 'Profil exportiert',
failedImport: 'Profil konnte nicht importiert werden',
failedExport: 'Profil konnte nicht exportiert werden',
exportNeedsHost: name => `Der Browser kann kein Profilarchiv speichern. Führe auf dem Hermes-Host aus: hermes profile export ${name}`,
allProfiles: 'Alle Profile',
showAllProfiles: 'Alle Profile anzeigen',
switchToProfile: name => `Zu ${name} wechseln`,
@@ -4616,6 +4618,7 @@ export const deOverrides = {
}
},
rightSidebar: {
terminalDropUnquotable: (shell, paths) => `Diese Namen lassen sich in ${shell} nicht sicher eintippen: ${paths}`,
aria: 'Rechte Sidebar',
panelsAria: 'Panels der rechten Sidebar',
files: 'Dateisystem',
@@ -5413,6 +5416,7 @@ export const deOverrides = {
imageAttach: 'Bild anhängen',
imageWriteFailed: 'Bild konnte nicht auf die Festplatte geschrieben werden.',
imageAttachFailed: 'Bild-Anhängen fehlgeschlagen',
attachFailed: 'Anhängen fehlgeschlagen',
pastedContent: 'Eingefügter Inhalt',
pasteAttachFailed: 'Eingefügter Text konnte nicht angehängt werden',
attachImages: 'Bilder anhängen',
@@ -5420,6 +5424,7 @@ export const deOverrides = {
noClipboardImage: 'Kein Bild in der Zwischenablage',
clipboardPasteFailed: 'Zwischenablage-Einfügen fehlgeschlagen',
dropFiles: 'Dateien ablegen',
dropFilesMore: (count: number) => `und ${count} weitere`,
handoff: {
pickPlatform: 'Ziel wählen',
success: platform => `Übergeben an ${platform}. Jederzeit hier fortsetzen.`,
+5
View File
@@ -454,6 +454,7 @@ export const en: Translations = {
microphoneConstraintsUnsupported: 'Microphone constraints are not supported by this device.',
microphoneFailed: 'Microphone failed',
microphoneInUse: 'Microphone is already in use by another app.',
microphoneNeedsHttps: 'The microphone needs a secure connection. Open the Webapp over HTTPS, or through an SSH tunnel to 127.0.0.1, to use it.',
microphonePermissionDenied: 'Microphone permission was denied.',
microphoneStartFailed: 'Could not start microphone recording.',
microphoneUnsupported: 'This runtime does not support microphone recording.',
@@ -2769,6 +2770,7 @@ export const en: Translations = {
exported: 'Profile exported',
failedImport: 'Failed to import profile',
failedExport: 'Failed to export profile',
exportNeedsHost: name => `The browser can't save a profile archive. On the Hermes host, run: hermes profile export ${name}`,
allProfiles: 'All profiles',
showAllProfiles: 'Show all profiles',
switchToProfile: name => `Switch to ${name}`,
@@ -4234,6 +4236,7 @@ export const en: Translations = {
terminalDropTooLarge: (names, limit) => `Each file can be up to ${limit}. Not uploaded: ${names}`,
terminalDropControlTitle: 'Not typed into the terminal',
terminalDropControl: paths => `These names hold control characters the shell would act on as keys: ${paths}`,
terminalDropUnquotable: (shell, paths) => `No safe way to type these names in ${shell}: ${paths}`,
addToChat: 'Add to chat'
},
@@ -4750,6 +4753,7 @@ export const en: Translations = {
imageAttach: 'Image attach',
imageWriteFailed: 'Failed to write image to disk.',
imageAttachFailed: 'Image attach failed',
attachFailed: 'Attach failed',
pastedContent: 'Pasted content',
pasteAttachFailed: 'Could not attach pasted text',
attachImages: 'Attach images',
@@ -4757,6 +4761,7 @@ export const en: Translations = {
noClipboardImage: 'No image found in clipboard',
clipboardPasteFailed: 'Clipboard paste failed',
dropFiles: 'Drop files',
dropFilesMore: (count: number) => `and ${count} more`,
handoff: {
pickPlatform: 'Choose a destination',
success: platform => `Handed off to ${platform}. Resume here anytime.`,
+5
View File
@@ -467,6 +467,7 @@ export const esOverrides = {
microphoneConstraintsUnsupported: 'Este dispositivo no admite las restricciones del micrófono.',
microphoneFailed: 'Falló el micrófono',
microphoneInUse: 'El micrófono ya está en uso por otra app.',
microphoneNeedsHttps: 'El micrófono necesita una conexión segura. Abre la Webapp por HTTPS, o mediante un túnel SSH a 127.0.0.1, para usarlo.',
microphonePermissionDenied: 'Se denegó el permiso del micrófono.',
microphoneStartFailed: 'No se pudo iniciar la grabación del micrófono.',
microphoneUnsupported: 'Este runtime no admite grabación de micrófono.',
@@ -3193,6 +3194,7 @@ export const esOverrides = {
exported: 'Perfil exportado',
failedImport: 'No se pudo importar el perfil',
failedExport: 'No se pudo exportar el perfil',
exportNeedsHost: name => `El navegador no puede guardar un archivo de perfil. En el host de Hermes, ejecuta: hermes profile export ${name}`,
allProfiles: 'Todos los perfiles',
showAllProfiles: 'Mostrar todos los perfiles',
switchToProfile: name => `Cambiar a ${name}`,
@@ -4600,6 +4602,7 @@ export const esOverrides = {
}
},
rightSidebar: {
terminalDropUnquotable: (shell, paths) => `Estos nombres no se pueden escribir de forma segura en ${shell}: ${paths}`,
aria: 'Barra lateral derecha',
panelsAria: 'Paneles de la barra lateral derecha',
files: 'Sistema de archivos',
@@ -5397,6 +5400,7 @@ export const esOverrides = {
imageAttach: 'Adjuntar imagen',
imageWriteFailed: 'No se pudo escribir la imagen en disco.',
imageAttachFailed: 'No se pudo adjuntar la imagen',
attachFailed: 'No se pudo adjuntar',
pastedContent: 'Contenido pegado',
pasteAttachFailed: 'No se pudo adjuntar el texto pegado',
attachImages: 'Adjuntar imágenes',
@@ -5404,6 +5408,7 @@ export const esOverrides = {
noClipboardImage: 'No se encontró ninguna imagen en el portapapeles',
clipboardPasteFailed: 'No se pudo pegar desde el portapapeles',
dropFiles: 'Soltar archivos',
dropFilesMore: (count: number) => `y ${count} más`,
handoff: {
pickPlatform: 'Elige un destino',
success: platform => `Transferido a ${platform}. Puedes reanudar aquí cuando quieras.`,
+5
View File
@@ -464,6 +464,7 @@ export const frOverrides = {
microphoneConstraintsUnsupported: 'Les contraintes du microphone ne sont pas prises en charge par cet appareil.',
microphoneFailed: 'Échec du microphone',
microphoneInUse: 'Le microphone est déjà utilisé par une autre application.',
microphoneNeedsHttps: "Le micro nécessite une connexion sécurisée. Ouvrez la Webapp en HTTPS, ou via un tunnel SSH vers 127.0.0.1, pour l'utiliser.",
microphonePermissionDenied: "L'autorisation du microphone a été refusée.",
microphoneStartFailed: "Impossible de démarrer l'enregistrement du microphone.",
microphoneUnsupported: "Ce runtime ne prend pas en charge l'enregistrement micro.",
@@ -3209,6 +3210,7 @@ export const frOverrides = {
exported: 'Profil exporté',
failedImport: "Échec de l'importation du profil",
failedExport: "Échec de l'exportation du profil",
exportNeedsHost: name => `Le navigateur ne peut pas enregistrer une archive de profil. Sur l'hôte Hermes, exécutez : hermes profile export ${name}`,
allProfiles: 'Tous les profils',
showAllProfiles: 'Afficher tous les profils',
switchToProfile: name => `Basculer vers ${name}`,
@@ -4624,6 +4626,7 @@ export const frOverrides = {
}
},
rightSidebar: {
terminalDropUnquotable: (shell, paths) => `Impossible de saisir ces noms en toute sécurité dans ${shell} : ${paths}`,
aria: 'Barre latérale droite',
panelsAria: 'Panneaux de la barre latérale droite',
files: 'Système de fichiers',
@@ -5420,6 +5423,7 @@ export const frOverrides = {
imageAttach: 'Image jointe',
imageWriteFailed: "Échec de l'écriture de l'image sur le disque.",
imageAttachFailed: "Échec de l'ajout de l'image",
attachFailed: "Échec de l'ajout",
pastedContent: 'Contenu collé',
pasteAttachFailed: 'Impossible de joindre le texte collé',
attachImages: 'Joindre des images',
@@ -5427,6 +5431,7 @@ export const frOverrides = {
noClipboardImage: 'Aucune image trouvée dans le presse-papiers',
clipboardPasteFailed: 'Échec du collage depuis le presse-papiers',
dropFiles: 'Déposer des fichiers',
dropFilesMore: (count: number) => `et ${count} de plus`,
handoff: {
pickPlatform: 'Choisissez une destination',
success: platform => `Transféré vers ${platform}. Reprenez ici à tout moment.`,
+5
View File
@@ -159,6 +159,7 @@ export const jaOverrides = {
microphoneConstraintsUnsupported: 'このデバイスはマイクの制約をサポートしていません。',
microphoneFailed: 'マイクが失敗しました',
microphoneInUse: 'マイクは他のアプリで使用中です。',
microphoneNeedsHttps: 'マイクには安全な接続が必要です。使うには、Webapp を HTTPS か 127.0.0.1 への SSH トンネル経由で開いてください。',
microphonePermissionDenied: 'マイクのアクセス許可が拒否されました。',
microphoneStartFailed: 'マイクの録音を開始できませんでした。',
microphoneUnsupported: 'このランタイムはマイク録音をサポートしていません。',
@@ -1778,6 +1779,7 @@ export const jaOverrides = {
exported: 'プロファイルをエクスポートしました',
failedImport: 'プロファイルのインポートに失敗しました',
failedExport: 'プロファイルのエクスポートに失敗しました',
exportNeedsHost: name => `ブラウザーではプロファイルのアーカイブを保存できません。Hermes ホストで次を実行してください: hermes profile export ${name}`,
allProfiles: 'すべてのプロファイル',
showAllProfiles: 'すべてのプロファイルを表示',
switchToProfile: name => `${name} に切り替え`,
@@ -3028,6 +3030,7 @@ export const jaOverrides = {
},
rightSidebar: {
terminalDropUnquotable: (shell, paths) => `これらの名前は ${shell} に安全に入力できません: ${paths}`,
terminalReadOnly: '読み取り専用の出力',
terminalReadOnlyHelp:
'プロンプトに応答するには、バックグラウンドのコマンドを停止し、新しいターミナルで実行してください。新しいターミナルは別のシェルを開き、このプロセスには接続しません。',
@@ -3636,6 +3639,7 @@ export const jaOverrides = {
imageAttach: '画像を添付',
imageWriteFailed: '画像のディスクへの書き込みに失敗しました。',
imageAttachFailed: '画像の添付に失敗しました',
attachFailed: '添付に失敗しました',
pastedContent: '貼り付けた内容',
pasteAttachFailed: '貼り付けたテキストを添付できませんでした',
attachImages: '画像を添付',
@@ -3643,6 +3647,7 @@ export const jaOverrides = {
noClipboardImage: 'クリップボードに画像が見つかりません',
clipboardPasteFailed: 'クリップボードからの貼り付けに失敗しました',
dropFiles: 'ファイルをドロップ',
dropFilesMore: (count: number) => `ほか ${count} 件`,
handoff: {
pickPlatform: '送信先を選択',
success: platform => `${platform} に引き継ぎました。いつでもここで再開できます。`,
+5
View File
@@ -147,6 +147,7 @@ export const ruOverrides = {
microphoneConstraintsUnsupported: 'Ограничения микрофона не поддерживаются этим устройством.',
microphoneFailed: 'Ошибка микрофона',
microphoneInUse: 'Микрофон уже используется другим приложением.',
microphoneNeedsHttps: 'Микрофону нужно защищённое соединение. Откройте Webapp по HTTPS или через SSH-туннель к 127.0.0.1, чтобы им пользоваться.',
microphonePermissionDenied: 'Доступ к микрофону запрещён.',
microphoneStartFailed: 'Не удалось начать запись с микрофона.',
microphoneUnsupported: 'Это окружение не поддерживает запись с микрофона.',
@@ -2070,6 +2071,7 @@ export const ruOverrides = {
exported: 'Профиль экспортирован',
failedImport: 'Не удалось импортировать профиль',
failedExport: 'Не удалось экспортировать профиль',
exportNeedsHost: name => `Браузер не может сохранить архив профиля. На хосте Hermes выполните: hermes profile export ${name}`,
allProfiles: 'Все профили',
showAllProfiles: 'Показать все профили',
switchToProfile: name => `Переключиться на ${name}`,
@@ -3283,6 +3285,7 @@ export const ruOverrides = {
}
},
rightSidebar: {
terminalDropUnquotable: (shell, paths) => `Эти имена нельзя безопасно ввести в ${shell}: ${paths}`,
terminalReadOnly: 'Вывод только для чтения',
terminalReadOnlyHelp:
'Чтобы ответить на запрос, остановите фоновую команду и запустите её в новом терминале. Он откроет отдельную оболочку и не подключится к этому процессу.',
@@ -3785,11 +3788,13 @@ export const ruOverrides = {
imageAttach: 'Прикрепление изображения',
imageWriteFailed: 'Не удалось записать изображение на диск.',
imageAttachFailed: 'Прикрепление изображения не удалось',
attachFailed: 'Прикрепление не удалось',
attachImages: 'Прикрепить изображения',
clipboard: 'Буфер обмена',
noClipboardImage: 'Изображение в буфере обмена не найдено',
clipboardPasteFailed: 'Вставка из буфера обмена не удалась',
dropFiles: 'Перетащите файлы',
dropFilesMore: (count: number) => `и ещё ${count}`,
handoff: {
pickPlatform: 'Выберите назначение',
success: platform => `Передаём в ${platform}. Возобновите здесь в любой момент.`,
+4
View File
@@ -481,6 +481,7 @@ catalog: CatalogTranslations
microphoneConstraintsUnsupported: string
microphoneFailed: string
microphoneInUse: string
microphoneNeedsHttps: string
microphonePermissionDenied: string
microphoneStartFailed: string
microphoneUnsupported: string
@@ -2438,6 +2439,7 @@ catalog: CatalogTranslations
exported: string
failedImport: string
failedExport: string
exportNeedsHost: (name: string) => string
allProfiles: string
showAllProfiles: string
switchToProfile: (name: string) => string
@@ -4194,6 +4196,7 @@ catalog: CatalogTranslations
imageAttach: string
imageWriteFailed: string
imageAttachFailed: string
attachFailed: string,
pastedContent: string
pasteAttachFailed: string
attachImages: string
@@ -4201,6 +4204,7 @@ catalog: CatalogTranslations
noClipboardImage: string
clipboardPasteFailed: string
dropFiles: string
dropFilesMore: (count: number) => string
handoff: {
pickPlatform: string
success: (platform: string) => string
@@ -48,5 +48,6 @@ export interface RightSidebarTranslations {
terminalDropTooLarge: (names: string, limit: string) => string
terminalDropControlTitle: string
terminalDropControl: (paths: string) => string
terminalDropUnquotable: (shell: string, paths: string) => string
addToChat: string
}
+2
View File
@@ -487,6 +487,7 @@ export const zhHantChat = {
imageAttach: '附加圖片',
imageWriteFailed: '無法將圖片寫入磁碟。',
imageAttachFailed: '附加圖片失敗',
attachFailed: '附加失敗',
pastedContent: '貼上內容',
pasteAttachFailed: '無法附加貼上的文字',
attachImages: '附加圖片',
@@ -494,6 +495,7 @@ export const zhHantChat = {
noClipboardImage: '剪貼簿中沒有圖片',
clipboardPasteFailed: '剪貼簿貼上失敗',
dropFiles: '拖曳檔案',
dropFilesMore: (count: number) => `以及另外 ${count} 項`,
handoff: {
pickPlatform: '選擇目標平台',
success: platform => `已移交到 ${platform}。隨時可在此處恢復。`,
+1
View File
@@ -421,6 +421,7 @@ export const zhHantChrome = {
},
rightSidebar: {
terminalDropUnquotable: (shell, paths) => `無法在 ${shell} 中安全地輸入這些名稱:${paths}`,
terminalReadOnly: '唯讀輸出',
terminalReadOnlyHelp:
'如需回應提示,請停止背景命令,再於新終端機中執行。新終端機會開啟獨立的 shell,不會連線至此程序。',
@@ -320,6 +320,7 @@ export const zhHantCommandCenter = {
exported: '設定檔已匯出',
failedImport: '匯入設定檔失敗',
failedExport: '匯出設定檔失敗',
exportNeedsHost: name => `瀏覽器無法儲存設定檔封存。請在 Hermes 主機上執行:hermes profile export ${name}`,
allProfiles: '全部設定檔',
showAllProfiles: '顯示全部設定檔',
switchToProfile: name => `切換至 ${name}`,
@@ -53,6 +53,7 @@ export const zhHantDiagnostics = {
microphoneConstraintsUnsupported: '此裝置不支援目前的麥克風限制條件。',
microphoneFailed: '麥克風發生錯誤',
microphoneInUse: '麥克風正被其他應用程式使用中。',
microphoneNeedsHttps: '麥克風需要安全連線。請透過 HTTPS 或到 127.0.0.1 的 SSH 通道開啟 Webapp 後使用。',
microphonePermissionDenied: '麥克風權限被拒絕。',
microphoneStartFailed: '無法開始麥克風錄音。',
microphoneUnsupported: '目前執行環境不支援麥克風錄音。',
+5
View File
@@ -244,6 +244,7 @@ export const zhOverrides = {
microphoneConstraintsUnsupported: '此设备不支持当前麦克风约束。',
microphoneFailed: '麦克风出错',
microphoneInUse: '麦克风正被其他应用占用。',
microphoneNeedsHttps: '麦克风需要安全连接。请通过 HTTPS 或到 127.0.0.1 的 SSH 隧道打开 Webapp 后使用。',
microphonePermissionDenied: '麦克风权限被拒绝。',
microphoneStartFailed: '无法开始麦克风录音。',
microphoneUnsupported: '当前运行环境不支持麦克风录音。',
@@ -2688,6 +2689,7 @@ export const zhOverrides = {
exported: '配置档案已导出',
failedImport: '导入配置档案失败',
failedExport: '导出配置档案失败',
exportNeedsHost: name => `浏览器无法保存配置档案归档。请在 Hermes 主机上运行:hermes profile export ${name}`,
allProfiles: '全部配置档案',
showAllProfiles: '显示全部配置档案',
switchToProfile: name => `切换到 ${name}`,
@@ -4030,6 +4032,7 @@ export const zhOverrides = {
},
rightSidebar: {
terminalDropUnquotable: (shell, paths) => `无法在 ${shell} 中安全地输入这些名称:${paths}`,
terminalReadOnly: '只读输出',
terminalReadOnlyHelp:
'如需回应提示,请停止后台命令,再在新终端中运行。新终端会打开独立的 shell,不会连接到此进程。',
@@ -4610,6 +4613,7 @@ export const zhOverrides = {
imageAttach: '附加图片',
imageWriteFailed: '无法将图片写入磁盘。',
imageAttachFailed: '附加图片失败',
attachFailed: '附加失败',
pastedContent: '粘贴内容',
pasteAttachFailed: '无法附加粘贴的文本',
attachImages: '附加图片',
@@ -4617,6 +4621,7 @@ export const zhOverrides = {
noClipboardImage: '剪贴板中没有图片',
clipboardPasteFailed: '粘贴剪贴板失败',
dropFiles: '拖放文件',
dropFilesMore: (count: number) => `以及另外 ${count} 项`,
handoff: {
pickPlatform: '选择目标平台',
success: platform => `已移交到 ${platform}。随时可在此处恢复。`,
@@ -778,6 +778,118 @@ describe('browser-hosted Desktop bridge', () => {
expect(stagedFile.type).toBe('text/plain')
})
it('opens a 2 MB HTML page through srcdoc, byte-exact and inert to the wrapper, never through a frame URL', async () => {
const win = mutableWindow()
seedWebappSession('served-token')
const blobs: Blob[] = []
vi.spyOn(URL, 'createObjectURL').mockImplementation(blob => {
blobs.push(blob as Blob)
return 'blob:http://127.0.0.1:9119/large'
})
expect(installBrowserDesktopBridge()).toBe(true)
// Chromium will not navigate a frame to a URL over 2 MiB: the old data: carrier left this blank.
const page = `<!doctype html><p title="a&amp;b">"q" &lt;x&gt;</p><!-- "></iframe><script>parent.x=1</script> --><script>/*${'x'.repeat(2_000_000)}*/</script>`
await win.hermesDesktop!.saveImageBuffer(new TextEncoder().encode(page), '.html')
const wrapper = new DOMParser().parseFromString(await blobs[0]!.text(), 'text/html')
const frame = wrapper.querySelector('iframe')!
expect(wrapper.querySelectorAll('iframe, script')).toHaveLength(1)
expect(frame.getAttribute('sandbox')).toBe('allow-scripts')
expect(frame.hasAttribute('src')).toBe(false)
expect(frame.getAttribute('srcdoc')).toBe(
page.replace('<!doctype html>', '<!doctype html><base href="about:srcdoc">')
)
})
it('gives an HTML page without its own <base> an about:srcdoc base so #anchor links stay in the page', async () => {
const win = mutableWindow()
seedWebappSession('served-token')
const blobs: Blob[] = []
vi.spyOn(URL, 'createObjectURL').mockImplementation(blob => {
blobs.push(blob as Blob)
return `blob:http://127.0.0.1:9119/page-${blobs.length}`
})
expect(installBrowserDesktopBridge()).toBe(true)
const srcdocOf = async (page: string) => {
await win.hermesDesktop!.saveImageBuffer(new TextEncoder().encode(page), '.html')
const wrapper = new DOMParser().parseFromString(await blobs.at(-1)!.text(), 'text/html')
return wrapper.querySelector('iframe')!.getAttribute('srcdoc')!
}
// Without a base of its own, the page inherits the wrapper's blob: URL and href="#sec" goes nowhere.
const bare = '<h1><a href="#sec">jump</a></h1><h2 id="sec">sec</h2>'
expect(await srcdocOf(bare)).toBe(`<base href="about:srcdoc">${bare}`)
// The base goes after the doctype, so the page is not pushed into quirks mode.
const standards = await srcdocOf(`<!DOCTYPE html>${bare}`)
expect(standards).toBe(`<!DOCTYPE html><base href="about:srcdoc">${bare}`)
const parsed = new DOMParser().parseFromString(standards, 'text/html')
expect(parsed.compatMode).toBe('CSS1Compat')
expect(parsed.querySelectorAll('base')).toHaveLength(1)
expect(parsed.querySelector('base')!.getAttribute('href')).toBe('about:srcdoc')
// A page that sets its own base keeps it, untouched.
const based = '<html><head><BASE href="https://example.com/docs/"></head><body><a href="#sec">x</a></body></html>'
expect(await srcdocOf(based)).toBe(based)
})
it('puts the about:srcdoc base inside the page\'s own <head>, so the head keeps its attributes', async () => {
const win = mutableWindow()
seedWebappSession('served-token')
const blobs: Blob[] = []
vi.spyOn(URL, 'createObjectURL').mockImplementation(blob => {
blobs.push(blob as Blob)
return `blob:http://127.0.0.1:9119/page-${blobs.length}`
})
expect(installBrowserDesktopBridge()).toBe(true)
const parsedSrcdocOf = async (page: string) => {
await win.hermesDesktop!.saveImageBuffer(new TextEncoder().encode(page), '.html')
const wrapper = new DOMParser().parseFromString(await blobs.at(-1)!.text(), 'text/html')
const srcdoc = wrapper.querySelector('iframe')!.getAttribute('srcdoc')!
return { doc: new DOMParser().parseFromString(srcdoc, 'text/html'), srcdoc }
}
// A base before <head> would open an implicit head, and the authored tag's attributes would be lost.
const report = '<!DOCTYPE html><HTML lang="en"><Head id="report-assets" data-x="1"><title>r</title></Head><body><a href="#sec">x</a></body></HTML>'
const withHead = await parsedSrcdocOf(report)
expect(withHead.srcdoc).toBe(report.replace('data-x="1">', 'data-x="1"><base href="about:srcdoc">'))
expect(withHead.doc.compatMode).toBe('CSS1Compat')
expect(withHead.doc.head.id).toBe('report-assets')
expect(withHead.doc.head.dataset.x).toBe('1')
expect(withHead.doc.querySelectorAll('base')).toHaveLength(1)
expect(withHead.doc.querySelector('base')!.parentElement).toBe(withHead.doc.head)
expect(withHead.doc.querySelector('base')!.getAttribute('href')).toBe('about:srcdoc')
// Without a <head>, the base goes right after <html>, which keeps its attributes too.
const headless = '<!doctype html><html lang="fr" data-theme="dark"><body><p>x</p></body></html>'
const withHtml = await parsedSrcdocOf(headless)
expect(withHtml.srcdoc).toBe(headless.replace('data-theme="dark">', 'data-theme="dark"><base href="about:srcdoc">'))
expect(withHtml.doc.documentElement.lang).toBe('fr')
expect(withHtml.doc.documentElement.dataset.theme).toBe('dark')
expect(withHtml.doc.querySelectorAll('base')).toHaveLength(1)
expect(withHtml.doc.querySelector('base')!.parentElement).toBe(withHtml.doc.head)
// <header> is not a <head>: the base stays after the doctype.
const banner = '<!doctype html><header id="top"><h1>t</h1></header><p>x</p>'
const withHeader = await parsedSrcdocOf(banner)
expect(withHeader.srcdoc).toBe(banner.replace('<!doctype html>', '<!doctype html><base href="about:srcdoc">'))
expect(withHeader.doc.querySelectorAll('base')).toHaveLength(1)
expect(withHeader.doc.querySelector('header')!.id).toBe('top')
})
it('stages non-image buffers as browser-local object URLs', async () => {
const win = mutableWindow()
seedWebappSession('served-token')
@@ -802,7 +914,8 @@ describe('browser-hosted Desktop bridge', () => {
expect(wrapper).toContain('sandbox="allow-scripts"')
expect(wrapper).not.toContain('allow-same-origin')
expect(wrapper).not.toContain('<h1>preview</h1>')
expect(wrapper).toContain('data:text/html;charset=utf-8;base64,PGgxPnByZXZpZXc8L2gxPg==')
expect(wrapper).toContain('srcdoc="&lt;base href=&quot;about:srcdoc&quot;&gt;&lt;h1&gt;preview&lt;/h1&gt;"')
expect(wrapper).not.toMatch(/\ssrc="data:/)
await win.hermesDesktop!.openPreviewInBrowser!(staged)
expect(open).toHaveBeenCalledExactlyOnceWith(staged, '_blank', 'noopener,noreferrer')
+2 -1
View File
@@ -74,7 +74,8 @@ export function createBrowserFilesBridge({
return (await fileEndpointUrl(bootstrap, 'stream', {
path: payload.path,
profile: payload.profile ?? currentProfile()
profile: payload.profile ?? currentProfile(),
session_id: payload.sessionId
})).href
},
readDir: (path: string) =>
@@ -64,6 +64,18 @@ it('persists the default separately from the active chat and synchronizes only t
expect(changed).not.toHaveBeenCalled()
})
it('records a live profile pick in the URL without dropping the router entry state', async () => {
const entry = { idx: 3, key: 'entry', usr: { from: 'sidebar' } }
window.history.replaceState(entry, '', '/hermes?profile=active#/session-1')
expect(installBrowserDesktopBridge()).toBe(true)
await window.hermesDesktop.profile.remember('work')
expect(new URLSearchParams(window.location.search).get('profile')).toBe('work')
expect(window.location.hash).toBe('#/session-1')
expect(window.history.state).toEqual(entry)
})
it('opens the requested profile on this host without carrying another session or spectator flags', async () => {
// A gated host opens the window directly; a launch-link tab hands it a session first.
bootstrap.__HERMES_AUTH_REQUIRED__ = true
+2 -1
View File
@@ -60,7 +60,8 @@ export function createBrowserProfileBridge({
const selected = profile?.trim() || null
const url = new URL(window.location.href)
setUrlProfile(url, selected)
window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`)
// Keep react-router's entry state (idx/key/usr): only the URL changes.
window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`)
return { profile: selected }
}
@@ -0,0 +1,294 @@
import '@/store/session-states'
import { EditorView } from '@codemirror/view'
import { LOCAL_CONNECTION_ID } from '@hermes/shared'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setApiRequestConnection, setApiRequestProfile } from '@/api/client'
import { PreviewTilePane } from '@/app/chat/right-rail/preview'
import { LocalFilePreview } from '@/app/chat/right-rail/preview-file'
import { SessionViewProvider } from '@/app/chat/session-view'
import { MarkdownImage } from '@/components/assistant-ui/markdown-text'
import { $previewTabs } from '@/store/preview'
import { $connection, _resetSessionOwnerHintsForTests, setSessionOwnerHint } from '@/store/session'
import { clearWebappSession, seedWebappSession } from '@/test/webapp-session'
import { installBrowserDesktopBridge } from './browser-desktop-bridge'
import { readDesktopFileText, writeDesktopFileText } from './desktop-fs'
import { normalizeOrLocalPreviewTarget } from './local-preview'
import { resolveMediaPlaybackSrc } from './media'
// The pane's consent dialog needs a QueryClientProvider; these tests read files, not consent.
vi.mock('@/app/chat/right-rail/real-profile-consent-dialog', () => ({ RealProfileConsentDialog: () => null }))
vi.mock('@/components/chat/shiki-highlighter', () => ({
LazyShiki: ({ code }: { code: string }) => <pre>{code}</pre>
}))
// WA-5: a Docker agent's /workspace is its own session's sandbox, so a Webapp file request names
// the session it belongs to. The gateway half is tests/hermes_cli/test_web_server_fs_session_workspace.py.
const OWNER = { connectionId: LOCAL_CONNECTION_ID, profile: 'default' }
const CLIP = '/workspace/clip.mp4'
const win = window
let requests: { body: Record<string, unknown>; method: string; url: URL }[] = []
// The profile each session belongs to. `ops-session` is a Bot's: the window stays on `default`.
const SESSION_OWNERS: Record<string, string> = { 'ops-session': 'ops', 'sess-1': 'default', 'stored-1': 'default' }
const profileOf = (request: (typeof requests)[number]) =>
request.url.searchParams.get('profile') ?? (request.body.profile as string | undefined) ?? null
const sessionOf = (request: (typeof requests)[number]) =>
request.url.searchParams.get('session_id') ?? (request.body.session_id as string | undefined) ?? null
beforeEach(() => {
requests = []
seedWebappSession('served-token')
_resetSessionOwnerHintsForTests()
for (const [sessionId, profile] of Object.entries(SESSION_OWNERS)) {
setSessionOwnerHint(sessionId, { connectionId: LOCAL_CONNECTION_ID, profile })
}
vi.stubGlobal(
'fetch',
vi.fn(async (input: URL, init?: RequestInit) => {
const request = { body: JSON.parse(String(init?.body ?? '{}')), method: init?.method ?? 'GET', url: input }
requests.push(request)
const sessionId = sessionOf(request)
// The gateway looks a session up in the profile the request names, nowhere else.
if (sessionId && SESSION_OWNERS[sessionId] !== (profileOf(request) ?? 'default')) {
return new Response(JSON.stringify({ detail: 'Session not found' }), { status: 404 })
}
const answers: Record<string, unknown> = {
'/api/files/ticket': { ticket: 'stream-ticket' },
'/api/fs/read-text': { path: '/workspace/note.txt', text: 'session note' },
'/api/fs/write-text': { ok: true, path: '/workspace/note.txt' }
}
return new Response(JSON.stringify(answers[input.pathname] ?? {}), { status: 200 })
})
)
expect(installBrowserDesktopBridge()).toBe(true)
setApiRequestConnection(LOCAL_CONNECTION_ID)
setApiRequestProfile('default')
$connection.set({ connectionId: LOCAL_CONNECTION_ID, mode: 'remote', profile: 'default' } as never)
})
afterEach(() => {
cleanup()
_resetSessionOwnerHintsForTests({ storage: true })
$previewTabs.set([])
clearWebappSession()
Reflect.deleteProperty(win, 'hermesDesktop')
globalThis.document.documentElement.removeAttribute('data-hermes-desktop-host')
setApiRequestConnection(null)
setApiRequestProfile(null)
$connection.set(null)
window.dispatchEvent(new Event('beforeunload'))
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('media in a session sandbox', () => {
it('tickets a clip for the session it belongs to', async () => {
const src = new URL(await resolveMediaPlaybackSrc(CLIP, OWNER, 'sess-1'))
const [ticket] = requests
expect(ticket.body).toEqual({ path: CLIP, profile: 'default', route: 'stream', session_id: 'sess-1' })
expect(Object.fromEntries(src.searchParams)).toEqual({
path: CLIP,
profile: 'default',
session_id: 'sess-1',
ticket: 'stream-ticket'
})
})
it('names no session when the caller has none', async () => {
await resolveMediaPlaybackSrc(CLIP, OWNER)
expect(requests[0].body).toEqual({ path: CLIP, profile: 'default', route: 'stream' })
})
it('plays a transcript clip from the transcript session', async () => {
const view = { $runtimeId: atom<null | string>('runtime-1'), $storedId: atom<null | string>('stored-1') }
const { container } = render(
<SessionViewProvider value={view as never}>
<MarkdownImage alt="clip" src={CLIP} />
</SessionViewProvider>
)
await waitFor(() => expect(container.querySelector('video')?.getAttribute('src')).toContain('ticket='))
expect(requests[0].body).toMatchObject({ path: CLIP, route: 'stream', session_id: 'stored-1' })
})
})
const NOTE = '/workspace/note.txt'
const readsOf = (route: string) => requests.filter(request => request.url.pathname === route)
describe('text previews in a session sandbox', () => {
it('reads a preview for the session it belongs to', async () => {
await readDesktopFileText(NOTE, { sessionId: 'sess-1' })
await readDesktopFileText(NOTE)
const [scoped, bare] = readsOf('/api/fs/read-text')
expect(scoped.url.searchParams.get('path')).toBe(NOTE)
expect(scoped.url.searchParams.getAll('session_id')).toEqual(['sess-1'])
expect(bare.url.searchParams.get('path')).toBe(NOTE)
expect(bare.url.searchParams.has('session_id')).toBe(false)
})
it('previews a tab from the session that owns it', async () => {
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(Date.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
$previewTabs.set([
{
id: 'file:note',
sessionId: 'stored-1',
target: { kind: 'file', label: 'note.txt', path: NOTE, previewKind: 'text', source: NOTE, url: `file://${NOTE}` }
}
])
await act(async () => {
render(<PreviewTilePane tabId="file:note" />)
})
await waitFor(() => expect(readsOf('/api/fs/read-text')).not.toHaveLength(0))
expect(readsOf('/api/fs/read-text')[0].url.searchParams.get('session_id')).toBe('stored-1')
})
})
describe('Save in a session sandbox', () => {
it('saves a file for the session it was read for', async () => {
await writeDesktopFileText(NOTE, 'edited', { sessionId: 'sess-1' })
await writeDesktopFileText(NOTE, 'edited')
const [scoped, bare] = readsOf('/api/fs/write-text')
expect(scoped).toMatchObject({ body: { content: 'edited', path: NOTE, session_id: 'sess-1' }, method: 'POST' })
expect(bare.body).toEqual({ content: 'edited', path: NOTE })
})
it('saves an edited preview to the session the tab belongs to', async () => {
const rangeDescriptors = Object.getOwnPropertyDescriptors(Range.prototype)
Object.defineProperties(Range.prototype, {
getClientRects: { configurable: true, value: () => [] },
getBoundingClientRect: { configurable: true, value: () => new DOMRect() }
})
try {
const rendered = render(
<LocalFilePreview
reloadKey={0}
sessionId="stored-1"
target={{ kind: 'file', label: 'note.txt', path: NOTE, previewKind: 'text', source: NOTE, url: `file://${NOTE}` }}
/>
)
fireEvent.click(await screen.findByRole('button', { name: 'Edit' }))
const editor = EditorView.findFromDOM(rendered.container.querySelector('.cm-content')!)!
act(() => editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: 'edited' } }))
fireEvent.click(screen.getByRole('button', { name: /Save/ }))
await waitFor(() => expect(readsOf('/api/fs/write-text')).toHaveLength(1))
expect(readsOf('/api/fs/write-text')[0].body).toEqual({ content: 'edited', path: NOTE, session_id: 'stored-1' })
// The first load, the stale-on-disk check before the write and the reload after it all
// read the same session's copy.
const sessions = readsOf('/api/fs/read-text').map(read => read.url.searchParams.get('session_id'))
expect(sessions.length).toBeGreaterThanOrEqual(2)
expect(new Set(sessions)).toEqual(new Set(['stored-1']))
} finally {
for (const key of ['getClientRects', 'getBoundingClientRect']) {
if (rangeDescriptors[key]) {
Object.defineProperty(Range.prototype, key, rangeDescriptors[key])
} else {
Reflect.deleteProperty(Range.prototype, key)
}
}
}
})
})
describe('a session owned by another profile than the window', () => {
const OPS = 'ops-session'
it('reads its file with the profile that owns it', async () => {
await expect(readDesktopFileText(NOTE, { sessionId: OPS })).resolves.toMatchObject({ text: 'session note' })
const [read] = readsOf('/api/fs/read-text')
expect([profileOf(read), sessionOf(read)]).toEqual(['ops', OPS])
})
it('saves its file with the profile that owns it', async () => {
await expect(writeDesktopFileText(NOTE, 'edited', { sessionId: OPS })).resolves.toEqual({ path: NOTE })
const [save] = readsOf('/api/fs/write-text')
expect([profileOf(save), sessionOf(save)]).toEqual(['ops', OPS])
})
it('tickets its clip with the profile that owns it', async () => {
const src = new URL(await resolveMediaPlaybackSrc(CLIP, OWNER, OPS))
expect(requests[0].body).toEqual({ path: CLIP, profile: 'ops', route: 'stream', session_id: OPS })
expect(Object.fromEntries(src.searchParams)).toMatchObject({ profile: 'ops', session_id: OPS })
})
it('enriches a preview of its file with the profile that owns it', async () => {
await normalizeOrLocalPreviewTarget(NOTE, '/workspace', { sessionId: OPS })
const [read] = readsOf('/api/fs/read-text')
expect([profileOf(read), sessionOf(read)]).toEqual(['ops', OPS])
})
it('names no session when its owner is unknown, as a sessionless read does', async () => {
await readDesktopFileText(NOTE, { sessionId: 'unknown-session' })
await writeDesktopFileText(NOTE, 'edited', { sessionId: 'unknown-session' })
await resolveMediaPlaybackSrc(CLIP, OWNER, 'unknown-session')
expect(requests.map(request => [request.url.pathname, profileOf(request), sessionOf(request)])).toEqual([
['/api/fs/read-text', 'default', null],
['/api/fs/write-text', 'default', null],
['/api/files/ticket', 'default', null]
])
})
})
describe('native Desktop reads', () => {
it('keeps a local save to its path', async () => {
globalThis.document.documentElement.removeAttribute('data-hermes-desktop-host')
const writeTextFile = vi.fn(async () => ({ path: NOTE }))
Object.defineProperty(win, 'hermesDesktop', { configurable: true, value: { writeTextFile } })
$connection.set({ mode: 'local' } as never)
await writeDesktopFileText(NOTE, 'edited', { sessionId: 'sess-1' })
expect(writeTextFile).toHaveBeenCalledWith(NOTE, 'edited')
expect(requests).toEqual([])
})
it('keeps a local read to its path', async () => {
globalThis.document.documentElement.removeAttribute('data-hermes-desktop-host')
const readFileText = vi.fn(async () => ({ path: NOTE, text: 'local' }))
Object.defineProperty(win, 'hermesDesktop', { configurable: true, value: { readFileText } })
$connection.set({ mode: 'local' } as never)
await readDesktopFileText(NOTE, { sessionId: 'sess-1' })
expect(readFileText).toHaveBeenCalledWith(NOTE)
expect(requests).toEqual([])
})
})
@@ -129,6 +129,19 @@ describe('persistent browser terminals', () => {
expect(resumed.sent.join('')).not.toContain('\u000c')
})
// A trimmed replay can start mid-way through a full-screen program; an unchanged size sends no SIGWINCH.
it('forces one redraw by a real size change only when resuming from a trimmed replay', async () => {
const api = install()
await api.start({ cwd: '/original', restoreKey: 'tab-one' })
Socket.instances[0].close(1006)
await vi.advanceTimersByTimeAsync(1000)
expect(Socket.instances[1].sent).toEqual(['\u001b[RESIZE:80;24]'])
Socket.reply = socket => socket.metadata({ truncated: true })
Socket.instances[1].close(1006)
await vi.advanceTimersByTimeAsync(1000)
expect(Socket.instances[2].sent).toEqual(['\u001b[RESIZE:80;23]', '\u001b[RESIZE:80;24]'])
})
it('closes explicitly with a fresh authenticated request even while disconnected', async () => {
const api = install()
const session = await api.start({ cwd: '/original', restoreKey: 'tab-one' })
@@ -174,6 +187,54 @@ describe('persistent browser terminals', () => {
expect(localStorage.length).toBe(0)
})
// The server closes with 4410 both when the shell exits (`exit`, Ctrl-D) and when it ends the
// shell itself (expiry, a deleted profile, shutdown); only an exit carries the reason.
it('reports a host shell that exited as an exit, as native Desktop does, and forgets it', async () => {
const api = install()
const session = await api.start({ cwd: '/original', restoreKey: 'tab-one' })
const exit = vi.fn()
api.onExit(session.id, exit)
Socket.instances[0].close(4410, 'Process exited')
await vi.advanceTimersByTimeAsync(30_000)
expect(Socket.instances).toHaveLength(1)
expect(exit).toHaveBeenCalledExactlyOnceWith({ code: null, signal: null })
expect(localStorage.length).toBe(0)
})
// The shell exited while the tab was away: the redial is refused with the exit reason.
it('reports a redial refused because the shell exited as an exit, and forgets it', async () => {
const api = install()
const session = await api.start({ cwd: '/original', restoreKey: 'tab-one' })
const exit = vi.fn()
api.onExit(session.id, exit)
Socket.instances[0].close(1006)
Socket.reply = socket => socket.close(4410, 'Process exited')
await vi.advanceTimersByTimeAsync(30_000)
expect(Socket.instances).toHaveLength(2)
expect(exit).toHaveBeenCalledExactlyOnceWith({ code: null, signal: null })
expect(localStorage.length).toBe(0)
})
it('rejects resuming a saved shell that exited with the exit signal, and forgets it', async () => {
const api = install()
await api.start({ cwd: '/original', restoreKey: 'tab-one' })
Socket.reply = socket => socket.close(4410, 'Process exited')
await expect(api.start({ restoreKey: 'tab-one', resumeOnly: true })).rejects.toMatchObject({ signal: 'exited' })
expect(Socket.instances[1].url.searchParams.get('attach')).toBe('opaque-server-id')
expect(localStorage.length).toBe(0)
})
it('reports a 4410 close without the exit reason as expired', async () => {
const api = install()
const session = await api.start({ cwd: '/original', restoreKey: 'tab-one' })
const exit = vi.fn()
api.onExit(session.id, exit)
Socket.instances[0].close(4410)
await vi.advanceTimersByTimeAsync(30_000)
expect(Socket.instances).toHaveLength(1)
expect(exit).toHaveBeenCalledExactlyOnceWith({ code: null, signal: 'expired' })
})
it('never retries denied attachments or silently adopts the active profile', async () => {
const api = install()
const session = await api.start({ cwd: '/original', restoreKey: 'tab-one' })
+21 -2
View File
@@ -47,6 +47,11 @@ interface TerminalState {
}
const META = '\u0000HERMES_TERMINAL_META:'
// 4410 also ends a shell the server closed (expiry, a deleted profile, shutdown); only a shell
// that exited by itself (`exit`, Ctrl-D) carries this reason (pty_session.WS_CLOSE_PROCESS_EXITED_REASON).
const PROCESS_EXITED_REASON = 'Process exited'
// The signal of a dial refused that way (the shell exited while no tab was attached): a process exit.
const EXITED = 'exited'
const MAX_RETRIES = 5
const RETRY_BACKOFF = { baseDelayMs: 500, capMs: 8000 }
const START_TIMEOUT = 10_000
@@ -68,6 +73,14 @@ function settleOpen(state: TerminalState): void {
state.openedAt = null
}
/** A trimmed replay can start mid-way through a full-screen program's output, and an
* unchanged size sends no SIGWINCH: step one row away first so the program redraws. The server
* holds the step back until the program has answered the step (web_host_terminal_sessions._resize). */
function sendResumeSize(socket: WebSocket, state: TerminalState, trimmed?: boolean): void {
if (trimmed) {socket.send(`\u001b[RESIZE:${state.cols};${state.rows > 1 ? state.rows - 1 : state.rows + 1}]`)}
socket.send(`\u001b[RESIZE:${state.cols};${state.rows}]`)
}
async function closeSavedShell(saved: SavedTerminal, websocketUrl: BrowserTerminalOptions['websocketUrl']): Promise<boolean> {
const url = new URL(await websocketUrl(saved.profile))
url.searchParams.set('attach', saved.terminalId)
@@ -210,7 +223,7 @@ async function dialTerminal(state: TerminalState, context: DialContext): Promise
handshakeComplete = true
// Resume query dimensions do not resize an existing server PTY.
if (resuming) {socket.send(`\u001b[RESIZE:${state.cols};${state.rows}]`)}
if (resuming) {sendResumeSize(socket, state, meta.truncated)}
setStatus(state, 'open')
state.openedAt = Date.now()
@@ -247,11 +260,12 @@ async function dialTerminal(state: TerminalState, context: DialContext): Promise
if (!current()) {return}
settleOpen(state)
const signal = CLOSE_SIGNALS[event.code] ?? 'disconnected'
const exited = event.code === 4410 && event.reason === PROCESS_EXITED_REASON
if (signal === 'expired') {forget(state)}
if (!handshakeComplete) {
fail(new TerminalConnectionError(event.reason || `Host terminal closed (${event.code})`, signal))
fail(new TerminalConnectionError(event.reason || `Host terminal closed (${event.code})`, exited ? EXITED : signal))
return
}
@@ -260,6 +274,9 @@ async function dialTerminal(state: TerminalState, context: DialContext): Promise
if (!state.terminalId) {
finish(state, event.code === 4410 ? null : 'disconnected')
} else if (exited) {
// The shell ended: report a process exit, which closes the tab as in native Desktop.
finish(state, null)
} else if (signal !== 'disconnected') {
finish(state, signal)
} else {
@@ -360,6 +377,8 @@ export function createBrowserTerminal(options: BrowserTerminalOptions): Terminal
// A 401 from the ticket route: signed out, and every further attempt would be refused.
if (isGatewayReauthRequired(error)) {
finish(state, 'signed-out')
} else if (error instanceof TerminalConnectionError && error.signal === EXITED) {
finish(state, null)
} else if (error instanceof TerminalConnectionError && error.signal !== 'disconnected') {
finish(state, error.signal)
} else {
@@ -1,5 +1,8 @@
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useComposerActions } from '@/app/chat/hooks/use-composer-actions'
import { $notifications, clearNotifications } from '@/store/notifications'
import { $connection } from '@/store/session'
import { clearWebappSession, seedWebappSession } from '@/test/webapp-session'
@@ -7,6 +10,8 @@ import { installBrowserDesktopBridge } from './browser-desktop-bridge'
import { BROWSER_UPLOAD_MAX_BYTES } from './browser-uploads'
afterEach(() => {
cleanup()
clearNotifications()
clearWebappSession()
Reflect.deleteProperty(window, 'hermesDesktop')
document.documentElement.removeAttribute('data-hermes-desktop-host')
@@ -54,3 +59,104 @@ describe('browser image paste size', () => {
expect((fetchMock.mock.calls[0] as [URL])[0].pathname).toBe('/api/chat/image-upload')
})
})
describe('browser file stage size', () => {
// A picked or dropped file over the upload limit gets the server's 413 "Upload request is too large",
// which doesn't say what the limit is, and a refused image pick used to show nothing at all.
const tooLarge = (name: string, type: string) => new File([new Uint8Array(BROWSER_UPLOAD_MAX_BYTES + 1)], name, { type })
const limitMessage = 'The file is larger than 16 MiB, the largest file the browser can attach.'
async function installBrowser() {
seedWebappSession('served-token')
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input), window.location.origin)
if (url.pathname === '/api/chat/file-upload') {
return new Response(JSON.stringify({ detail: 'Upload request is too large' }), { status: 413 })
}
throw new Error(`Unexpected fetch: ${url}`)
})
vi.stubGlobal('fetch', fetchMock)
expect(installBrowserDesktopBridge()).toBe(true)
$connection.set(await window.hermesDesktop.getConnection('owner'))
const uploads = () => fetchMock.mock.calls.filter(([input]) => String(input).includes('/api/chat/file-upload'))
return { uploads }
}
const renderComposer = () =>
renderHook(() => useComposerActions({ activeSessionId: null, currentCwd: '/workspace', requestGateway: vi.fn() }))
it('refuses a picked image over the limit, names the limit, and uploads nothing', async () => {
const { uploads } = await installBrowser()
const { result } = renderComposer()
vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(function (this: HTMLInputElement) {
Object.defineProperty(this, 'files', { configurable: true, value: [tooLarge('huge.png', 'image/png')] })
this.dispatchEvent(new Event('change'))
})
await act(async () => {
await result.current.pickImages()
})
expect(uploads()).toHaveLength(0)
expect($notifications.get()).toContainEqual(expect.objectContaining({ kind: 'error', message: limitMessage }))
})
// Attach → Files… used to leave the refused stage as an unhandled rejection, with nothing shown.
it('refuses a picked file over the limit with one notice naming the limit, and uploads nothing', async () => {
const { uploads } = await installBrowser()
const { result } = renderComposer()
vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(function (this: HTMLInputElement) {
Object.defineProperty(this, 'files', { configurable: true, value: [tooLarge('huge.zip', 'application/zip')] })
this.dispatchEvent(new Event('change'))
})
await act(async () => {
await result.current.pickContextPaths('file')
})
expect(uploads()).toHaveLength(0)
expect($notifications.get()).toEqual([
expect.objectContaining({ kind: 'error', message: limitMessage, title: 'Attach failed' })
])
})
it('names the limit when a dropped file over it cannot be attached', async () => {
const { uploads } = await installBrowser()
const { result } = renderComposer()
let attached = true
await act(async () => {
attached = await result.current.attachDroppedItems([{ file: tooLarge('huge.zip', 'application/zip'), path: '' }])
})
expect(attached).toBe(false)
expect(uploads()).toHaveLength(0)
expect($notifications.get()).toContainEqual(
expect.objectContaining({ kind: 'warning', message: `Could not attach huge.zip: ${limitMessage}` })
)
})
// The reason is the same for any name: a long one used to push it past readableError's length cap.
it('still names the limit when the dropped file has a long name', async () => {
await installBrowser()
const { result } = renderComposer()
const name = `${'a'.repeat(150)}.zip`
await act(async () => {
await result.current.attachDroppedItems([{ file: tooLarge(name, 'application/zip'), path: '' }])
})
const warning = $notifications.get().find(notice => notice.kind === 'warning')
expect(warning?.message).toMatch(/^Could not attach a+/)
expect(warning?.message).toContain(limitMessage)
})
})
+43 -3
View File
@@ -1,5 +1,5 @@
import type { HermesApiRequest, HermesSelectPathsOptions, HermesStagedUpload } from '@/global'
import { blobToDataUrl, bytesToBase64 } from '@/lib/base64'
import { blobToDataUrl } from '@/lib/base64'
import { type BrowserBootstrap, browserFetch } from '@/lib/browser-transport'
const STAGED_UPLOAD_CACHE_LIMIT = 256
@@ -46,15 +46,46 @@ function rememberBounded<K, V>(cache: Map<K, V>, key: K, value: V): void {
}
}
// The page reaches its opaque-origin frame through srcdoc, never a URL: Chromium refuses to
// navigate a frame to a URL over 2 MiB, so a base64 data URL left any page above ~1.5 MB blank.
// Decoded as the data URL's charset=utf-8 did, and escaped for a double-quoted attribute (`&`
// first) so no byte of the page parses as markup of this same-origin wrapper.
// A srcdoc document inherits the wrapper's blob: base URL, so `#anchor` links would resolve to
// blob:…#anchor and do nothing. A page without its own <base> gets about:srcdoc, placed inside
// the page's own <head> start tag (else its <html> tag, else after any doctype): a base before
// <head> opens an implicit head, and the parser then drops the authored tag and its attributes.
// After the doctype, the page keeps its rendering mode.
const SRCDOC_BASE = '<base href="about:srcdoc">'
const LEADING_DOCTYPE = /^\s*<!doctype[^>]*>/i
const BASE_ELEMENT = /<base[\s/>]/i
const START_TAG_REST = String.raw`(?=[\s/>])(?:"[^"]*"|'[^']*'|[^'">])*>`
const HEAD_START = new RegExp(`<head${START_TAG_REST}`, 'i')
const HTML_START = new RegExp(`<html${START_TAG_REST}`, 'i')
function withSrcdocBase(page: string): string {
if (BASE_ELEMENT.test(page)) {
return page
}
const anchor = HEAD_START.exec(page) ?? HTML_START.exec(page) ?? LEADING_DOCTYPE.exec(page)
const at = anchor ? anchor.index + anchor[0].length : 0
return page.slice(0, at) + SRCDOC_BASE + page.slice(at)
}
function sandboxedHtmlBlob(bytes: Uint8Array): Blob {
const source = `data:text/html;charset=utf-8;base64,${bytesToBase64(bytes)}`
const srcdoc = withSrcdocBase(new TextDecoder('utf-8').decode(bytes))
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
const wrapper = [
'<!doctype html>',
'<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">',
'<style>html,body,iframe{box-sizing:border-box;width:100%;height:100%;margin:0;border:0}</style>',
'</head><body>',
`<iframe sandbox="allow-scripts" referrerpolicy="no-referrer" src="${source}"></iframe>`,
`<iframe sandbox="allow-scripts" referrerpolicy="no-referrer" srcdoc="${srcdoc}"></iframe>`,
'</body></html>'
].join('')
@@ -94,6 +125,15 @@ async function stageBrowserFile(
profile: null | string,
onStaged: OnStaged
): Promise<string> {
// The server refuses a larger file anyway, with a 413 that doesn't name the limit: say it here.
// The reason leaves out the name, which a drop warning adds: a long name would push the reason
// past readableError's length cap and lose the limit.
if (file.size > BROWSER_UPLOAD_MAX_BYTES) {
throw new Error(
`The file is larger than ${BROWSER_UPLOAD_MAX_BYTES / (1024 * 1024)} MiB, the largest file the browser can attach.`
)
}
const form = new FormData()
form.append('file', file, file.name || 'attachment')
+56 -13
View File
@@ -1,4 +1,4 @@
import { hermesApi } from '@/api/client'
import { hermesApi, sessionFileOwnerScope } from '@/api/client'
import type {
HermesConnection,
HermesReadDirResult,
@@ -54,8 +54,31 @@ export function desktopFsProfile(): string | undefined {
return $connection.get()?.profile || undefined
}
function fsPath(endpoint: string, filePath: string) {
return `/api/fs/${endpoint}?path=${encodeURIComponent(filePath)}`
/** The chat a gateway file belongs to. A Docker agent's `/workspace` is that session's own
* sandbox, so a gateway read names it; a local (native) read has no use for it. */
export interface DesktopFsSessionScope {
sessionId?: null | string
}
interface FsRoute {
owner: { connectionId?: string; profile?: string }
sessionId?: string
}
/** Where a gateway file request goes. A session id travels only with the profile (and
* connection) that owns it: a Bot or pooled chat's owner can differ from the window's. With
* no known owner the request names no session and goes to the window's profile. */
function fsRoute(scope?: DesktopFsSessionScope): FsRoute {
const sessionId = scope?.sessionId || undefined
const owner = sessionId ? sessionFileOwnerScope(sessionId) : null
return owner ? { owner, sessionId } : { owner: { profile: desktopFsProfile() } }
}
function fsPath(endpoint: string, filePath: string, sessionId?: string) {
const session = sessionId ? `&session_id=${encodeURIComponent(sessionId)}` : ''
return `/api/fs/${endpoint}?path=${encodeURIComponent(filePath)}${session}`
}
function bridge() {
@@ -68,10 +91,8 @@ function bridge() {
return desktop
}
function remoteFsApi<T>(path: string, body?: Record<string, unknown>): Promise<T> {
return hermesApi<T>(
body ? { body, method: 'POST', path, profile: desktopFsProfile() } : { path, profile: desktopFsProfile() }
)
function remoteFsApi<T>(path: string, body?: Record<string, unknown>, owner = fsRoute().owner): Promise<T> {
return hermesApi<T>(body ? { ...owner, body, method: 'POST', path } : { ...owner, path })
}
/** True when a bridge read returned the main process's structured "file is not
@@ -110,7 +131,10 @@ export async function readDesktopDir(path: string): Promise<HermesReadDirResult>
return remoteFsApi<HermesReadDirResult>(fsPath('list', path))
}
export async function readDesktopFileText(path: string): Promise<HermesReadFileTextResult> {
export async function readDesktopFileText(
path: string,
scope?: DesktopFsSessionScope
): Promise<HermesReadFileTextResult> {
if (!isDesktopFsRemoteMode()) {
const result = await bridge().readFileText(path)
@@ -121,14 +145,21 @@ export async function readDesktopFileText(path: string): Promise<HermesReadFileT
return result
}
return remoteFsApi<HermesReadFileTextResult>(fsPath('read-text', path))
const route = fsRoute(scope)
return remoteFsApi<HermesReadFileTextResult>(fsPath('read-text', path, route.sessionId), undefined, route.owner)
}
// Save UTF-8 text back to a file. Local writes go through the hardened Electron
// IPC; remote writes hit the dashboard's POST /api/fs/write-text (same path
// hardening, parent-must-exist, size cap) so the editor behaves identically in
// both modes. Stale-on-disk detection is the caller's job (re-read before save).
export async function writeDesktopFileText(path: string, content: string): Promise<{ path: string }> {
// A gateway save names the session the file was read for, so it lands where that read did.
export async function writeDesktopFileText(
path: string,
content: string,
scope?: DesktopFsSessionScope
): Promise<{ path: string }> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
@@ -139,7 +170,13 @@ export async function writeDesktopFileText(path: string, content: string): Promi
return desktop.writeTextFile(path, content)
}
const result = await remoteFsApi<{ ok?: boolean; path?: string }>('/api/fs/write-text', { content, path })
const route = fsRoute(scope)
const result = await remoteFsApi<{ ok?: boolean; path?: string }>(
'/api/fs/write-text',
{ content, path, ...(route.sessionId ? { session_id: route.sessionId } : {}) },
route.owner
)
return { path: result.path || path }
}
@@ -152,7 +189,7 @@ export async function createRemoteDir(path: string): Promise<string> {
return result.path || path
}
export async function readDesktopFileDataUrl(path: string): Promise<string> {
export async function readDesktopFileDataUrl(path: string, scope?: DesktopFsSessionScope): Promise<string> {
if (!isDesktopFsRemoteMode()) {
const result = await bridge().readFileDataUrl(path)
@@ -163,7 +200,13 @@ export async function readDesktopFileDataUrl(path: string): Promise<string> {
return result
}
const result = await remoteFsApi<string | { dataUrl?: string }>(fsPath('read-data-url', path))
const route = fsRoute(scope)
const result = await remoteFsApi<string | { dataUrl?: string }>(
fsPath('read-data-url', path, route.sessionId),
undefined,
route.owner
)
return typeof result === 'string' ? result : result.dataUrl || ''
}
+2 -1
View File
@@ -62,7 +62,8 @@ describe('remote HTML previews', () => {
...remoteTarget,
dataUrl
})
expect(readDesktopFileDataUrl).toHaveBeenCalledWith('/srv/report.html')
// No session scope: the read names none.
expect(readDesktopFileDataUrl).toHaveBeenCalledWith('/srv/report.html', undefined)
})
it('falls back to source mode when the transport is not canonical HTML', async () => {
+14 -8
View File
@@ -1,7 +1,12 @@
import { fileUrlToNativePath } from '@hermes/shared'
import DOMPurify from 'dompurify'
import { isDesktopFsRemoteMode, readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
import {
type DesktopFsSessionScope,
isDesktopFsRemoteMode,
readDesktopFileDataUrl,
readDesktopFileText
} from '@/lib/desktop-fs'
import { isWindowsAbsolutePath } from '@/lib/path-compare'
import type { PreviewTarget } from '@/store/preview'
@@ -267,7 +272,7 @@ export function localPreviewTarget(rawTarget: string, cwd?: string | null): Prev
}
}
async function enrichPreviewTarget(target: PreviewTarget | null): Promise<PreviewTarget | null> {
async function enrichPreviewTarget(target: PreviewTarget | null, scope?: DesktopFsSessionScope): Promise<PreviewTarget | null> {
if (
!isDesktopFsRemoteMode() ||
!target ||
@@ -280,7 +285,7 @@ async function enrichPreviewTarget(target: PreviewTarget | null): Promise<Previe
if (target.previewKind === 'html') {
try {
const dataUrl = validatedRemoteHtmlDataUrl(await readDesktopFileDataUrl(target.path || target.source))
const dataUrl = validatedRemoteHtmlDataUrl(await readDesktopFileDataUrl(target.path || target.source, scope))
return dataUrl ? { ...target, dataUrl } : { ...target, renderMode: 'source', transient: true }
} catch {
@@ -289,7 +294,7 @@ async function enrichPreviewTarget(target: PreviewTarget | null): Promise<Previe
}
try {
const result = await readDesktopFileText(target.path || target.source)
const result = await readDesktopFileText(target.path || target.source, scope)
return {
...target,
@@ -306,7 +311,8 @@ async function enrichPreviewTarget(target: PreviewTarget | null): Promise<Previe
export async function normalizeOrLocalPreviewTarget(
rawTarget: string,
cwd?: string | null
cwd?: string | null,
scope?: DesktopFsSessionScope
): Promise<PreviewTarget | null> {
try {
const normalized = await window.hermesDesktop?.normalizePreviewTarget?.(rawTarget, cwd || undefined)
@@ -319,10 +325,10 @@ export async function normalizeOrLocalPreviewTarget(
// machine, so remote mode keeps the fabricated fallback the gateway
// read resolves later.
if (normalized.previewKind === 'directory' || normalized.previewKind === 'missing') {
return isDesktopFsRemoteMode() ? enrichPreviewTarget(localPreviewTarget(rawTarget, cwd)) : null
return isDesktopFsRemoteMode() ? enrichPreviewTarget(localPreviewTarget(rawTarget, cwd), scope) : null
}
return enrichPreviewTarget(normalized)
return enrichPreviewTarget(normalized, scope)
}
// The main process resolved the target against the real filesystem and
@@ -339,5 +345,5 @@ export async function normalizeOrLocalPreviewTarget(
// through to renderer-side local classification so text/images still open.
}
return enrichPreviewTarget(localPreviewTarget(rawTarget, cwd))
return enrichPreviewTarget(localPreviewTarget(rawTarget, cwd), scope)
}
+9 -4
View File
@@ -1,6 +1,6 @@
import { fileUrlToNativePath, LOCAL_CONNECTION_ID } from '@hermes/shared'
import { capabilityScoped, hermesApi, type OwnerScope } from '@/api/client'
import { capabilityScoped, hermesApi, type OwnerScope, sessionFileOwnerScope } from '@/api/client'
import type { HermesConnection } from '@/global'
import { translateNow } from '@/i18n'
import { desktopFsCacheKey, isReadFileErrorResult, readDesktopFileDataUrl } from '@/lib/desktop-fs'
@@ -239,7 +239,7 @@ export function rememberMediaImageFailure(key: string): void {
// Audio/video need a seekable source instead of a whole-file data URL. Keep
// remote URLs untouched. The Webapp supplies an authenticated HTTP source;
// Electron's media protocol reads local files or proxies remote gateways.
export async function resolveMediaPlaybackSrc(path: string, owner?: OwnerScope): Promise<string> {
export async function resolveMediaPlaybackSrc(path: string, owner?: OwnerScope, sessionId?: string): Promise<string> {
if (isInlineMediaSrc(path)) {
return path
}
@@ -247,13 +247,18 @@ export async function resolveMediaPlaybackSrc(path: string, owner?: OwnerScope):
if (window.hermesDesktop && ['audio', 'video'].includes(mediaKind(path))) {
if (window.hermesDesktop.getGatewayFileStreamUrl) {
const conn = $connection.get()
// A Docker agent's /workspace is its session's own sandbox. The session id goes only with
// the profile that owns it; with no known owner the clip names no session.
const session = sessionId ? sessionFileOwnerScope(sessionId) : null
const via = session ?? owner
// A tile keeps its owner's profile for every ticket, renewals included, as its image reads do.
return window.hermesDesktop.getGatewayFileStreamUrl({
connectionId: owner?.connectionId || conn?.connectionId,
connectionId: via?.connectionId || conn?.connectionId,
// The gateway OS owns file-URI conversion, including drive letters and UNC hosts.
path,
profile: owner?.profile || conn?.profile
profile: via?.profile || conn?.profile,
...(session ? { sessionId } : {})
})
}
+6
View File
@@ -192,6 +192,12 @@ export function monitorSpeechDuringPlayback(callbacks: BargeMonitorCallbacks): (
active.stop()
}
// No mediaDevices (an insecure origin): there is no mic to monitor. The conversation's own
// listening start already reports that, through the recorder's "microphone unsupported" copy.
if (!navigator.mediaDevices?.getUserMedia) {
return cleanup
}
void (async () => {
try {
// Don't open a second capture context while the recorder's is still
+7
View File
@@ -280,6 +280,13 @@ export class VoiceLiveSession {
throw new Error('GPT-Live session already started')
}
// An insecure origin (a Webapp opened over plain HTTP from another machine) has no
// mediaDevices. Fail before opening a peer, with the error the recorder's copy maps to
// "microphone unsupported", instead of a TypeError on the missing API.
if (!navigator.mediaDevices?.getUserMedia) {
throw new DOMException('Microphone capture is unavailable here', 'NotSupportedError')
}
const connection = new RTCPeerConnection()
this.peer = connection
@@ -0,0 +1,57 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { micError } from '@/app/chat/composer/hooks/use-mic-recorder'
import { en } from '@/i18n/en'
import { monitorSpeechDuringPlayback } from './voice-barge-in'
import { VoiceLiveSession } from './voice-live'
// A Webapp opened over plain HTTP from another machine has no navigator.mediaDevices; nor has jsdom.
afterEach(() => vi.unstubAllGlobals())
it('live voice fails with "microphone unsupported" without mediaDevices, before opening a peer', async () => {
expect(navigator.mediaDevices).toBeUndefined()
const peers = vi.fn()
vi.stubGlobal('RTCPeerConnection', class {
constructor() { peers() }
addEventListener() {}
close() {}
})
const session = new VoiceLiveSession({ onClosed: vi.fn(), onDelegation: vi.fn(), onError: vi.fn() })
const failure = await session.start([]).catch((error: unknown) => error)
expect(micError(failure, en.notifications.voice).message).toBe(en.notifications.voice.microphoneUnsupported)
expect(peers).not.toHaveBeenCalled()
})
describe('on an insecure origin', () => {
beforeEach(() => Object.defineProperty(window, 'isSecureContext', { configurable: true, value: false }))
afterEach(() => {
Reflect.deleteProperty(window, 'isSecureContext')
delete document.documentElement.dataset.hermesDesktopHost
})
const unsupported = () => micError(new DOMException('no mic here', 'NotSupportedError'), en.notifications.voice)
it('the Webapp says HTTPS or a tunnel brings the microphone back', () => {
document.documentElement.dataset.hermesDesktopHost = 'browser'
expect(unsupported().message).toBe(en.notifications.voice.microphoneNeedsHttps)
})
it('native Desktop keeps the plain "microphone unsupported" copy', () => {
expect(unsupported().message).toBe(en.notifications.voice.microphoneUnsupported)
})
})
it('barge-in never reaches for a microphone it cannot have', () => {
const audioContext = vi.fn()
vi.stubGlobal('AudioContext', audioContext)
const stop = monitorSpeechDuringPlayback({ onSpeech: vi.fn(), onUtterance: vi.fn() })
expect(audioContext).not.toHaveBeenCalled()
stop()
})
@@ -386,6 +386,7 @@ export type BotsMessages = {
otherControls: string
agentControls: string
controlTaken: string
needsHttps: string
}
/** Bot-scoped scheduled jobs. Generic scheduling chrome (weekday names,
+8 -4
View File
@@ -410,7 +410,8 @@ const en: BotsMessages = {
youControl: 'You are in control',
otherControls: 'Another viewer is in control',
agentControls: 'Bot is in control',
controlTaken: 'Another viewer took control. Watching only.'
controlTaken: 'Another viewer took control. Watching only.',
needsHttps: 'Bot screens need a secure connection. Open the Webapp over HTTPS, or through an SSH tunnel to 127.0.0.1, to view this one.'
},
cron: {
untitled: 'Untitled job',
@@ -855,7 +856,8 @@ const ja: BotsMessages = {
youControl: 'あなたが操作中',
otherControls: '別のビューアが操作中',
agentControls: 'ボットが操作中',
controlTaken: '別のビューアが操作を引き継ぎました。閲覧のみ。'
controlTaken: '別のビューアが操作を引き継ぎました。閲覧のみ。',
needsHttps: 'ボットの画面には安全な接続が必要です。表示するには、Webapp を HTTPS か 127.0.0.1 への SSH トンネル経由で開いてください。'
},
cron: {
untitled: '無題のジョブ',
@@ -1283,7 +1285,8 @@ const zh: BotsMessages = {
youControl: '你正在控制',
otherControls: '另一位查看者正在控制',
agentControls: '机器人正在控制',
controlTaken: '另一位查看者已接管控制。仅可观看。'
controlTaken: '另一位查看者已接管控制。仅可观看。',
needsHttps: '机器人屏幕需要安全连接。请通过 HTTPS 或到 127.0.0.1 的 SSH 隧道打开 Webapp 后查看。'
},
cron: {
untitled: '未命名任务',
@@ -1711,7 +1714,8 @@ const zhHant: BotsMessages = {
youControl: '你正在控制',
otherControls: '另一位檢視者正在控制',
agentControls: '機器人正在控制',
controlTaken: '另一位檢視者已接手控制。僅可觀看。'
controlTaken: '另一位檢視者已接手控制。僅可觀看。',
needsHttps: '機器人螢幕需要安全連線。請透過 HTTPS 或到 127.0.0.1 的 SSH 通道開啟 Webapp 後檢視。'
},
cron: {
untitled: '未命名工作',
@@ -78,6 +78,14 @@ async function loadRfb(): Promise<
) => RfbLike
}
/** The viewer hashes its lease id with crypto.subtle and noVNC needs a secure context: over plain
* HTTP from another machine there is neither, so the pane says why instead of a stream. */
function ScreenNeedsHttps({ label }: { label: string }) {
return globalThis.crypto?.subtle ? null : (
<div className="absolute inset-0 grid place-items-center p-6 text-center text-xs text-white/70">{label}</div>
)
}
export function BotScreenPane({ bot }: { bot: RosterRow }) {
const t = useBots()
const screen = useValue($screenState)
@@ -183,6 +191,16 @@ export function BotScreenPane({ bot }: { bot: RosterRow }) {
}
detach()
// The viewer hashes its lease id with crypto.subtle, and noVNC needs a secure context too.
// A Webapp opened over plain HTTP from another machine has neither: never ask the bot for a
// viewer there (the pane says why) instead of failing on a missing digest afterwards.
if (!globalThis.crypto?.subtle) {
setConn('error')
return
}
const generation = attachGeneration.current
setConn('attaching')
setError(null)
@@ -563,6 +581,7 @@ export function BotScreenPane({ bot }: { bot: RosterRow }) {
{conn === 'error' && error ? (
<div className="absolute inset-x-0 bottom-0 bg-red-950/80 px-3 py-1.5 text-xs text-red-200">{error}</div>
) : null}
<ScreenNeedsHttps label={t.screen.needsHttps} />
</div>
</div>
)
@@ -43,7 +43,8 @@ vi.mock('./i18n', () => ({
handBackForceHint: 'Force',
takeOver: 'Take over',
reconnect: 'Reconnect',
streamLost: 'Stream lost'
streamLost: 'Stream lost',
needsHttps: 'Needs HTTPS'
}
})
}))
@@ -175,3 +176,18 @@ it('does not offer Take over while no server-minted viewer id exists, even once
await waitFor(() => expect(view.getByText('Take over').closest('button')?.disabled).toBe(false))
view.unmount()
})
it('says the screen needs HTTPS where an insecure origin has no crypto.subtle, before asking for a viewer', async () => {
Object.defineProperty(globalThis.crypto, 'subtle', { configurable: true, value: undefined })
try {
const view = render(<BotScreenPane bot={bot} />)
await waitFor(() => expect(vi.mocked(displayRequest)).toHaveBeenCalledWith(bot, 'display.status'))
await act(async () => {})
expect(view.getByText('Needs HTTPS')).toBeTruthy()
expect(vi.mocked(displayRequest)).not.toHaveBeenCalledWith(bot, 'display.observe', expect.anything())
view.unmount()
} finally {
Reflect.deleteProperty(globalThis.crypto, 'subtle')
}
})
+1 -1
View File
@@ -1124,7 +1124,7 @@ function createSecondary(profile: string, connectionId: null | string = null): S
// the second dial fails (tunnel/token are per-backend) and the closed socket
// poisons the active gateway with "not connected" even though the primary is
// open right next to it.
async function sharedPrimaryRoute(profile: string): Promise<boolean> {
export async function sharedPrimaryRoute(profile: string): Promise<boolean> {
const desktop = window.hermesDesktop
if (!desktop) {
@@ -110,6 +110,32 @@ describe('applyDesktopOverlay', () => {
})
})
describe('runExportProfileFlow', () => {
// The browser Webapp bridge has no save dialog: Export must say so, not do nothing.
it('explains itself in the browser Webapp instead of silently returning', async () => {
const { installBrowserDesktopBridge } = await import('@/lib/browser-desktop-bridge')
const { clearWebappSession, seedWebappSession } = await import('@/test/webapp-session')
const { runExportProfileFlow } = await import('./profile-share')
const { $notifications } = await import('./notifications')
seedWebappSession('served-token')
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}')))
try {
expect(installBrowserDesktopBridge()).toBe(true)
expect(window.hermesDesktop.selectSavePath).toBeUndefined()
await expect(runExportProfileFlow('glam')).resolves.toBeNull()
expect(exportProfileArchive).not.toHaveBeenCalled()
expect($notifications.get().at(-1)).toMatchObject({ kind: 'error', title: 'Failed to export profile' })
expect($notifications.get().at(-1)?.message).toContain('hermes profile export glam')
} finally {
clearWebappSession()
Reflect.deleteProperty(window, 'hermesDesktop')
document.documentElement.removeAttribute('data-hermes-desktop-host')
vi.unstubAllGlobals()
}
})
})
describe('exportProfileBundle', () => {
it('stages desktop.json into the archive through extra_files', async () => {
skinPref.assign('glam', 'mono')
+4
View File
@@ -160,7 +160,11 @@ export async function runExportProfileFlow(profile?: string): Promise<null | str
const target = normalizeProfileKey(profile ?? activeProfileKey())
const pick = window.hermesDesktop?.selectSavePath
// The browser Webapp has no save dialog for a host-side archive. Say how to
// export there instead of doing nothing.
if (!pick) {
notifyError(new Error(translateNow('profiles.exportNeedsHost', target)), translateNow('profiles.failedExport'))
return null
}
+2 -2
View File
@@ -336,8 +336,8 @@ export function clearSessionSubagents(sid: string) {
* from the display while background subagents that outlived the spawning turn
* remain visible (and still accept late progress/complete events).
*
* Distinct from `clearSessionSubagents` (used by the Stop action, which
* genuinely cancels running subagents and so should drop them all) and from
* Also what the Stop action uses: Stop ends the turn, not the background
* subagents it spawned. Distinct from `clearSessionSubagents` (drops every row) and from
* `pruneDelegateFallbackSubagents` (which filters by id prefix to remove
* placeholder rows once the real native event arrives).
*/
+5 -4
View File
@@ -778,8 +778,8 @@ def save_config_value(key_path: str, value: any) -> bool:
try:
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(config_path.parent)
from utils import atomic_roundtrip_yaml_update
atomic_roundtrip_yaml_update(config_path, key_path, value)
from hermes_cli.config_backend import write_config_key
write_config_key(config_path, key_path, value)
try: # owner-only: config files contain API keys
os.chmod(config_path, 0o600)
except (OSError, NotImplementedError):
@@ -1040,14 +1040,15 @@ class HermesCLI(CLIInitMixin, CLITuiRuntimeMixin, CLIProcessNotificationsMixin,
def show_config(self):
"""Display current configuration with kawaii ASCII art."""
from hermes_cli.config_backend import config_exists
terminal_env = os.getenv("TERMINAL_ENV", "local")
terminal_cwd = os.getenv("TERMINAL_CWD", os.getcwd())
terminal_timeout = os.getenv("TERMINAL_TIMEOUT", "60")
config_path = _hermes_home / 'config.yaml'
if not config_path.exists():
if not config_exists(config_path):
config_path = Path(__file__).parent / 'cli-config.yaml'
config_status = _t("cli.config.loaded") if config_path.exists() else _t("cli.config.not_found")
config_status = _t("cli.config.loaded") if config_exists(config_path) else _t("cli.config.not_found")
# ``api_key`` may be a callable bearer provider (Entra ID, Vertex OAuth, key_cmd, ...):
# label it by its tagged source and never invoke it (that would mint a token just to mask
+2 -1
View File
@@ -1656,10 +1656,11 @@ def _main_model_pin() -> tuple[Optional[str], Optional[str]]:
"""``(provider, model)`` the main agent runs on right now (``model.default`` + the provider it
resolves to), for ``pinned=True`` jobs: the lock is a plain per-job pin, so the scheduler needs
no second precedence axis. ``(None, None)`` when nothing is configured (the job stays unpinned)."""
from hermes_cli.config_backend import config_exists
from hermes_cli.config_effective import load_user_config_effective
cfg_path = get_hermes_home() / "config.yaml"
cfg = load_user_config_effective(cfg_path) if cfg_path.exists() else {}
cfg = load_user_config_effective(cfg_path) if config_exists(cfg_path) else {}
model_cfg = cfg.get("model") or {}
model = model_cfg.get("default") or model_cfg.get("model") if isinstance(model_cfg, dict) else model_cfg
model = _normalize_job_optional_text(model)
+2 -1
View File
@@ -1635,6 +1635,7 @@ def _load_cron_job_config(job: dict, job_id: str, job_name: str) -> _CronJobConf
the main agent model (config ``model:``, then HERMES_MODEL) > the claimed welcome trial's. Re-read
every tick (no cache) so ``hermes cron edit --model`` and ``hermes model`` both apply next tick."""
from hermes_cli.anon_auth import trial_model
from hermes_cli.config_backend import config_exists
model = job.get("model") or cron_env_setting("HERMES_MODEL") or ""
_cron_default_provider = ""
_cfg: dict = {}
@@ -1642,7 +1643,7 @@ def _load_cron_job_config(job: dict, job_id: str, job_name: str) -> _CronJobConf
try:
from hermes_cli.config_effective import load_user_config_effective
_cfg_path = str(_get_hermes_home() / "config.yaml")
if os.path.exists(_cfg_path):
if config_exists(_cfg_path):
_cfg = load_user_config_effective(Path(_cfg_path))
# Coerce null to {} so a falsy default never clobbers a resolved env value.
_model_cfg = _cfg.get("model") or {}
+5 -8
View File
@@ -493,15 +493,12 @@ def read_yaml_layers(home: Path) -> dict:
(``gateway.relay.relay_explicitly_disabled``) reads through here so it cannot disagree with
``load_gateway_config()`` on which files count.
"""
config_yaml_path = home / "config.yaml"
yaml_cfg: dict = {}
if config_yaml_path.exists():
# The installer seeds config.yaml by copying cli-config.yaml.example (~120 KB, almost all
# comments), and nothing caches this loader — so the pure-Python parser dominates the load.
from utils import fast_safe_load
from hermes_cli.config_backend import config_exists, read_config_doc
with open(config_yaml_path, encoding="utf-8-sig") as f:
yaml_cfg = fast_safe_load(f) or {}
config_path = Path(home) / "config.yaml"
yaml_cfg: dict = {}
if config_exists(config_path):
yaml_cfg = read_config_doc(config_path) or {}
from hermes_cli.config import _expand_env_vars
+4 -4
View File
@@ -1004,13 +1004,13 @@ def _warn_unresolved_docker_media(candidate: Path, session_key: str, reason: str
def _translate_docker_container_media_path(
candidate: Path, session_key: str = "", workspace_key: str = "", *, quiet: bool = False,
candidate: Path, session_key: str = "", workspace_key: str = "", *, quiet: bool = False, workspace_cwd: str = "",
) -> Optional[Path]:
"""Container-absolute path -> host path via longest-prefix match over ``docker_volumes``, the
auto-mounted cache dirs (``/root/.hermes/...``), persistent ``/workspace`` and ``/root``.
``workspace_key`` is the 16-hex digest of one session's host cwd. It selects that
session's ``<base>:ws:<digest>`` sandbox and is empty for MEDIA delivery.
``workspace_key`` is the 16-hex digest of one session's host cwd ``workspace_cwd`` (its cwd
mount). It selects that session's ``<base>:ws:<digest>`` sandbox and is empty for MEDIA delivery.
"""
if not candidate.as_posix().startswith("/"): # POSIX even on Windows gateways (not is_absolute)
return None
@@ -1029,7 +1029,7 @@ def _translate_docker_container_media_path(
if "/workspace" not in mounted:
mounts.extend(
(root, Path("/workspace"))
for root in _default_docker_workspace_host_roots(session_key, workspace_key))
for root in _default_docker_workspace_host_roots(session_key, workspace_key, workspace_cwd))
# Synthetic /root mounts catch stray home writes (/root/out.png; cache mounts are longer
# prefixes). /root/.hermes/* that missed a cache mount is the container's credential surface —
# translating it via the home mount would dodge the host denylist.
+52 -8
View File
@@ -75,9 +75,9 @@ def _docker_persistent_sandbox_roots(
return []
def _mounted_workspace_cwd() -> list[Path]:
def _mounted_workspace_cwd(workspace_cwd: str = "") -> list[Path]:
tenv, _truthy, _persistent = _scope()
cwd = tenv("TERMINAL_CWD") or os.getcwd()
cwd = workspace_cwd or tenv("TERMINAL_CWD") or os.getcwd()
try:
host = Path(os.path.expanduser(cwd)).resolve(strict=False)
except (OSError, RuntimeError, ValueError):
@@ -85,15 +85,56 @@ def _mounted_workspace_cwd() -> list[Path]:
return [host] if host.is_dir() else []
def _session_mounted_workspace(workspace_key: str, workspace_cwd: str) -> list[Path]:
"""The host dir a session bound to a workspace mounts at ``/workspace``, or nothing.
A gateway turn runs with the session's own cwd as ``TERMINAL_CWD``
(``gateway.session_policy.build_policy``), and that cwd is also what
``bind_turn_workspace`` registers and hashes into ``workspace_key``. So the
mount is that cwd, never the profile's ``terminal.cwd``. A cwd whose digest
is not ``workspace_key`` names some other container's mount: no root.
"""
from tools.terminal_tool import workspace_key_for
cwd = (workspace_cwd or "").strip()
if not cwd or "\0" in cwd or not os.path.isabs(os.path.expanduser(cwd)):
return []
if workspace_key_for(cwd) != workspace_key:
return []
return _mounted_workspace_cwd(cwd)
def _shared_cwd_mount_guess(workspace_key: str) -> list[Path]:
"""The profile cwd that stands in for a shared container's ``/workspace``, or nothing.
With ``TERMINAL_DOCKER_SHARED_CONTAINER_KEY`` every session reuses one
``shared:<key>`` container, which mounts whatever cwd the session that
created it brought, so a session's own cwd is not its mount. Nothing records
that first cwd: shared containers carry no mount fingerprint label, and a
cross-process attach never inspects the container's mounts. Reads keep the
profile cwd, as before; a session Save there is a guess the caller refuses.
"""
tenv, truthy, persistent = _scope()
if not persistent() or not _WORKSPACE_KEY_RE.fullmatch(workspace_key or ""):
return []
if tenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").strip().lower() not in truthy:
return []
if not tenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "").strip():
return []
return _mounted_workspace_cwd()
def _default_docker_workspace_host_roots(
session_key: str = "", workspace_key: str = "",
session_key: str = "", workspace_key: str = "", workspace_cwd: str = "",
) -> list[Path]:
"""Host dirs that stand in for ``/workspace``.
A cwd mount is tried first. With no workspace digest that mount is the
only candidate, so MEDIA delivery (which has no session) stays cwd-only.
A download that names a digest also tries that session's sandbox after
the mount: the container key can be ``default:ws:<digest>`` while
profile's cwd and the only candidate, so MEDIA delivery (which has no
session) stays cwd-only. A download that names a digest mounts that
session's own cwd (``workspace_cwd``) instead, unless its container is a
shared one (``_shared_cwd_mount_guess``), then tries that session's
sandbox: the container key can be ``default:ws:<digest>`` while
``/workspace`` is still the sandbox directory, because the host cwd was
not bind-mounted when the container was created.
"""
@@ -103,9 +144,12 @@ def _default_docker_workspace_host_roots(
roots: list[Path] = []
mounted = tenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").strip().lower() in truthy
if mounted:
roots.extend(_mounted_workspace_cwd())
if not _WORKSPACE_KEY_RE.fullmatch(workspace_key or ""):
return roots
return _mounted_workspace_cwd()
if tenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "").strip():
roots.extend(_shared_cwd_mount_guess(workspace_key))
else:
roots.extend(_session_mounted_workspace(workspace_key, workspace_cwd))
for root in _docker_persistent_sandbox_roots(session_key, "workspace", workspace_key):
if root not in roots:
roots.append(root)
+3 -3
View File
@@ -9,7 +9,6 @@ from pathlib import Path
from typing import Any
from hermes_constants import get_hermes_home
from utils import load_yaml_file_readonly
_DISK_DEGRADED_PERCENT = 90.0
@@ -50,11 +49,12 @@ def _probe_state_db(home: Path) -> dict[str, Any]:
def _probe_config(home: Path) -> dict[str, Any]:
from hermes_cli.config_backend import config_exists, read_config_doc_readonly
path = home / "config.yaml"
if not path.exists():
if not config_exists(path):
return _check("ok", "using defaults")
try:
raw = load_yaml_file_readonly(path)
raw = read_config_doc_readonly(path)
except Exception as exc:
return _check("degraded", f"invalid config ({type(exc).__name__})")
return _check("ok") if raw is None or isinstance(raw, dict) else _check("degraded", "top level is not a mapping")
+5 -13
View File
@@ -43,7 +43,8 @@ from agent.interrupt_compat import request_hard_interrupt
from agent.message_metadata import ABSORBED_MESSAGE_UIDS, MESSAGE_UID, copy_identity_fields, is_compaction_scaffolding, scaffolding_replay_fields
from agent.turn_context import compression_made_progress
from agent.session_activity import ActivityProvenance, AwakeIdleMeter
from hermes_cli.config import _is_ssh_remote_tilde_cwd, cfg_get
from hermes_cli.config import _is_ssh_remote_tilde_cwd, cfg_get, config_exists
from gateway.run_common import _csv_or_list_to_set
from hermes_cli.fallback_config import pre_agent_fallback_notice
from gateway.turn_executor import _UnboundedThreadExecutor
@@ -1182,15 +1183,6 @@ def _uses_telegram_observed_group_context(channel_prompt: Optional[str]) -> bool
return bool(channel_prompt and _TELEGRAM_OBSERVED_CONTEXT_PROMPT_MARKER in channel_prompt)
def _csv_or_list_to_set(raw: Any) -> set[str]:
"""Normalize a config list or comma-separated scalar into a string set."""
if raw is None:
return set()
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
return {part.strip() for part in str(raw).split(",") if part.strip()}
def _slack_ignored_channels_from_gateway_config(config: Any, adapter: Any = None) -> set[str]:
"""Return Slack channels that the generic gateway must never dispatch.
@@ -1605,7 +1597,7 @@ def _bridge_max_turns_from_config(home: Path) -> None:
if profile_scoped():
return
config_path = home / 'config.yaml'
if not config_path.exists():
if not config_exists(config_path):
return
try:
cfg = _load_bridge_config(config_path)
@@ -1627,7 +1619,7 @@ def _current_max_iterations() -> int:
if override:
config_path = Path(override) / 'config.yaml'
try:
cfg = _load_bridge_config(config_path) if config_path.exists() else {}
cfg = _load_bridge_config(config_path) if config_exists(config_path) else {}
except Exception:
cfg = {}
agent_cfg = cfg.get("agent")
@@ -2128,7 +2120,7 @@ def _load_bridge_config(config_path: Path) -> dict:
_config_path = _hermes_home / 'config.yaml'
_cfg: dict = {}
if _config_path.exists():
if config_exists(_config_path):
try:
_cfg = _load_bridge_config(_config_path)
_bridge_config_to_env(_cfg)
+11
View File
@@ -4,5 +4,16 @@ Kept import-cycle free (imports nothing from ``gateway.run``) because these valu
are used as default-argument sentinels, which must resolve at ``def`` time.
"""
from typing import Any
# Sentinel for "caller did not pass metadata" vs "caller passed None".
_UNSET = object()
def _csv_or_list_to_set(raw: Any) -> set[str]:
"""Normalize a config list or comma-separated scalar into a string set."""
if raw is None:
return set()
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
return {part.strip() for part in str(raw).split(",") if part.strip()}
+2 -1
View File
@@ -532,6 +532,7 @@ class GatewayConfigLoadersMixin:
and keeps one last-known-good chain per home: a single runner-wide slot filled from the launch
home handed every secondary profile the default profile's fallback chain.
"""
from hermes_cli.config_backend import config_exists
from gateway.run import _gateway_config_home
from hermes_constants import hermes_home_key
home = _gateway_config_home()
@@ -542,7 +543,7 @@ class GatewayConfigLoadersMixin:
try:
from hermes_cli.config_effective import load_user_config_effective
cfg_path = home / "config.yaml"
if not cfg_path.exists():
if not config_exists(cfg_path):
by_home[home_key] = self._fallback_model = None
return self._fallback_model
# fail_closed: a torn mid-edit write must raise so the per-home last known-good chain
+2 -5
View File
@@ -896,14 +896,11 @@ class SessionAuthority:
self.pending_stops[ref.session_id] = generation
if agent is not None:
self.delivered_stops[ref.session_id] = generation
# Main's Stop: hard-cancel (aborts compression too) and end the session's own background
# delegations (spawned by this session only: a viewer must not kill other gateway work).
# Stop hard-cancels this turn (aborts compression too). Background delegations are not the
# turn: they keep running and report back; `/stop` and session close/reset end them.
from agent.interrupt_compat import request_hard_interrupt
from tools.async_delegation import interrupt_for_session
if agent is not None:
request_hard_interrupt(agent)
for parent in dict.fromkeys((ref.session_id, self.physical_target(ref))):
interrupt_for_session(session_key=live.route, parent_session_id=parent, reason='user_stop')
return self._handle(ref)
def _classic_turn_holds(self, route):
+2 -2
View File
@@ -81,6 +81,7 @@ async def config_get(connection, ref, params):
def read():
from gateway.run import _profile_runtime_scope
from hermes_cli.config import load_config
from hermes_cli.config_backend import config_version_or_none, version_token
from hermes_cli.config_effective import load_user_config_effective
from hermes_constants import DEFAULT_INDICATOR_STYLE
from tools.approval_context import _get_approval_mode
@@ -92,8 +93,7 @@ async def config_get(connection, ref, params):
# The user's own settings, not DEFAULT_CONFIG: clients read an absent key as their
# own default (the TUI streams unless display.streaming is explicitly false).
'full': lambda: {'config': _client_config(load_user_config_effective(home / 'config.yaml'))},
'mtime': lambda: {'mtime': (home / 'config.yaml').stat().st_mtime
if (home / 'config.yaml').exists() else 0,
'mtime': lambda: {'mtime': version_token(config_version_or_none(home / 'config.yaml')),
# Frozen tool policy is not a live MCP reload request.
'mcp_rev': mcp_revision(policy, cfg)},
'reasoning': lambda: _reasoning(agent, policy, cfg, connection, ref),
+2 -2
View File
@@ -489,8 +489,8 @@ def _get_process_start_time(pid: int) -> Optional[int]:
"""
stat_path = Path(f"/proc/{pid}/stat")
try:
# Field 22 in /proc/<pid>/stat is process start time (clock ticks).
return int(stat_path.read_text(encoding="utf-8").split()[21]) # windows-footgun: ok (/proc is BOM-free)
# Field 22 (ticks) after comm's last ")": comm may hold spaces or non-UTF-8 bytes.
return int(stat_path.read_bytes().rsplit(b")", 1)[1].split()[19])
except (FileNotFoundError, IndexError, PermissionError, ValueError, OSError):
pass
+2 -2
View File
@@ -90,12 +90,12 @@ set/get/unset <NAME>` route any bare name registered in `OPTIONAL_ENV_VARS` / `_
- **One writer seam.** Every write of a `config.yaml` (main or profile) goes through
`hermes_cli.config.atomic_config_write` (refuses deletion by omission) or the explicit
`atomic_config_replace` full-state path (→ `utils.atomic_roundtrip_yaml_save`, ruamel
round-trip): comments, key order, quoting and blank lines survive, and the fail-closed unreadable-file
guard runs first. Deliberate `pop()`/unset/migration paths use `atomic_config_replace`; additive
round-trip): formatting survives, and the fail-closed unreadable-file guard runs first. Deliberate `pop()`/unset/migration paths use `atomic_config_replace`; additive
writers stay on `atomic_config_write`. Never call
`atomic_yaml_write` / `yaml.dump` / `yaml.safe_dump` on a config path — `scripts/check_config_yaml_writers.py`
(CI lint) rejects it, and `tests/hermes_cli/test_config_yaml_comment_preservation.py` guards each
path (#92554). The commented example blocks are appended only when the file is created.
- **Reads** too go only through `config_backend.py`; see `developer-guide/config-backend.md`.
- **Three loaders — know which you're in:** `load_cli_config()` (CLI, `cli.py`); `load_config()`
(`hermes tools/setup`, most subcommands, `hermes_cli/config.py`, merges `DEFAULT_CONFIG`);
`hermes_cli/config_effective.py::load_user_config_effective()` (gateway runtime via
+2 -2
View File
@@ -249,7 +249,7 @@ BUILTIN_PROVIDER_IDS = frozenset(PROVIDER_REGISTRY)
# a plugin never observes a partially initialized auth module (CONTRACT: during discovery a plugin may
# rely only on ``ProviderConfig`` and ``PROVIDER_REGISTRY`` from here — nothing defined below).
from hermes_cli.config import (
atomic_config_replace, get_hermes_home, get_config_path, read_raw_config, require_readable_config_before_write)
atomic_config_replace, config_exists, get_hermes_home, get_config_path, read_raw_config, require_readable_config_before_write)
# Plugin profiles (plugins/model-providers/<name>/) are mirrored into PROVIDER_REGISTRY with the
# auth_type they declare; the mirror lives in the sibling so it can be re-run after discovery.
@@ -2342,7 +2342,7 @@ def _logout_default_provider_from_config() -> Optional[str]:
def _reset_config_provider() -> Path:
"""Reset config.yaml provider back to auto after logout."""
config_path = get_config_path()
if not config_path.exists():
if not config_exists(config_path):
return config_path
require_readable_config_before_write(config_path)
config = read_raw_config()
+6 -27
View File
@@ -35,10 +35,12 @@ from hermes_cli.backup_restore import (
_default_new_file_mode,
_detect_prefix,
_extract_member_atomically,
_get_config_path_value,
_import_db_member,
_restore_auth_json,
_restore_destination_epoch,
_safe_restore_db,
_set_config_path_value,
_validate_backup_zip,
)
@@ -741,11 +743,8 @@ def run_import(args) -> Optional[int]:
print(f"Error: Not a valid zip file: {zip_path}")
sys.exit(1)
# The restore target must be the home the command operates under — the
# same path printed as "Target:" via display_hermes_home(). Resolving
# through get_default_hermes_root() instead maps a profile home
# (<root>/profiles/<name>) back to <root>, silently retargeting the
# restore at the live root while the profile directory stays empty.
# The home this command operates under (the "Target:" display_hermes_home() prints):
# get_default_hermes_root() would map a profile home back to <root> and restore the live root.
hermes_root = get_hermes_home()
with zipfile.ZipFile(zip_path, "r") as zf:
@@ -766,7 +765,7 @@ def run_import(args) -> Optional[int]:
print(f"Detected archive prefix: {prefix!r} (will be stripped)")
# Check for existing installation
has_config = (hermes_root / "config.yaml").exists()
has_config = (hermes_root / "config.yaml").exists() # config-reader: ok — profile marker for the backup scope (file tooling)
has_env = (hermes_root / ".env").exists()
if (has_config or has_env) and not args.force:
@@ -863,7 +862,7 @@ def run_import(args) -> Optional[int]:
continue
profile_name = entry.name
# Only create wrappers for directories with config
if not (entry / "config.yaml").exists() and not (entry / ".env").exists():
if not (entry / "config.yaml").exists() and not (entry / ".env").exists(): # config-reader: ok — profile marker for the backup scope (file tooling)
continue
collision = check_alias_collision(profile_name)
if collision:
@@ -1873,26 +1872,6 @@ def _read_raw_yaml_dict(path: Path) -> Optional[dict[str, Any]]:
return data if isinstance(data, dict) else None
def _get_config_path_value(data: dict[str, Any], dotted: tuple[str, ...]) -> Any:
node: Any = data
for key in dotted:
if not isinstance(node, dict):
return None
node = node.get(key)
return node
def _set_config_path_value(data: dict[str, Any], dotted: tuple[str, ...], value: Any) -> None:
node = data
for key in dotted[:-1]:
child = node.get(key)
if not isinstance(child, dict):
child = {}
node[key] = child
node = child
node[dotted[-1]] = value
def restore_config_model_settings_if_rewritten(
snapshot_id: str,
hermes_home: Optional[Path] = None,
+21 -1
View File
@@ -18,7 +18,7 @@ import tempfile
import zipfile
from contextlib import closing, contextmanager
from pathlib import Path
from typing import List, Optional, Tuple
from typing import Any, List, Optional, Tuple
from hermes_state_holders import read_only_db_uri
from hermes_cli.backup_sqlite import _safe_copy_db
@@ -656,3 +656,23 @@ def _import_db_member_exclusive(
os.unlink(tmp_name)
except OSError:
pass
def _get_config_path_value(data: dict[str, Any], dotted: tuple[str, ...]) -> Any:
node: Any = data
for key in dotted:
if not isinstance(node, dict):
return None
node = node.get(key)
return node
def _set_config_path_value(data: dict[str, Any], dotted: tuple[str, ...], value: Any) -> None:
node = data
for key in dotted[:-1]:
child = node.get(key)
if not isinstance(child, dict):
child = {}
node[key] = child
node = child
node[dotted[-1]] = value
+6 -6
View File
@@ -12,7 +12,6 @@ import os
import sys
from pathlib import Path
from typing import List, Dict, Any
from utils import fast_safe_load
# Log-record parity with the origin module.
logger = logging.getLogger("cli")
@@ -262,8 +261,10 @@ def load_cli_config() -> dict[str, Any]:
``HERMES_IGNORE_USER_CONFIG=1`` skips the user config entirely (``.env`` still loads).
"""
from cli import _cli_config_defaults, _hermes_home, _merge_file_config, _mirror_config_to_env
from hermes_cli.config_backend import config_exists, read_config_doc
config_path = _hermes_home / 'config.yaml'
if not config_path.exists() or os.environ.get("HERMES_IGNORE_USER_CONFIG") == "1":
# A backend whose user layer always exists never takes the repo-file fallback.
if not config_exists(config_path) or os.environ.get("HERMES_IGNORE_USER_CONFIG") == "1":
config_path = Path(__file__).parent / 'cli-config.yaml'
defaults = _cli_config_defaults()
@@ -271,12 +272,11 @@ def load_cli_config() -> dict[str, Any]:
# Only a file's terminal section may overwrite terminal env vars already set by .env.
_file_has_terminal_config = False
if config_path.exists():
if config_exists(config_path):
try:
with open(config_path, "r", encoding="utf-8-sig") as f:
from hermes_cli.config import _normalize_root_model_keys
from hermes_cli.config import _normalize_root_model_keys
file_config = _normalize_root_model_keys(fast_safe_load(f) or {})
file_config = _normalize_root_model_keys(read_config_doc(config_path) or {})
_file_has_terminal_config = "terminal" in file_config
_merge_file_config(defaults, file_config)
+5 -9
View File
@@ -18,7 +18,7 @@ from typing import Any, NamedTuple
from agent.i18n import t
from hermes_constants import is_termux as _is_termux_environment
from rich.markup import escape as _escape
from utils import base_url_hostname, file_signature
from utils import base_url_hostname
from hermes_cli.cli_modal_mixin import _gated_confirm
from hermes_cli.colors import Colors as _Colors
@@ -864,7 +864,7 @@ class CLIInfoMixin:
cache** (the next message re-sends the full input prefix, expensive on long-context / high-reasoning
models). See #1474.
"""
import hermes_yaml as _yaml
from hermes_cli.config_backend import config_version_or_none, read_config_doc
now = time.monotonic()
if now - self._last_config_check < CONFIG_WATCH_INTERVAL:
@@ -873,19 +873,15 @@ class CLIInfoMixin:
from hermes_cli.config import get_config_path as _get_config_path
cfg_path = _get_config_path()
if not cfg_path.exists():
return
try:
sig = file_signature(cfg_path.stat())
except OSError:
sig = config_version_or_none(cfg_path)
if sig is None:
return
if sig == self._config_sig:
return # unchanged — fast path
self._config_sig = sig
try:
with open(cfg_path, encoding="utf-8-sig") as f:
new_cfg = _yaml.safe_load(f) or {}
new_cfg = read_config_doc(cfg_path) or {}
except Exception:
return
+2 -2
View File
@@ -1817,6 +1817,7 @@ class CLITuiMixin:
def _tui_init_run_state(self):
"""Reset the per-run REPL state (queues, modal states, voice state, config watcher)."""
from hermes_cli.config_backend import config_version_or_none
self._agent_running = False
self._pending_input = queue.Queue() # normal input (commands + new queries)
self._interrupt_queue = queue.Queue() # messages typed while the agent is running
@@ -1835,9 +1836,8 @@ class CLITuiMixin:
# Config file watcher — detect mcp_servers changes and auto-reload.
from hermes_cli.config import get_config_path as _get_config_path
from utils import file_signature
_cfg_path = _get_config_path()
self._config_sig: tuple | None = file_signature(_cfg_path.stat()) if _cfg_path.exists() else None
self._config_sig: tuple | None = config_version_or_none(_cfg_path)
self._config_mcp_servers: dict = self.config.get("mcp_servers") or {}
self._last_config_check: float = 0.0 # monotonic time of last check
+2 -3
View File
@@ -31,13 +31,12 @@ def _personalities_from_cli_config() -> dict[str, Any]:
Falls back to a fresh load when the file cannot be stat'ed."""
global _personalities_memo
from cli import load_cli_config
from utils import file_signature
from hermes_cli.personality import available_personalities
try:
from hermes_cli.config import get_config_path
from hermes_cli.config_backend import config_version
cfg_path = get_config_path()
st = cfg_path.stat()
sig = (str(cfg_path), *file_signature(st))
sig = (str(cfg_path), *config_version(cfg_path))
except Exception:
sig = (None, None, None, None, None)
if _personalities_memo is None or _personalities_memo[0] != sig:
+32 -111
View File
@@ -23,10 +23,7 @@ import subprocess
import sys
import tempfile
import threading
import time
import unicodedata
from contextlib import contextmanager, suppress
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Dict, Any, Literal, Optional, List, Tuple, Set
@@ -48,6 +45,10 @@ from hermes_constants import (
# Re-export from hermes_constants — canonical definition lives there.
from hermes_constants import get_hermes_home, get_process_hermes_home
from utils import atomic_replace, fast_safe_load, file_signature, mkstemp_beside
from hermes_cli.config_issues import ConfigIssue, _issue
from hermes_cli.config_backend import (
config_exists, config_version, probe_config_readable, read_config_doc,
write_config_document)
from hermes_cli.config_read_errors import (
_CONFIG_PARSE_FAILURES, _FIX_PERMS, _FIX_YAML, FailedConfigRead, _backups_dir_display,
_refuse_failed_read, _refuse_overwrite, _warn_config_parse_failure, _yaml_error_details,
@@ -444,8 +445,7 @@ def require_parseable_user_config(*, ignore_user_config: bool = False) -> None:
config_path = get_config_path()
try:
with open(config_path, encoding="utf-8-sig") as f:
data = fast_safe_load(f)
data = read_config_doc(config_path)
except FileNotFoundError:
return
except Exception as exc:
@@ -974,18 +974,6 @@ _VALID_CUSTOM_PROVIDER_FIELDS = {
_CUSTOM_PROVIDER_LIKE_FIELDS = {"base_url", "api_key", "rate_limit_delay", "api_mode"}
@dataclass
class ConfigIssue:
"""A detected config structure problem."""
severity: str # "error", "warning"
message: str
hint: str
def _issue(issues: list[ConfigIssue], severity: str, message: str, hint: str) -> None:
issues.append(ConfigIssue(severity, message, hint))
def _require_fields(
issues: list[ConfigIssue], entry: dict[str, Any], label: str,
fields: tuple[tuple[str, str], ...], suffix: str = "") -> None:
@@ -1006,50 +994,6 @@ _FB_SINGLE_REQUIRED_FIELDS = (
("model", "Add: model: anthropic/claude-sonnet-4 (or another model)"))
def _validate_voice(config: dict[str, Any], issues: list[ConfigIssue]) -> None:
voice_cfg = config.get("voice")
if not (isinstance(voice_cfg, dict) and "submit_mode" in voice_cfg):
return
submit_mode = voice_cfg.get("submit_mode")
normalized = submit_mode.strip().lower() if isinstance(submit_mode, str) else None
if normalized not in {"direct", "draft"}:
_issue(issues, "error", f"voice.submit_mode must be 'direct' or 'draft', got {submit_mode!r}",
"Set voice.submit_mode to direct (submit immediately) or draft (edit before sending)")
def _validate_timezone(config: dict[str, Any], issues: list[ConfigIssue]) -> None:
"""``timezone`` must be an IANA name the runtime can load.
``hermes_time._get_zoneinfo()`` swallows an invalid name behind a single WARNING in the
gateway log, then runs the agent clock AND every cron schedule on server-local time.
Surface it here, where doctor and the startup check both look. Silent when the
interpreter has no tz database at all (bare Windows without ``tzdata``) — nothing can be
judged there.
"""
if "timezone" not in config:
return
tz = config.get("timezone")
hint = ("Use an IANA zone name such as America/New_York or Asia/Tokyo (see "
"`timedatectl list-timezones`). With an invalid value the agent clock and cron "
"schedules silently fall back to server-local time. HERMES_TIMEZONE overrides "
"this key when set.")
if tz is not None and not isinstance(tz, str):
_issue(issues, "error", f"timezone must be an IANA zone name string, got {tz!r}", hint)
return
if not (isinstance(tz, str) and tz.strip()):
return
name = tz.strip()
try:
import zoneinfo
zoneinfo.ZoneInfo("UTC") # is a tz database available at all?
except Exception:
return
try:
zoneinfo.ZoneInfo(name)
except Exception:
_issue(issues, "error", f"timezone {name!r} is not a valid IANA zone name", hint)
def _validate_entry_list(
entries: list, label: str, issues: list[ConfigIssue], fields, *, non_dict: tuple[str, str, str],
) -> None:
@@ -1102,31 +1046,6 @@ def _validate_fallback_model(fb: Any, issues: list[ConfigIssue]) -> None:
suffix=" — fallback will be disabled")
def _validate_web_backends(config: dict[str, Any], issues: list[ConfigIssue]) -> None:
"""A stale web backend selection otherwise fails only at the first web_search/web_extract
call with a generic "no registered provider" error; warn at startup instead."""
# See #99199.
web_cfg = config.get("web")
if not isinstance(web_cfg, dict):
return
try:
from tools.tool_backend_helpers import removed_backend_note
except Exception:
return
seen: set = set()
for _key in ("backend", "search_backend", "extract_backend"):
_val = str(web_cfg.get(_key) or "").strip().lower()
if not _val or _val in seen:
continue
seen.add(_val)
note = removed_backend_note("web", _val)
if note:
_issue(issues, "warning",
f"web.{_key} is set to '{_val}', but {note} — "
"web_search/web_extract will fail until it is changed",
"Run 'hermes tools' and pick a different Web Search & Extract provider")
def _container_slots() -> dict[str, str]:
"""Dotted key -> ``"list"``/``"mapping"`` for every slot the schema fixes to a container:
``DEFAULT_CONFIG`` (sections included) plus the known-container table for roots it omits."""
@@ -1179,6 +1098,7 @@ def validate_config_structure(config: Optional[dict[str, Any]] = None) -> list[C
from hermes_cli.config_home import config_load_issue
return [config_load_issue(exc)]
from hermes_cli.config_value_checks import _validate_timezone, _validate_voice, _validate_web_backends
issues: list[ConfigIssue] = []
_validate_voice(config, issues)
_validate_timezone(config, issues)
@@ -1884,6 +1804,12 @@ def _raw_config_cache_hit(path_key: str, cache_key: tuple[Any, ...]) -> Optional
return None
def _raw_config_cache_store(path_key: str, cache_key: tuple[Any, ...], data: dict[str, Any]) -> None:
"""Publish ``data`` (caller-owned copy) as the raw config for ``path_key`` at ``cache_key``.
One whole-tuple replace, so the lock-free readers see either the old or the new entry."""
_RAW_CONFIG_CACHE[path_key] = (*cache_key, data)
def _worker_config_snapshot() -> Optional[dict[str, Any]]:
"""A safe worker's frozen config, or None. Install-time loads run from ``hermes_cli`` alone
(the installer's completion step ships no ``agent`` package), and without ``agent`` there
@@ -1909,7 +1835,7 @@ def _read_raw_config_impl(*, want_deepcopy: bool) -> dict[str, Any]:
# every cached read for the duration). A lost race just falls through to the locked re-check.
try:
config_path = get_config_path()
cache_key = file_signature(config_path.stat())
cache_key = config_version(config_path)
hit = _raw_config_cache_hit(str(config_path), cache_key)
if hit is not None:
return copy.deepcopy(hit) if want_deepcopy else hit
@@ -1919,7 +1845,7 @@ def _read_raw_config_impl(*, want_deepcopy: bool) -> dict[str, Any]:
with _CONFIG_LOCK:
config_path = get_config_path()
try:
cache_key = file_signature(config_path.stat())
cache_key = config_version(config_path)
except FileNotFoundError:
return {}
except OSError as e:
@@ -1931,8 +1857,7 @@ def _read_raw_config_impl(*, want_deepcopy: bool) -> dict[str, Any]:
return copy.deepcopy(hit) if want_deepcopy else hit
try:
with open(config_path, encoding="utf-8-sig") as f:
data = fast_safe_load(f) or {}
data = read_config_doc(config_path) or {}
except Exception as e:
_warn_config_parse_failure(config_path, e)
return FailedConfigRead(error=e)
@@ -1943,7 +1868,7 @@ def _read_raw_config_impl(*, want_deepcopy: bool) -> dict[str, Any]:
# The cache stores its own deepcopy. The readonly path returns THAT object (identity
# invariant: later cache hits return the same dict); the mutable path returns the parse.
cached_copy = copy.deepcopy(data)
_RAW_CONFIG_CACHE[path_key] = (*cache_key, cached_copy)
_raw_config_cache_store(path_key, cache_key, cached_copy)
return data if want_deepcopy else cached_copy
@@ -1960,8 +1885,7 @@ def read_user_config_raw(config_path: Optional[Path] = None) -> dict[str, Any]:
if config_path is None:
config_path = get_config_path()
try:
with open(config_path, encoding="utf-8-sig") as f:
data = fast_safe_load(f) or {}
data = read_config_doc(config_path) or {}
except FileNotFoundError:
return {}
return data if isinstance(data, dict) else {}
@@ -1987,15 +1911,14 @@ def require_readable_config_before_write(config_path: Optional[Path] = None) ->
return copy.deepcopy(snapshot)
config_path = get_config_path()
try:
config_path.stat()
config_version(config_path)
except FileNotFoundError:
return {}
except OSError as exc:
raise _refuse_overwrite(config_path, "cannot be accessed", exc, _FIX_PERMS) from exc
try:
with open(config_path, encoding="utf-8-sig") as f:
loaded = fast_safe_load(f)
loaded = read_config_doc(config_path)
except OSError as exc:
raise _refuse_overwrite(config_path, "cannot be read", exc, _FIX_PERMS) from exc
except Exception as exc:
@@ -2043,8 +1966,6 @@ def _write_config_state(
extra_content_on_create: Optional[str] = None,
) -> None:
"""Shared comment-preserving config writer; omission policy is selected by the public wrapper."""
from utils import atomic_roundtrip_yaml_save
_refuse_failed_read(config_path, data)
if not allow_omissions:
existing = require_readable_config_before_write(config_path)
@@ -2061,7 +1982,7 @@ def _write_config_state(
"Pass the complete current config, or use atomic_config_replace() only when "
"deletion by omission is deliberate.",
) from exc
atomic_roundtrip_yaml_save(config_path, data, extra_content_on_create=extra_content_on_create)
write_config_document(config_path, data, extra_content_on_create=extra_content_on_create)
def atomic_config_write(
@@ -2223,13 +2144,12 @@ def _load_config_cache_sig(config_path: Path) -> tuple[Optional[tuple[int, int,
The managed config file's signature is folded in ((0, 0, 0, 0) = none) so editing it invalidates
the merged result. ``cache_sig`` is None only when neither file exists (nothing to cache on)."""
try:
st = config_path.stat()
user_sig: Optional[tuple[int, int, int, int]] = file_signature(st)
user_sig: Optional[tuple[Any, ...]] = config_version(config_path)
except FileNotFoundError:
user_sig = None
managed_dir = managed_scope.get_managed_dir()
try:
mst = (managed_dir / "config.yaml").stat() if managed_dir else None
mst = (managed_dir / "config.yaml").stat() if managed_dir else None # config-reader: ok — managed overlay, not a user layer
managed_sig = file_signature(mst) if mst else (0, 0, 0, 0)
except OSError:
managed_sig = (0, 0, 0, 0)
@@ -2304,19 +2224,21 @@ def _load_config_cache_hit(path_key: str, cache_sig: Any) -> Optional[dict[str,
pin unexpanded literals (e.g. auxiliary.<task>.api_key) for the process lifetime (#58514).
Shared by the lock-free fast path and the locked re-check of ``_load_config_impl``."""
cached = _LOAD_CONFIG_CACHE.get(path_key)
if cached is None or cache_sig is None or cached[:8] != cache_sig:
if cached is None or cache_sig is None:
return None
hit = cached[8]
n = len(cache_sig) # the backend's version tuple + the managed file's signature
if cached[:n] != cache_sig:
return None
hit = cached[n]
if isinstance(hit, FailedConfigRead) and isinstance(hit.read_error, OSError):
# A read error (EMFILE/EIO/sharing violation) can clear without touching the file's
# signature: serve the fallback only while the file still cannot be read.
try:
with open(path_key, "rb") as f:
f.read()
return None
probe_config_readable(path_key)
except OSError:
return hit
env_snapshot = cached[9] if len(cached) > 9 else {}
return None
env_snapshot = cached[n + 1] if len(cached) > n + 1 else {}
if all(_env_ref_lookup(k) == v for k, v in env_snapshot.items()):
return hit
return None
@@ -2359,8 +2281,7 @@ def _load_config_impl(*, want_deepcopy: bool) -> dict[str, Any]:
if user_sig is not None:
try:
with open(config_path, encoding="utf-8-sig") as f:
user_config = fast_safe_load(f) or {}
user_config = read_config_doc(config_path) or {}
_CONFIG_PARSE_FAILURES.pop(path_key, None) # the file reads now (a transient error left the record)
if "max_turns" in user_config:
@@ -2996,7 +2917,7 @@ def edit_config():
managed_error("edit configuration")
return
config_path = get_config_path()
if not config_path.exists():
if not config_path.exists(): # config-reader: ok — file tooling (edits the local file)
seed_config_file(config_path)
print(f"Created {config_path}")
+216
View File
@@ -0,0 +1,216 @@
"""Where the user config layer lives: the ``ConfigBackend`` seam (config-config design §4.1, D10).
Every read and write of a profile's user ``config.yaml`` goes through the backend returned by
:func:`get_config_backend` (``scripts/check_config_yaml_readers.py`` and
``scripts/check_config_yaml_writers.py`` reject direct file access elsewhere). The key per call is
the hermes home (profile): one multiplexed process serves N profiles, and every config cache
already keys on the config path, so a backend keeps per-home state.
:class:`FileBackend` is the only backend and is today's behaviour byte for byte:
the version is the ``file_signature`` stat tuple, reads are ``open`` + ``fast_safe_load`` and raise
exactly what those raise (``FileNotFoundError`` for an absent file, ``OSError``, YAML errors), and
writes are the ruamel round-trip writers in ``utils``. Callers keep their own error handling, so a
backend whose layer always exists simply never takes the "no file" branches.
The managed scope (``/etc/hermes``) is NOT a backend concern: it stays an overlay applied on top of
whatever user layer the backend returns.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Mapping, Optional, Protocol, Union, runtime_checkable
CONFIG_FILENAME = "config.yaml"
PathLike = Union[str, "os.PathLike[str]"]
@dataclass(frozen=True)
class Changes:
"""A write to one profile's user layer.
``document`` replaces the whole user-layer document (``atomic_config_write``: the file backend
merges it onto the on-disk document, comment-preserving); ``set`` / ``unset`` are single dotted
keys (``atomic_roundtrip_yaml_update`` semantics). A backend applies ``document`` first.
"""
document: Optional[dict] = None
set: Mapping[str, Any] = field(default_factory=dict)
unset: tuple[str, ...] = ()
extra_content_on_create: Optional[str] = None
@runtime_checkable
class ConfigBackend(Protocol):
name: str
def read_user_doc(self, home: Path) -> Any:
"""The parsed user layer as written (None for an empty document); version checks are separate."""
...
def read_user_doc_readonly(self, home: Path) -> Any:
"""``read_user_doc(home)`` re-read only when ``version`` changes; never mutate it."""
...
def version(self, home: Path) -> tuple[Any, ...]:
"""Cheap cache signature; changes whenever the user layer does."""
...
def exists(self, home: Path) -> bool: ...
def probe_readable(self, home: Path) -> None:
"""Raise ``OSError`` while the user layer cannot be read; no parse."""
...
def write_changes(self, home: Path, changes: Changes) -> None: ...
class FileBackend:
"""The user layer is ``<home>/config.yaml`` on local disk."""
name = "file"
# Path-level primitives: the file backend's whole behaviour, also used for explicit files
# that are not a home's config.yaml (see ``_route``).
@staticmethod
def version_path(path: Path) -> tuple[int, int, int, int]:
st = path.stat() # before the import: a missing file must not need the utils stack (PM runtime)
from utils import file_signature
return file_signature(st)
@staticmethod
def read_path(path: Path) -> Any:
from utils import fast_safe_load
with open(path, encoding="utf-8-sig") as f:
return fast_safe_load(f)
@staticmethod
def read_path_readonly(path: Path) -> Any:
from utils import load_yaml_file_readonly
return load_yaml_file_readonly(path)
@staticmethod
def write_path(path: Path, changes: Changes) -> None:
from utils import atomic_roundtrip_yaml_save, atomic_roundtrip_yaml_update
if changes.document is not None:
atomic_roundtrip_yaml_save(path, changes.document, extra_content_on_create=changes.extra_content_on_create)
for key, value in changes.set.items():
atomic_roundtrip_yaml_update(path, key, value)
for key in changes.unset:
atomic_roundtrip_yaml_update(path, key, None)
def config_path(self, home: Path) -> Path:
return Path(home) / CONFIG_FILENAME
def read_user_doc(self, home: Path) -> Any:
return self.read_path(self.config_path(home))
def read_user_doc_readonly(self, home: Path) -> Any:
return self.read_path_readonly(self.config_path(home))
def version(self, home: Path) -> tuple[int, int, int, int]:
return self.version_path(self.config_path(home))
def exists(self, home: Path) -> bool:
return self.config_path(home).exists()
def probe_readable(self, home: Path) -> None:
path = self.config_path(home)
with open(path, "rb") as f:
f.read(1)
def write_changes(self, home: Path, changes: Changes) -> None:
self.write_path(self.config_path(home), changes)
_FILE_BACKEND = FileBackend()
def get_config_backend() -> ConfigBackend:
"""The backend serving every home's user layer: the file backend, the only one there is."""
return _FILE_BACKEND
class _ExplicitFile(FileBackend):
"""A config file that is not a home's user layer (a test fixture, an import source): the file
backend's behaviour on that exact path, whichever backend is selected."""
def __init__(self, path: Path):
self._path = path
def config_path(self, home: Path) -> Path:
return self._path
def _route(config_path: PathLike) -> tuple[ConfigBackend, Path]:
"""``(backend, home)`` serving a config path through the one ``ConfigBackend`` surface.
``<home>/config.yaml`` is that home's user layer and goes to the selected backend. Any other
file name is an explicit file, served by the file backend on that path.
"""
path = Path(config_path)
if path.name == CONFIG_FILENAME:
return get_config_backend(), path.parent
return _ExplicitFile(path), path.parent
def read_config_doc(config_path: PathLike) -> Any:
"""Parsed root of a config file (None when empty); raises like ``open`` + ``fast_safe_load``."""
backend, home = _route(config_path)
return backend.read_user_doc(home)
def read_config_doc_readonly(config_path: PathLike) -> Any:
"""Signature-cached :func:`read_config_doc`; the result is shared — never mutate it."""
backend, home = _route(config_path)
return backend.read_user_doc_readonly(home)
def config_version(config_path: PathLike) -> tuple[Any, ...]:
"""Cache signature of a config file; raises ``FileNotFoundError`` / ``OSError`` like ``stat``."""
backend, home = _route(config_path)
return backend.version(home)
def config_version_or_none(config_path: PathLike) -> Optional[tuple[Any, ...]]:
""":func:`config_version`, or None when the config is absent or cannot be stat'ed."""
try:
return config_version(config_path)
except OSError:
return None
def version_token(version: Optional[tuple[Any, ...]]) -> float:
"""A number that changes with a backend ``version`` (0 = no config), for clients that only
compare successive values: the file backend's version leads with ``st_mtime_ns``, kept as seconds.
Any other int-led version is scaled the same way, which is fine: only changes are compared."""
if not version:
return 0
if isinstance(version[0], int):
return version[0] / 1_000_000_000
import zlib
return float(zlib.crc32(repr(version).encode("utf-8")) or 1)
def probe_config_readable(config_path: PathLike) -> None:
"""Raise ``OSError`` while a config file cannot be read; no parse."""
backend, home = _route(config_path)
backend.probe_readable(home)
def config_exists(config_path: PathLike) -> bool:
backend, home = _route(config_path)
return backend.exists(home)
def write_config_document(config_path: PathLike, document: dict, *, extra_content_on_create: Optional[str] = None) -> None:
"""Replace a config file's document (comment-preserving merge for the file backend)."""
backend, home = _route(config_path)
backend.write_changes(home, Changes(document=document, extra_content_on_create=extra_content_on_create))
def write_config_key(config_path: PathLike, key_path: str, value: Any) -> None:
"""Set one dotted key (``None`` removes it) — ``atomic_roundtrip_yaml_update`` semantics."""
backend, home = _route(config_path)
backend.write_changes(home, Changes(unset=(key_path,)) if value is None else Changes(set={key_path: value}))
+3 -3
View File
@@ -48,7 +48,7 @@ def backup_config(config_path: Path, reason: str, *, keep: int = DEFAULT_KEEP) -
identical bytes. Never raises: a failed backup must not block the write it precedes.
"""
try:
if not config_path.is_file() or config_path.stat().st_size == 0:
if not config_path.is_file() or config_path.stat().st_size == 0: # config-reader: ok — file tooling (copies the local file)
return None
root = backups_dir(config_path)
root.mkdir(parents=True, exist_ok=True)
@@ -56,12 +56,12 @@ def backup_config(config_path: Path, reason: str, *, keep: int = DEFAULT_KEEP) -
existing = list_config_backups(config_path, reason)
# Not filecmp.cmp: its process-wide cache keys on (size, mtime), so an equal-size edit
# within one mtime tick of an earlier comparison is reported identical and never backed up.
if existing and config_path.read_bytes() == existing[0].read_bytes():
if existing and config_path.read_bytes() == existing[0].read_bytes(): # config-reader: ok — file tooling (copies the local file)
return None
dest = root / f"{config_path.name}.{reason}.{time.strftime('%Y%m%d-%H%M%S')}"
if dest.is_symlink() or dest.exists(): # never write through a planted link
return None
shutil.copy2(config_path, dest)
shutil.copy2(config_path, dest) # config-reader: ok — file tooling (copies the local file)
for stale in [dest, *existing][keep:]:
stale.unlink(missing_ok=True)
return dest
+10 -10
View File
@@ -20,8 +20,8 @@ from typing import Any, Dict, Optional, Tuple
from hermes_cli import config as _config
from hermes_cli import managed_scope
from hermes_cli.config_backend import read_config_doc
from hermes_cli.config_read_errors import _warn_config_parse_failure
from utils import fast_safe_load
# path -> raw user mapping from the last successful parse in this process; served (through the
# normal pipeline) when the file is later found mid-edit as broken YAML.
@@ -73,27 +73,27 @@ def load_user_config_effective(config_path: Optional[Path] = None, *, fail_close
with _config._CONFIG_LOCK:
user_sig, cache_sig = _config._load_config_cache_sig(config_path)
cached = _EFFECTIVE_CACHE.get(path_key)
if cached is not None and cache_sig is not None and cached[:8] == cache_sig:
if all(_config._env_ref_lookup(k) == v for k, v in cached[9].items()):
return copy.deepcopy(cached[8])
n = len(cache_sig) if cache_sig is not None else 0
if cached is not None and cache_sig is not None and cached[:n] == cache_sig:
if all(_config._env_ref_lookup(k) == v for k, v in cached[n + 1].items()):
return copy.deepcopy(cached[n])
raw: dict[str, Any] = {}
recovered = False
raw_hit = _config._RAW_CONFIG_CACHE.get(path_key)
if user_sig is not None and raw_hit is not None and raw_hit[:4] == user_sig:
raw = copy.deepcopy(raw_hit[4]) # one parse per process, shared with read_raw_config()
raw_hit = _config._raw_config_cache_hit(path_key, user_sig) if user_sig is not None else None
if raw_hit is not None:
raw = copy.deepcopy(raw_hit) # one parse per process, shared with read_raw_config()
_LAST_GOOD_USER_RAW.setdefault(path_key, copy.deepcopy(raw))
elif user_sig is not None:
try:
with open(config_path, encoding="utf-8-sig") as f:
loaded = fast_safe_load(f)
loaded = read_config_doc(config_path)
except Exception as exc:
if fail_closed:
raise
raw, recovered = _recover_user_raw(config_path, path_key, exc), True
else:
raw = loaded if isinstance(loaded, dict) else {}
_config._RAW_CONFIG_CACHE[path_key] = (*user_sig, copy.deepcopy(raw))
_config._raw_config_cache_store(path_key, user_sig, copy.deepcopy(raw))
_LAST_GOOD_USER_RAW[path_key] = copy.deepcopy(raw)
# Same copy load_config keeps: a fresh process recovers from it (see _recover_user_raw).
# Only for the ACTIVE home — a read of another profile's file (doctor, TUI cwd lookup)
+1 -1
View File
@@ -81,7 +81,7 @@ def initialize_home(home: Path, subdirs: tuple[str, ...], ensured: set[str]) ->
def config_load_issue(exc: Exception):
from hermes_cli.config import ConfigIssue
from hermes_cli.config_issues import ConfigIssue
if isinstance(exc, (HomeInitializationError, OSError)):
return ConfigIssue(
+18
View File
@@ -0,0 +1,18 @@
"""The issue record config validation emits (``hermes config check``, doctor, startup warnings).
Its own module so the facade (``hermes_cli.config``) and its check siblings share it without
a facade import from the siblings."""
from dataclasses import dataclass
@dataclass
class ConfigIssue:
"""A detected config structure problem."""
severity: str # "error", "warning"
message: str
hint: str
def _issue(issues: list[ConfigIssue], severity: str, message: str, hint: str) -> None:
issues.append(ConfigIssue(severity, message, hint))
+5 -5
View File
@@ -13,7 +13,6 @@ import sys
from pathlib import Path
from typing import Any, Optional
from utils import file_signature
logger = logging.getLogger(__name__)
@@ -76,8 +75,8 @@ def _warn_config_parse_failure(
in ``hermes_cli.config._load_config_impl``).
"""
try:
st = config_path.stat()
sig = file_signature(st)
from hermes_cli.config_backend import config_version
sig = config_version(config_path)
key = (str(config_path), *sig)
_CONFIG_PARSE_FAILURES[str(config_path)] = (*sig, str(exc))
except OSError:
@@ -104,9 +103,10 @@ def get_active_config_parse_failure() -> Optional[str]:
(mtime_ns + size + ino + ctime_ns) to the file that failed to parse; else None."""
from hermes_cli.config import get_config_path
try:
from hermes_cli.config_backend import config_version
record = _CONFIG_PARSE_FAILURES[str(path := get_config_path())]
st = path.stat()
return record[4] if file_signature(st) == record[:4] else None
sig = config_version(path)
return record[len(sig)] if sig == record[:len(sig)] else None
except Exception:
return None
+75
View File
@@ -0,0 +1,75 @@
"""Value checks for single config sections (voice, timezone, web backends), run by
``hermes_cli.config.validate_config_structure`` for doctor and the startup warnings."""
from typing import Any
from hermes_cli.config_issues import ConfigIssue, _issue
def _validate_voice(config: dict[str, Any], issues: list[ConfigIssue]) -> None:
voice_cfg = config.get("voice")
if not (isinstance(voice_cfg, dict) and "submit_mode" in voice_cfg):
return
submit_mode = voice_cfg.get("submit_mode")
normalized = submit_mode.strip().lower() if isinstance(submit_mode, str) else None
if normalized not in {"direct", "draft"}:
_issue(issues, "error", f"voice.submit_mode must be 'direct' or 'draft', got {submit_mode!r}",
"Set voice.submit_mode to direct (submit immediately) or draft (edit before sending)")
def _validate_timezone(config: dict[str, Any], issues: list[ConfigIssue]) -> None:
"""``timezone`` must be an IANA name the runtime can load.
``hermes_time._get_zoneinfo()`` swallows an invalid name behind a single WARNING in the
gateway log, then runs the agent clock AND every cron schedule on server-local time.
Surface it here, where doctor and the startup check both look. Silent when the
interpreter has no tz database at all (bare Windows without ``tzdata``) — nothing can be
judged there.
"""
if "timezone" not in config:
return
tz = config.get("timezone")
hint = ("Use an IANA zone name such as America/New_York or Asia/Tokyo (see "
"`timedatectl list-timezones`). With an invalid value the agent clock and cron "
"schedules silently fall back to server-local time. HERMES_TIMEZONE overrides "
"this key when set.")
if tz is not None and not isinstance(tz, str):
_issue(issues, "error", f"timezone must be an IANA zone name string, got {tz!r}", hint)
return
if not (isinstance(tz, str) and tz.strip()):
return
name = tz.strip()
try:
import zoneinfo
zoneinfo.ZoneInfo("UTC") # is a tz database available at all?
except Exception:
return
try:
zoneinfo.ZoneInfo(name)
except Exception:
_issue(issues, "error", f"timezone {name!r} is not a valid IANA zone name", hint)
def _validate_web_backends(config: dict[str, Any], issues: list[ConfigIssue]) -> None:
"""A stale web backend selection otherwise fails only at the first web_search/web_extract
call with a generic "no registered provider" error; warn at startup instead."""
# See #99199.
web_cfg = config.get("web")
if not isinstance(web_cfg, dict):
return
try:
from tools.tool_backend_helpers import removed_backend_note
except Exception:
return
seen: set = set()
for _key in ("backend", "search_backend", "extract_backend"):
_val = str(web_cfg.get(_key) or "").strip().lower()
if not _val or _val in seen:
continue
seen.add(_val)
note = removed_backend_note("web", _val)
if note:
_issue(issues, "warning",
f"web.{_key} is set to '{_val}', but {note} — "
"web_search/web_extract will fail until it is changed",
"Run 'hermes tools' and pick a different Web Search & Extract provider")
Loaded 100 of 203 files, more files were not shown because too many files have changed in this diff. Show more