mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
commit
2f644f3fed
203 files changed
+7254
-897
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+141
@@ -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(() => {
|
||||
|
||||
+60
-9
@@ -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}
|
||||
|
||||
Vendored
+2
@@ -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
|
||||
|
||||
@@ -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}. استأنف هنا في أي وقت.`,
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -73,6 +73,7 @@ export const arDiagnostics = {
|
||||
microphoneConstraintsUnsupported: 'قيود الميكروفون غير مدعومة على هذا الجهاز.',
|
||||
microphoneFailed: 'فشل الميكروفون',
|
||||
microphoneInUse: 'الميكروفون مستخدم من تطبيق آخر.',
|
||||
microphoneNeedsHttps: 'يحتاج الميكروفون إلى اتصال آمن. افتح Webapp عبر HTTPS، أو عبر نفق SSH إلى 127.0.0.1، لاستخدامه.',
|
||||
microphonePermissionDenied: 'تم رفض إذن الميكروفون.',
|
||||
microphoneStartFailed: 'تعذر بدء تسجيل الميكروفون.',
|
||||
microphoneUnsupported: 'هذا المتصفح لا يدعم تسجيل الميكروفون.',
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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} に引き継ぎました。いつでもここで再開できます。`,
|
||||
|
||||
@@ -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}. Возобновите здесь в любой момент.`,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}。隨時可在此處恢復。`,
|
||||
|
||||
@@ -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: '目前執行環境不支援麥克風錄音。',
|
||||
|
||||
@@ -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&b">"q" <x></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="<base href="about:srcdoc"><h1>preview</h1>"')
|
||||
expect(wrapper).not.toMatch(/\ssrc="data:/)
|
||||
|
||||
await win.hermesDesktop!.openPreviewInBrowser!(staged)
|
||||
expect(open).toHaveBeenCalledExactlyOnceWith(staged, '_blank', 'noopener,noreferrer')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
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')
|
||||
|
||||
|
||||
@@ -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 || ''
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 } : {})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()}
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
|
||||
@@ -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}))
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
Reference in new issue
Block a user