feat(webapp): carry the browser-hosted Desktop's server pieces from #93508

Carried from BearHuddleston's #93508 onto main. The Webapp relay and the
profile-incarnation fence stay out: carried code follows main's
named-profile rule and refuses a missing or deleted profile home.

- Webapp routes: one-use launch tickets, window handoff, and gateway
  WebSocket tickets minted for the signed-in browser
- Browser surface policy and the SPA document policy
- A host terminal for the browser that survives reconnects
- Staged browser uploads and file tickets; file.attach takes a staged upload
- hermes sessions adopt
- Webapp bundle build helpers and the renderer freshness check
- Regenerated gateway contracts

Co-authored-by: emozilla <emozilla@nousresearch.com>
This commit is contained in:
Bear Huddlestonandemozilla authored and Teknium committed 2026-10-25 03:22:14 -07:00
1 parent e3cc65d767
commit b5eb5ee7a8
79 files changed
+6946 -361

No files matched your search

+2
View File
@@ -94,6 +94,8 @@ hermes_cli/web_dist/
.web_ui_build.lock
apps/desktop/build/
apps/desktop/dist/
apps/desktop/dist-webapp/
.dist-webapp-build-*
# tsc-emitted artifacts (a stray `tsc -b` compiles into src/, and vite then
# resolves the stale .js OVER the .tsx — never track these)
@@ -2826,6 +2826,13 @@ export interface FileAttachParams {
path?: string | null
data_url?: string | null
name?: string | null
staged_upload?: StagedFileUpload | null
}
/** Source identity returned by the browser upload route, checked against the live profile. */
export interface StagedFileUpload {
install_id: string
path: string
profile_home: string
}
export interface FileAttachResult {
attached: boolean
@@ -18008,6 +18008,17 @@
],
"default": null,
"title": "Name"
},
"staged_upload": {
"anyOf": [
{
"$ref": "#/components/schemas/StagedFileUpload"
},
{
"type": "null"
}
],
"default": null
}
},
"required": [
@@ -41749,6 +41760,31 @@
"title": "SpawnTreeSaveResult",
"type": "object"
},
"StagedFileUpload": {
"additionalProperties": false,
"description": "Source identity returned by the browser upload route, checked against the live profile.",
"properties": {
"install_id": {
"title": "Install Id",
"type": "string"
},
"path": {
"title": "Path",
"type": "string"
},
"profile_home": {
"title": "Profile Home",
"type": "string"
}
},
"required": [
"install_id",
"path",
"profile_home"
],
"title": "StagedFileUpload",
"type": "object"
},
"StatusUpdatePayload": {
"additionalProperties": false,
"description": "``server._status_update`` and the direct emitters (goal / loop / heartbeat / process).",
+39 -7
View File
@@ -138,13 +138,21 @@ async def attach_file(connection, ref, params):
"""Desktop's non-image attachment: stage the file for this session (its frozen launch cwd and
the owner profile's ``attachments/``) and hand back the ``@file:`` ref its next submit carries.
The sidecar handler looked the session up in its own table, which never holds an authority
session, so every file drop failed with ``session not found``."""
if (set(params) - {'session_id', 'profile', 'path', 'data_url', 'name'} or not ref.session_id
or any(not isinstance(value, str) for value in params.values())):
session, so every file drop failed with ``session not found``.
A browser pick arrives as ``staged_upload`` (the Webapp upload route's receipt,
``hermes_cli/staged_uploads.py``): it is copied out of the profile it was uploaded to, which must
be one this connection may reach."""
staged_upload = params.get('staged_upload')
if (set(params) - {'session_id', 'profile', 'path', 'data_url', 'name', 'staged_upload'} or not ref.session_id
or any(not isinstance(value, str) for key, value in params.items() if key != 'staged_upload')
or (staged_upload is not None and not isinstance(staged_upload, dict))):
raise RuntimeStoreError('invalid_params')
if ref.session_id not in connection.subscriptions:
raise RuntimeStoreError('permission_denied')
authorize(connection, ref, params, 'session:submit')
if staged_upload is not None and not _reachable_upload_home(connection, staged_upload.get('profile_home')):
raise RuntimeStoreError('permission_denied')
from gateway.session_policy import policy_for_source
authority = connection.authority
policy = policy_for_source(authority.runner, authority.sessions[ref.session_id].source)
@@ -153,11 +161,17 @@ async def attach_file(connection, ref, params):
# The staging helpers are bound onto the sidecar module's globals (method_ctx.bind_module).
from tui_gateway import server
session = {'cwd': policy.cwd, 'profile_home': authority.profile_id}
def stage():
if staged_upload is not None:
# Only the receipt is read: a supplied path/data_url never stands in for a missing upload.
return server._stage_browser_file_attachment(session, staged_upload, params.get('name', ''))
return server._stage_session_file_attachment(
session, raw_path=params.get('path', ''), data_url=params.get('data_url', ''),
name=params.get('name', ''))
try:
stored, uploaded = await asyncio.to_thread(
server._stage_session_file_attachment, session, raw_path=params.get('path', ''),
data_url=params.get('data_url', ''), name=params.get('name', ''))
except ValueError as exc:
stored, uploaded = await asyncio.to_thread(stage)
except (ValueError, OverflowError) as exc:
raise RuntimeStoreError('invalid_params') from exc
except OSError as exc:
raise RuntimeStoreError('storage_unavailable') from exc
@@ -166,6 +180,24 @@ async def attach_file(connection, ref, params):
'ref_text': f'@file:{server._format_ref_value(ref_path)}', 'uploaded': uploaded}
def _reachable_upload_home(connection, raw_home) -> bool:
"""A staged upload's source profile: this authority's own home or, for a host-scoped grant, a
sibling this process serves (an owner-routed tile can receive a foreground browser pick). Never
any other profile on disk, whatever its descriptor names."""
from pathlib import Path
if not isinstance(raw_home, str) or not raw_home:
return False
homes = {connection.authority.profile_id}
if connection.host_scoped:
registry = getattr(getattr(connection.authority, 'runner', None), 'session_authorities', None)
homes |= {sibling.profile_id for sibling in (registry or ())}
try:
target = Path(raw_home).resolve()
return any(Path(home).resolve() == target for home in homes)
except (OSError, ValueError):
return False
async def approvals(connection, ref, params, *, ack=False):
"""The Desktop's approval replay (``approval.pending``) and card ack (``approval.received``)
over the owner's generation-bound prompt projection. Left to the legacy sidecar they answered
+6 -2
View File
@@ -24,8 +24,12 @@ def _stage(directory, params):
from tui_gateway.prompt_attachments import (
_ATTACH_BYTES_MAX_BYTES, _decode_attach_base64, _sniff_image_ext, stage_image_bytes,
)
data = _decode_attach_base64(params.get('content_base64') or params.get('data') or '', mime_prefix='image/')
if not data or len(data) > _ATTACH_BYTES_MAX_BYTES:
try:
data = _decode_attach_base64(params.get('content_base64') or params.get('data') or '',
mime_prefix='image/', max_bytes=_ATTACH_BYTES_MAX_BYTES)
except OverflowError as exc:
raise RuntimeStoreError('invalid_params') from exc
if not data:
raise RuntimeStoreError('invalid_params')
hint = params.get('ext', '').strip().lower().lstrip('.')
ext = _sniff_image_ext(data, params.get('filename') or (f'x.{hint}' if hint else ''))
@@ -4,6 +4,7 @@ from __future__ import annotations
import logging
import time
from typing import Callable, Optional
from urllib.parse import urlsplit
from fastapi import Request
from fastapi.responses import JSONResponse
@@ -34,6 +35,23 @@ def extract_bearer(request: Request) -> str:
return ""
def _http_origin(value: str) -> tuple[str, str, int] | None:
"""Parse one serialized HTTP origin, never an opaque or URL-like origin."""
if not value or any(ord(c) <= 32 or ord(c) >= 127 or c in "\\,?#%" for c in value):
return None
try:
parsed = urlsplit(value)
if (parsed.scheme not in {"http", "https"} or not parsed.hostname
or parsed.username is not None or parsed.password is not None or parsed.path
or parsed.netloc.endswith(":")):
return None
port = parsed.port
effective_port = port if port is not None else (443 if parsed.scheme == "https" else 80)
return parsed.scheme, parsed.hostname, effective_port
except ValueError:
return None
def is_safe_next_path(path: str) -> bool:
"""Same-origin post-login target: rejects non-relative and protocol-relative (``//evil``)
values, the auth routes themselves, and every ``/api`` path."""
+1 -1
View File
@@ -273,7 +273,7 @@ _RESERVED_NAMES = frozenset({"hermes", "default", "test", "tmp", "root", "sudo"}
# Hermes subcommands that cannot be used as profile names/aliases
_HERMES_SUBCOMMANDS = frozenset({
"chat", "model", "gateway", "setup", "whatsapp", "login", "logout",
"status", "cron", "doctor", "dump", "config", "pairing", "skills", "tools",
"status", "cron", "doctor", "dump", "config", "pairing", "skills", "tools", "serve", "dashboard", "webapp",
"mcp", "connectors", "sessions", "insights", "version", "update", "uninstall", "profile", "plugins", "honcho", "acp",
})
+50 -23
View File
@@ -17,8 +17,9 @@ import signal
import struct
import sys
import termios # windows-footgun: ok — POSIX-only module by design (see docstring)
import threading
import time
from typing import Optional, Sequence
from typing import Callable, Optional, Sequence
try:
import ptyprocess # type: ignore
@@ -103,8 +104,8 @@ def _psutil_alive(proc) -> bool:
class PtyBridge:
"""Thin wrapper around ``ptyprocess.PtyProcess`` for byte streaming. Not thread-safe: owned by
the WebSocket handler that spawned it; reads run in an executor thread, writes are awaited on
the loop. The master fd is non-blocking so input backpressure suspends only the owning
WebSocket task, never the dashboard event loop.
the loop (or run in a worker under a caller's ``fence``). The master fd is non-blocking so
input backpressure suspends only the owning WebSocket task, never the dashboard event loop.
"""
_waiter = None # FdWaiter for the master; None until __init__ registers it
@@ -113,6 +114,9 @@ class PtyBridge:
self._proc = proc
self._fd: int = proc.fd
self._closed = False
# A fenced write runs in a worker thread; it must never reach a descriptor
# number that close() has already released for reuse.
self._fd_lock = threading.Lock()
# Recorded at spawn: once the leader is reaped getpgid() can no longer find its group,
# but the helpers it started still belong to it.
try:
@@ -197,9 +201,9 @@ class PtyBridge:
raise
return data or None
async def _wait_writable(self, timeout: float) -> bool:
"""Wait without blocking the event loop until the master accepts input."""
if self._closed or timeout <= 0:
async def _wait_writable(self, timeout: Optional[float]) -> bool:
"""Wait without blocking the event loop until the master accepts input (``None``: no limit)."""
if self._closed or (timeout is not None and timeout <= 0):
return False
loop = asyncio.get_running_loop()
ready = loop.create_future()
@@ -210,7 +214,11 @@ class PtyBridge:
try:
loop.add_writer(self._fd, _mark_ready)
await asyncio.wait_for(ready, timeout=timeout)
# wait_for can swallow cancellation on Python 3.11 if readiness
# completes in the same tick as viewer takeover. Never retry input
# from that cancelled writer after the new viewer owns the PTY.
async with asyncio.timeout(timeout):
await ready
return not self._closed
except (TimeoutError, OSError, ValueError):
return False
@@ -220,12 +228,35 @@ class PtyBridge:
except (OSError, ValueError):
pass
async def write(self, data: bytes, *, timeout: float = 10.0) -> bool:
def _write_some(self, view: memoryview) -> Optional[int]:
"""One non-blocking write: bytes accepted (0 while the child's buffer is full), None once closed."""
with self._fd_lock:
if self._closed:
return None
try:
return os.write(self._fd, view)
except OSError as exc:
if exc.errno in {errno.EIO, errno.EBADF, errno.EPIPE}:
return None
if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}:
return 0
raise
async def write(
self, data: bytes, *, timeout: Optional[float] = 10.0,
fence: Optional[Callable[[Callable[[], Optional[int]]], Optional[int]]] = None,
) -> bool:
"""Write all raw bytes without ever blocking the dashboard event loop.
Returns ``False`` when the bridge closes or the child leaves its input
buffer full for ``timeout`` seconds. Callers can then recycle only the
affected terminal session while the rest of the dashboard stays live.
``timeout=None`` waits for as long as the child leaves its input unread
(cancel the call to stop waiting), so ``False`` then means the bridge closed.
``fence`` wraps every non-blocking write attempt in a worker thread, so a
caller can hold a lock around exactly the bytes that reach the child;
whatever it raises propagates. Backpressure waits happen outside it.
"""
if self._closed:
return False
@@ -233,20 +264,15 @@ class PtyBridge:
return True
loop = asyncio.get_running_loop()
deadline = loop.time() + max(0.0, timeout)
deadline = None if timeout is None else loop.time() + max(0.0, timeout)
view = memoryview(data)
while view:
if self._closed:
if fence is None:
n = self._write_some(view)
else:
n = await asyncio.to_thread(fence, lambda chunk=view: self._write_some(chunk))
if n is None:
return False
try:
n = os.write(self._fd, view)
except OSError as exc:
if exc.errno in {errno.EIO, errno.EBADF, errno.EPIPE}:
return False
if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}:
n = 0
else:
raise
if n > 0:
view = view[n:]
# A very large paste can otherwise monopolize the loop while
@@ -255,7 +281,7 @@ class PtyBridge:
await asyncio.sleep(0)
continue
remaining = deadline - loop.time()
remaining = None if deadline is None else deadline - loop.time()
if not await self._wait_writable(remaining):
return False
return True
@@ -298,9 +324,10 @@ class PtyBridge:
the dashboard never leaks zombies, and close fds. Idempotent; blocks, so call it off the
event loop.
"""
if self._closed:
return
self._closed = True
with self._fd_lock:
if self._closed:
return
self._closed = True
pgid = self._pgid
leader_was_alive = self._proc.isalive()
+270 -83
View File
@@ -7,12 +7,34 @@ opaque token replays the buffer and resumes live.
from __future__ import annotations
import asyncio
import logging
import threading
import time
from concurrent.futures import Executor
from pathlib import Path
from typing import Callable, Dict, Optional, Tuple
from fastapi import WebSocketDisconnect
logger = logging.getLogger(__name__)
WS_CLOSE_PROCESS_EXITED = 4410
WS_CLOSE_SUPERSEDED = 4409
# Going Away: the viewer stopped taking output, but its session lives on and the same
# token reattaches. The dashboard chat tab and the Webapp terminal both redial on 1001;
# the chat tab reads 1011 as a failed start and offers a new session instead.
WS_CLOSE_VIEWER_STALLED = 1001
WS_CLOSE_VIEWER_STALLED_REASON = "Terminal viewer stopped reading; reconnect"
# How long one frame may wait on the viewer before it counts as stalled.
_VIEWER_SEND_TIMEOUT_SECONDS = 5.0
# A reattach replay is a single frame of up to the 1 MiB buffer (host-terminal clients take exactly
# the next binary frame as the replay, so it is not chunked). It gets the stall limit plus the time
# to cross a link this slow (about 256 kbit/s; 37 s for 1 MiB), else a weak connection redials forever.
_REPLAY_MIN_BYTES_PER_SECOND = 32 * 1024
# A viewer send that fails because the browser is gone: Starlette raises WebSocketDisconnect for a
# dead transport and RuntimeError for a socket already closed; OSError covers the TimeoutError of
# a viewer that stopped reading.
_VIEWER_SEND_ERRORS = (WebSocketDisconnect, RuntimeError, OSError)
TUI_FORCE_REDRAW = b"\x0c"
@@ -35,10 +57,10 @@ class RingBuffer:
return bytes(self._buf)
async def _close_ws(ws, code: int) -> None:
async def _close_ws(ws, code: int, reason: Optional[str] = None) -> None:
try:
if ws is not None:
await ws.close(code=code)
await asyncio.wait_for(ws.close(code=code, reason=reason), timeout=5.0)
except Exception:
pass
@@ -66,7 +88,10 @@ def _key_segments(key: str) -> tuple[str, str]:
class PtySession:
def __init__(self, key: str, bridge, *, buffer_cap: int, read_timeout: float, active_session_file: Optional[Path] = None) -> None:
def __init__(
self, key: str, bridge, *, buffer_cap: int, read_timeout: float,
active_session_file: Optional[Path] = None, drain_executor: Optional[Executor] = None,
) -> None:
self.key = key
self.bridge = bridge
self.active_session_file = active_session_file
@@ -74,45 +99,90 @@ class PtySession:
self.buffer = RingBuffer(buffer_cap)
self.alive = True
self.attached = False
self.last_detached_at: Optional[float] = None
# Admission can fail before the first attach (e.g. lost metadata).
self.last_detached_at: Optional[float] = time.monotonic()
self._read_timeout = read_timeout
# Runs the blocking reads; None is the loop's default executor.
self._drain_executor = drain_executor
self._ws = None
self._attach_generation = 0
# Only final sink writes and viewer claims hold this lock. Profile
# admission/backpressure waits happen outside it.
self._viewer_lock = threading.Lock()
self._drain_task: Optional[asyncio.Task] = None
self._write_lock = asyncio.Lock()
self._output_lock = asyncio.Lock()
self._input_task: Optional[asyncio.Task] = None
self._close_task: Optional[asyncio.Task] = None
async def start(self) -> None:
self._drain_task = asyncio.create_task(self._drain())
async def _drain(self) -> None:
loop = asyncio.get_running_loop()
while True:
chunk = await loop.run_in_executor(None, self.bridge.read, self._read_timeout)
while self.alive:
try:
chunk = await loop.run_in_executor(self._drain_executor, self.bridge.read, self._read_timeout)
except OSError:
chunk = None
if chunk is None: # EOF — the agent process exited
self.alive = False
await _close_ws(self._ws, WS_CLOSE_PROCESS_EXITED)
ws = self._ws
async with self._output_lock:
if self._ws is ws:
await _close_ws(ws, WS_CLOSE_PROCESS_EXITED)
return
if not chunk: # idle tick
await asyncio.sleep(0)
continue
self.buffer.append(chunk)
ws = self._ws
output_lock = self._output_lock
try:
if ws is not None:
await ws.send_bytes(chunk)
async with output_lock:
if self._ws is ws:
await asyncio.wait_for(ws.send_bytes(chunk), timeout=_VIEWER_SEND_TIMEOUT_SECONDS)
except Exception:
# The viewer is gone; nothing else observes this failure (the handler's finally
# only runs once ws.receive() sees the disconnect). detach() is a no-op when a
# replacement socket attached during the send, so the new viewer keeps its session.
self.detach(ws)
if self._ws is ws:
self.detach(ws)
await _close_ws(ws, WS_CLOSE_VIEWER_STALLED, WS_CLOSE_VIEWER_STALLED_REASON)
async def write(self, ws, data: bytes) -> bool:
"""Serialize input and discard bytes from a superseded socket."""
async def write(self, ws, data: bytes, *, fence=None, **bridge_options) -> bool:
"""Serialize input and discard bytes from a superseded socket.
``fence`` is handed to the bridge (see ``PtyBridge.write``); what it raises propagates.
``bridge_options`` (``timeout``) go to the bridge's ``write`` unchanged.
"""
async with self._write_lock:
if self._ws is not ws:
if self._ws is not ws or not self.alive:
return True
generation = self._attach_generation
delivered = await self.bridge.write(data)
def viewer_fence(write):
def admitted_write():
with self._viewer_lock:
if self._ws is not ws or self._attach_generation != generation or not self.alive:
return None
return write()
return fence(admitted_write)
if fence is not None:
bridge_options["fence"] = viewer_fence
task = self._input_task = asyncio.create_task(self.bridge.write(data, **bridge_options))
try:
delivered = await task
except asyncio.CancelledError:
if self._attach_generation != generation:
return False
raise
finally:
if self._input_task is task:
self._input_task = None
# A replacement socket can attach while the bridge write is
# suspended on backpressure. A late failure from the superseded
# socket must not poison the replacement's shared PTY session.
@@ -124,26 +194,67 @@ class PtySession:
self.alive = False
return delivered
async def attach(self, ws, *, force_redraw: bool = False) -> bool:
def resize(self, ws, *, cols: int, rows: int) -> None:
if self._ws is ws and self.alive:
self.bridge.resize(cols=cols, rows=rows)
async def attach(self, ws, *, force_redraw: bool = False, initial_text: Optional[str] = None) -> bool:
"""Attach a browser terminal and replay buffered PTY output.
The TUI renders differentially on an alternate screen, so a bounded ANSI tail is not a
self-contained frame; ``force_redraw`` asks the live TUI for one full redraw after replay.
"""
if self._ws is not ws:
await _close_ws(self._ws, WS_CLOSE_SUPERSEDED)
self._ws = ws
self._attach_generation += 1
self.attached = True
self.last_detached_at = None
if snap := self.buffer.snapshot():
try:
await ws.send_bytes(snap)
except Exception:
# Client dropped mid-replay; the caller never reaches its writer loop, so undo the
# attach here or reap_idle() can never reclaim this PTY (#110849).
self.detach(ws)
generation = self._attach_generation
if self._input_task is not None:
self._input_task.cancel()
# Revoke queued workers first, then serialize the claim after any
# already-admitted sink write. ConPTY writes can block: keep the loop
# free to run their timeout/cancellation cleanup while we wait.
while not self._viewer_lock.acquire(blocking=False):
await asyncio.sleep(0.001)
if self._attach_generation != generation:
return False
try:
if self._attach_generation != generation:
return False
old_ws = self._ws
self._ws = ws
self.attached = True
self.last_detached_at = None
# Per-viewer lock: a stalled old send must not block its replacement.
output_lock = self._output_lock = asyncio.Lock()
finally:
self._viewer_lock.release()
try:
async with output_lock:
snap = self.buffer.snapshot()
if old_ws is not ws:
await _close_ws(old_ws, WS_CLOSE_SUPERSEDED)
if self._ws is not ws:
return False
if initial_text is not None:
await asyncio.wait_for(ws.send_text(initial_text), timeout=_VIEWER_SEND_TIMEOUT_SECONDS)
if self._ws is not ws:
return False
# Metadata-bearing host clients treat exactly the next binary
# frame as replay. Send an empty snapshot too, so the first live
# frame never loses terminal-query replies on an empty history.
if snap or initial_text is not None:
replay_timeout = _VIEWER_SEND_TIMEOUT_SECONDS + len(snap) / _REPLAY_MIN_BYTES_PER_SECOND
await asyncio.wait_for(ws.send_bytes(snap), timeout=replay_timeout)
except _VIEWER_SEND_ERRORS:
self.detach(ws)
return False
except BaseException: # cancellation or a bug: never leave this socket attached
self.detach(ws)
raise
if self._ws is not ws:
return False
if not self.alive:
self.detach(ws)
await _close_ws(ws, WS_CLOSE_PROCESS_EXITED)
return False
if force_redraw:
return await self.write(ws, TUI_FORCE_REDRAW)
return True
@@ -175,13 +286,30 @@ class PtySession:
return leader == pid or leader in _process_ancestors(pid)
async def close(self) -> None:
self.alive = False
# Disconnect cancellation must not strand a child after its registry
# entry was removed. Cleanup has independent, idempotent ownership.
if self._close_task is None:
self.alive = False
self._attach_generation += 1
if self._input_task is not None:
self._input_task.cancel()
ws = self._ws
self.detach(ws)
self._close_task = asyncio.create_task(self._close(ws))
await asyncio.shield(self._close_task)
async def _close(self, ws) -> None:
await _close_ws(ws, WS_CLOSE_PROCESS_EXITED)
if self._drain_task is not None:
self._drain_task.cancel()
try:
await self._drain_task
except (asyncio.CancelledError, Exception):
except asyncio.CancelledError:
pass
except Exception:
# The drain handles its own read and viewer errors, so this is a bug; the
# child below must still be closed.
logger.warning("PTY output drain failed", exc_info=True)
try:
# bridge.close() joins the child — blocking; keep it off the event loop.
# See #53227.
@@ -219,52 +347,96 @@ async def run_reaper(registry: PtySessionRegistry, *, interval: float = 60.0) ->
class PtySessionRegistry:
def __init__(self, *, ttl: float, max_sessions: int, buffer_cap: int, read_timeout: float) -> None:
def __init__(
self, *, ttl: float, max_sessions: int, buffer_cap: int, read_timeout: float,
drain_executor: Optional[Executor] = None,
) -> None:
self._ttl = ttl
self._max = max_sessions
self._buffer_cap = buffer_cap
self._read_timeout = read_timeout
self._drain_executor = drain_executor
self._sessions: dict[str, PtySession] = {}
# The get-or-spawn decision spans awaits (reap_idle, the spawn thread,
# session.start), so two connections racing one attach token both saw
# "no session" and forked a PTY each: the token then mapped to whichever
# registered last while the other tab's live session fell out of the
# registry — never reaped, and a reattach landed on the wrong terminal
# (#115304). Serialize the decision so a token maps to one PTY.
# ponytail: one registry-wide lock, not per key — argv resolution is
# already serialized globally for the same reason, and a spawn only
# delays NEW chats. Per-key locks if spawn throughput ever matters.
self._attach_lock = asyncio.Lock()
# Sessions popped from the registry but still closing in the background; close_all()
# awaits them too, and holding the tasks keeps them from being garbage-collected.
# One registry-wide reservation spans lookup, spawn, and registration:
# racing connections with one attach token must share one tracked PTY.
self._spawn_lock = asyncio.Lock()
self._spawn_cleanup: set[asyncio.Task] = set()
self._closed = False
# Retain ownership of removed sessions until helper-process shutdown completes.
self._background_closes: set[asyncio.Task] = set()
async def attach_or_spawn(self, key: str, *, spawn: Callable[[], object], active_session_file: Optional[Path] = None) -> tuple[PtySession, bool]:
async def attach_or_spawn(
self, key: str, *, spawn: Callable[[], object], active_session_file: Optional[Path] = None,
) -> tuple[PtySession, bool]:
# Reserve capacity and the key across blocking fork/exec. On cancellation
# the registry, not the request's cancellation scope, owns that reservation
# until the admission finishes and any unclaimed child has been closed.
await self._spawn_lock.acquire()
admission = asyncio.create_task(
self._attach_or_spawn(key, spawn=spawn, active_session_file=active_session_file))
try:
# wait() leaves admission running on caller cancellation without
# shield() reporting its late exception (Python 3.14). The registry's
# discard task owns that result once the caller disconnects.
await asyncio.wait({admission})
result = admission.result()
except asyncio.CancelledError:
cleanup = asyncio.create_task(self._discard_admission(admission))
self._spawn_cleanup.add(cleanup)
cleanup.add_done_callback(self._spawn_cleanup.discard)
raise
except BaseException:
self._spawn_lock.release()
raise
self._spawn_lock.release()
return result
async def _discard_admission(self, admission: asyncio.Task) -> None:
try:
try:
session, created = await admission
except Exception:
# The disconnected caller cannot observe a late fork/exec
# failure: retrieve it here and keep the traceback.
logger.debug("PTY spawn failed after its caller disconnected", exc_info=True)
return
if created:
if self._sessions.get(session.key) is session:
self._sessions.pop(session.key)
await session.close()
finally:
self._spawn_lock.release()
async def _attach_or_spawn(
self, key: str, *, spawn: Callable[[], object], active_session_file: Optional[Path] = None,
) -> tuple[PtySession, bool]:
if self._closed:
raise RegistryFull("Terminal service is shutting down.")
await self.reap_idle()
async with self._attach_lock:
existing = self._sessions.get(key)
if existing is not None and existing.alive:
return existing, False
if existing is not None: # dead remnant
# Close in the background: ending a dead leader's helpers can take the helper
# grace, and this lock serializes every new chat.
self._sessions.pop(key, None)
self._close_in_background(existing)
if len(self._sessions) >= self._max:
self._reap_one_idle_or_raise()
# PTY spawn does blocking fork/exec work — keep it off the event loop.
# See #53227.
bridge = await asyncio.to_thread(spawn)
session = PtySession(
key,
bridge,
buffer_cap=self._buffer_cap,
read_timeout=self._read_timeout,
active_session_file=active_session_file,
)
await session.start()
self._sessions[key] = session
return session, True
existing = self._sessions.get(key)
if existing is not None and existing.alive:
return existing, False
if existing is not None: # dead remnant
# Close in the background: ending a dead leader's helpers can take the helper
# grace, and this reservation serializes every new chat.
self._sessions.pop(key, None)
self._close_in_background(existing)
if len(self._sessions) >= self._max:
self._reap_one_idle_or_raise()
# PTY spawn does blocking fork/exec work — keep it off the event loop.
# See #53227.
bridge = await asyncio.to_thread(spawn)
session = PtySession(
key,
bridge,
buffer_cap=self._buffer_cap,
read_timeout=self._read_timeout,
active_session_file=active_session_file,
drain_executor=self._drain_executor,
)
await session.start()
self._sessions[key] = session
return session, True
async def close_other_sessions(self, prefix: str, *, keep_key: str) -> None:
"""Close sessions belonging to the same logical client except ``keep_key``.
@@ -274,7 +446,7 @@ class PtySessionRegistry:
remain alive long enough to hold the TUI session lease and reject a
later return to that chat.
"""
async with self._attach_lock:
async with self._spawn_lock:
keys = [
key for key in self._sessions
if key != keep_key and (key == prefix or key.startswith(prefix + "\0"))
@@ -284,8 +456,11 @@ class PtySessionRegistry:
if session is not None:
# A sibling tab sharing the attach token may still be viewing this
# PTY: supersede it explicitly (4409) instead of leaving it silent
# until its next keystroke fails with 1013.
await _close_ws(session._ws, WS_CLOSE_SUPERSEDED)
# until its next keystroke fails with 1013. Detach first so close()
# does not overwrite that code with WS_CLOSE_PROCESS_EXITED.
ws = session._ws
session.detach(ws)
await _close_ws(ws, WS_CLOSE_SUPERSEDED)
await session.close()
async def close_orphaned_sessions(
@@ -308,7 +483,7 @@ class PtySessionRegistry:
# The requested chat is (profile, session): the same session id in
# another profile's store is a different chat whose terminal stays.
target = (_key_segments(keep_key)[0], resume)
async with self._attach_lock:
async with self._spawn_lock:
doomed = [
key for key, session in self._sessions.items()
if key != keep_key and not session.attached
@@ -326,20 +501,26 @@ class PtySessionRegistry:
if s is not None:
s.detach(ws)
def expired(self, s: PtySession, now: Optional[float] = None) -> bool:
"""Whether ``s`` has had no viewer for longer than the retention TTL."""
return (not s.attached and s.last_detached_at is not None
and (time.monotonic() if now is None else now) - s.last_detached_at > self._ttl)
def _reapable(self, s: PtySession, now: Optional[float] = None) -> bool:
# EOF never arrives if a helper still holds the PTY slave after the child died (#76759);
# ask the process itself (a WNOHANG waitpid).
return not s.alive or not s.bridge.is_alive() or self.expired(s, now)
async def reap_idle(self, now: Optional[float] = None) -> None:
now = time.monotonic() if now is None else now
doomed = [
key for key, s in self._sessions.items()
if not s.alive
or (not s.attached and s.last_detached_at is not None and (now - s.last_detached_at) > self._ttl)
# EOF never arrives if a helper still holds the PTY slave after the child died (#76759);
# ask the process itself (a WNOHANG waitpid).
or not s.bridge.is_alive()
]
for key in doomed:
doomed = [(key, s) for key, s in self._sessions.items() if self._reapable(s, now)]
for key, expected in doomed:
# Reaps overlap (attach_or_spawn and the background reaper) and close()
# awaits, so a concurrent reap can have popped this key already — skip
# it instead of raising KeyError into the websocket handler.
# it instead of raising KeyError into the websocket handler. A viewer can
# also re-attach during an earlier close, so re-check before closing.
if self._sessions.get(key) is not expected or not self._reapable(expected, now):
continue
session = self._sessions.pop(key, None)
if session is not None:
await session.close()
@@ -352,13 +533,19 @@ class PtySessionRegistry:
self._sessions.pop(oldest.key, None)
self._close_in_background(oldest)
def _close_in_background(self, session: PtySession) -> None:
def _close_in_background(self, session: PtySession) -> asyncio.Task:
task = asyncio.create_task(session.close())
self._background_closes.add(task)
task.add_done_callback(self._background_closes.discard)
return task
async def close_all(self) -> None:
# Close concurrently: each close() may wait out its helpers' SIGHUP grace, and shutdown runs
# under the backend's SIGTERM -> SIGKILL budget (dashboard_procs._POSIX_TERM_GRACE_SECONDS).
self._closed = True
# Wait for any admitted spawn before snapshotting shutdown ownership.
async with self._spawn_lock:
pass
if self._spawn_cleanup:
await asyncio.shield(asyncio.gather(*self._spawn_cleanup))
# Helper-process grace must run concurrently inside the backend shutdown budget.
sessions = [self._sessions.pop(key) for key in list(self._sessions)]
await asyncio.gather(*(s.close() for s in sessions), *self._background_closes, return_exceptions=True)
+135
View File
@@ -0,0 +1,135 @@
"""``hermes sessions adopt``: bring this profile's pre-gateway history under its gateway, as the host.
Since #106742 the profile's gateway owns its sessions. Chats written before that (main's CLI, TUI and
Desktop rows) carry no gateway receipt until one native, same-OS-user client resumes them, which
adopts them (``gateway/session_local_migration.py::adopt_legacy_session``). A Webapp browser reaches
the gateway through its dashboard mount as the login it authenticated, never natively, so it may not
make that first claim. Run this once on the host instead: it is what opening each old chat in the
native TUI or Desktop would do, and afterwards the browser continues every adopted chat through
shared operator access.
"""
from __future__ import annotations
from collections import Counter
import sys
_PROGRESS_EVERY = 25
def adoptable_sessions(path) -> list[dict]:
"""This store's unadopted pre-gateway local chats: one compression tip each, oldest first.
The same eligibility the owner applies on resume (``validate_legacy_row``), read-only, so the
count a dry run reports is what an adoption run attempts. A row the owner already bound is not
offered again, which makes a second run a no-op."""
from hermes_state import SessionDB
from hermes_state_local import POLICY_PREFIX
from hermes_state_local_migration import LEGACY_LOCAL_SOURCES, LEGACY_PREFIX, validate_legacy_row
from hermes_state_runtime import RuntimeStoreError
db = SessionDB(db_path=path, read_only=True)
try:
sources = tuple(sorted(LEGACY_LOCAL_SOURCES))
with db._read_ctx() as conn:
ids = [row[0] for row in conn.execute(
f"SELECT id FROM sessions WHERE source IN ({','.join('?' * len(sources))}) ORDER BY started_at, id",
sources)]
bound = {row[0] for row in conn.execute(
'SELECT key FROM state_meta WHERE key LIKE ? OR key LIKE ?', (POLICY_PREFIX + '%', LEGACY_PREFIX + '%'))}
found, seen = [], set()
for sid in ids:
tip_id = db.get_compression_tip(sid) or sid
if tip_id in seen:
continue
seen.add(tip_id)
tip = db.get_session(tip_id)
key = tip and (tip.get('chat_id') or tip_id)
if tip is None or POLICY_PREFIX + key in bound or LEGACY_PREFIX + key in bound:
continue
try:
validate_legacy_row(db, tip)
except RuntimeStoreError:
continue
found.append(tip)
return found
finally:
db.close()
async def adopt_sessions(rows, *, report=print) -> tuple[int, Counter]:
"""Resume each row once over this host's native connection (the owner adopts it), then detach
without ending it. ``(adopted, skipped-by-refusal)``; a row the owner refuses is skipped,
never retried as another principal."""
from hermes_cli.gateway_client import GatewayRPCError, connect_gateway
from hermes_cli.web_server_boundary import _DESKTOP_ATTACHMENT_WS_MAX_BYTES
adopted, skipped = 0, Counter()
# The owner answers a resume with the session's whole history; a long chat outgrows the default frame.
async with connect_gateway(max_size=_DESKTOP_ATTACHMENT_WS_MAX_BYTES) as client:
for done, row in enumerate(rows, 1):
try:
snapshot = await client.rpc('session.resume', session_id=row['id'])
except GatewayRPCError as exc:
skipped[_refusal(exc)] += 1
continue
await client.rpc('session.detach', session_id=snapshot.get('session_id') or row['id'],
subscription_id=snapshot['subscription_id'])
adopted += 1
if done % _PROGRESS_EVERY == 0:
report(f' {done}/{len(rows)} …')
return adopted, skipped
_SKIP_HINTS = {
'launch_credentials_unavailable': 'its saved model route is gone; open it once and pick a model',
'not_found': 'not adoptable here (another identity or profile owns it)',
}
def _refusal(exc) -> str:
"""One owner refusal as the report prints it: the reason code, then why. A field-level refusal
(a launch directory that no longer exists) names the field and the owner's bounded detail."""
reason, field, detail = str(exc), exc.data.get('field'), exc.data.get('detail')
why = _SKIP_HINTS.get(reason) or (f'{field}: {detail}' if field and detail else 'refused by the gateway')
return f'{reason} ({why})'
def cmd_adopt(args) -> int:
import asyncio
from websockets.exceptions import WebSocketException
from hermes_cli.gateway_client import GatewayClientError
from hermes_constants import display_hermes_home, get_hermes_home
path = get_hermes_home() / 'state.db'
if not path.exists():
print(f'No session database at {display_hermes_home()}/state.db (nothing to adopt).')
return 0
rows = adoptable_sessions(path)
if not rows:
print('Nothing to adopt: every local chat in this profile already belongs to its gateway.')
return 0
counts = Counter(row['source'] for row in rows)
print(f"{len(rows)} pre-gateway chat(s) to adopt ({', '.join(f'{n} {s}' for s, n in sorted(counts.items()))}).")
if args.dry_run:
print('Dry run: nothing was adopted.')
return 0
try:
adopted, skipped = asyncio.run(adopt_sessions(rows))
except (GatewayClientError, OSError, TimeoutError, WebSocketException) as exc:
message = str(exc) if isinstance(exc, GatewayClientError) else 'Gateway connection/read failed'
print('Error: ' + message, file=sys.stderr)
return 1
print(f'Adopted {adopted} chat(s).')
for refusal, count in sorted(skipped.items()):
print(f'Skipped {count}: {refusal}.')
return 0
def add_adopt_parser(sessions_subparsers) -> None:
parser = sessions_subparsers.add_parser(
'adopt', help="Bring this profile's pre-gateway chats under its gateway (run on the host)",
description="Chats written before the gateway owned sessions become gateway sessions the first "
"time a native client on this host opens them. This adopts them all at once, so remote "
"clients such as the Webapp can continue them. Run it as the gateway's OS user; use "
"`hermes -p <profile> sessions adopt` for another profile.")
parser.add_argument('--dry-run', action='store_true', help='Only count what would be adopted')
+6
View File
@@ -1254,11 +1254,17 @@ def _cmd_discard(args):
return cmd_discard(args)
def _cmd_adopt(args):
from hermes_cli.sessions_adopt import cmd_adopt
return cmd_adopt(args)
_PRE_DB_HANDLERS = {
"repair": _cmd_repair, "recover": _cmd_recover, "import": _cmd_import,
"repair-profiles": _cmd_repair_profiles, # opens every profile's store itself
"set-journal-mode": _cmd_set_journal_mode, # offline: must not open the store it converts
"discard": _cmd_discard, # a gateway control (prompt.resolve_unknown), never a direct store write
"adopt": _cmd_adopt, # native resumes on the owner; reads its own store read-only
}
_OBSERVATIONAL_DB_ACTIONS = frozenset({"list", "stats", "pinned"})
_DB_HANDLERS = {
+13 -3
View File
@@ -73,13 +73,17 @@ def run_source_script(project_root: Path, script: str, *args: str, env: dict, la
indent=" ", cwd=project_root, env=env)
def prepare_source_dependencies(project_root: Path, workspaces: tuple[str, ...], *, env: dict,
explicit: bool = False) -> None:
def _install_args(explicit: bool) -> tuple[str, ...]:
from pm import lazy_installs_allowed
return () if explicit or lazy_installs_allowed() else ("--no-install",)
def prepare_source_dependencies(project_root: Path, workspaces: tuple[str, ...], *, env: dict,
explicit: bool = False) -> None:
run_source_script(
project_root, "scripts/build/node-deps.mjs", "--source", str(project_root), "--reuse",
*(() if explicit or lazy_installs_allowed() else ("--no-install",)),
*_install_args(explicit),
*(arg for workspace in workspaces for arg in ("--workspace", workspace)), env=env,
label="Preparing Node dependencies",
)
@@ -112,6 +116,12 @@ def build_source_web(project_root: Path, *, env: dict, icons: Path | None = None
label="Building the web UI")
def build_source_webapp(project_root: Path, *, env: dict, explicit: bool = False) -> None:
"""Browser renderer; its dependencies live in a private workspace, never the checkout's."""
run_source_script(project_root, "apps/desktop/scripts/build-webapp.mjs", *_install_args(explicit),
env=env, label="Building the Webapp renderer")
def source_frontends(project_root: Path) -> tuple[str, ...]:
"""The frontend workspaces this checkout carries. A source slice without them
(python-only installs, the installer's acceptance fixture) has no products
+214
View File
@@ -0,0 +1,214 @@
"""Browser uploads staged in a profile home, and the receipt ``file.attach`` redeems.
``POST /api/chat/file-upload`` publishes a picked file at ``<profile home>/uploads/web-<hex>-<name>``
and returns a ``staged_upload`` descriptor: that path, the profile home and this installation's id.
The gateway's ``file.attach`` trusts the descriptor only after resolving it back to exactly that
layout, so the writer and the reader of the layout live here together.
A named profile that is missing or deleted is refused on both sides by the rule every named-home
write follows (``hermes_constants.assert_named_profile_home_live`` / ``mkdir_under_hermes_home``):
an upload never recreates a deleted home, and a receipt from one is never redeemed.
"""
from __future__ import annotations
from contextlib import contextmanager, suppress
import os
from pathlib import Path
import re
import secrets
import stat
import time
from fastapi import HTTPException
from hermes_constants import WEBAPP_ATTACHMENT_MAX_BYTES
from hermes_cli.install_identity import get_install_id
from hermes_cli.web_deps import late
_is_current_profile = late("_is_current_profile", "hermes_cli.web_server_profiles")
_resolve_profile_dir = late("_resolve_profile_dir", "hermes_cli.web_server_profiles")
_UPLOADS_DIR = "uploads"
_STAGED_PREFIX = "web-"
_CHUNK_BYTES = 1024 * 1024
_UPLOAD_RETENTION_SECONDS = 7 * 24 * 60 * 60
_SAFE_FILENAME = re.compile(r"[^A-Za-z0-9._-]+")
def _safe_filename(value: str | None) -> str:
name = Path(str(value or "attachment"))
# Sanitizing the whole name would turn an all-non-ASCII stem into a
# leading dot, then strip the extension separator needed by read_file.
stem = _SAFE_FILENAME.sub("-", name.stem).strip(".-") or "attachment"
suffix = _SAFE_FILENAME.sub("-", name.suffix).rstrip(".-")[:119]
return stem[:120 - len(suffix)] + suffix
def _prune_stale_uploads(root: Path, *, now: float | None = None) -> None:
"""Bound abandoned browser-picker staging without following symlinks."""
cutoff = (time.time() if now is None else now) - _UPLOAD_RETENTION_SECONDS
try:
entries = list(root.iterdir())
except OSError:
return
for entry in entries:
if not entry.name.startswith(_STAGED_PREFIX):
continue
try:
metadata = entry.stat(follow_symlinks=False)
if stat.S_ISREG(metadata.st_mode) and metadata.st_mtime < cutoff:
entry.unlink()
except OSError:
continue
@contextmanager
def removed_on_failure(target: Path):
"""Unlink a partially published file; a cleanup error never masks the cause."""
try:
yield
except BaseException:
with suppress(OSError):
target.unlink(missing_ok=True)
raise
def resolve_upload_home(profile: str | None) -> Path:
"""Resolve the profile home an upload is published into, with ``_profile_scope``'s 400/404s.
Without entering that scope: nothing here reads config or secrets, so an upload must not queue
on its process-wide skills lock or wait on its secret-source hydration. A named home that is
missing or deleted is a 404.
"""
from hermes_constants import assert_named_profile_home_live, get_hermes_home
if _is_current_profile(profile):
home = get_hermes_home()
else:
home = _resolve_profile_dir(profile.strip())
try:
assert_named_profile_home_live(home)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail="Profile home is unavailable") from exc
return home
def ensure_profile_subdir(
home: Path,
name: str,
*,
denied: str,
failed: str,
mode: int | None = None,
) -> Path:
"""``<home>/<name>``, created without ever recreating a missing or deleted named home.
That case is a 404 (``mkdir_under_hermes_home``). Other directory errors are 403 *denied* and
500 ``"<failed>: <exc>"``; *mode* is applied inside that mapping.
"""
from hermes_constants import mkdir_under_hermes_home
directory = home / name
try:
mkdir_under_hermes_home(directory)
if mode is not None:
directory.chmod(mode)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail="Profile was deleted during upload") from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail=denied) from exc
except OSError as exc:
raise HTTPException(status_code=500, detail=f"{failed}: {exc}") from exc
return directory
def publish_staged_upload(staged, profile_home: Path, filename: str) -> Path:
"""Publish staged bytes as a new owner-only file under ``<profile_home>/uploads``."""
from hermes_constants import assert_named_profile_home_live
upload_root = ensure_profile_subdir(
profile_home,
_UPLOADS_DIR,
denied="Upload directory is not writable",
failed="Could not create upload directory",
mode=0o700,
)
_prune_stale_uploads(upload_root)
target = upload_root / f"{_STAGED_PREFIX}{secrets.token_hex(8)}-{_safe_filename(filename)}"
try:
with removed_on_failure(target):
fd = os.open(
target,
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
0o600,
)
with os.fdopen(fd, "wb") as handle:
staged.seek(0)
while chunk := staged.read(_CHUNK_BYTES):
handle.write(chunk)
handle.flush()
os.fsync(handle.fileno())
# A deletion recorded while the bytes were written wins: never report a path in it.
assert_named_profile_home_live(profile_home)
return target
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail="Profile was deleted during upload") from exc
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not stage file: {exc}") from exc
def staged_upload_descriptor(path: Path, profile_home: Path) -> dict | None:
"""The ``staged_upload`` receipt for a published file, or None without an install identity."""
install_id = get_install_id()
if not install_id:
# Identity is already persisted by Hermes. If it is unavailable, keep
# the legacy path/byte-upload flow rather than invent an ephemeral id.
return None
return {
"install_id": install_id,
"path": str(path),
"profile_home": str(profile_home),
}
def parse_staged_upload(descriptor: object) -> tuple[str, str]:
"""``(profile_home, path)`` of a descriptor this installation issued.
Checked before the source is touched. A replacement backend at the same URL has a different
install id, and the source bytes alone never authorize reuse.
"""
if not isinstance(descriptor, dict):
raise ValueError("invalid staged upload")
install_id = get_install_id()
if not install_id or descriptor.get("install_id") != install_id:
raise ValueError("Staged attachment belongs to another Hermes backend; select the file again")
raw_home = descriptor.get("profile_home")
raw_path = descriptor.get("path")
if not isinstance(raw_home, str) or not raw_home or not isinstance(raw_path, str) or not raw_path:
raise ValueError("invalid staged upload source")
return raw_home, raw_path
def resolve_staged_upload(home: Path, raw_path: str) -> Path:
"""The staged regular file *raw_path* names under *home*, within the browser size cap.
The check is anchored on *home*, never on where the descriptor's path claims to point. A named
*home* that is missing or deleted raises ``FileNotFoundError``: its receipts are void.
"""
from hermes_constants import assert_named_profile_home_live
assert_named_profile_home_live(home)
source = Path(raw_path)
if source.is_symlink():
raise ValueError("staged upload is no longer a regular file")
source = source.resolve(strict=True)
if (
source.parent != (home / _UPLOADS_DIR).resolve(strict=True)
or not source.name.startswith(_STAGED_PREFIX)
or not source.is_file()
):
raise ValueError("staged upload is outside its source profile")
if source.stat().st_size > WEBAPP_ATTACHMENT_MAX_BYTES:
raise ValueError("staged upload exceeds the browser attachment size limit")
return source
+2
View File
@@ -26,6 +26,8 @@ def _add_rename_and_discard(sessions_subparsers) -> None:
sessions_rename.add_argument("title", nargs="+", help="New title for the session")
from hermes_cli.sessions_discard import add_discard_parser
add_discard_parser(sessions_subparsers)
from hermes_cli.sessions_adopt import add_adopt_parser
add_adopt_parser(sessions_subparsers)
def build_sessions_parser(subparsers, *, cmd_sessions: Callable) -> None:
+1 -1
View File
@@ -40,7 +40,7 @@ _ZIP_PRESERVED_TOP_LEVEL = {"venv", ".venv", "node_modules", ".git", ".env"}
# its own node_modules (electron itself), and the dashboard assets. The dirty-tree guard admits them and
# `_stage_entries` grafts the live copies into the staged tree so the swap keeps them (#90495).
_ZIP_PRESERVED_NESTED = {
"apps": ("desktop/release", "desktop/dist", "desktop/node_modules", "desktop/build"),
"apps": ("desktop/release", "desktop/dist", "desktop/dist-webapp", "desktop/node_modules", "desktop/build"),
"hermes_cli": ("web_dist",),
"scripts": ("whatsapp-bridge/node_modules",),
"ui-tui": ("dist", "node_modules", "packages/hermes-ink/dist"),
+161
View File
@@ -0,0 +1,161 @@
"""Host-terminal policy and process setup for the browser-hosted Desktop UI."""
from __future__ import annotations
import os
from collections.abc import Mapping
from pathlib import Path
from typing import Optional
META_PREFIX = "\0HERMES_TERMINAL_META:"
def request_allowed() -> bool:
"""Allow host shells only for Webapp on loopback or behind authentication."""
from hermes_cli.web_server import app
from hermes_cli.web_server_chat import _LOOPBACK_HOSTS
from hermes_cli.web_server_surface import policy
if not policy(app.state).host_terminal:
return False
if getattr(app.state, "auth_required", False):
return True
return (getattr(app.state, "bound_host", "") or "").strip().lower() in _LOOPBACK_HOSTS
def shell_command(candidate: str) -> Optional[str]:
"""Return an executable shell path for ``candidate``, or None.
A relative path is refused: the PTY chdirs into the workspace before exec,
so it would name a different file than the one checked here.
"""
from hermes_platform.resolver import locate_command
raw = (candidate or "").strip()
found = locate_command(raw).command if raw else ()
return found[0] if found else None
def shell_spec() -> tuple[list[str], str]:
"""Resolve the interactive-shell ladder the native Desktop uses (electron/terminal-ipc.ts).
POSIX honours ``$SHELL``, then /bin/zsh, /bin/bash, /bin/sh. Windows ignores ``$SHELL``
(usually a stray MSYS/Git path) and takes PowerShell 7, Windows PowerShell, ``COMSPEC``,
then cmd.exe. Electron's ``HERMES_DESKTOP_SHELL`` override is not read here: settings do
not ride on ``HERMES_*`` environment variables.
"""
if os.name == "nt":
system_root = os.environ.get("SystemRoot") or os.environ.get("windir") or r"C:\Windows"
windows_powershell = Path(system_root) / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
ladder = ("pwsh.exe", "pwsh", str(windows_powershell), "powershell.exe", os.environ.get("COMSPEC", ""))
fallback = "cmd.exe"
else:
ladder = (os.environ.get("SHELL", ""), "/bin/zsh", "/bin/bash", "/bin/sh")
fallback = "/bin/sh"
command = next(filter(None, map(shell_command, ladder)), fallback)
name = Path(command).name.lower()
if name.startswith(("pwsh", "powershell")):
args = ["-NoLogo"]
elif name.startswith("cmd"):
args = []
elif "zsh" in name or "bash" in name:
args = ["-il"]
else:
args = ["-i"]
return [command, *args], name
def safe_cwd(requested: Optional[str]) -> str:
fallback = Path.home()
try:
candidate = Path((requested or "").strip() or fallback).expanduser().resolve()
if candidate.is_dir():
return str(candidate)
if candidate.is_file():
return str(candidate.parent)
except (OSError, RuntimeError, ValueError):
pass
return str(fallback)
def terminal_lc_ctype(env: Mapping[str, str], platform: str) -> Optional[str]:
"""Return the LC_CTYPE a host shell's ``env`` needs added, or None to leave it alone.
macOS accepts the bare charset name ``UTF-8`` as a locale; glibc rejects it,
and LC_CTYPE outranks LANG, so forcing it on Linux breaks even a valid LANG.
There a set LANG is left in charge (copying it would pin a value a login rc
may still change) and only a missing one falls back to glibc's C.UTF-8.
Windows shells take their code page from the console, not LC_CTYPE. Desktop's
terminalLcCtype (electron/terminal-ipc.ts) shares the darwin and C.UTF-8 choices
but copies LANG into LC_CTYPE and does not defer to LC_ALL.
"""
if env.get("LC_ALL") or env.get("LC_CTYPE") or platform == "win32":
return None
if platform == "darwin":
return "UTF-8"
return None if env.get("LANG") else "C.UTF-8"
def resolve_argv(
*, home: Path, requested_cwd: Optional[str] = None,
) -> tuple[list[str], str, dict[str, str], str]:
"""Return argv/cwd/env/name for Webapp's authenticated host terminal in ``home``."""
from gateway.run import _profile_runtime_scope
from hermes_cli import __version__
from hermes_cli.config import TERMINAL_CONFIG_ENV_MAP
from hermes_constants import (
get_process_hermes_home, reset_hermes_home_override, set_hermes_home_override,
)
from hermes_platform.host.facts import os_family
from tools.environments.local import build_subprocess_env, served_profile_child_env
from tools.terminal_scope import enforce_no_refusal, get_terminal_scope
from tui_gateway.launch_profile_policy import (
activate_multi_profile_hosting, launch_profile_runtime_scope,
)
if home.resolve() != get_process_hermes_home().resolve():
activate_multi_profile_hosting()
scope = _profile_runtime_scope(home)
else:
scope = launch_profile_runtime_scope(home)
# The startup eager-multiplex guard applies even before a secondary's first
# request. Home alone cannot authorize passthrough reads; both shell paths
# need the same complete scope, including the frozen launch environment.
override_token = set_hermes_home_override(str(home))
try:
with scope:
enforce_no_refusal()
base_env = served_profile_child_env(target_home=home)
for env_var in TERMINAL_CONFIG_ENV_MAP.values():
base_env.pop(env_var, None)
terminal_scope = get_terminal_scope()
assert terminal_scope is not None # both runtime scopes bind the complete policy
base_env.update(terminal_scope)
env = build_subprocess_env(base=base_env, scrub_secrets=True)
finally:
reset_hermes_home_override(override_token)
for key in list(env):
if key == "npm_config_prefix" or key.startswith(("npm_config_", "npm_package_")):
env.pop(key, None)
for key in ("NO_COLOR", "FORCE_COLOR", "COLORFGBG"):
env.pop(key, None)
env["COLORTERM"] = "truecolor"
env["TERM"] = "xterm-256color"
env["TERM_PROGRAM"] = "Hermes"
env["TERM_PROGRAM_VERSION"] = __version__
env["HERMES_DESKTOP_TERMINAL"] = "1"
if lc_ctype := terminal_lc_ctype(env, os_family()):
env["LC_CTYPE"] = lc_ctype
argv, shell_name = shell_spec()
return argv, safe_cwd(requested_cwd), env, shell_name
def query_dimension(raw: Optional[str], default: int, maximum: int) -> int:
try:
value = int(raw or default)
except (TypeError, ValueError, OverflowError):
return default
return max(2, min(maximum, value))
+384
View File
@@ -0,0 +1,384 @@
"""Process-lifetime host shells, owned by Webapp identity and profile home.
This is a separate capability namespace from Dashboard's client-selected chat IDs.
The existing PTY registry owns processes/drains; this module owns host admission.
A shell dies with its profile: once a named home is missing or deleted (main's
tombstone rule, ``hermes_constants.assert_named_profile_home_live``) its input is
refused and the reaper closes it.
"""
from __future__ import annotations
import asyncio
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass
import json
from pathlib import Path
import re
import secrets
from fastapi import HTTPException, WebSocket, WebSocketDisconnect
from hermes_cli.pty_session import (
WS_CLOSE_VIEWER_STALLED, WS_CLOSE_VIEWER_STALLED_REASON, PtySession, PtySessionRegistry,
RegistryFull, _close_ws, run_reaper,
)
from hermes_cli.web_host_terminal import META_PREFIX
from hermes_constants import assert_named_profile_home_live, get_hermes_home
# No survival across backend restart. Disconnects retain a bounded ANSI tail.
RETENTION_SECONDS = 15 * 60
_MAX_SHELLS = 16
# Inbound frames buffered while one batch is written (backpressure bound).
_INPUT_BATCH_FRAMES = 64
TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{43}$")
class TerminalDenied(Exception):
pass
class TerminalExpired(Exception):
pass
def _home_live(home: Path) -> bool:
"""A named home must exist and carry no deletion tombstone; any other home only has to exist."""
try:
assert_named_profile_home_live(home)
except FileNotFoundError:
return False
return home.is_dir()
@dataclass(frozen=True)
class HostOwner:
identity: tuple[str, str]
home: Path
shell: str
cwd: str
def current(self) -> bool:
"""Blocking filesystem reads (home, tombstone): call off-loop."""
return _home_live(self.home)
def admit(self, write):
"""Run ``write`` while this owner's profile home is live, or raise ``TerminalExpired``.
Input sent once the profile is deleted never reaches the shell. Blocking: bridges call
it off-loop.
"""
if not self.current():
raise TerminalExpired()
return write()
def _request_identity(ws: WebSocket) -> tuple[str, str]:
identity = getattr(ws, "_hermes_auth_identity", None)
if identity:
user, provider = identity.get("user_id"), identity.get("provider")
if not isinstance(user, str) or not user or not isinstance(provider, str) or not provider:
raise TerminalDenied()
return provider, user
from hermes_cli.web_server import app
if getattr(app.state, "auth_required", False):
raise TerminalDenied()
# _ws_gate already validated the process's loopback credential.
return "loopback", "session-token"
def _request_home(profile: str | None) -> Path:
from hermes_cli.web_server_profiles import _resolve_profile_dir
requested = (profile or "").strip()
return (
_resolve_profile_dir(requested)
if requested and requested.lower() != "current" else get_hermes_home()
).resolve()
class HostTerminalRegistry(PtySessionRegistry):
def __init__(self):
# Every live shell keeps a read in flight. On the loop's default executor, retained
# shells would queue the keystroke fences, ownership checks, uploads and SessionDB
# hops that share it; a pool sized to capacity gives each shell its own reader.
super().__init__(
ttl=RETENTION_SECONDS, max_sessions=_MAX_SHELLS, buffer_cap=1024 * 1024, read_timeout=0.2,
drain_executor=ThreadPoolExecutor(_MAX_SHELLS, thread_name_prefix="host-pty-drain"))
self.owners: dict[str, HostOwner] = {}
def _reap_one_idle_or_raise(self) -> None:
# Retention is a promise, not an LRU hint; never evict a live shell to
# make room for a different pane, even if its viewer just disconnected.
raise RegistryFull()
async def create(self, ws: WebSocket, identity: tuple[str, str]):
from hermes_cli.web_host_terminal import query_dimension, resolve_argv
from hermes_cli.web_server_chat import PtyBridge
token = secrets.token_urlsafe(32)
owner = None
def spawn():
nonlocal owner
home = _request_home(ws.query_params.get("profile"))
if not _home_live(home):
raise TerminalExpired()
argv, cwd, env, shell = resolve_argv(home=home, requested_cwd=ws.query_params.get("cwd"))
bridge = PtyBridge.spawn(
argv, cwd=cwd, env=env,
cols=query_dimension(ws.query_params.get("cols"), 80, 2000),
rows=query_dimension(ws.query_params.get("rows"), 24, 1000))
owner = HostOwner(identity, home, shell, cwd)
return bridge
session, _ = await self.attach_or_spawn(token, spawn=spawn)
assert owner is not None # spawn completed
self.owners[token] = owner
if not await asyncio.to_thread(owner.current):
await self.remove(token)
raise TerminalExpired()
return session, owner
async def resolve(self, token: str, ws: WebSocket, identity: tuple[str, str], *, closing=False):
session = self._sessions.get(token)
owner = self.owners.get(token)
if session is None or owner is None:
raise TerminalExpired()
if owner.identity != identity:
raise TerminalDenied()
def validate():
if _request_home(ws.query_params.get("profile")) != owner.home:
raise TerminalDenied()
owner.admit(lambda: None)
await asyncio.to_thread(validate)
# No await between the final check and the caller's attach claim. The
# home was just checked off-loop; every input write checks it again.
if (self._sessions.get(token) is not session
or (not closing and (not session.alive or self.expired(session)))):
raise TerminalExpired()
return session, owner
async def remove(self, token: str):
session = self._sessions.pop(token, None)
self.owners.pop(token, None)
if session is not None:
await asyncio.shield(self._close_in_background(session))
async def reap_idle(self, now=None):
# Durable tombstones also detect deletion by a different process; attached
# viewers do not exempt a deleted profile.
# One thread hop per tick keeps every shell's marker reads off the loop.
owners = list(self.owners.items())
retired = await asyncio.to_thread(lambda: {token for token, owner in owners if not owner.current()})
for token, owner in owners:
session = self._sessions.get(token)
if session is not None and (token in retired or self._reapable(session, now)):
await self.remove(token)
for token in list(self.owners):
if token not in self._sessions:
self.owners.pop(token, None)
async def close_all(self):
await super().close_all()
self.owners.clear()
# Every drain is cancelled by now; a read still in flight returns within its
# timeout, so let it finish behind us. A closed registry never spawns again.
self._drain_executor.shutdown(wait=False)
def get_host_terminals(app) -> HostTerminalRegistry:
registry = getattr(app.state, "host_terminals", None)
if registry is None or registry._closed:
raise RuntimeError("Host terminal service is not running; start the application lifespan first.")
return registry
@asynccontextmanager
async def host_terminal_lifespan(app):
# Overlapping lifespans must not share loop-bound sessions or teardown.
registry = HostTerminalRegistry()
registries = getattr(app.state, "_host_terminal_registries", None)
if registries is None:
registries = app.state._host_terminal_registries = []
registries.append(registry)
app.state.host_terminals = registry
reaper = asyncio.create_task(run_reaper(registry, interval=1.0))
try:
yield
finally:
# Withdraw ownership before awaiting cleanup so another teardown cannot
# restore this registry while its reaper/sessions are shutting down.
registries.remove(registry)
if getattr(app.state, "host_terminals", None) is registry:
if registries:
app.state.host_terminals = registries[-1]
else:
del app.state.host_terminals
if not registries:
del app.state._host_terminal_registries
reaper.cancel()
with suppress(asyncio.CancelledError):
await reaper
await registry.close_all()
def _metadata(token, owner, session, registry, *, reconnected):
return META_PREFIX + json.dumps({
"shell": owner.shell, "terminalId": token, "cwd": owner.cwd,
"reconnected": reconnected, "retentionSeconds": registry._ttl,
"truncated": session.buffer.truncated,
}, separators=(",", ":"))
async def _send_closed(ws: WebSocket, token: str) -> None:
await ws.send_text(META_PREFIX + json.dumps({"terminalId": token, "closed": True}))
await ws.close(code=1000)
async def _admit(ws: WebSocket, registry: HostTerminalRegistry):
"""Authorize the request and return ``(session, owner)`` to attach.
Returns None once a ``close`` request has been served. Raises the ``Terminal*``
errors ``host_terminal`` maps to close codes.
"""
token = ws.query_params.get("attach")
action = ws.query_params.get("action")
identity = _request_identity(ws)
if action not in (None, "close") or (action == "close" and token is None):
raise TerminalDenied()
if token is not None and not TOKEN_RE.fullmatch(token):
raise TerminalExpired()
if action == "close" and token not in registry._sessions:
# Missing is idempotent, but never bypass auth/host policy.
await _send_closed(ws, token)
return None
if token is None:
return await registry.create(ws, identity)
session, owner = await registry.resolve(token, ws, identity, closing=action == "close")
if action == "close":
await registry.remove(token)
await _send_closed(ws, token)
return None
return session, owner
async def _read_frames(ws: WebSocket, inbox: asyncio.Queue) -> None:
"""Queue inbound frames. The last item is None, or the error that ended reading
(re-raised by ``_pump_input`` so the route's handlers still see it)."""
end = None
try:
while (msg := await ws.receive()).get("type") != "websocket.disconnect":
await inbox.put(msg)
except RuntimeError: # a superseding/drain task already closed us
pass
# health: allow BLE001 -- forwarded, not swallowed: _pump_input re-raises it, and a reader that died unqueued would hang the pump
except Exception as exc:
end = exc
await inbox.put(end)
def _input_runs(frames: list[dict], resize_re):
"""``frames`` in order: consecutive keystrokes joined into one run, resizes as ``(cols, rows)``."""
run = b""
for msg in frames:
raw = msg.get("bytes")
if raw is None:
raw = (msg.get("text") or "").encode("utf-8")
match = resize_re.fullmatch(raw) if raw else None
if match is None:
run += raw
continue
if run:
yield run
run = b""
yield int(match[1]), int(match[2])
if run:
yield run
async def _pump_input(ws: WebSocket, registry: HostTerminalRegistry, token: str,
session: PtySession, owner: HostOwner) -> None:
"""Forward keystrokes and resizes while ``ws`` is the attached viewer.
Keystrokes reach the shell only inside ``owner.admit``, so input sent after the
profile is deleted never does, even when it queued behind input that was admitted
first. The fence reads files, so it runs off the loop, once per run of keystrokes in
a batch (the frames that queued while the previous batch was written): a paste of
many frames costs a few fences, not one each.
Writes have no deadline. While the foreground program is not reading (``sleep``,
``npm install``) the PTY takes about 1 KB of input on macOS, so a paste waits for
that program, in order, with any Ctrl-C behind it, as in native Desktop's terminal.
A re-attach cancels the wait and keeps the shell; only a closed bridge (the shell
exited or the terminal was closed) or a deleted profile drops the session. ConPTY
buffers input itself, so Windows writes land at once and keep the bridge's deadline.
"""
from hermes_cli.web_server_chat import _RESIZE_RE
inbox: asyncio.Queue = asyncio.Queue(maxsize=_INPUT_BATCH_FRAMES)
reader = asyncio.create_task(_read_frames(ws, inbox))
try:
# No Ctrl-L/redraw input: arbitrary foreground shell programs own stdin.
while session._ws is ws and session.alive:
batch = [await inbox.get()]
while not inbox.empty():
batch.append(inbox.get_nowait())
frames = [msg for msg in batch if isinstance(msg, dict)]
for item in _input_runs(frames, _RESIZE_RE):
if isinstance(item, tuple):
session.resize(ws, cols=item[0], rows=item[1])
continue
try:
delivered = await session.write(ws, item, fence=owner.admit, timeout=None)
except (TerminalExpired, FileNotFoundError):
await registry.remove(token)
return
if not delivered:
# A closed bridge marked the session dead. A takeover only cancelled this
# write: drop the rest of the batch and leave this socket for the new viewer
# to supersede, so this tab does not reconnect and take the shell back.
if not session.alive:
await registry.remove(token)
return
break
if len(frames) < len(batch):
if isinstance(batch[-1], Exception):
raise batch[-1]
return
finally:
reader.cancel()
with suppress(asyncio.CancelledError):
await reader
async def host_terminal(ws: WebSocket) -> None:
"""Called only after both the existing WS gate and host policy have passed."""
from hermes_cli.web_server_chat import _pty_fail
session = None
try:
registry = get_host_terminals(ws.app)
admitted = await _admit(ws, registry)
if admitted is None:
return
session, owner = admitted
token = session.key
initial_text = _metadata(
token, owner, session, registry, reconnected=ws.query_params.get("attach") is not None)
if not await session.attach(ws, initial_text=initial_text):
if session._ws is None:
await _close_ws(ws, WS_CLOSE_VIEWER_STALLED, WS_CLOSE_VIEWER_STALLED_REASON)
return
await _pump_input(ws, registry, token, session, owner)
except TerminalDenied:
await ws.close(code=4403, reason="Terminal belongs to another identity or profile")
except (TerminalExpired, FileNotFoundError, HTTPException):
await ws.close(code=4410, reason="Terminal or profile expired; open a new terminal")
except RegistryFull:
await ws.close(code=1013, reason="Terminal capacity reached; close a terminal and retry")
except WebSocketDisconnect:
pass
except (OSError, RuntimeError) as exc:
await _pty_fail(ws, exc, surface="Terminal")
finally:
if session is not None:
session.detach(ws)
+7
View File
@@ -103,6 +103,13 @@ class ManagedFileDelete(BaseModel):
path: str
recursive: bool = False
class FileTicketRequest(BaseModel):
"""POST /api/files/ticket — the exact query the ticketed download/stream URL carries."""
route: Literal["download", "stream"]
path: str
profile: Optional[str] = None
session_id: Optional[str] = None
class ModelAssignment(BaseModel):
"""POST /api/model/set — assign a provider/model to a slot.
+14 -85
View File
@@ -19,10 +19,9 @@ from agent.interrupt_scope import InterruptScope, bind_interrupt_scope
from hermes_cli.active_sessions import ActiveSessionRegistryError, active_session_registry_snapshot
from hermes_cli.pty_session import RegistryFull
from hermes_cli.web_deps import LateState, late
from hermes_cli.web_routers.chat_ws_errors import chat_start_failure_message
from hermes_cli.web_server_chat import (
_build_sidecar_url, _close_stalled_pty_input, _get_console_executor, _legacy_pump, _ws_auth_ok,
_ws_request_is_allowed,
_build_sidecar_url, _close_stalled_pty_input, _get_console_executor, _legacy_pump, _pty_fail, _ws_auth_ok,
_ws_close_reason, _ws_gate, _ws_request_is_allowed,
)
_log = logging.getLogger("hermes_cli.web_server")
@@ -33,9 +32,6 @@ _active_session_file_for_channel = late("_active_session_file_for_channel", "her
_profile_scope = late("_profile_scope", "hermes_cli.web_server_profiles")
_resolve_chat_argv_async = late("_resolve_chat_argv_async", "hermes_cli.web_server_chat")
_resolve_profile_dir = late("_resolve_profile_dir", "hermes_cli.web_server_profiles")
_ws_auth_reason = late("_ws_auth_reason", "hermes_cli.web_server_chat")
_ws_client_reason = late("_ws_client_reason", "hermes_cli.web_server_chat")
_ws_host_origin_reason = late("_ws_host_origin_reason", "hermes_cli.web_server_chat")
_DASHBOARD_EMBEDDED_CHAT_ENABLED = LateState("_DASHBOARD_EMBEDDED_CHAT_ENABLED")
@@ -75,18 +71,6 @@ def _discard_active_session_file(app: FastAPI, channel: Optional[str], path: Opt
pass
def _ws_auth_mode() -> str:
"""Short label for the active WS auth mode — logged on every connection."""
from hermes_cli.web_server_chat import _LOOPBACK_HOSTS
from hermes_cli.web_server import app
if getattr(app.state, "auth_required", False):
return "gated"
bound_host = (getattr(app.state, "bound_host", "") or "").strip().lower()
if bound_host and bound_host not in _LOOPBACK_HOSTS:
return "insecure"
return "loopback"
async def _broadcast_event(app: Any, channel: str, payload: str) -> None:
"""Fan out one publisher frame to every subscriber on `channel`."""
event_channels, event_lock = _get_event_state(app)
@@ -114,57 +98,13 @@ def _read_active_session_file(path: Path) -> Optional[str]:
return str(data.get("session_id") or "").strip() or None
def _ws_close_reason(text: str) -> str:
"""Clamp to RFC 6455's 123-byte close-reason limit (uvicorn raises past it);
reasons embed an attacker-controlled origin, so truncate rather than crash."""
encoded = text.encode("utf-8", "replace")
if len(encoded) <= 123:
return text
return encoded[:120].decode("utf-8", "ignore") + "..."
async def _ws_gate(ws: WebSocket, kind: str) -> Optional[tuple[str, str, str]]:
"""Run the pre-accept gates for /api/console and /api/pty.
Each gate maps to a distinct close code so the log and the browser banner
agree on the cause: 4404 chat disabled, 4401 bad credential, 4403
host/origin mismatch, 4408 peer not allowed. Returns ``(peer, mode, cred)``
once every gate passes, or None after closing the socket.
"""
peer = ws.client.host if ws.client else "?"
if not _DASHBOARD_EMBEDDED_CHAT_ENABLED:
_log.info("%s refused: embedded chat disabled peer=%s", kind, peer)
await ws.close(code=4404, reason="embedded chat disabled")
return None
auth_reason, cred = _ws_auth_reason(ws)
mode = _ws_auth_mode()
if auth_reason is not None:
_log.warning("%s auth rejected reason=%s mode=%s cred=%s peer=%s", kind, auth_reason, mode, cred, peer)
await ws.close(code=4401, reason=_ws_close_reason(f"auth: {auth_reason}"))
return None
host_origin_reason = _ws_host_origin_reason(ws)
if host_origin_reason is not None:
_log.warning("%s refused: %s peer=%s", kind, host_origin_reason, peer)
await ws.close(code=4403, reason=_ws_close_reason(host_origin_reason))
return None
client_reason = _ws_client_reason(ws)
if client_reason is not None:
_log.warning("%s refused: %s", kind, client_reason)
await ws.close(code=4408, reason=_ws_close_reason(client_reason))
return None
return peer, mode, cred
async def _close_unless_sidecar_allowed(ws: WebSocket) -> bool:
async def _close_unless_sidecar_allowed(ws: WebSocket, *, allow_internal: bool = False) -> bool:
"""Pre-accept gates for the /api/ws, /api/pub and /api/events sidecars:
4403 when chat is disabled or the request isn't allowed, 4401 on bad auth."""
if not _DASHBOARD_EMBEDDED_CHAT_ENABLED:
await ws.close(code=4403)
return False
if not _ws_auth_ok(ws):
if not _ws_auth_ok(ws, allow_internal=allow_internal):
await ws.close(code=4401)
return False
if not _ws_request_is_allowed(ws):
@@ -449,14 +389,6 @@ async def console_ws(ws: WebSocket) -> None:
pass
async def _pty_fail(ws: WebSocket, exc: BaseException) -> None:
"""Tell the user why chat could not start, then close 1011 so the SPA renders
"Start new session". The raw exception goes to the server log only."""
_log.warning("pty start failed: %s: %s", type(exc).__name__, exc)
await ws.send_text(f"\r\n\x1b[31m{chat_start_failure_message(exc)}\x1b[0m\r\n")
await ws.close(code=1011)
def _lease_holder_pid(session_id: Optional[str], *, registry_home: Optional[str] = None) -> Optional[int]:
"""The live process holding ``session_id``'s single-writer lease, if any.
@@ -500,10 +432,13 @@ async def _pump_keepalive_input(ws: WebSocket, session: Any) -> None:
# Resize escape is consumed locally, never written to the PTY.
match = _RESIZE_RE.match(raw)
if match and match.end() == len(raw):
session.bridge.resize(cols=int(match.group(1)), rows=int(match.group(2)))
session.resize(ws, cols=int(match.group(1)), rows=int(match.group(2)))
continue
if not await session.write(ws, raw):
await _close_stalled_pty_input(ws, path="keepalive")
# Only a stalled or exited child marks the session dead; a takeover just cancelled
# this write, and the new viewer closes this socket as superseded.
if not session.alive:
await _close_stalled_pty_input(ws, path="keepalive")
break
@@ -548,13 +483,7 @@ async def pty_ws(ws: WebSocket) -> None:
# Native Windows can't import the POSIX PTY bridge: say so and close cleanly.
if not _PTY_BRIDGE_AVAILABLE:
await ws.send_text(
"\r\n\x1b[31mChat unavailable: the embedded terminal requires a "
"POSIX PTY, which native Windows Python doesn't provide.\x1b[0m\r\n"
"\x1b[33mInstall Hermes inside WSL2 to use the dashboard's /chat "
"tab — the rest of the dashboard works here.\x1b[0m\r\n"
)
await ws.close(code=1011)
await _pty_fail(ws, PtyUnavailableError("Pseudo-terminal support is not installed on this host."))
return
raw_resume = ws.query_params.get("resume") or None
@@ -701,7 +630,7 @@ async def pty_ws(ws: WebSocket) -> None:
@router.websocket("/api/ws")
async def gateway_ws(ws: WebSocket) -> None:
if not await _close_unless_sidecar_allowed(ws):
if not await _close_unless_sidecar_allowed(ws, allow_internal=True):
return
from hermes_cli.mcp_startup import start_deferred_mcp_discovery_now
from tui_gateway.ws import handle_ws
@@ -741,8 +670,8 @@ async def gateway_ws(ws: WebSocket) -> None:
# child's stdio handshake with Ink.
async def _accept_channel_ws(ws: WebSocket) -> Optional[str]:
if not await _close_unless_sidecar_allowed(ws):
async def _accept_channel_ws(ws: WebSocket, *, allow_internal: bool = False) -> Optional[str]:
if not await _close_unless_sidecar_allowed(ws, allow_internal=allow_internal):
return None
channel = _channel_or_close_code(ws)
if not channel:
@@ -754,7 +683,7 @@ async def _accept_channel_ws(ws: WebSocket) -> Optional[str]:
@router.websocket("/api/pub")
async def pub_ws(ws: WebSocket) -> None:
channel = await _accept_channel_ws(ws)
channel = await _accept_channel_ws(ws, allow_internal=True)
if channel is None:
return
try:
+9 -9
View File
@@ -14,15 +14,15 @@ from fastapi import HTTPException
from hermes_cli.pty_session import RegistryFull
CHAT_NEEDS_NODE = (
"Chat could not start: Hermes needs Node.js to run the terminal chat. "
"{surface} could not start: Hermes needs Node.js to run the terminal chat. "
"Install Node 18+ (for example from nodejs.org) and reopen this tab."
)
CHAT_TOO_MANY_TERMINALS = (
"Chat could not start: too many chat terminals are open in other tabs. "
"{surface} could not start: too many chat terminals are open in other tabs. "
"Close one and click Start new session."
)
CHAT_PROFILE_UNKNOWN = "Chat could not start: {detail} Pick another profile from the switcher and reopen this tab."
CHAT_START_FAILED = "Chat could not start: {detail} Check the server log (`hermes dashboard` terminal) and click Start new session."
CHAT_PROFILE_UNKNOWN = "{surface} could not start: {detail} Pick another profile from the switcher and reopen this tab."
CHAT_START_FAILED = "{surface} could not start: {detail} Check the server log (`hermes dashboard` terminal) and click Start new session."
def _node_missing(exc: BaseException) -> bool:
@@ -30,15 +30,15 @@ def _node_missing(exc: BaseException) -> bool:
return isinstance(exc, SystemExit) or "'node'" in text or "'npm'" in text or "Node.js" in text
def chat_start_failure_message(exc: BaseException) -> str:
def chat_start_failure_message(exc: BaseException, *, surface: str = "Chat") -> str:
"""One plain sentence for the terminal pane; never the bare exception."""
if isinstance(exc, RegistryFull):
return CHAT_TOO_MANY_TERMINALS
return CHAT_TOO_MANY_TERMINALS.format(surface=surface)
if isinstance(exc, HTTPException):
return CHAT_PROFILE_UNKNOWN.format(detail=_sentence(str(exc.detail)))
return CHAT_PROFILE_UNKNOWN.format(surface=surface, detail=_sentence(str(exc.detail)))
if _node_missing(exc):
return CHAT_NEEDS_NODE
return CHAT_START_FAILED.format(detail=_sentence(str(exc) or type(exc).__name__))
return CHAT_NEEDS_NODE.format(surface=surface)
return CHAT_START_FAILED.format(surface=surface, detail=_sentence(str(exc) or type(exc).__name__))
def _sentence(text: str) -> str:
+161 -24
View File
@@ -23,17 +23,20 @@ from pathlib import Path
from typing import Any, Dict, Optional
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from hermes_cli import web_server_file_tickets as file_tickets
from hermes_cli._subprocess_compat import windows_hide_flags
from hermes_cli.sqlite_safe_read import LiveConnectionError, offline_file_access
from hermes_cli.staged_uploads import ensure_profile_subdir, removed_on_failure, resolve_upload_home
from hermes_cli.web_deps import late
from hermes_cli.web_server_files import (
SANDBOX_NOT_FOUND, _agent_fs_path, _fs_path, _hosted_fs_read_guard, _is_unmapped_container_path,
_managed_file_entry, _managed_response_meta, _resolve_managed_path,
)
from hermes_cli.web_models import (
ChatImageUpload, FsWriteText, ManagedDirectoryCreate, ManagedFileDelete, ManagedFileUpload,
ChatImageUpload, FileTicketRequest, FsWriteText, ManagedDirectoryCreate, ManagedFileDelete,
ManagedFileUpload,
)
router = APIRouter()
@@ -285,6 +288,35 @@ def _fs_backend(profile: Optional[str] = None):
return get_ssh_workspace_fs(profile_key, terminal)
def _chat_image_dir(home: Path) -> Path:
"""Where browser image pastes land: the profile's canonical image staging dir on this host
(``gateway.platforms.base.get_image_cache_dir``), the only one attachment admission accepts."""
from hermes_constants import get_hermes_dir
return get_hermes_dir("cache/images", "image_cache", home=home)
def _is_host_image(path: str, profile: Optional[str]) -> bool:
"""Whether *path* is in the profile's image cache, which lives on this host even when the
workspace is an SSH machine: a browser paste is saved there, then read back to be sent."""
try:
target = Path(path).resolve(strict=True)
except (OSError, RuntimeError):
return False # not on this host: the workspace's to answer
with _profile_scope(profile):
cache = _chat_image_dir(get_hermes_home())
try:
return target.parent == cache.resolve(strict=True)
except (OSError, RuntimeError):
return False
def _remote_fs_backend(path: str, profile: Optional[str]):
"""The SSH workspace reader for *path*, or None when this host owns it."""
backend = _fs_backend(profile)
return None if backend is None or _is_host_image(path, profile) else backend
def _raise_fs_backend_error(exc: Exception) -> None:
from hermes_cli.ssh_workspace_fs import SshWorkspaceFsError
@@ -485,18 +517,29 @@ async def upload_chat_image(payload: ChatImageUpload, profile: Optional[str] = N
"""
def _run():
data, mime_type, ext = _decode_chat_image_upload(payload)
with _profile_scope(profile):
from gateway.platforms.base import get_image_cache_dir
img_dir = get_image_cache_dir()
with _io_errors("Image directory is not writable", "Could not create image directory"):
img_dir.mkdir(parents=True, exist_ok=True)
home = resolve_upload_home(profile)
from hermes_constants import apply_secure_dir_policy, assert_named_profile_home_live
stem = Path(_sanitize_chat_image_filename(payload.filename)).stem or "pasted-image"
stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", stem).strip("._-") or "pasted-image"
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
target = img_dir / f"dashboard_{ts}_{secrets.token_hex(4)}_{stem}{ext}"
img_dir = ensure_profile_subdir(
home,
str(_chat_image_dir(home).relative_to(home)),
denied="Image directory is not writable",
failed="Could not create image directory",
)
apply_secure_dir_policy(img_dir, home=home)
stem = Path(_sanitize_chat_image_filename(payload.filename)).stem or "pasted-image"
stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", stem).strip("._-") or "pasted-image"
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
target = img_dir / f"dashboard_{ts}_{secrets.token_hex(4)}_{stem}{ext}"
with removed_on_failure(target):
with _io_errors("Image directory is not writable", "Could not write image"):
target.write_bytes(data)
try:
# A deletion recorded while the bytes were written wins: never report a path in it.
assert_named_profile_home_live(home)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail="Profile was deleted during upload") from exc
return {
"ok": True,
@@ -506,9 +549,8 @@ async def upload_chat_image(payload: ChatImageUpload, profile: Optional[str] = N
"mime_type": mime_type,
}
# _profile_scope takes _SKILLS_PROFILE_LOCK and the body does file I/O — both
# off the loop; to_thread copies the contextvar context so the override
# stays scoped to the worker thread.
# The body does file I/O: keep it off the loop. It resolves the profile home without
# entering _profile_scope, so a paste never queues behind its process-wide skills lock.
return await asyncio.to_thread(_run)
@@ -538,13 +580,17 @@ async def list_managed_files(request: Request, path: Optional[str] = None):
return {"path": display_path, "parent": parent, "entries": entries, **_managed_response_meta(policy)}
def _managed_readable_file(request: Request, path: str) -> tuple[Any, Path, str, int, str]:
def _managed_readable_file(
request: Request, path: str, *, create_root: bool = True,
) -> tuple[Any, Path, str, int, str]:
"""Resolve + guard a managed file for reading: existence, regular file,
sensitive-path denylist. Returns (policy, target, display_path, max_bytes,
mime_type). Callers own the live-SQLite 409 (held through the read for
/api/files/read, point-in-time for streamed responses)."""
from hermes_cli.web_server import _MANAGED_FILE_MAX_BYTES
policy, target, display_path = _resolve_managed_path(path, request, for_agent_read=True)
policy, target, display_path = _resolve_managed_path(
path, request, create_root=create_root, for_agent_read=True,
)
if not target.exists():
raise HTTPException(status_code=404, detail="File not found")
if not target.is_file():
@@ -589,6 +635,18 @@ async def _managed_file_response(
media_only: bool = False,
) -> FileResponse:
"""Range-aware response after applying managed-file policy."""
authority = getattr(request.state, "file_ticket_authority", None)
if authority is not None:
from hermes_cli.web_server_file_response import OpenedFileResponse
return OpenedFileResponse(
path=str(authority.target),
opener=lambda: _open_ticket_file(request, authority, media_only=media_only),
media_type=_fs_mime_type(authority.target),
filename=authority.target.name,
content_disposition_type=content_disposition_type,
headers={"X-Content-Type-Options": "nosniff"} if media_only else None,
)
_policy, target, _display_path, max_bytes, mime_type = _managed_readable_file(request, path)
if media_only and target.suffix.lower() not in _STREAMABLE_MEDIA_EXTENSIONS:
raise HTTPException(status_code=415, detail="Unsupported media type")
@@ -603,16 +661,92 @@ async def _managed_file_response(
)
def _open_ticket_file(request: Request, authority: file_tickets.FileAuthority, *, media_only: bool):
"""Check the ticket's profile homes are still live and pin the file the ticket names."""
try:
authority.check_live()
_policy, target, _display, max_bytes, _mime = _managed_readable_file(
request, str(authority.target), create_root=False,
)
if target != authority.target:
raise HTTPException(status_code=403, detail="File ticket target changed")
if media_only and target.suffix.lower() not in _STREAMABLE_MEDIA_EXTENSIONS:
raise HTTPException(status_code=415, detail="Unsupported media type")
_refuse_live_database(target)
handle = target.open("rb")
try:
opened = os.fstat(handle.fileno())
if not stat.S_ISREG(opened.st_mode):
raise HTTPException(status_code=400, detail="Only regular files can be read")
if opened.st_size > max_bytes:
raise HTTPException(status_code=413, detail="File is too large")
return handle
except BaseException:
handle.close()
raise
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail="File ticket profile or resource is unavailable") from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail="File is not readable") from exc
@router.post("/api/files/ticket")
async def issue_file_ticket(payload: FileTicketRequest, request: Request):
"""Ticket a browser download/media URL instead of putting the session token in it."""
from hermes_cli.web_routers.sessions import _history_profile_home
from hermes_constants import named_profile_home
def owner_home():
return Path(_history_profile_home(payload.profile))
def capture():
_policy, target, _display, _max, _mime = _managed_readable_file(request, path, create_root=False)
homes = [owner]
target_home = named_profile_home(target)
if target_home is not None and target_home not in homes:
homes.append(target_home)
authority = file_tickets.FileAuthority(target, tuple(homes))
authority.check_live()
return authority
try:
# Capture the session/profile owner BEFORE looking up its cwd. The file
# may be outside that home, but the ticket must still die with its owner.
owner = await asyncio.to_thread(owner_home)
path = payload.path
if payload.session_id is not None or path.lower().startswith("file:"):
path = str(await _fs_download_path(path, payload.profile, payload.session_id))
authority = await asyncio.to_thread(capture)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail="File ticket profile is unavailable") from exc
query = {"path": payload.path, "profile": payload.profile, "session_id": payload.session_id}
ticket = file_tickets.mint(
payload.route, {key: value for key, value in query.items() if value is not None}, authority=authority,
)
return JSONResponse({"ticket": ticket}, headers={"Cache-Control": "no-store"})
@router.get("/api/files/download")
async def download_managed_file(request: Request, path: str):
@router.head("/api/files/download")
async def download_managed_file(
request: Request, path: str, profile: Optional[str] = None, session_id: Optional[str] = None,
):
"""Stream a managed file as an attachment download.
``auth_middleware`` also accepts the session token as ``?token=`` here so a
shell/browser-opened download (no session header) still authenticates.
``auth_middleware`` also accepts a path-bound ``?ticket=`` (browsers) or the
session token as ``?token=`` (Electron's remote external link) here so a
download without the session header still authenticates.
Chromium marks ``<audio>``/``<video>`` subresource requests via
``Sec-Fetch-Dest``; those are served inline for Desktop builds that still
use this route as their player source, attachment semantics otherwise.
"""
# Match Desktop's session/host-OS path resolution, then retain the managed
# root, sensitive-file and size checks. Unscoped managed-relative paths
# still resolve under the configured files root, not the server cwd.
if getattr(request.state, "file_ticket_authority", None) is None and (
session_id is not None or path.lower().startswith("file:")
):
path = str(await _fs_download_path(path, profile, session_id))
fetch_destination = request.headers.get("sec-fetch-dest", "").lower()
is_media_subresource = fetch_destination in {"audio", "video"}
return await _managed_file_response(
@@ -625,11 +759,14 @@ async def download_managed_file(request: Request, path: str):
@router.get("/api/files/stream")
@router.head("/api/files/stream")
async def stream_managed_file(request: Request, path: str):
async def stream_managed_file(request: Request, path: str, profile: Optional[str] = None):
"""Stream managed audio/video inline with HTTP Range support — Electron's
media pipeline may reject an attachment response as an ``<audio>``/
``<video>`` source. Same auth, size cap, sensitive guard and MIME detection
as download."""
``<video>`` source. Browser media elements authenticate with a reusable
path-bound ``?ticket=``; the session token is never accepted in this URL.
Same size cap, sensitive guard and MIME detection as download."""
if getattr(request.state, "file_ticket_authority", None) is None and path.lower().startswith("file:"):
path = str(await _fs_download_path(path, profile, None))
return await _managed_file_response(request, path, content_disposition_type="inline", media_only=True)
@@ -928,7 +1065,7 @@ async def fs_read_data_url(
path: str, request: Request, profile: Optional[str] = None, session_id: Optional[str] = None,
):
from hermes_cli.web_server import _FS_DATA_URL_MAX_BYTES
backend = await asyncio.to_thread(_fs_backend, profile)
backend = await asyncio.to_thread(_remote_fs_backend, path, profile)
if backend is not None:
try:
data, _size, target = await asyncio.to_thread(
@@ -953,7 +1090,7 @@ async def fs_read_data_url(
async def fs_download(
path: str, request: Request = None, profile: Optional[str] = None, session_id: Optional[str] = None,
):
backend = await asyncio.to_thread(_fs_backend, profile)
backend = await asyncio.to_thread(_remote_fs_backend, path, profile)
if backend is not None:
try:
target, _size = await asyncio.to_thread(backend.inspect_file, path)
+55
View File
@@ -0,0 +1,55 @@
"""Authenticated browser-to-host attachment staging for Hermes Webapp."""
from __future__ import annotations
import asyncio
import os
from fastapi import APIRouter, File, HTTPException, UploadFile
from hermes_constants import WEBAPP_ATTACHMENT_MAX_BYTES
from hermes_cli.staged_uploads import (
publish_staged_upload,
resolve_upload_home,
staged_upload_descriptor,
)
router = APIRouter()
_MAX_UPLOAD_BYTES = WEBAPP_ATTACHMENT_MAX_BYTES
@router.post("/api/chat/file-upload")
async def upload_chat_file(
file: UploadFile = File(...),
profile: str | None = None,
):
"""Stage one user-selected browser file under the active Hermes profile.
The client never chooses a server path. A unique 0600 file under
``$HERMES_HOME/uploads`` is returned for the existing ``file.attach`` flow.
"""
profile_home = await asyncio.to_thread(resolve_upload_home, profile)
try:
# FastAPI has already spooled the complete upload outside the profile.
# Check the actual bytes, then publish that spool into the resolved home.
total = await asyncio.to_thread(file.file.seek, 0, os.SEEK_END)
if total > _MAX_UPLOAD_BYTES:
cap_mib = _MAX_UPLOAD_BYTES // (1024 * 1024)
raise HTTPException(
status_code=413,
detail=f"File is too large; cap is {cap_mib} MiB",
)
target = await asyncio.to_thread(
publish_staged_upload,
file.file,
profile_home,
file.filename or "attachment",
)
finally:
await file.close()
result = {"ok": True, "path": str(target), "size": total}
if descriptor := await asyncio.to_thread(staged_upload_descriptor, target, profile_home):
result["staged_upload"] = descriptor
return result
+324
View File
@@ -0,0 +1,324 @@
"""Webapp-only routes: the host terminal (loopback or authenticated binds), the
one-use ticket each browser socket dial mints (``/api/webapp/gateway-ticket``, for
``/api/ws`` on this same origin), and the one-use handoffs (child window, auto-opened
launch tab) for the private, loopback Webapp session."""
from __future__ import annotations
import ipaddress
import json
import logging
import os
import secrets
import sys
import threading
import time
from fastapi import APIRouter, HTTPException, Request, WebSocket
from fastapi.responses import HTMLResponse, JSONResponse
from hermes_cli.dashboard_auth.request_utils import _http_origin
from hermes_cli.web_server_surface import policy, private_launch
_log = logging.getLogger("hermes_cli.web_server")
router = APIRouter()
WINDOW_TICKET_TTL_SECONDS = 30
#: A sandboxed (snap/Flatpak) browser's cold start can take several seconds to reach the page.
LAUNCH_TICKET_TTL_SECONDS = 60
_MAX_WINDOW_TICKETS = 128
_ticket_lock = threading.Lock()
def _require_private_webapp(request: Request) -> None:
if not private_launch(request.app.state):
raise HTTPException(404, "Private Webapp window handoff is unavailable")
@router.websocket("/api/host-terminal")
async def host_terminal_ws(ws: WebSocket) -> None:
from hermes_cli import web_host_terminal
from hermes_cli.web_host_terminal_sessions import host_terminal
from hermes_cli.web_server_chat import _PTY_BRIDGE_AVAILABLE, PtyUnavailableError, _pty_fail, _ws_gate
gate = await _ws_gate(ws, "host-terminal")
if gate is None:
return
peer, mode, cred = gate
if not web_host_terminal.request_allowed():
await ws.close(code=4403, reason="host terminal requires authenticated Webapp")
return
await ws.accept()
_log.info("host terminal accepted peer=%s mode=%s cred=%s", peer, mode, cred)
if not _PTY_BRIDGE_AVAILABLE:
await _pty_fail(
ws, PtyUnavailableError("Pseudo-terminal support is not installed on this host."), surface="Terminal")
return
await host_terminal(ws)
def _mint_handoff_ticket(state, ttl: float, *, launch: bool) -> str:
"""One-use ticket that ``/webapp/window-session`` exchanges for this start's session token."""
from hermes_cli.web_server import _SESSION_TOKEN
now = time.monotonic()
with _ticket_lock:
tickets = getattr(state, "webapp_window_tickets", {})
tickets = {key: value for key, value in tickets.items()
if value[0] > now and value[1] == _SESSION_TOKEN}
state.webapp_window_tickets = tickets
if len(tickets) >= _MAX_WINDOW_TICKETS:
raise HTTPException(429, "Too many pending Webapp windows; try again shortly")
ticket = secrets.token_urlsafe(32)
tickets[ticket] = (now + ttl, _SESSION_TOKEN, launch)
return ticket
def mint_launch_ticket(state) -> str:
"""Ticket for the browser the server opens itself: a launcher's argv carries this, not the token."""
return _mint_handoff_ticket(state, LAUNCH_TICKET_TTL_SECONDS, launch=True)
def _loopback_peer_uid(client: tuple[str, int], server: tuple[str, int]) -> int | None:
"""Owner UID of the client end of a local TCP connection, from Linux ``/proc/net/tcp*``.
Both ends of a loopback connection are listed with their socket owner; the client's is
the row whose local address is *client* and remote address is *server*. None when absent
(another network namespace, a closed connection) or unreadable.
"""
def _normal(host: str | bytes, port: int):
address = ipaddress.ip_address(host)
return getattr(address, "ipv4_mapped", None) or address, port
def _row_address(field: str):
host_hex, port_hex = field.split(":")
raw = bytes.fromhex(host_hex)
# The kernel prints each 32-bit word of the address in host byte order.
packed = b"".join(int.from_bytes(raw[i:i + 4], "big").to_bytes(4, sys.byteorder)
for i in range(0, len(raw), 4))
return _normal(packed, int(port_hex, 16))
try:
wanted = (_normal(*client), _normal(*server))
except ValueError:
return None
for table in ("/proc/net/tcp", "/proc/net/tcp6"):
try:
with open(table, encoding="ascii") as stream:
rows = stream.read().splitlines()[1:]
except OSError:
continue
for row in rows:
fields = row.split()
try:
if (_row_address(fields[1]), _row_address(fields[2])) == wanted:
return int(fields[7])
except (IndexError, ValueError):
continue
return None
def _peer_is_server_user(request: Request) -> bool:
"""Whether this request's connection belongs to the OS user running the server.
Linux authenticates the connection owner. macOS has no authenticated
launch handoff here, so an argv-carried launch ticket cannot be redeemed.
Authenticated child-window tickets do not use this launcher channel.
"""
if sys.platform == "darwin":
return False
if sys.platform != "linux":
return True
client, server = request.client, request.scope.get("server")
if client is None or not server:
return False
return _loopback_peer_uid((client.host, client.port), (server[0], server[1])) == os.getuid() # windows-footgun: ok — Linux-only branch
@router.post("/api/webapp/window-ticket")
async def issue_window_ticket(request: Request):
from hermes_cli.web_server import _require_token
_require_private_webapp(request)
_require_token(request)
ticket = _mint_handoff_ticket(request.app.state, WINDOW_TICKET_TTL_SECONDS, launch=False)
return JSONResponse({"ticket": ticket}, headers={"Cache-Control": "no-store"})
def _ticket_principal(request: Request) -> dict:
"""``{user_id, provider, issuer}`` of the caller: its verified login, or on an unauthenticated
loopback bind the session-token owner, the identity ``chat_ws.gateway_ws`` gives a ``?token=``
socket (and REST mutations are forwarded as)."""
session = getattr(request.state, "session", None)
if session is not None:
return {"user_id": session.user_id, "provider": session.provider,
"issuer": getattr(session, "issuer", "") or ""}
if not getattr(request.app.state, "auth_required", False):
from hermes_cli.web_server import _has_valid_session_token
if _has_valid_session_token(request):
return {"user_id": "legacy-token-owner", "provider": "session-token", "issuer": ""}
raise HTTPException(status_code=401, detail="Unauthorized")
@router.post("/api/webapp/gateway-ticket")
async def issue_gateway_ticket(request: Request, profile: str | None = None):
"""The browser's one-use ticket for its live socket: ``/api/ws`` on this same origin, offered
as the ``hermes-gateway-ticket.`` subprotocol and redeemed by the upgrade gate
(``web_server_chat._consume_ws_ticket``) as this caller's login. Minted locally: the server
that serves this page runs the socket's sessions. *profile* is validated (400/404) so a stale
tab learns its profile is gone here rather than from a socket that keeps closing."""
from hermes_cli.dashboard_auth.ws_tickets import TTL_SECONDS, mint_ticket
from hermes_cli.web_server_cron import _cron_profile_home
if not policy(request.app.state).gateway_ticket:
raise HTTPException(404, "Webapp gateway tickets are unavailable on this surface")
identity = _ticket_principal(request)
_cron_profile_home(profile)
ticket = mint_ticket(user_id=identity["user_id"], provider=identity["provider"], issuer=identity["issuer"])
return JSONResponse({"ticket": ticket, "ttl_seconds": TTL_SECONDS}, headers={"Cache-Control": "no-store"})
@router.post("/webapp/window-session")
async def redeem_window_ticket(request: Request):
from hermes_cli.web_server import _SESSION_TOKEN
_require_private_webapp(request)
# This endpoint deliberately sits outside /api: the child window or launch
# tab has no session yet. Only a purpose-specific ticket can exchange for
# the existing one.
# Private links use the actual bind origin, which may differ from a
# configured loopback public_url. Host validation remains in middleware;
# proxy-normalized ASGI scheme/Host (not raw forwarded headers) are trusted.
origins = request.headers.getlist("origin")
origin = _http_origin(origins[0]) if len(origins) == 1 else None
target_origin = _http_origin(f"{request.url.scheme}://{request.url.netloc}")
if origin is None or origin != target_origin:
raise HTTPException(403, "Webapp window handoff requires a same-origin request")
# Keep the unauthenticated exchange body-free; never parse an arbitrary
# JSON/multipart payload just to extract a fixed-size capability.
ticket = request.headers.get("X-Hermes-Window-Ticket", "")
with _ticket_lock:
tickets = getattr(request.app.state, "webapp_window_tickets", {})
entry = tickets.get(ticket)
# A launch ticket crossed a browser launcher's argv: only a connection owned by this
# server's user may spend it, and a refused attempt must not burn it for the real browser.
if entry is not None and entry[2] and not _peer_is_server_user(request):
entry = None
elif entry is not None:
del tickets[ticket]
if entry is None or entry[0] <= time.monotonic() or entry[1] != _SESSION_TOKEN:
raise HTTPException(403, "Webapp window link expired or was already used; open a new window")
return JSONResponse({"token": _SESSION_TOKEN}, headers={"Cache-Control": "no-store"})
_LAUNCH_SCRIPT = r"""
const basePath = __BASE_PATH__;
const params = new URLSearchParams(location.hash.slice(1));
history.replaceState(null, '', location.pathname);
const ticket = params.get('ticket') || '';
const fail = () => {
document.getElementById('status').textContent = 'This launch link was already used or has expired. '
+ 'Open the private link printed by hermes webapp, or restart hermes webapp for a new one.';
};
if (!/^[A-Za-z0-9_-]{43}$/.test(ticket)) {
fail();
} else {
fetch(`${basePath}/webapp/window-session`, {
method: 'POST', credentials: 'same-origin', headers: {'X-Hermes-Window-Ticket': ticket}
}).then(response => response.ok ? response.json() : Promise.reject()).then(({token}) => {
if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(token)) throw new Error();
// The SPA consumes and strips this fragment exactly as it does for the printed link.
const target = new URL(`${basePath}/`, location.origin);
target.search = params.get('query') || '';
target.hash = `hermes-session=${token}`;
location.replace(target.href);
}).catch(fail);
}
"""
def _handoff_page(request: Request, title: str, status: str, script: str) -> HTMLResponse:
"""Credential-free, uncached page whose only script is *script* (``__BASE_PATH__`` bound)."""
from hermes_cli.web_server_dashboard import _normalise_prefix
prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix"))
nonce = secrets.token_urlsafe(16)
script = script.replace("__BASE_PATH__", json.dumps(prefix).replace("</", "<\\/"))
return HTMLResponse(
'<!doctype html><meta charset="utf-8"><meta name="referrer" content="no-referrer">'
f'<title>{title}</title><p id="status">{status}</p>'
f'<script nonce="{nonce}">{script}</script>',
headers={
"Cache-Control": "no-store",
"Referrer-Policy": "no-referrer",
"Content-Security-Policy": f"default-src 'none'; script-src 'nonce-{nonce}'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
},
)
@router.get("/webapp/launch")
async def launch_page(request: Request):
"""Where the server's own browser-open lands: spends the one-use launch ticket from the
fragment (never sent to the server or kept in history) and enters the Webapp."""
_require_private_webapp(request)
return _handoff_page(request, "Opening Hermes", "Opening Hermes Webapp…", _LAUNCH_SCRIPT)
# No credential appears in this public page or in its URL. The fragment
# names a transient channel; the authorized parent mints a one-use ticket.
_WINDOW_SCRIPT = r"""
const basePath = __BASE_PATH__;
const params = new URLSearchParams(location.hash.slice(1));
history.replaceState(null, '', location.pathname);
const id = params.get('channel') || '';
const status = document.getElementById('status');
if (!/^[a-f0-9-]{36}$/.test(id)) {
status.textContent = 'Open this window from an authorized Hermes Webapp tab.';
} else {
const channel = new BroadcastChannel(`hermes.webapp.window:${id}`);
let receiving = false;
let finished = false;
const controller = new AbortController();
const fail = message => {
if (finished) return;
finished = true;
controller.abort();
status.textContent = message;
channel.postMessage({type: 'error'});
channel.close();
clearTimeout(timeout);
};
const timeout = setTimeout(() => fail('This window link expired. Open a new window from the original tab.'), 30000);
channel.onmessage = async event => {
if (receiving) return;
if (event.data?.error) { fail(event.data.error); return; }
const ticket = event.data?.ticket;
if (typeof ticket !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(ticket)) return;
receiving = true;
try {
const response = await fetch(`${basePath}/webapp/window-session`, {
method: 'POST', credentials: 'same-origin',
headers: {'X-Hermes-Window-Ticket': ticket}, signal: controller.signal
});
if (!response.ok) throw new Error('Window authorization expired. Open a new window from the original tab.');
const {token} = await response.json();
if (finished) return;
if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(token)) throw new Error('Invalid window authorization.');
sessionStorage.setItem(`hermes.webapp.session.v1:${JSON.stringify([location.origin, basePath])}`, token);
const target = new URL(`${basePath}/`, location.origin);
target.search = params.get('query') || '';
target.hash = params.get('route') || '/';
channel.postMessage({type: 'done'});
finished = true;
clearTimeout(timeout);
channel.close();
location.replace(target.href);
} catch (error) { fail(error.message || 'Could not authorize this window. Open it again from the original tab.'); }
};
channel.postMessage({type: 'ready'});
}
"""
@router.get("/webapp/window")
async def child_window_page(request: Request):
_require_private_webapp(request)
return _handoff_page(request, "Opening Hermes", "Authorizing this Hermes window…", _WINDOW_SCRIPT)
+21 -3
View File
@@ -25,6 +25,7 @@ import urllib.parse
from hermes_cli.install_identity import get_install_id as _shared_get_install_id
from hermes_cli.process_identity import is_desktop_owned_backend
from hermes_cli.pty_session import run_reaper
from hermes_cli import web_server_file_tickets as _file_tickets
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
@@ -33,6 +34,7 @@ PROJECT_ROOT = Path(__file__).parent.parent.resolve()
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from hermes_constants import WEBAPP_ATTACHMENT_MAX_BYTES
from hermes_cli.config import load_config
from hermes_cli.version_info import get_version_info
@@ -283,6 +285,12 @@ def _ssh_runtime_intact() -> bool:
# share one testable seam.
_DASHBOARD_EMBEDDED_CHAT_ENABLED = True
from hermes_cli.web_upload_limit import ChatFileUploadLimitMiddleware
# Inside every auth/Host middleware: those checks run before body ingestion,
# and the receive limit runs before FastAPI can spool a multipart attachment.
app.add_middleware(ChatFileUploadLimitMiddleware)
# Endpoints that do NOT require the session token; everything else under /api/
# is gated below. Shared with the OAuth gate so the two allowlists cannot
# drift (/api/status once 401'd under the OAuth gate, breaking the portal probe).
@@ -303,12 +311,17 @@ def _has_valid_session_token(request: Request) -> bool:
return hmac.compare_digest(auth.encode(), f"Bearer {_SESSION_TOKEN}".encode())
# Routes that may also authenticate via ``?token=`` (download links opened by
# the OS shell / a new tab, where no header can be set). Kept narrow.
# Routes that may also authenticate via ``?token=`` for Electron's remote
# "open externally" download link. Browser pages use path-bound file tickets
# instead (``web_server_file_tickets``), so the session token never lands in
# their download history or a copyable media URL. The OAuth gate still requires
# its session cookie; query credentials are only accepted by the loopback gate.
_QUERY_TOKEN_API_PATHS: frozenset[str] = frozenset({"/api/files/download"})
def _has_valid_query_token(request: Request, path: str) -> bool:
if _file_tickets.redeem(request):
return True
if path not in _QUERY_TOKEN_API_PATHS:
return False
token = request.query_params.get("token", "")
@@ -637,7 +650,7 @@ elif _GATEWAY_HEALTH_TIMEOUT > _GATEWAY_HEALTH_TIMEOUT_MAX:
_MANAGED_FILE_MAX_BYTES = 100 * 1024 * 1024
_FS_DATA_URL_MAX_BYTES = 16 * 1024 * 1024
_FS_DATA_URL_MAX_BYTES = WEBAPP_ATTACHMENT_MAX_BYTES
# Multipart uploads stream to a temp file in fixed chunks and rename into
# place: constant memory, no base64 inflation, no proxy body-size 502s (NS-501).
_UPLOAD_CHUNK_BYTES = 1024 * 1024
@@ -781,11 +794,15 @@ from hermes_cli.web_routers import (
chat_ws as _chat_ws_routes,
chat_workspaces as _chat_workspaces_routes,
dashboard_ui as _dashboard_ui_routes,
webapp as _webapp_routes,
shared_metrics as _shared_metrics_routes,
delegation as _delegation_routes,
)
import hermes_cli.web_routers.uploads as _upload_routes
app.include_router(_files_routes.router)
app.include_router(_upload_routes.router)
app.include_router(_git_routes.router)
app.include_router(_local_models_routes.router)
app.include_router(_status_routes.router)
@@ -815,6 +832,7 @@ app.include_router(_analytics_routes.router)
app.include_router(_chat_ws_routes.router)
app.include_router(_chat_workspaces_routes.router)
app.include_router(_dashboard_ui_routes.router)
app.include_router(_webapp_routes.router)
app.include_router(_shared_metrics_routes.router)
app.include_router(_delegation_routes.router)
+4 -1
View File
@@ -130,7 +130,9 @@ async def standalone_lifespan(app: "FastAPI"):
)
cron_thread.start()
# Reap idle/dead keep-alive PTY sessions (30-min TTL).
# Reap idle/dead keep-alive PTY sessions (30-min TTL). The registry is process-wide and
# close_all() refuses new spawns, so a re-entered lifespan reopens it first.
PTY_REGISTRY._closed = False
pty_reaper_task = asyncio.create_task(run_reaper(PTY_REGISTRY))
# Periodic authenticated self-test feeding the ``dashboard`` component on /api/status.
selftest_task = asyncio.create_task(_dashboard_selftest_loop())
@@ -205,6 +207,7 @@ async def http_lifespan(app: FastAPI):
app.state.event_lock = asyncio.Lock()
app.state.pty_active_session_files = {}
app.state.chat_argv_lock = asyncio.Lock()
PTY_REGISTRY._closed = False # a prior lifespan's close_all() refuses spawns until reopened
tasks = [
asyncio.create_task(run_reaper(PTY_REGISTRY), name="gateway-api-pty-reaper"),
asyncio.create_task(_dashboard_selftest_loop(), name="gateway-api-selftest"),
+118 -29
View File
@@ -19,7 +19,7 @@ from typing import Optional
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from pathlib import Path
from typing import Optional
from hermes_cli.pty_session import PtySessionRegistry
from hermes_cli.pty_session import PtySessionRegistry, WS_CLOSE_PROCESS_EXITED
# Same logger the code used before extraction (record parity).
_log = logging.getLogger("hermes_cli.web_server")
@@ -71,10 +71,12 @@ async def _legacy_pump(ws: WebSocket, bridge) -> None:
loop = asyncio.get_running_loop()
async def pump_pty_to_ws() -> None:
process_exited = False
try:
while True:
chunk = await loop.run_in_executor(None, bridge.read, _PTY_READ_CHUNK_TIMEOUT)
if chunk is None: # EOF
process_exited = True
return
if not chunk: # no data this tick; yield control and retry
await asyncio.sleep(_PTY_IDLE_BACKOFF)
@@ -94,7 +96,7 @@ async def _legacy_pump(ws: WebSocket, bridge) -> None:
# reap independent of that cancellation race (#54028).
await asyncio.to_thread(bridge.close)
with contextlib.suppress(Exception):
await ws.close()
await ws.close(code=WS_CLOSE_PROCESS_EXITED if process_exited else 1000)
reader_task = asyncio.create_task(pump_pty_to_ws())
@@ -197,7 +199,7 @@ def _ws_request_view(ws: WebSocket) -> Request:
})
def _ws_auth_reason(ws: WebSocket) -> tuple[Optional[str], str]:
def _ws_auth_reason(ws: WebSocket, *, allow_internal: bool = False) -> tuple[Optional[str], str]:
"""Validate WS-upgrade auth; return ``(reason, credential)``.
``reason`` is None when accepted, else a short token (``no_credential``,
@@ -234,8 +236,7 @@ def _ws_auth_reason(ws: WebSocket) -> tuple[Optional[str], str]:
# Lazy import — keeps this function importable in test harnesses
# that don't bring in the dashboard_auth layer.
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
from hermes_cli.dashboard_auth.ws_tickets import (
TicketInvalid, consume_internal_credential, consume_ticket)
from hermes_cli.dashboard_auth.ws_tickets import TicketInvalid, consume_internal_credential
def _reject(reason: str) -> None:
audit_log(
@@ -255,6 +256,9 @@ def _ws_auth_reason(ws: WebSocket) -> tuple[Optional[str], str]:
internal = ws.query_params.get("internal", "")
if internal:
if not allow_internal:
_reject("internal: endpoint not allowed")
return "internal_not_allowed", "internal"
try:
_stamp_identity(consume_internal_credential(internal))
return None, "internal"
@@ -262,28 +266,9 @@ def _ws_auth_reason(ws: WebSocket) -> tuple[Optional[str], str]:
_reject(f"internal: {exc}")
return "internal_invalid", "internal"
protocol_ticket, protocol_reason = _gateway_ws_ticket_from_subprotocol(ws)
if protocol_reason == "invalid":
return "ticket_invalid", "ticket-subprotocol"
ticket = protocol_ticket or ws.query_params.get("ticket", "")
if ticket:
try:
info = consume_ticket(ticket)
if info.get("provider") == "bot-desktop":
# A display ticket admits one RFB bridge on /api/display/ws (a watch-only
# capability handed to a screen viewer); it must not double as a login here.
raise TicketInvalid("display ticket presented as a gateway login")
_stamp_identity(info)
if protocol_ticket:
# Select only the stable public protocol during accept. The
# ticket-bearing protocol is a credential and must never be
# reflected back to the browser or retained after admission.
ws._hermes_ws_subprotocol = _GATEWAY_WS_PROTOCOL
return None, "ticket-subprotocol"
return None, "ticket"
except TicketInvalid as exc:
_reject(str(exc))
return "ticket_invalid", "ticket"
consumed = _consume_ws_ticket(ws, _reject)
if consumed is not None:
return consumed
# A user session access token (``?token=``) is verified against the
# dashboard auth session providers — the SAME ``verify_session`` seam
@@ -332,6 +317,11 @@ def _ws_auth_reason(ws: WebSocket) -> tuple[Optional[str], str]:
return "no_credential", "none"
# Loopback: a one-use ticket ``/api/webapp/gateway-ticket`` minted for the session-token owner
# (the Webapp's live socket on this same origin), else the legacy ``?token=``.
consumed = _consume_ws_ticket(ws, lambda reason: None)
if consumed is not None:
return consumed
token = ws.query_params.get("token", "")
if not token:
return "no_credential", "none"
@@ -340,9 +330,108 @@ def _ws_auth_reason(ws: WebSocket) -> tuple[Optional[str], str]:
return "token_mismatch", "token"
def _ws_auth_ok(ws: WebSocket) -> bool:
def _consume_ws_ticket(ws: WebSocket, reject) -> Optional[tuple[Optional[str], str]]:
"""A one-use ticket offered as the ``hermes-gateway-ticket.`` subprotocol or ``?ticket=``:
``(reason, credential)`` once judged, or None when the upgrade presents none. An accepted ticket
stamps the server-minted identity onto the socket; the client never supplies it through RPC
params."""
from hermes_cli.dashboard_auth.ws_tickets import TicketInvalid, consume_ticket
protocol_ticket, protocol_reason = _gateway_ws_ticket_from_subprotocol(ws)
if protocol_reason == "invalid":
return "ticket_invalid", "ticket-subprotocol"
ticket = protocol_ticket or ws.query_params.get("ticket", "")
if not ticket:
return None
try:
info = consume_ticket(ticket)
except TicketInvalid as exc:
reject(str(exc))
return "ticket_invalid", "ticket"
if info.get("provider") == "bot-desktop":
# A display ticket admits one RFB bridge on /api/display/ws (a watch-only capability handed
# to a screen viewer); it must not double as a login here.
reject("display ticket presented as a gateway login")
return "ticket_invalid", "ticket"
# Only identity fields are carried; bookkeeping such as ``minted_at`` is not part of it.
ws._hermes_auth_identity = {"user_id": info.get("user_id"), "provider": info.get("provider")}
if info.get("issuer"):
ws._hermes_auth_identity["issuer"] = info["issuer"]
if not protocol_ticket:
return None, "ticket"
# Select only the stable public protocol during accept. The ticket-bearing protocol is a
# credential and must never be reflected back to the browser or retained after admission.
ws._hermes_ws_subprotocol = _GATEWAY_WS_PROTOCOL
return None, "ticket-subprotocol"
def _ws_auth_ok(ws: WebSocket, *, allow_internal: bool = False) -> bool:
"""True when the WS-upgrade credential is accepted. See _ws_auth_reason."""
return _ws_auth_reason(ws)[0] is None
return _ws_auth_reason(ws, allow_internal=allow_internal)[0] is None
def _ws_auth_mode() -> str:
"""Short label for the active WS auth mode — logged on every connection."""
from hermes_cli.web_server import app
if getattr(app.state, "auth_required", False):
return "gated"
bound_host = (getattr(app.state, "bound_host", "") or "").strip().lower()
if bound_host and bound_host not in _LOOPBACK_HOSTS:
return "insecure"
return "loopback"
def _ws_close_reason(text: str) -> str:
"""Clamp to RFC 6455's 123-byte close-reason limit (uvicorn raises past it);
reasons embed an attacker-controlled origin, so truncate rather than crash."""
encoded = text.encode("utf-8", "replace")
if len(encoded) <= 123:
return text
return encoded[:120].decode("utf-8", "ignore") + "..."
async def _ws_gate(ws: WebSocket, kind: str) -> Optional[tuple[str, str, str]]:
"""Run the pre-accept gates for /api/console, /api/pty and /api/host-terminal.
Each gate maps to a distinct close code so the log and the browser banner
agree on the cause: 4404 chat disabled, 4401 bad credential, 4403 host/origin
mismatch, 4408 peer not allowed. Returns ``(peer, mode, cred)``
once every gate passes, or None after closing the socket.
"""
from hermes_cli.web_server import _DASHBOARD_EMBEDDED_CHAT_ENABLED
peer = ws.client.host if ws.client else "?"
if not _DASHBOARD_EMBEDDED_CHAT_ENABLED:
_log.info("%s refused: embedded chat disabled peer=%s", kind, peer)
await ws.close(code=4404, reason="embedded chat disabled")
return None
auth_reason, cred = _ws_auth_reason(ws)
mode = _ws_auth_mode()
if auth_reason is not None:
_log.warning("%s auth rejected reason=%s mode=%s cred=%s peer=%s", kind, auth_reason, mode, cred, peer)
await ws.close(code=4401, reason=_ws_close_reason(f"auth: {auth_reason}"))
return None
host_origin_reason = _ws_host_origin_reason(ws)
if host_origin_reason is not None:
_log.warning("%s refused: %s peer=%s", kind, host_origin_reason, peer)
await ws.close(code=4403, reason=_ws_close_reason(host_origin_reason))
return None
client_reason = _ws_client_reason(ws)
if client_reason is not None:
_log.warning("%s refused: %s", kind, client_reason)
await ws.close(code=4408, reason=_ws_close_reason(client_reason))
return None
return peer, mode, cred
async def _pty_fail(ws: WebSocket, exc: BaseException, *, surface: str = "Chat") -> None:
"""Tell the user why chat could not start, then close 1011 so the SPA renders
"Start new session". The raw exception goes to the server log only."""
from hermes_cli.web_routers.chat_ws_errors import chat_start_failure_message
_log.warning("pty start failed: %s: %s", type(exc).__name__, exc)
await ws.send_text(f"\r\n\x1b[31m{chat_start_failure_message(exc, surface=surface)}\x1b[0m\r\n")
await ws.close(code=1011)
def _resolve_chat_argv(
+39 -16
View File
@@ -107,17 +107,21 @@ _HEADLESS_MSG = (
def mount_spa(application: FastAPI):
"""Mount the built SPA; unmatched paths fall back to index.html for client-side routing.
The session token is injected into index.html via a ``<script>`` tag so the SPA can
authenticate without a separate token-dispensing endpoint. Behind a path-prefix reverse
Dashboard injects its session token into index.html; Webapp instead receives
the same credential through an operator-only launch fragment. Behind a path-prefix reverse
proxy (``X-Forwarded-Prefix: /hermes``) the served index.html is rewritten so absolute
asset URLs and the runtime ``__HERMES_BASE_PATH__`` honour that prefix without a rebuild.
A missing WEB_DIST is deliberately NOT a mount-time terminal state: every route copes
with a missing dist per-request (404 JSON / ``check_dir=False``), so a long-lived
``--skip-build`` process recovers the moment a build appears on disk — no restart.
The dist is also chosen per request: this mount runs at import, and a host that serves
another renderer (the Webapp's ``dist-webapp``) records it on ``application.state.web_dist``
later.
"""
from hermes_cli.web_server import WEB_DIST, _DASHBOARD_EMBEDDED_CHAT_ENABLED, app
from hermes_cli.web_server import WEB_DIST, _DASHBOARD_EMBEDDED_CHAT_ENABLED
from hermes_cli.web_deps import _server
from hermes_cli.web_server_surface import policy
# `hermes serve` is the headless backend: it must NEVER serve the browser SPA, even if a
# dist is lying around, so only the JSON-RPC/WS/API surface is reachable.
@@ -132,7 +136,7 @@ def mount_spa(application: FastAPI):
# gate is off: on a gated serve the token must never be readable without auth.
# See #94227, #95575.
gated = bool(getattr(application.state, "auth_required", False))
withheld = gated or bool(getattr(app.state, "withhold_session_token", False))
withheld = gated or bool(getattr(_server().app.state, "withhold_session_token", False))
if full_path == "" and not withheld:
return HTMLResponse(
"<!doctype html><html><head><script>"
@@ -152,6 +156,9 @@ def mount_spa(application: FastAPI):
# index.html is unreadable; the asset mounts use check_dir=False and 404 on missing files), so mounting
# them unconditionally makes the dashboard recover the moment a build appears on disk — no restart
# needed.
def web_dist() -> Path:
return getattr(application.state, "web_dist", None) or WEB_DIST
def _serve_index(prefix: str = ""):
"""index.html with the session token + base-path injected.
@@ -161,14 +168,19 @@ def mount_spa(application: FastAPI):
and /api/ws (ticket vs token).
"""
try:
html = (WEB_DIST / "index.html").read_text(encoding="utf-8-sig")
html = (web_dist() / "index.html").read_text(encoding="utf-8-sig")
except OSError:
# Partial build / wiped dist / permissions: same JSON 404 as a fully-missing dist.
return JSONResponse({"error": "Frontend not built. Run: cd web && npm run build"}, status_code=404)
chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false"
gated = bool(getattr(app.state, "auth_required", False))
# The gateway-hosted listener (gateway/run_api.py) never publishes it: local clients use tickets.
withheld = gated or bool(getattr(app.state, "withhold_session_token", False))
gated = bool(getattr(application.state, "auth_required", False))
surface = getattr(application.state, "ui_surface", "dashboard")
# Webapp grants host files and shell.exec as well as PTYs: its existing
# session token must come from the operator, never anonymous HTML. The
# gateway-hosted listener (gateway/run_api.py) never publishes it either:
# local clients use tickets.
withheld = (gated or bool(getattr(_server().app.state, "withhold_session_token", False))
or not policy(application.state).html_token)
token_js = "" if withheld else f'window.__HERMES_SESSION_TOKEN__="{_server()._SESSION_TOKEN}";'
# Launcher-preselected profile (``--open-profile``): the SPA's fallback scope when the URL
# omits ``?profile=`` (#73085). ``</`` escaped so a hostile name cannot close the script tag.
@@ -185,6 +197,7 @@ def mount_spa(application: FastAPI):
f'window.__HERMES_BASE_PATH__="{prefix}";'
f"window.__HERMES_AUTH_REQUIRED__={'true' if gated else 'false'};"
f"window.__HERMES_INITIAL_PROFILE__={initial_profile_js};"
f"window.__HERMES_UI_SURFACE__={json.dumps(surface)};"
f"window.__HERMES_DASHBOARD_PROFILE__={serving_profile_js};"
f"</script>"
)
@@ -204,8 +217,9 @@ def mount_spa(application: FastAPI):
# BEFORE the StaticFiles mount and rewrite when a prefix is in play.
@application.get("/assets/{filename}.css")
async def serve_css(filename: str, request: Request):
css_path = WEB_DIST / "assets" / f"{filename}.css"
if not css_path.is_file() or not css_path.resolve().is_relative_to(WEB_DIST.resolve()):
dist = web_dist()
css_path = dist / "assets" / f"{filename}.css"
if not css_path.is_file() or not css_path.resolve().is_relative_to(dist.resolve()):
return JSONResponse({"error": "not found"}, status_code=404)
prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix"))
css = css_path.read_text(encoding="utf-8-sig")
@@ -226,10 +240,16 @@ def mount_spa(application: FastAPI):
response.headers["Cache-Control"] = _IMMUTABLE_ASSET_CACHE_CONTROL
return response
# check_dir=False: the dist may not exist yet; StaticFiles 404s per-request until it does.
application.mount(
"/assets", _ImmutableAssetFiles(directory=WEB_DIST / "assets", check_dir=False), name="assets"
)
asset_files: dict[Path, StaticFiles] = {}
async def serve_assets(scope, receive, send):
# check_dir=False: the dist may not exist yet; StaticFiles 404s per-request until it does.
directory = web_dist() / "assets"
if directory not in asset_files:
asset_files[directory] = _ImmutableAssetFiles(directory=directory, check_dir=False)
await asset_files[directory](scope, receive, send)
application.mount("/assets", serve_assets, name="assets")
@application.get("/{full_path:path}")
async def serve_spa(full_path: str, request: Request):
@@ -238,11 +258,14 @@ def mount_spa(application: FastAPI):
# real 404 JSON instead of index.html (which breaks JSON clients with a SyntaxError).
if full_path == "api" or full_path.startswith("api/"):
return JSONResponse({"detail": f"No such API endpoint: /{full_path}"}, status_code=404)
file_path = WEB_DIST / full_path
if full_path == "index.html":
return _serve_index(prefix)
dist = web_dist()
file_path = dist / full_path
# Prevent path traversal via url-encoded sequences (%2e%2e/)
if (
full_path
and file_path.resolve().is_relative_to(WEB_DIST.resolve())
and file_path.resolve().is_relative_to(dist.resolve())
and file_path.exists()
and file_path.is_file()
):
+75
View File
@@ -0,0 +1,75 @@
"""Range responses over a descriptor opened under the file ticket's profile lease."""
from __future__ import annotations
import os
from secrets import token_hex
import anyio
from anyio.to_thread import run_sync
from starlette.datastructures import MutableHeaders
from starlette.responses import FileResponse
class OpenedFileResponse(FileResponse):
"""Keep Starlette's Range parsing/headers, but never reopen a reusable path.
The opener runs immediately before response dispatch and returns a checked
file object. Its lifecycle lease ends before any network await. Every exit,
including HEAD, invalid Range and disconnect, closes the pinned descriptor.
"""
def __init__(self, *args, opener, **kwargs):
super().__init__(*args, **kwargs)
self._opener = opener
async def __call__(self, scope, receive, send):
handle = await run_sync(self._opener)
self._file = anyio.wrap_file(handle)
try:
self.stat_result = os.fstat(handle.fileno())
self._file_size = self.stat_result.st_size
self.set_stat_headers(self.stat_result)
await super().__call__(scope, receive, send)
finally:
with anyio.CancelScope(shield=True):
await self._file.aclose()
async def _send_range(self, send, start, end):
await self._file.seek(start)
while start < end:
chunk = await self._file.read(min(self.chunk_size, end - start))
if not chunk:
break
start += len(chunk)
await send({"type": "http.response.body", "body": chunk, "more_body": True})
async def _handle_simple(self, send, send_header_only, send_pathsend):
# ASGI pathsend would reopen the pathname and lose the pinned identity.
await send({"type": "http.response.start", "status": self.status_code, "headers": self.raw_headers})
if not send_header_only:
await self._send_range(send, 0, self._file_size)
await send({"type": "http.response.body", "body": b"", "more_body": False})
async def _handle_single_range(self, send, start, end, file_size, send_header_only):
headers = MutableHeaders(raw=list(self.raw_headers))
headers["content-range"] = f"bytes {start}-{end - 1}/{file_size}"
headers["content-length"] = str(end - start)
await send({"type": "http.response.start", "status": 206, "headers": headers.raw})
if not send_header_only:
await self._send_range(send, start, end)
await send({"type": "http.response.body", "body": b"", "more_body": False})
async def _handle_multiple_ranges(self, send, ranges, file_size, send_header_only):
boundary = token_hex(13)
length, header = self.generate_multipart(ranges, boundary, file_size, self.headers["content-type"])
headers = MutableHeaders(raw=list(self.raw_headers))
headers["content-type"] = f"multipart/byteranges; boundary={boundary}"
headers["content-length"] = str(length)
await send({"type": "http.response.start", "status": 206, "headers": headers.raw})
if not send_header_only:
for start, end in ranges:
await send({"type": "http.response.body", "body": header(start, end), "more_body": True})
await self._send_range(send, start, end)
await send({"type": "http.response.body", "body": b"\r\n", "more_body": True})
await send({"type": "http.response.body", "body": f"--{boundary}--".encode("ascii"), "more_body": True})
await send({"type": "http.response.body", "body": b"", "more_body": False})
+105
View File
@@ -0,0 +1,105 @@
"""Path-bound tickets for browser file URLs (``/api/files/download|stream``).
A download link or an ``<audio>``/``<video>`` source cannot send the session
header, and the session token grants host shells and file access, so it must
never appear in a URL the browser records (download history, "Copy video
address"). An authenticated ``POST /api/files/ticket`` mints a ticket that lets
the loopback token gate admit GET/HEAD of exactly one route + query and nothing
else. A download is one request, so its ticket is single-use; a media element
issues many Range requests for one source, so a stream ticket is reusable until
it expires. Each ticket captures the resolved file and the named profile homes it
belongs to; the resource opener refuses it once one of those homes is missing or
deleted (``hermes_constants.assert_named_profile_home_live``). In-memory only:
tickets die with the process, like the token.
"""
from __future__ import annotations
import secrets
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Iterable, Mapping
if TYPE_CHECKING:
from starlette.requests import Request
DOWNLOAD_TTL_SECONDS = 60
#: A transcript video may be played long after it mounted; seeking past the
#: buffered range needs the ticket again.
STREAM_TTL_SECONDS = 60 * 60
_MAX_TICKETS = 1024
ROUTES: Mapping[str, str] = {"download": "/api/files/download", "stream": "/api/files/stream"}
_Scope = tuple[str, tuple[tuple[str, str], ...]]
@dataclass(frozen=True)
class FileAuthority:
"""Private, resolved resource identity; none of this is supplied by a ticket holder."""
target: Path
homes: tuple[Path, ...]
def check_live(self) -> None:
"""``FileNotFoundError`` once a named profile home this file belongs to is missing or deleted."""
from hermes_constants import assert_named_profile_home_live
for home in self.homes:
assert_named_profile_home_live(home)
@dataclass(frozen=True)
class _Ticket:
scope: _Scope
expires_at: float
single_use: bool
authority: FileAuthority
_lock = threading.Lock()
_tickets: dict[str, _Ticket] = {}
def _scope(route_path: str, query: Iterable[tuple[str, str]]) -> _Scope:
return route_path, tuple(sorted(query))
def mint(route: str, query: Mapping[str, str], *, authority: FileAuthority) -> str:
"""Ticket for GET/HEAD of ``ROUTES[route]`` with exactly ``query`` (plus ``ticket``)."""
route_path = ROUTES[route]
single_use = route == "download"
now = time.monotonic()
ticket = secrets.token_urlsafe(32)
entry = _Ticket(
scope=_scope(route_path, query.items()),
expires_at=now + (DOWNLOAD_TTL_SECONDS if single_use else STREAM_TTL_SECONDS),
single_use=single_use,
authority=authority,
)
with _lock:
for stale in [key for key, value in _tickets.items() if value.expires_at <= now]:
del _tickets[stale]
while len(_tickets) >= _MAX_TICKETS:
del _tickets[next(iter(_tickets))]
_tickets[ticket] = entry
return ticket
def redeem(request: Request) -> bool:
"""True when ``?ticket=`` authorizes this exact request; consumes a download ticket."""
ticket = request.query_params.get("ticket", "")
if not ticket or request.method not in ("GET", "HEAD"):
return False
scope = _scope(request.url.path, ((k, v) for k, v in request.query_params.multi_items() if k != "ticket"))
with _lock:
entry = _tickets.get(ticket)
if entry is None or entry.scope != scope or entry.expires_at <= time.monotonic():
return False
if entry.single_use:
del _tickets[ticket]
# Carry the captured authority through middleware to the resource opener.
# A middleware-only check would race deletion before FileResponse opens it.
request.state.file_ticket_authority = entry.authority
return True
+2 -2
View File
@@ -300,10 +300,10 @@ def _managed_files_policy(request: Optional[Request], *, create_root: bool = Tru
def _resolve_managed_path(
raw_path: str | None, request: Request, *, for_write: bool = False,
raw_path: str | None, request: Request, *, for_write: bool = False, create_root: bool = True,
for_agent_read: bool = False,
) -> tuple[ManagedFilesPolicy, Path, str]:
policy = _managed_files_policy(request)
policy = _managed_files_policy(request, create_root=create_root)
text = _path_text(raw_path)
root = policy.locked_root
+47
View File
@@ -0,0 +1,47 @@
"""What each launch surface of the one web-server stack may do.
The host serving the app records its surface once on ``app.state.ui_surface`` (unset
reads as ``dashboard``); every surface-dependent decision reads this table instead of
re-deriving it. ``auth_required`` is decided at runtime by the auth gate, so the
private-launch predicate is a function over state, not a table field.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class SurfacePolicy:
#: Mounts a browser UI; ``serve`` is JSON-RPC/WS only.
serves_spa: bool
#: Public HTML may carry the session token. Where it may not, every HTML entry
#: (``/index.html`` included) gets the token-free bootstrap instead of the raw file.
html_token: bool
#: ``/api/host-terminal`` may open host shells (still gated per request).
host_terminal: bool
#: An unauthenticated start hands the operator a private launch link.
launch_link: bool
#: ``/api/webapp/gateway-ticket`` mints the browser's one-use ticket for ``/api/ws`` on this
#: same origin (accepted by the loopback and gated upgrade gates alike).
gateway_ticket: bool
POLICIES: dict[str, SurfacePolicy] = {
"serve": SurfacePolicy(
serves_spa=False, html_token=True, host_terminal=False, launch_link=False, gateway_ticket=False),
"dashboard": SurfacePolicy(
serves_spa=True, html_token=True, host_terminal=False, launch_link=False, gateway_ticket=False),
"webapp": SurfacePolicy(
serves_spa=True, html_token=False, host_terminal=True, launch_link=True, gateway_ticket=True),
}
def policy(state: Any) -> SurfacePolicy:
return POLICIES[getattr(state, "ui_surface", "dashboard")]
def private_launch(state: Any) -> bool:
"""Webapp on an unauthenticated bind: the launch link is the only way in."""
return policy(state).launch_link and not getattr(state, "auth_required", False)
+65
View File
@@ -0,0 +1,65 @@
"""Bound Webapp attachment ingestion before FastAPI parses/spools multipart."""
from __future__ import annotations
from starlette.datastructures import Headers
from starlette.formparsers import MultiPartException
from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from hermes_constants import WEBAPP_ATTACHMENT_MAX_BYTES
# The file cap remains exact in the handler; allow ordinary multipart headers
# and boundaries without letting arbitrary extra parts consume unbounded disk.
CHAT_FILE_BODY_MAX_BYTES = WEBAPP_ATTACHMENT_MAX_BYTES + 64 * 1024
class ChatFileUploadLimitMiddleware:
def __init__(self, app: ASGIApp):
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
path = scope.get("path", "")
root = scope.get("root_path", "")
if root and path.startswith(root + "/"):
path = path[len(root) :]
if scope["type"] != "http" or scope["method"] != "POST" or path != "/api/chat/file-upload":
await self.app(scope, receive, send)
return
rejection = JSONResponse(status_code=413, content={"detail": "Upload request is too large"})
length = Headers(scope=scope).get("content-length", "").lstrip("0") or "0"
if (
length.isascii()
and length.isdecimal()
and (len(length) > len(str(CHAT_FILE_BODY_MAX_BYTES)) or int(length) > CHAT_FILE_BODY_MAX_BYTES)
):
await rejection(scope, receive, send)
return
received = 0
exceeded = False
async def limited_receive() -> Message:
nonlocal received, exceeded
message = await receive()
if message["type"] == "http.request":
received += len(message.get("body", b""))
if received > CHAT_FILE_BODY_MAX_BYTES:
exceeded = True
# This exception is deliberate: Starlette's multipart parser
# closes *all* open spools on MultiPartException, unlike an
# HTTPException or cancellation. The rejected chunk never
# reaches the parser. Request._get_form converts it to 400.
raise MultiPartException("Upload request is too large")
return message
async def limited_send(message: Message) -> None:
if not exceeded:
await send(message)
elif message["type"] == "http.response.start":
# Replace the parser's 400 (including its Content-Length) with
# 413, after its cleanup, and suppress the original body.
await rejection(scope, receive, send)
await self.app(scope, limited_receive, limited_send)
+88
View File
@@ -0,0 +1,88 @@
"""Build and launch plumbing for the browser-hosted Desktop workspace.
The browser surface shares Hermes' hardened dashboard server. This module owns
only the separate renderer artifact and the one-way handoff into that server;
it never creates a second HTTP/WebSocket stack. The renderer is compiled by the
shared product builders (``apps/desktop/scripts/build-webapp.mjs``), whose
receipt inside ``dist-webapp`` decides freshness.
"""
from __future__ import annotations
from contextlib import contextmanager
from pathlib import Path
import subprocess
# Serialize renderer publication with Dashboard builds in this checkout.
_LOCK_NAME = ".web_ui_build.lock"
_LOCK_WAIT_SECONDS = 30 * 60
class WebappBuildError(RuntimeError):
"""The browser-hosted Desktop renderer could not be prepared."""
def webapp_dist_dir(project_root: Path) -> Path:
return project_root / "apps" / "desktop" / "dist-webapp"
@contextmanager
def _exclusive_build_lock(path: Path):
"""Cross-platform exclusive lock for one renderer generation."""
from pm.filesystem import lock_fd, unlock_fd
try:
path.parent.mkdir(parents=True, exist_ok=True)
handle = path.open("a+b")
except OSError as exc:
raise WebappBuildError(f"Could not open Webapp build lock {path}: {exc}") from exc
try:
try:
if not lock_fd(handle.fileno(), wait=False):
print("→ Another Hermes Webapp build is running; waiting for it...")
if not lock_fd(handle.fileno(), wait=True, timeout=_LOCK_WAIT_SECONDS):
raise WebappBuildError(f"Timed out waiting for another Webapp build ({path})")
except OSError as exc:
raise WebappBuildError(f"Could not lock Webapp build {path}: {exc}") from exc
yield
finally:
unlock_fd(handle.fileno())
handle.close()
def prepare_webapp_renderer(
project_root: Path,
*,
force: bool = False,
skip_build: bool = False,
explicit: bool = False,
) -> Path:
"""Return a verified browser renderer, serializing concurrent builds.
``explicit`` (a requested build) may install dependencies even when lazy
installs are disabled, like ``hermes desktop --build-only``.
"""
from hermes_cli.source_build import build_source_webapp, source_build_env, source_product_current
project_root = project_root.resolve()
dist = webapp_dist_dir(project_root)
if skip_build:
# Reuse takes no build lock, like `hermes dashboard --skip-build`: the
# lock lives in the install root, which a read-only install cannot write.
if not (dist / "index.html").is_file():
raise WebappBuildError(
f"--skip-build was passed but no Webapp renderer exists at {dist}"
)
print(f"→ Reusing Hermes Webapp renderer at {dist} (--skip-build)")
return dist
with _exclusive_build_lock(project_root / _LOCK_NAME):
if not force and source_product_current(project_root, "webapp", dist):
print(f"✓ Hermes Webapp renderer is up to date: {dist}")
return dist
try:
build_source_webapp(project_root, env=source_build_env(explicit=explicit), explicit=explicit)
except (OSError, subprocess.SubprocessError, RuntimeError) as exc:
raise WebappBuildError(f"Browser-hosted Desktop build failed: {exc}") from exc
print(f"✓ Hermes Webapp renderer built: {dist}")
return dist
+83 -13
View File
@@ -4,8 +4,8 @@ from __future__ import annotations
import asyncio
import logging
import select
import sys
import time
from typing import Optional, Sequence
try:
@@ -25,6 +25,7 @@ __all__ = ["PtyUnavailableError", "WinPtyBridge"]
_MIN_DIMENSION = 1
_MAX_COLS = 2000
_MAX_ROWS = 1000
_WRITE_TIMEOUT = 10.0
_WRITE_SHUTDOWN_GRACE = 1.0
@@ -42,12 +43,14 @@ class PtyUnavailableError(RuntimeError):
class WinPtyBridge:
"""pywinpty-backed bridge with the same interface as ``PtyBridge``. ``read`` runs inside
``run_in_executor``; ConPTY has no selectable fd, so reads poll and writes run in a worker
thread to keep the same non-blocking event-loop contract as the POSIX bridge."""
``run_in_executor`` and, like the POSIX bridge, returns within its timeout; writes run in a
worker thread to keep the same non-blocking event-loop contract as the POSIX bridge."""
def __init__(self, proc: PtyProcess) -> None: # type: ignore[name-defined]
self._proc = proc
self._closed = False
# Deadline guards for writes whose caller left while they waited on a fence.
self._late_writes: set[asyncio.Task] = set()
@classmethod
def is_available(cls) -> bool:
@@ -81,17 +84,25 @@ class WinPtyBridge:
return False
def read(self, timeout: float = 0.2) -> Optional[bytes]:
"""Up to 64 KiB of child output."""
"""Up to 64 KiB of child output, waiting at most ``timeout`` seconds for it.
``b""`` = nothing yet; ``None`` = EOF / closed (also after :meth:`close`).
"""
if self._closed:
return None
# pywinpty's read is a blocking recv on the loopback socket its reader thread
# fills (``fileno()``). Unbounded, an idle shell would hold this executor
# thread until it next printed, and a few quiet terminals would take every
# default-executor thread (min(32, cpu count + 4)) from keystrokes and cleanup.
try:
readable, _, _ = select.select([self._proc], [], [], timeout)
if not readable:
return b""
data = self._proc.read(65536) # pywinpty returns str
except Exception:
return None
if not data:
# No fd to select on; sleep so the executor thread doesn't pin a core while idle.
time.sleep(min(timeout, 0.02))
return b""
return b"" # pywinpty's placeholder for an empty ConPTY read
if isinstance(data, bytes):
return data
# pywinpty decodes internally, so a multibyte UTF-8 sequence can split across reads;
@@ -109,9 +120,19 @@ class WinPtyBridge:
return False
return True
async def write(self, data: bytes, *, timeout: float = 10.0) -> bool:
async def write(self, data: bytes, *, timeout: Optional[float] = _WRITE_TIMEOUT, fence=None) -> bool:
"""Write off-loop and tear down ConPTY when its input pipe wedges.
``fence`` (see ``PtyBridge.write``) wraps the whole blocking write, and
whatever it raises propagates. Waiting to enter the fence is not a stall:
its profile lease can be held by other work (a cold SessionDB open, an
attachment write) for longer than the deadline, so the deadline starts
when the bytes reach ConPTY. ConPTY takes input whether or not the child
reads it (conhost buffers it), so a write that misses the deadline means
ConPTY itself is wedged: ``timeout=None``, which on POSIX waits for the
child to read, still gets that deadline here and never holds the fence
open-ended.
``wait_for(to_thread(...))`` alone only cancels the asyncio wrapper;
the worker remains blocked inside pywinpty. Keep the worker future,
force-close the ConPTY on timeout, and wait briefly for that close to
@@ -120,18 +141,39 @@ class WinPtyBridge:
Cancellation (the owning socket went away mid-write) is not evidence
of a wedged child: the PTY outlives its socket by design, so give the
in-flight write the grace window and only terminate if it never lands.
A write cancelled while it still waits for its fence keeps waiting off
the loop and gets the deadline once it reaches ConPTY.
"""
if self._closed:
return False
if not data:
return True
loop = asyncio.get_running_loop()
write_future = loop.run_in_executor(None, self._write_blocking, data)
entered = asyncio.Event()
def enter_conpty(chunk: bytes) -> bool:
loop.call_soon_threadsafe(entered.set)
return self._write_blocking(chunk)
if fence is None:
entered.set()
write = self._write_blocking
else:
def write(chunk: bytes):
return fence(lambda: enter_conpty(chunk))
write_future = loop.run_in_executor(None, write, data)
deadline = _WRITE_TIMEOUT if timeout is None else max(0.0, timeout)
try:
return await asyncio.wait_for(
asyncio.shield(write_future),
timeout=max(0.0, timeout),
)
if not await self._entered_conpty(entered, write_future):
return await write_future # the fence refused: its result, or what it raised
except asyncio.CancelledError:
if entered.is_set():
await asyncio.shield(self._settle_or_stop_write(write_future))
else:
self._bound_late_write(entered, write_future, deadline)
raise
try:
return await asyncio.wait_for(asyncio.shield(write_future), timeout=deadline)
except TimeoutError:
await self._stop_stalled_write(write_future)
return False
@@ -139,6 +181,34 @@ class WinPtyBridge:
await asyncio.shield(self._settle_or_stop_write(write_future))
raise
@staticmethod
async def _entered_conpty(entered: asyncio.Event, write_future: asyncio.Future) -> bool:
"""Wait, with no deadline, until the write reaches ConPTY or the fence returns without it."""
if entered.is_set():
return True
entering = asyncio.ensure_future(entered.wait())
try:
await asyncio.wait({entering, write_future}, return_when=asyncio.FIRST_COMPLETED)
finally:
entering.cancel()
return entered.is_set()
def _bound_late_write(self, entered: asyncio.Event, write_future: asyncio.Future, deadline: float) -> None:
"""Hold a write cancelled before its fence admitted it to the deadline once it reaches ConPTY."""
async def bound() -> None:
if await self._entered_conpty(entered, write_future):
done, _ = await asyncio.wait({write_future}, timeout=deadline)
if not done:
await self._stop_stalled_write(write_future)
return
# Nobody awaits this write any more, so report what it raised here.
if not write_future.cancelled() and (error := write_future.exception()) is not None:
_log.debug("Host terminal write failed after its caller left", exc_info=error)
task = asyncio.get_running_loop().create_task(bound())
self._late_writes.add(task)
task.add_done_callback(self._late_writes.discard)
async def _settle_or_stop_write(self, write_future: asyncio.Future) -> None:
"""Let a cancelled write finish within the grace window; terminate only if it stalls."""
try:
+6
View File
@@ -22,6 +22,12 @@ _HERMES_HOME_OVERRIDE: ContextVar[str | object] = ContextVar("_HERMES_HOME_OVERR
INDICATOR_STYLES: tuple[str, ...] = ("ascii", "emoji", "kaomoji", "unicode")
DEFAULT_INDICATOR_STYLE: str = "kaomoji"
# Browser-hosted attachments are staged on the Hermes host and then read back
# as a base64 data URL for the normal remote attachment RPC. Keep the upload
# and read-back boundaries identical so a successful picker/drop can always be
# attached afterward.
WEBAPP_ATTACHMENT_MAX_BYTES: int = 16 * 1024 * 1024
def set_hermes_home_override(path: str | Path | None) -> Token:
"""Set a context-local Hermes home override and return its reset token.
+3
View File
@@ -2373,6 +2373,7 @@
"preview.web.commentPlaceholder",
"preview.web.commentTitle",
"preview.web.commenting",
"preview.web.embeddedPreviewHint",
"preview.web.failedRestarting",
"preview.web.failedToLoad",
"preview.web.fallbackTitle",
@@ -3522,6 +3523,8 @@
"settings.gateway.tokenTitle",
"settings.gateway.unavailableDesc",
"settings.gateway.unavailableTitle",
"settings.gateway.webappHostDesc",
"settings.gateway.webappHostTitle",
"settings.hudModifier.description",
"settings.hudModifier.missingHelper",
"settings.hudModifier.permission",
+13
View File
@@ -5,6 +5,7 @@ may import a dependency or another PM module.
"""
from __future__ import annotations
from contextlib import suppress
import errno
import hashlib
import ntpath
@@ -94,6 +95,18 @@ def lock_fd(fd: int, *, wait: bool, timeout: float | None = None) -> bool:
time.sleep(_LOCK_POLL_SECONDS)
def unlock_fd(fd: int) -> None:
"""Release :func:`lock_fd`'s byte lock; best effort, since closing the descriptor releases it too."""
with suppress(OSError):
if os.name == "nt":
import msvcrt
os.lseek(fd, 0, os.SEEK_SET)
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(fd, fcntl.LOCK_UN)
def read_bytes_or_none(path: Path) -> bytes | None:
try:
return path.read_bytes()
+55 -22
View File
@@ -7,7 +7,8 @@ import { isMain } from './frontend-common.mjs'
const receiptName = 'hermes-build.json'
const workspaces = { tui: 'ui-tui', web: 'web', desktop: 'apps/desktop' }
const generated = new Set(['node_modules', 'dist', 'build', 'release', '.cache', '.git', 'coverage', 'test-results', 'playwright-report'])
// Another product's output under a workspace is never this product's input.
const generated = new Set(['node_modules', 'dist', 'dist-webapp', 'build', 'release', '.cache', '.git', 'coverage', 'test-results', 'playwright-report'])
// OS file-manager metadata is never a build input, and it can land in ANY hashed
// tree (source, output or a prepared dir) the moment the checkout is opened in
@@ -31,42 +32,74 @@ const tuiInputs = [
'ui-tui/scripts/gateway_bootstrap.py', 'ui-tui/scripts/clipboard_image.py',
]
function treeHash(root, inputs, skip, contents = () => true) {
const hash = createHash('sha256')
function visit(name) {
if (osMetadata(name) || skip(name)) return
const file = join(root, name)
hash.update(name.replaceAll('\\', '/')).update('\0')
if (!existsSync(file)) { hash.update('missing\0'); return }
if (statSync(file).isDirectory()) {
hash.update('directory\0')
for (const child of readdirSync(file).sort()) visit(`${name}/${child}`)
} else {
hash.update(contents(name) ? readFileSync(file) : 'file').update('\0')
}
}
for (const input of inputs) visit(input)
return hash.digest('hex')
}
// The browser renderer compiles Desktop and its shared package in a prepared
// workspace. Release metadata (pyproject.toml, uv.lock, icons) is not its input.
const webappInputs = [
'apps/desktop', 'apps/shared', 'package.json', 'package-lock.json', '.npmrc', 'pm/lock.json',
'scripts/build/webapp.mjs', 'scripts/build/node-deps.mjs', 'scripts/build/frontend-common.mjs',
'scripts/build/freshness.mjs',
]
export function sourceHash(source, product) {
function productInputs(product) {
if (product === 'tui') return tuiInputs
if (product === 'webapp') return webappInputs
const workspace = workspaces[product]
if (!workspace) throw new Error(`Unknown frontend product: ${product}`)
return treeHash(source, product === 'tui' ? tuiInputs : [
return [
workspace, 'apps/shared', 'package.json', 'package-lock.json', '.npmrc', 'pm/lock.json',
'scripts/build',
'scripts/generate-icons.mjs', 'scripts/generate_icons.py',
// The root install-stamp.json is runtime identity rewritten after every install; desktop's
// baked stamp is a prepared input.
'assets', 'pyproject.toml', 'uv.lock',
], name => {
]
}
function skipInput(product) {
return name => {
// Build scripts are inputs; workspace build directories are outputs.
const parts = name.split('/')
return (!name.startsWith('scripts/') && parts.some(part => generated.has(part)))
|| (product === 'tui' && (parts.includes('__tests__') || /\.(test|spec)(-d)?\.[cm]?[jt]sx?$/.test(name)))
|| parts.some(part => part.startsWith('.dist-') || part.startsWith('.staging-') || part === '__pycache__')
|| name.endsWith('.tsbuildinfo') || name.endsWith('.pyc')
}
}
// Pre-order walk: a directory is reported before its sorted children.
function walkTree(root, inputs, skip, onEntry) {
function visit(name) {
if (osMetadata(name) || skip(name)) return
const file = join(root, name)
if (!existsSync(file)) return onEntry(name, 'missing')
if (!statSync(file).isDirectory()) return onEntry(name, 'file')
onEntry(name, 'directory')
for (const child of readdirSync(file).sort()) visit(`${name}/${child}`)
}
for (const input of inputs) visit(input)
}
function treeHash(root, inputs, skip, contents = () => true) {
const hash = createHash('sha256')
walkTree(root, inputs, skip, (name, kind) => {
hash.update(name.replaceAll('\\', '/')).update('\0')
if (kind === 'file') hash.update(contents(name) ? readFileSync(join(root, name)) : 'file').update('\0')
else hash.update(`${kind}\0`)
})
return hash.digest('hex')
}
export function sourceHash(source, product) {
return treeHash(source, productInputs(product), skipInput(product))
}
/** The present entries a product's source hash covers, for mirroring a prepared workspace. */
export function sourceEntries(source, product) {
const entries = []
walkTree(source, productInputs(product), skipInput(product), (name, kind) => {
if (kind !== 'missing') entries.push({ name, kind })
})
return entries
}
function outputHash(out) {
@@ -144,7 +177,7 @@ export function buildInputs(source, product, prepared = {}, env = process.env) {
prepared: Object.entries(prepared).sort().map(([name, path]) => ({
name, path: resolve(path), hash: name === 'stamp' ? stampContentHash(resolve(path)) : treeHash(resolve(path), ['.'], () => false),
})),
...(product === 'desktop' ? { env: rendererEnv(env) } : {}),
...(product === 'desktop' || product === 'webapp' ? { env: rendererEnv(env) } : {}),
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ function contains(parent, child) {
const productMarker = '.hermes-product'
const productOwner = 'hermes-frontend-product-v1\n'
const developerOutputs = ['ui-tui/dist', 'hermes_cli/web_dist', 'apps/desktop/dist', 'apps/desktop/build/native-deps']
const developerOutputs = ['ui-tui/dist', 'hermes_cli/web_dist', 'apps/desktop/dist', 'apps/desktop/dist-webapp', 'apps/desktop/build/native-deps']
// `npm run builder -- --<platform> --<arch>` stages one native tree per target
// (apps/desktop/scripts/run-electron-builder.mjs): build/native-deps-<platform>-<arch>.
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env node
// Pure browser-renderer compilation. The workspace is prepared by the caller
// (apps/desktop/scripts/build-webapp.mjs); the receipt certifies the checkout.
import { existsSync, readdirSync } from 'node:fs'
import { join, relative, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { productOutput, withProduct, workspaceTool } from './frontend-common.mjs'
import { buildInputs, recordProduct, sourceHash } from './freshness.mjs'
export async function buildWebapp({ source, workspace, out }) {
if (!workspace) throw new Error('A prepared Webapp workspace is required')
const app = 'apps/desktop'
workspace = resolve(workspace)
;({ source, out } = productOutput(source, out, [
// The output lives inside the Desktop workspace: protect its siblings, not the directory.
...readdirSync(join(resolve(source), app)).filter(name => !['dist', 'dist-webapp', 'build'].includes(name)).map(name => `${app}/${name}`),
'apps/shared', 'package.json', 'package-lock.json', 'scripts/build', 'node_modules',
relative(resolve(source), workspace),
]))
const inputs = buildInputs(source, 'webapp')
// The workspace is what compiles; a stale mirror must not publish under a current receipt.
if (sourceHash(workspace, 'webapp') !== inputs.sourceHash) {
throw new Error(`Prepared Webapp workspace ${workspace} does not match ${source}; prepare it again`)
}
const { build } = await import(pathToFileURL(workspaceTool(workspace, app, 'vite')).href)
await withProduct(out, async (product, scratch) => {
await build({
root: join(workspace, app),
configLoader: 'runner',
cacheDir: join(scratch, 'vite-cache'),
build: { outDir: product, emptyOutDir: true },
})
if (!existsSync(join(product, 'index.html'))) throw new Error('Webapp build did not produce index.html')
recordProduct({ source, product: 'webapp', out: product, inputs })
}, { source })
return { out, index: join(out, 'index.html') }
}
+1
View File
@@ -374,6 +374,7 @@ _UPDATE_DEPENDENCIES = (
# The compilers source_build / the Desktop build run (freshness, node-deps,
# tui, web, desktop and their shared frontend-common).
"scripts/build/",
"apps/desktop/scripts/build-webapp.mjs", # source_build's Webapp renderer build
)
# General-purpose modules an entry point imports but that half the product
# imports too (>= HUB_MIN_IMPORTERS product modules, checked by the test). The
+67
View File
@@ -0,0 +1,67 @@
"""A real gateway owner (``python -m gateway.run``) in a throwaway home, for tests that dial it."""
import contextlib
import json
import os
from pathlib import Path
import signal
import subprocess
import sys
import time
ROOT = Path(__file__).resolve().parents[3]
def owner_env(home: Path, user: Path, model_url: str) -> dict:
env = {k: os.environ[k] for k in ('PATH', 'LANG', 'TZ', 'TIRITH_ENABLED') if k in os.environ}
env.update(HOME=str(user), USERPROFILE=str(user), HERMES_HOME=str(home), PYTHONPATH=str(ROOT),
PYTHONUNBUFFERED='1', OPENAI_API_KEY='loopback-only', OPENAI_BASE_URL=model_url)
return env
def write_owner_config(home: Path, model_url: str) -> None:
(home / 'config.yaml').write_text(json.dumps({
'gateway': {'multiplex_profiles': False},
'model': {'provider': 'custom', 'default': 'local-wire-stub', 'base_url': model_url},
'auxiliary': {'title_generation': {'enabled': False}},
}))
@contextlib.contextmanager
def running_owner(tmp_path: Path, model_peer, *, prepare=None, extra_env=None):
"""Yield ``(home, descriptor, env)`` once the owner answers ``identify`` as ready. ``prepare(home)``
runs before boot (seed the store the owner will open); ``extra_env`` is added to the owner's
environment."""
from tests.gateway.test_normal_runtime_boot import control
home, user = tmp_path / 'state', tmp_path / 'user'
home.mkdir(mode=0o700)
user.mkdir()
model_url = f'http://127.0.0.1:{model_peer.server_port}/v1'
write_owner_config(home, model_url)
if prepare is not None:
prepare(home)
env = {**owner_env(home, user, model_url), **(extra_env or {})}
with (tmp_path / 'gateway.log').open('w+') as log:
process = subprocess.Popen([sys.executable, '-m', 'gateway.run'], cwd=ROOT, env=env,
stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT)
try:
descriptor, deadline = {}, time.monotonic() + 35
while process.poll() is None and time.monotonic() < deadline:
try:
descriptor = control(home, 'identify')
if descriptor.get('state') == 'ready':
break
except (OSError, ValueError, AssertionError):
pass
time.sleep(.1)
log.flush()
log.seek(0)
assert descriptor.get('state') == 'ready', (descriptor, log.read())
yield home, descriptor, env
finally:
if process.poll() is None:
process.send_signal(signal.SIGINT)
try:
process.wait(timeout=20)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
@@ -0,0 +1,73 @@
"""Authenticated WS fixture: a browser pick's ``staged_upload`` receipt attached on the shared owner."""
import asyncio
import io
import json
import os
from pathlib import Path
import sys
import traceback
async def probe():
from gateway.run import GatewayRunner
from gateway.run_api import start_gateway_api, stop_gateway_api
from gateway.session_authority import initialize_session_authority
from hermes_cli import web_server
from hermes_cli.dashboard_auth.ws_tickets import mint_ticket
from hermes_cli.staged_uploads import publish_staged_upload, staged_upload_descriptor
from tests.gateway.fixtures.local_recovery_probe import rpc
from websockets.asyncio.client import connect
home = Path(os.environ['HERMES_HOME'])
(home / 'config.yaml').write_text('gateway:\n multiplex_profiles: false\nmodel:\n provider: custom\n default: no-inference\n base_url: http://127.0.0.1:1/v1\nauxiliary:\n title_generation:\n enabled: false\n')
runner = GatewayRunner()
await initialize_session_authority(runner, profile_id=str(home), instance_id='staged-attach')
api = await start_gateway_api(runner)
web_server.app.state.auth_required = True
receipt = {}
def staged(profile_home, payload):
# Exactly the Webapp upload route: publish into the profile home, return its receipt.
path = publish_staged_upload(io.BytesIO(payload), profile_home, 'notes.txt')
return staged_upload_descriptor(path, profile_home)
try:
ticket = mint_ticket(user_id='owner', provider='fixture')
async with connect(api.api_origin.replace('http:', 'ws:') + '/api/ws?ticket=' + ticket) as ws:
sid = (await rpc(ws, 'session.create', request_id='desktop', source='gui', toolsets=[]))['result']['session_id']
async def attach(descriptor):
reply = await rpc(ws, 'file.attach', name='notes.txt', session_id=sid, staged_upload=descriptor)
return reply.get('result') or reply.get('error', {}).get('message')
own = staged(home, b'BROWSER PAYLOAD')
attached = await attach(own)
receipt['own'] = {k: attached[k] for k in ('attached', 'uploaded', 'ref_text')} \
if isinstance(attached, dict) else attached
receipt['own_bytes'] = Path(attached['path']).read_text() if isinstance(attached, dict) else None
# Another backend's receipt, a path outside the profile's upload dir, and a profile this
# owner does not serve: none is copied, whatever the descriptor claims.
receipt['foreign_install'] = await attach(dict(staged(home, b'x'), install_id='another-install'))
secret = home / 'secret.txt'
secret.write_text('SECRET')
receipt['outside_uploads'] = await attach(dict(own, path=str(secret)))
stranger = home.parent / 'stranger-profile'
stranger.mkdir()
receipt['unserved_profile'] = await attach(staged(stranger, b'y'))
copies = home / 'attachments'
receipt['copied'] = sorted(p.read_text() for p in copies.iterdir()) if copies.is_dir() else []
finally:
await stop_gateway_api(api)
(home / 'receipt.json').write_text(json.dumps(receipt))
if __name__ == '__main__':
status = 0
try:
asyncio.run(probe())
except BaseException:
traceback.print_exc()
status = 1
sys.stdout.flush()
sys.stderr.flush()
os._exit(status)
@@ -0,0 +1,36 @@
"""A browser pick attaches through the shared owner, copied only from a profile it may reach.
The Webapp's upload route stages a browser file and hands the renderer a ``staged_upload`` receipt
(``hermes_cli/staged_uploads.py``); once the browser is an attach client of the gateway, that
receipt reaches the owner's canonical ``file.attach``, which only knew ``path``/``data_url`` and
answered ``invalid_params`` for every browser file drop.
"""
import json
from pathlib import Path
import subprocess
import sys
from tests.gateway.fixtures.local_recovery_probe import child_env
def test_a_staged_browser_upload_attaches_only_from_its_own_backend_and_profile(tmp_path):
root = Path(__file__).resolve().parents[2]
home, user = tmp_path / 'state', tmp_path / 'user'
home.mkdir()
user.mkdir()
env = child_env()
env.update(HOME=str(user), USERPROFILE=str(user), HERMES_HOME=str(home),
PYTHONPATH=str(root), PYTHONUNBUFFERED='1')
result = subprocess.run([sys.executable, str(root / 'tests/gateway/fixtures/staged_file_attach_peer.py')],
cwd=root, env=env, capture_output=True, text=True, timeout=90, check=False)
assert result.returncode == 0, result.stdout + result.stderr
receipt = json.loads((home / 'receipt.json').read_text())
assert isinstance(receipt['own'], dict) and receipt['own']['attached'] is True, receipt
assert receipt['own']['ref_text'].startswith('@file:'), receipt
assert receipt['own_bytes'] == 'BROWSER PAYLOAD', receipt
# A replacement backend's receipt and a path outside the upload dir are refused by the source
# checks; a profile the owner does not serve never reaches them.
assert receipt['foreign_install'] == 'invalid_params', receipt
assert receipt['outside_uploads'] == 'invalid_params', receipt
assert receipt['unserved_profile'] == 'permission_denied', receipt
assert receipt['copied'] == ['BROWSER PAYLOAD'], receipt
@@ -0,0 +1,160 @@
"""A browser pick reaches the session only from the exact upload it names, through the owner's ``file.attach``.
The Webapp upload route publishes a browser file into its profile's ``uploads/`` and hands the renderer
a ``staged_upload`` receipt (``hermes_cli/staged_uploads.py``). The browser is an attach client of the
gateway, so that receipt arrives at ``gateway/session_ancillary.py::attach_file``, which copies it into
the session's ``attachments/``. The receipt is only a claim: a deleted source profile, another
backend's install, a missing source or an oversized file must never be copied, and a failed copy must
leave the upload in place for a retry.
"""
import io
import os
from pathlib import Path
import shutil
from types import SimpleNamespace
import uuid
import pytest
import pytest_asyncio
async def _authority_with_old_desktop_chat(tmp_path, monkeypatch, cwd=None):
"""This home's session authority over a store holding one ended pre-gateway Desktop chat."""
from gateway import run
from gateway.config import GatewayConfig
from gateway.session import SessionStore
from gateway.session_authority import initialize_session_authority
monkeypatch.setenv('HERMES_HOME', str(tmp_path))
monkeypatch.setattr(run, '_load_gateway_config', lambda: {'platform_toolsets': {'gui': []}})
store = SessionStore(tmp_path / 'sessions', GatewayConfig())
db = store._db
db.create_session('old-desktop', source='desktop', model='old-model', cwd=str(cwd or tmp_path))
db.append_message('old-desktop', role='user', content='OLD-DESKTOP')
db.end_session('old-desktop', 'session_switch')
runner = SimpleNamespace(session_store=store, _session_db=db, adapters={}, _draining=False,
_cached_agent_for=lambda route: None)
runner._adapter_for_source = lambda source: runner.adapters.get(source.platform)
return await initialize_session_authority(runner, profile_id=str(tmp_path), instance_id='first')
def _attach(authority, tickets, purpose, subject):
"""Exactly gateway/run_api.py: redeem the minted host-scoped ticket and bind its operator."""
from gateway.session_controls import AuthorityConnection
ticket = tickets.mint(profile_id=authority.profile_id, subject=subject, purpose=purpose, scope='host')
grant = tickets.redeem(ticket, profile_id=None, purpose=purpose)
identity = {'user_id': grant['subject'], 'provider': 'local', 'profile_id': grant['profile_id'],
'instance_id': grant['instance_id'], 'capabilities': grant['capabilities'],
'native_bootstrap': True, 'profile_scope': grant.get('scope', 'profile')}
return AuthorityConnection(authority, object(), identity, operator=True)
@pytest_asyncio.fixture
async def owner(tmp_path, monkeypatch):
"""The profile's owner with one adopted chat open on a native connection, serving a sibling profile."""
from gateway.runtime_bootstrap import TicketStore
from hermes_cli import install_identity
monkeypatch.setattr(install_identity, '_INSTALL_ID_CACHE', {'root': None, 'value': None})
# The chat runs in a workspace of its own: a pick from the profile's uploads/ is copied, never referenced.
workspace = tmp_path / 'workspace'
workspace.mkdir()
authority = await _authority_with_old_desktop_chat(tmp_path, monkeypatch, cwd=workspace)
worker = tmp_path / 'profiles' / 'worker'
worker.mkdir(parents=True)
(worker / 'config.yaml').write_text('{}\n', encoding='utf-8')
# A multiplexer serves its sibling profiles; a host-scoped grant may take a pick from one.
from gateway.session_authorities import SessionAuthorities
served = SessionAuthorities(tmp_path, multiplexed=True)
served.add(tmp_path, authority)
served.add(worker, SimpleNamespace(profile_id=str(worker), retired=False))
authority.runner.session_authorities = served
host = _attach(authority, TicketStore('first', frozenset({authority.profile_id})), 'interactive', 'uid:1000')
resumed = await host.dispatch({'id': 'resume', 'method': 'session.resume', 'params': {'session_id': 'old-desktop'}})
assert 'result' in resumed, resumed
def upload(home=tmp_path, payload=b'browser payload'):
# Exactly the Webapp upload route: publish into the profile home, return its receipt.
from hermes_cli.staged_uploads import publish_staged_upload, staged_upload_descriptor
path = publish_staged_upload(io.BytesIO(payload), home, 'notes.txt')
return staged_upload_descriptor(path, home)
async def attach(receipt, connection=host, **extra):
reply = await connection.dispatch({'id': 'attach', 'method': 'file.attach', 'params': {
'session_id': 'old-desktop', 'name': 'notes.txt', 'staged_upload': receipt, **extra}})
return reply.get('result') or reply.get('error', {}).get('message')
return SimpleNamespace(home=tmp_path, worker=worker, attachments=tmp_path / 'attachments', upload=upload,
attach=attach, authority=authority, install_identity=install_identity)
@pytest.mark.asyncio
@pytest.mark.parametrize('source', ['own', 'sibling'])
async def test_a_staged_pick_is_copied_into_the_session_cache_without_retransmission(owner, source):
receipt = owner.upload(owner.home if source == 'own' else owner.worker)
attached = await owner.attach(receipt)
assert isinstance(attached, dict) and attached['attached'] is True and attached['uploaded'] is True, attached
stored = Path(attached['path'])
# The cache directory container/SSH terminal backends mount.
from tools.credential_files import _CACHE_DIRS
assert stored.parent == owner.attachments and stored.parent.name in {rel for rel, _ in _CACHE_DIRS}
assert stored.read_bytes() == Path(receipt['path']).read_bytes() == b'browser payload'
assert attached['ref_text'].startswith('@file:')
@pytest.mark.asyncio
async def test_a_deleted_source_profile_does_not_vouch_for_its_receipt(owner):
"""The rule every named-home write follows (``assert_named_profile_home_live``): a profile whose
deletion is recorded, or whose home is gone, has no live uploads, even while its files remain."""
from hermes_constants import mark_named_profile_deleted
receipt = owner.upload(owner.worker)
source = Path(receipt['path'])
mark_named_profile_deleted(owner.worker)
assert isinstance(await owner.attach(receipt), str) # refused
assert not owner.attachments.exists()
assert source.read_bytes() == b'browser payload'
shutil.rmtree(owner.worker)
assert isinstance(await owner.attach(receipt), str) # refused
assert not owner.attachments.exists()
@pytest.mark.asyncio
async def test_another_backend_install_does_not_vouch_for_the_receipt(owner):
receipt = owner.upload()
# A replacement backend at the same path persists a different install identity.
(owner.home / 'install_id').write_text(uuid.uuid4().hex + '\n', encoding='utf-8')
owner.install_identity._INSTALL_ID_CACHE.clear()
assert isinstance(await owner.attach(receipt), str) # refused
assert not owner.attachments.exists()
assert Path(receipt['path']).is_file()
@pytest.mark.asyncio
async def test_a_failed_copy_keeps_the_upload_for_a_retry(owner):
receipt = owner.upload()
# The session's attachments/ cannot be created: a file holds its name.
owner.attachments.write_bytes(b'')
assert isinstance(await owner.attach(receipt), str) # refused
owner.attachments.unlink()
assert Path(receipt['path']).read_bytes() == b'browser payload'
recovered = await owner.attach(receipt)
assert isinstance(recovered, dict) and recovered['attached'] is True, recovered
assert Path(recovered['path']).read_bytes() == b'browser payload'
@pytest.mark.asyncio
async def test_a_missing_upload_never_falls_back_to_supplied_bytes(owner):
receipt = owner.upload()
Path(receipt['path']).unlink()
refused = await owner.attach(receipt, path=receipt['path'], data_url='data:text/plain;base64,d3Jvbmc=')
assert isinstance(refused, str), refused
assert not owner.attachments.exists()
@pytest.mark.asyncio
async def test_an_upload_that_grew_past_the_browser_limit_is_not_copied(owner):
from hermes_constants import WEBAPP_ATTACHMENT_MAX_BYTES
receipt = owner.upload()
os.truncate(receipt['path'], WEBAPP_ATTACHMENT_MAX_BYTES + 1)
assert isinstance(await owner.attach(receipt), str) # refused
assert not owner.attachments.exists()
@@ -123,6 +123,31 @@ class TestWsTicketEndpoint:
assert len(body["ticket"]) >= 32
assert body["ttl_seconds"] == 30
def test_authenticated_spa_bootstrap_uses_cookie_mode_without_injected_token(
self, monkeypatch, tmp_path
):
from fastapi import FastAPI
dist = tmp_path / "desktop-webapp"
dist.mkdir()
(dist / "assets").mkdir()
(dist / "index.html").write_text(
"<!doctype html><html><head></head><body><div id='root'></div></body></html>",
encoding="utf-8",
)
monkeypatch.setattr(web_server, "WEB_DIST", dist)
monkeypatch.delenv("HERMES_SERVE_HEADLESS", raising=False)
monkeypatch.setattr(web_server.app.state, "auth_required", True, raising=False)
application = FastAPI()
application.state.auth_required = True
web_server.mount_spa(application)
response = TestClient(application).get("/")
assert response.status_code == 200
assert "window.__HERMES_AUTH_REQUIRED__=true" in response.text
assert "__HERMES_SESSION_TOKEN__" not in response.text
def test_unauthenticated_returns_401_or_redirect(self, gated_app):
r = gated_app.post("/api/auth/ws-ticket", follow_redirects=False)
# gated_auth_middleware short-circuits before the route — it
@@ -209,6 +234,24 @@ class TestWsAuthOkLoopback:
ws = _fake_ws(query={"token": web_server._SESSION_TOKEN})
assert _web_server_chat._ws_auth_ok(ws) is True
def test_webapp_socket_ticket_is_accepted_once(self, loopback_app):
"""The Webapp on a loopback bind mints the browser's one-use socket ticket
(``POST /api/webapp/gateway-ticket``) for the session-token owner: the gate takes it once
and selects only the public protocol. A Bot Desktop screen ticket is never a login here
either."""
ticket = mint_ticket(user_id="legacy-token-owner", provider="session-token")
protocols = (
_web_server_chat._GATEWAY_WS_PROTOCOL,
f"{_web_server_chat._GATEWAY_WS_TICKET_PROTOCOL_PREFIX}{ticket}",
)
ws_one = _fake_ws(query={}, path="/api/ws", protocols=protocols)
assert _web_server_chat._ws_auth_ok(ws_one) is True
assert ws_one._hermes_auth_identity == {"user_id": "legacy-token-owner", "provider": "session-token"}
assert ws_one._hermes_ws_subprotocol == _web_server_chat._GATEWAY_WS_PROTOCOL
assert _web_server_chat._ws_auth_ok(_fake_ws(query={}, path="/api/ws", protocols=protocols)) is False
screen = mint_ticket(user_id="display:v", provider="bot-desktop", extra={"hermes_home": "/srv/hermes"})
assert _web_server_chat._ws_auth_ok(_fake_ws(query={"ticket": screen}, path="/api/ws")) is False
class TestWsAuthOkGated:
"""Gate ON — ticket path only."""
@@ -269,6 +312,26 @@ class TestWsAuthOkGated:
ws = _fake_ws(query={"token": web_server._SESSION_TOKEN})
assert _web_server_chat._ws_auth_ok(ws) is False
def test_internal_credential_is_scoped_to_server_child_endpoints(self, gated_app):
credential = internal_ws_credential()
for path in (
"/api/pty",
"/api/console",
"/api/events",
"/api/audio/speak-stream",
):
ws = _fake_ws(query={"internal": credential}, path=path)
assert _web_server_chat._ws_auth_reason(ws) == (
"internal_not_allowed",
"internal",
)
gateway = _fake_ws(query={"internal": credential}, path="/api/ws")
publisher = _fake_ws(query={"internal": credential}, path="/api/pub")
assert _web_server_chat._ws_auth_ok(gateway, allow_internal=True) is True
assert _web_server_chat._ws_auth_ok(publisher, allow_internal=True) is True
def test_session_token_accepted_in_gated_mode(self, gated_app):
"""A provider-verified session access token (?token=) is accepted in
gated mode — the credential a token-mode Remote desktop connection
@@ -0,0 +1,207 @@
"""Viewer takeover linearizes at the PTY sink, including off-loop writes."""
import asyncio
import os
from pathlib import Path
import threading
import pytest
from hermes_cli.pty_session import PtySession, WS_CLOSE_SUPERSEDED
from hermes_cli.web_host_terminal_sessions import HostOwner, HostTerminalRegistry
from tests.waiting import async_wait_until
class Socket:
def __init__(self):
self.closed = []
async def close(self, code, reason=None):
self.closed.append(code)
@pytest.mark.platforms("posix")
@pytest.mark.asyncio
@pytest.mark.parametrize("pause_at_sink", [False, True])
async def test_takeover_revokes_waiting_workers_and_waits_for_an_admitted_write(tmp_path, monkeypatch, pause_at_sink):
import pty
import select
import tty
from hermes_cli import pty_bridge
home = tmp_path / ".hermes"
profile = home / "profiles" / "worker"
profile.mkdir(parents=True)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
owner = HostOwner(("loopback", "test"), profile, "sh", str(tmp_path))
master, slave = pty.openpty()
tty.setraw(slave)
os.set_blocking(master, False)
bridge = pty_bridge.PtyBridge.__new__(pty_bridge.PtyBridge)
bridge._fd, bridge._closed, bridge._fd_lock = master, False, threading.Lock()
session = PtySession("terminal", bridge, buffer_cap=32, read_timeout=0.01)
old, new = Socket(), Socket()
entered, release, finished = threading.Event(), threading.Event(), threading.Event()
real_write = os.write
def pause():
entered.set()
assert release.wait(5), "write barrier was not released"
def sink(fd, data):
if pause_at_sink and fd == master and bytes(data) == b"OLD\n":
pause()
return real_write(fd, data)
def old_fence(write):
try:
if not pause_at_sink:
pause()
return owner.admit(write)
finally:
finished.set()
monkeypatch.setattr(pty_bridge.os, "write", sink)
writing = takeover = None
try:
assert await session.attach(old)
writing = asyncio.create_task(session.write(old, b"OLD\n", fence=old_fence))
assert await asyncio.to_thread(entered.wait, 3)
takeover = asyncio.create_task(session.attach(new))
if pause_at_sink:
# The sink already admitted OLD: takeover waits, with the event loop
# still free to run the bridge timeout/cancellation cleanup.
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(asyncio.shield(takeover), 0.05)
release.set()
assert await asyncio.wait_for(takeover, 3)
assert await asyncio.wait_for(writing, 3) is False
assert old.closed == [WS_CLOSE_SUPERSEDED]
assert await session.write(new, b"NEW\n", fence=owner.admit)
release.set()
assert await asyncio.to_thread(finished.wait, 3)
readable, _, _ = await asyncio.to_thread(select.select, [slave], [], [], 3)
assert readable
assert os.read(slave, 1024) == (b"OLD\nNEW\n" if pause_at_sink else b"NEW\n")
assert session.alive
finally:
release.set()
for task in (writing, takeover):
if task is not None:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
await asyncio.to_thread(finished.wait, 5)
os.close(master)
os.close(slave)
class InputSocket(Socket):
"""A viewer that sends ``frames`` and then reads until it is closed."""
def __init__(self, *frames):
super().__init__()
self.inbound = asyncio.Queue()
for frame in frames:
self.inbound.put_nowait({"type": "websocket.receive", "bytes": frame})
async def receive(self):
return await self.inbound.get()
async def close(self, code, reason=None):
await super().close(code, reason)
self.inbound.put_nowait({"type": "websocket.disconnect"})
@pytest.mark.platforms("posix")
@pytest.mark.asyncio
async def test_takeover_during_an_admitted_input_write_keeps_the_shell(tmp_path, monkeypatch):
"""The cancelled write returns False while the old socket is still the viewer (the takeover
waits for the sink); the pump must neither close the shell nor let the old tab reconnect."""
from types import SimpleNamespace
from hermes_cli import pty_bridge
from hermes_cli.web_host_terminal_sessions import _pump_input
home = tmp_path / ".hermes"
profile = home / "profiles" / "worker"
profile.mkdir(parents=True)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
owner = HostOwner(("loopback", "test"), profile, "sh", str(tmp_path))
import pty
import tty
master, slave = pty.openpty()
tty.setraw(slave)
os.set_blocking(master, False)
bridge = pty_bridge.PtyBridge.__new__(pty_bridge.PtyBridge)
bridge._fd, bridge._closed, bridge._fd_lock = master, False, threading.Lock()
session = PtySession("terminal", bridge, buffer_cap=32, read_timeout=0.01)
entered, release = threading.Event(), threading.Event()
real_write = os.write
def sink(fd, data):
if fd == master and bytes(data) == b"OLD\n":
entered.set()
assert release.wait(5), "write barrier was not released"
return real_write(fd, data)
monkeypatch.setattr(pty_bridge.os, "write", sink)
removed = []
async def remove(token):
removed.append(token)
old, new = InputSocket(b"OLD\n"), Socket()
pump = takeover = None
try:
assert await session.attach(old)
pump = asyncio.create_task(_pump_input(old, SimpleNamespace(remove=remove), "terminal", session, owner))
assert await asyncio.to_thread(entered.wait, 3)
takeover = asyncio.create_task(session.attach(new))
# The takeover cancelled the write and waits for the sink; the old socket is still the viewer.
await async_wait_until(lambda: session._input_task is None, timeout=3)
assert session._ws is old
release.set()
assert await asyncio.wait_for(takeover, 3)
async with asyncio.timeout(3):
await pump
assert removed == [] and session.alive and not bridge._closed
assert old.closed == [WS_CLOSE_SUPERSEDED]
assert await session.write(new, b"NEW\n", fence=owner.admit)
finally:
release.set()
for task in (pump, takeover):
if task is not None:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
os.close(master)
os.close(slave)
@pytest.mark.platforms("posix")
@pytest.mark.asyncio
async def test_host_reaper_releases_dead_shell_before_drain_observes_eof(tmp_path):
import signal
from hermes_cli.pty_bridge import PtyBridge
bridge = PtyBridge.spawn(["/bin/sh", "-c", "trap '' HUP; sleep 60 & echo ready; exec sleep 60"], cwd=str(tmp_path))
registry = HostTerminalRegistry()
session = PtySession("terminal", bridge, buffer_cap=32, read_timeout=0.01)
registry._sessions[session.key] = session
registry.owners[session.key] = HostOwner(("loopback", "test"), tmp_path, "sh", str(tmp_path))
viewer = Socket()
try:
async with asyncio.timeout(5):
while b"ready" not in (await asyncio.to_thread(bridge.read, 0.05) or b""):
await asyncio.sleep(0)
assert await session.attach(viewer)
os.kill(bridge.pid, signal.SIGKILL)
await async_wait_until(lambda: not bridge.is_alive(), timeout=5)
# The drain has not observed EOF. A helper can delay it indefinitely
# on Linux; macOS can report EOF as soon as the leader exits.
assert session.alive and session.attached
await registry.reap_idle()
assert not registry._sessions and not registry.owners
assert bridge._closed
finally:
await registry.close_all()
await asyncio.to_thread(bridge.close)
@@ -0,0 +1,37 @@
"""Only observed PTY EOF proves that the process exited."""
import asyncio
from unittest.mock import Mock
import pytest
from starlette.websockets import WebSocket
from hermes_cli.pty_session import WS_CLOSE_PROCESS_EXITED
from hermes_cli.web_server_chat import _legacy_pump
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.mark.anyio
@pytest.mark.parametrize("failure", [None, "send", "read"])
async def test_legacy_pump_close_distinguishes_eof_from_transport_failure(failure):
incoming = asyncio.Queue()
await incoming.put({"type": "websocket.connect"})
closes = []
async def send(message):
if message["type"] == "websocket.send" and failure == "send":
raise RuntimeError("transport failed")
if message["type"] == "websocket.close":
closes.append(message["code"])
await incoming.put({"type": "websocket.disconnect", "code": message["code"]})
ws = WebSocket({"type": "websocket"}, incoming.get, send)
await ws.accept()
bridge = Mock()
bridge.read.side_effect = [RuntimeError("read failed")] if failure == "read" else [b"output", None]
await asyncio.wait_for(_legacy_pump(ws, bridge), timeout=3)
assert closes == [WS_CLOSE_PROCESS_EXITED if failure is None else 1000]
bridge.close.assert_called()
+6
View File
@@ -13,6 +13,7 @@ import shutil
import signal
import subprocess
import sys
import threading
import time
import pytest
@@ -77,6 +78,7 @@ class TestPtyBridgeIO:
bridge = PtyBridge.__new__(PtyBridge)
bridge._fd = 123
bridge._closed = False
bridge._fd_lock = threading.Lock()
wait_started = asyncio.Event()
release_write = asyncio.Event()
write_calls = 0
@@ -120,6 +122,7 @@ class TestPtyBridgeIO:
bridge = PtyBridge.__new__(PtyBridge)
bridge._fd = 123
bridge._closed = False
bridge._fd_lock = threading.Lock()
def fake_write(_fd, _data):
raise BlockingIOError(errno.EAGAIN, "buffer full")
@@ -278,6 +281,7 @@ class TestPtyBridgeClose:
# recorded at spawn; the child leads its own group (pgid == pid)
bridge._pgid = 12345
bridge._closed = False
bridge._fd_lock = threading.Lock()
bridge.close()
@@ -316,6 +320,7 @@ class TestPtyBridgeClose:
# recorded at spawn: the child was found in OUR group (pgid != pid)
bridge._pgid = 67890
bridge._closed = False
bridge._fd_lock = threading.Lock()
bridge.close()
@@ -396,6 +401,7 @@ class TestPtyBridgeClose:
# recorded at spawn: the child was found in OUR group (pgid != pid)
bridge._pgid = os.getpgid(popen.pid)
bridge._closed = False
bridge._fd_lock = threading.Lock()
bridge.close()
finally:
@@ -57,6 +57,9 @@ def pty_keepalive_harness(monkeypatch):
return (["x", resume or "fresh"], "/tmp", env)
monkeypatch.setattr(_web_server_chat, "_resolve_chat_argv_async", fake_argv)
# TestClient without ``with`` skips the lifespan, which reopens the registry
# on startup; a test that ran close_all() must not shut it for the next one.
monkeypatch.setattr(_web_server_chat.PTY_REGISTRY, "_closed", False)
try:
yield spawned
+67
View File
@@ -5,6 +5,7 @@ import time
import pytest
from hermes_cli.pty_session import RingBuffer
from tests.waiting import async_wait_until
def test_ringbuffer_keeps_everything_under_capacity():
@@ -147,6 +148,72 @@ async def test_drain_send_failure_detaches_current_socket_but_not_a_replacement(
await s.close()
@pytest.mark.asyncio
async def test_viewer_that_stops_reading_live_output_is_told_to_reconnect(monkeypatch):
"""The send limit drops a stalled viewer, not its session. 1011 makes the
dashboard chat tab offer a new session; 1001 is the code both it and the
Webapp terminal redial on, and the same key then reattaches the same PTY."""
from hermes_cli import pty_session
from hermes_cli.pty_session import PtySessionRegistry
monkeypatch.setattr(pty_session, "_VIEWER_SEND_TIMEOUT_SECONDS", 0.1)
registry = PtySessionRegistry(ttl=60.0, max_sessions=2, buffer_cap=1024, read_timeout=0.01)
bridge = FakeBridge([])
session, _ = await registry.attach_or_spawn("k", spawn=lambda: bridge)
stalled = FailingWS(hold=True) # stopped reading: its send never completes
assert await session.attach(stalled) # empty history: no replay frame to send
bridge._chunks.append(b"live output")
await async_wait_until(lambda: stalled.close_code is not None, timeout=15)
assert stalled.close_code == 1001
assert session.alive and not session.attached
again, created = await registry.attach_or_spawn("k", spawn=lambda: FakeBridge([]))
viewer = FakeWS()
assert again is session and not created
assert await again.attach(viewer)
assert viewer.sent == [("bytes", b"live output")]
await registry.close_all()
class SlowLinkWS(FakeWS):
"""Carries every frame in ``seconds``: a viewer still reading, over a slow link."""
def __init__(self, seconds):
super().__init__()
self._seconds = seconds
async def send_bytes(self, data):
await asyncio.sleep(self._seconds) # health: allow HX013 -- the link's transfer time is the behaviour under test
await super().send_bytes(data)
@pytest.mark.asyncio
async def test_a_full_replay_over_a_slow_link_is_not_a_stalled_viewer(monkeypatch):
"""The stall limit covers one live frame. A reattach replay of up to the 1 MiB buffer gets time
to cross a slow link too, or every redial replays, times out and redials again."""
from hermes_cli import pty_session
from hermes_cli.pty_session import PtySession
monkeypatch.setattr(pty_session, "_VIEWER_SEND_TIMEOUT_SECONDS", 0.05)
history = b"x" * (1024 * 1024)
session = PtySession("k", FakeBridge([]), buffer_cap=len(history), read_timeout=0.01)
session.buffer.append(history)
viewer = SlowLinkWS(0.5) # ten stall limits, far inside a full replay's budget
assert await session.attach(viewer)
assert viewer.sent == [("bytes", history)] and viewer.close_code is None
assert session._ws is viewer
# A viewer that stopped reading still gets dropped, within the limit plus its replay's share.
small = PtySession("k2", FakeBridge([]), buffer_cap=1024, read_timeout=0.01)
small.buffer.append(b"tail")
stalled = FailingWS(hold=True) # the replay never completes
try:
assert await small.attach(stalled) is False
assert small._ws is None
finally:
stalled.release.set()
@pytest.mark.asyncio
async def test_reattach_can_force_complete_tui_redraw_after_replay():
"""A fresh terminal cannot reconstruct a differential ANSI tail alone."""
@@ -0,0 +1,373 @@
"""Ownership, replay ordering, and spawn reservation contracts for PTY sessions."""
import asyncio
import threading
import pytest
from hermes_cli.pty_session import PtySession, PtySessionRegistry, RegistryFull
from tests.waiting import async_wait_until
class Bridge:
def __init__(self):
self.closed = False
self._closing = threading.Event()
self.written = []
self.resized = []
self.output = []
def read(self, timeout):
if self.output:
return self.output.pop(0)
self._closing.wait(timeout)
return b""
async def write(self, data):
self.written.append(data)
return True
def resize(self, cols, rows):
self.resized.append((cols, rows))
def close(self):
self.closed = True
self._closing.set()
def is_alive(self):
return not self.closed
class Socket:
def __init__(self):
self.frames = []
self.closed = None
async def send_text(self, text):
self.frames.append(text)
async def send_bytes(self, data):
self.frames.append(data)
async def close(self, code, reason=None):
self.closed = code
@pytest.mark.asyncio
async def test_metadata_replay_live_order_and_superseded_input_fence():
bridge = Bridge()
session = PtySession("k", bridge, buffer_cap=1024, read_timeout=0.01)
session.buffer.append(b"replay")
entered, release = asyncio.Event(), asyncio.Event()
class SlowMetadata(Socket):
async def send_text(self, text):
entered.set()
await release.wait()
await super().send_text(text)
old = SlowMetadata()
attaching = asyncio.create_task(session.attach(old, initial_text="meta"))
await entered.wait()
bridge.output.append(b"live")
await session.start()
# Eventual drain observation, not a timing assertion.
await async_wait_until(lambda: session.buffer.snapshot() == b"replaylive", timeout=2)
assert old.frames == []
release.set()
assert await attaching
await async_wait_until(lambda: len(old.frames) == 3, timeout=2)
assert old.frames == ["meta", b"replay", b"live"]
replacement = Socket()
assert await session.attach(replacement, initial_text="resume")
assert replacement.frames == ["resume", b"replaylive"]
assert old.closed == 4409
await session.write(old, b"stale")
session.resize(old, cols=5, rows=6)
session.detach(old)
assert session._ws is replacement
assert bridge.written == []
assert bridge.resized == []
await session.write(replacement, b"current")
session.resize(replacement, cols=80, rows=24)
assert bridge.written == [b"current"]
assert bridge.resized == [(80, 24)]
await session.close()
@pytest.mark.asyncio
async def test_concurrent_attach_claims_before_slow_old_close():
session = PtySession("k", Bridge(), buffer_cap=1024, read_timeout=0.01)
entered, release = asyncio.Event(), asyncio.Event()
class SlowClose(Socket):
async def close(self, code, reason=None):
entered.set()
await release.wait()
await super().close(code)
old, first, winner = SlowClose(), Socket(), Socket()
await session.attach(old)
one = asyncio.create_task(session.attach(first, initial_text="first"))
await entered.wait()
assert await session.attach(winner, initial_text="winner")
release.set()
assert not await one
assert session._ws is winner
assert first.closed == 4409
assert first.frames == []
assert winner.frames == ["winner", b""]
await session.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("failure", ["metadata", "replay"])
async def test_failed_initial_delivery_has_finite_retention(failure):
reg = PtySessionRegistry(ttl=30, max_sessions=1, buffer_cap=32, read_timeout=0.01)
bridge = Bridge()
session, _ = await reg.attach_or_spawn("k", spawn=lambda: bridge)
session.buffer.append(b"buffer")
class BrokenSocket(Socket):
async def send_text(self, text):
if failure == "metadata":
raise RuntimeError("lost metadata")
await super().send_text(text)
async def send_bytes(self, data):
raise RuntimeError("lost replay")
assert not await session.attach(BrokenSocket(), initial_text="meta")
assert not session.attached and session.last_detached_at is not None
await reg.reap_idle(now=session.last_detached_at + 31)
assert bridge.closed
assert not reg._sessions
await reg.close_all()
@pytest.mark.asyncio
async def test_concurrent_spawn_reserves_key_capacity_and_shutdown():
reg = PtySessionRegistry(ttl=30, max_sessions=1, buffer_cap=32, read_timeout=0.01)
entered, release = threading.Event(), threading.Event()
bridge = Bridge()
calls = []
def spawn():
calls.append(1)
entered.set()
assert release.wait(5)
return bridge
one = asyncio.create_task(reg.attach_or_spawn("k", spawn=spawn))
assert await asyncio.to_thread(entered.wait, 2)
two = asyncio.create_task(reg.attach_or_spawn("k", spawn=spawn))
release.set()
(first, made), (second, made_again) = await asyncio.gather(one, two)
assert first is second and made and not made_again
assert len(calls) == 1
await first.attach(Socket())
with pytest.raises(RegistryFull):
await reg.attach_or_spawn("other", spawn=spawn)
await reg.close_all()
assert bridge.closed
# Shutdown must wait for an admitted off-thread spawn and close its child.
reg = PtySessionRegistry(ttl=30, max_sessions=1, buffer_cap=32, read_timeout=0.01)
bridge = Bridge()
entered.clear()
release.clear()
spawning = asyncio.create_task(reg.attach_or_spawn("k", spawn=spawn))
assert await asyncio.to_thread(entered.wait, 2)
closing = asyncio.create_task(reg.close_all())
await asyncio.sleep(0)
release.set()
await asyncio.gather(spawning, closing)
assert bridge.closed and not reg._sessions
@pytest.mark.asyncio
@pytest.mark.parametrize("spawn_fails", [False, True])
async def test_cancelled_spawn_retains_capacity_until_cleanup(spawn_fails):
import gc
reg = PtySessionRegistry(ttl=30, max_sessions=1, buffer_cap=32, read_timeout=0.01)
entered, release = threading.Event(), threading.Event()
bridge, replacement = Bridge(), Bridge()
events, unhandled = [], []
loop = asyncio.get_running_loop()
original_handler = loop.get_exception_handler()
loop.set_exception_handler(lambda _loop, context: unhandled.append(context))
def spawn():
entered.set()
assert release.wait(5)
events.append("spawn finished")
if spawn_fails:
raise OSError("fork failed after caller disconnected")
return bridge
def next_spawn():
events.append("next spawn")
assert spawn_fails or bridge.closed, "capacity released before abandoned child was closed"
return replacement
spawning = asyncio.create_task(reg.attach_or_spawn("k", spawn=spawn))
next_request = None
try:
assert await asyncio.to_thread(entered.wait, 2)
spawning.cancel()
await asyncio.sleep(0)
spawning.cancel()
# Request cancellation must finish without waiting for fork/exec, even
# under a level-triggered ASGI cancellation scope.
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(spawning, 2)
next_request = asyncio.create_task(reg.attach_or_spawn("k", spawn=next_spawn))
await asyncio.sleep(0)
release.set()
session, made = await asyncio.wait_for(next_request, 3)
assert made and session.bridge is replacement
assert events == ["spawn finished", "next spawn"]
assert spawn_fails or bridge.closed
assert session.last_detached_at is not None
await reg.reap_idle(now=session.last_detached_at + 31)
assert replacement.closed and not reg._sessions
# A late spawn failure has no waiting caller, but must still be consumed.
gc.collect()
await asyncio.sleep(0)
assert not unhandled
finally:
release.set()
await asyncio.gather(spawning, *([next_request] if next_request else []), return_exceptions=True)
await reg.close_all()
bridge.close()
replacement.close()
loop.set_exception_handler(original_handler)
@pytest.mark.asyncio
async def test_spawn_failure_reaches_caller_and_releases_capacity():
reg = PtySessionRegistry(ttl=30, max_sessions=1, buffer_cap=32, read_timeout=0.01)
failure = OSError("fork failed with caller still connected")
bridge = Bridge()
def spawn():
raise failure
try:
with pytest.raises(OSError) as caught:
await reg.attach_or_spawn("k", spawn=spawn)
assert caught.value is failure
session, made = await asyncio.wait_for(
reg.attach_or_spawn("k", spawn=lambda: bridge), 2,
)
assert made and session.bridge is bridge
finally:
await reg.close_all()
assert bridge.closed
@pytest.mark.asyncio
async def test_supersede_cancels_inflight_input_before_replacement_writes():
entered, release = asyncio.Event(), asyncio.Event()
class BlockedBridge(Bridge):
async def write(self, data):
if data == b"old":
entered.set()
await release.wait()
return await super().write(data)
bridge = BlockedBridge()
session = PtySession("k", bridge, buffer_cap=32, read_timeout=0.01)
old, new = Socket(), Socket()
await session.attach(old)
writing = asyncio.create_task(session.write(old, b"old"))
await entered.wait()
await session.attach(new)
release.set()
assert not await writing
assert await session.write(new, b"new")
assert bridge.written == [b"new"]
await session.close()
@pytest.mark.platforms("linux")
@pytest.mark.asyncio
async def test_superseded_input_cancellation_wins_completed_readiness(monkeypatch):
import errno
import os
import pty
import select
import tty
from hermes_cli.pty_bridge import PtyBridge
master, slave = pty.openpty()
tty.setraw(slave)
os.set_blocking(master, False)
bridge = PtyBridge.__new__(PtyBridge)
bridge._fd, bridge._closed, bridge._fd_lock = master, False, threading.Lock()
session = PtySession("k", bridge, buffer_cap=32, read_timeout=0.01)
old, new = Socket(), Socket()
entered, callbacks, writes = asyncio.Event(), [], []
loop = asyncio.get_running_loop()
real_write = os.write
def write(fd, data):
if fd == master:
if not entered.is_set():
raise BlockingIOError(errno.EAGAIN, "buffer full")
writes.append(bytes(data))
return real_write(fd, data)
def add_writer(fd, callback):
assert fd == master
# Control selector delivery, not _wait_writable: a real PTY's capacity
# changes asynchronously and filling it cannot pin this same-tick race.
callbacks.append(callback)
entered.set()
monkeypatch.setattr(os, "write", write)
monkeypatch.setattr(loop, "add_writer", add_writer)
writing = None
try:
await session.attach(old)
writing = asyncio.create_task(session.write(old, b"STALE"))
await asyncio.wait_for(entered.wait(), 2)
callbacks[0]()
# Ready then cancel before the waiting task resumes: wait_for on 3.11
# used to return its completed Future and swallow this cancellation.
await session.attach(new)
result = await asyncio.wait_for(writing, 2)
assert await session.write(new, b"CURRENT")
assert writes == [b"CURRENT"]
assert not result and session.alive
readable, _, _ = await asyncio.to_thread(select.select, [slave], [], [], 2)
assert readable and os.read(slave, 1024) == b"CURRENT"
finally:
if writing is not None:
writing.cancel()
await asyncio.gather(writing, return_exceptions=True)
os.close(master)
os.close(slave)
@pytest.mark.asyncio
async def test_cancelled_close_still_reaps_child():
bridge = Bridge()
session = PtySession("k", bridge, buffer_cap=32, read_timeout=0.01)
entered, release = asyncio.Event(), asyncio.Event()
class SlowClose(Socket):
async def close(self, code, reason=None):
entered.set()
await release.wait()
await session.attach(SlowClose())
closing = asyncio.create_task(session.close())
await entered.wait()
closing.cancel()
with pytest.raises(asyncio.CancelledError):
await closing
release.set()
await session.close()
assert bridge.closed
@@ -124,7 +124,8 @@ def test_first_gateway_ws_client_starts_the_armed_discovery_once(monkeypatch):
)
assert calls == []
async def _allowed(ws):
async def _allowed(ws, *, allow_internal=False):
assert allow_internal is True
return True
async def _handle_ws(ws, **kwargs):
+115
View File
@@ -0,0 +1,115 @@
"""`hermes sessions adopt` brings a profile's pre-gateway chats under its gateway, as the host.
The Webapp's browser reaches the gateway through its dashboard mount as the login it authenticated
(on a loopback bind, the session-token owner), which may not make the first claim on an unowned
historical row (gateway-local-sessions.md, shared operator access). The host's own native client
adopts them in one pass, after which the browser login continues each.
"""
import argparse
import asyncio
import json
import pytest
from tests.gateway.fixtures.owner_process import running_owner
#: The owner's dashboard session token (``HERMES_DASHBOARD_SESSION_TOKEN``): what a loopback browser
#: login presents on the gateway's dashboard mount.
_BROWSER_TOKEN = 'b' * 43
_OWNER_ENV = {'HERMES_DASHBOARD_SESSION_TOKEN': _BROWSER_TOKEN}
_OLD_CHATS = (('old-desktop', 'desktop', None), ('old-desktop-nous', 'desktop', 'nous:account'),
('old-cli', 'cli', None), ('foreign-desktop', 'desktop', 'telegram:42'),
('api-row', 'api_server', None))
def _seed(home):
from hermes_state import SessionDB
db = SessionDB(db_path=home / 'state.db')
try:
for sid, source, user_id in _OLD_CHATS:
db.create_session(sid, source=source, model='old-model', cwd=str(home), user_id=user_id)
db.append_message(sid, role='user', content=sid.upper())
db.end_session(sid, 'session_switch')
finally:
db.close()
def _adopt(capsys, *argv):
from hermes_cli import sessions_cmd
from hermes_cli.subcommands.sessions import build_sessions_parser
parser = argparse.ArgumentParser()
build_sessions_parser(parser.add_subparsers(dest='command'), cmd_sessions=sessions_cmd.cmd_sessions)
args = parser.parse_args(['sessions', 'adopt', *argv])
status = args.func(args)
return status, capsys.readouterr().out
async def _rpc(socket, method, **params):
await socket.send(json.dumps({'jsonrpc': '2.0', 'id': method, 'method': method, 'params': params}))
async with asyncio.timeout(15):
while True:
reply = json.loads(await socket.recv())
if reply.get('id') == method:
return reply
async def _browser_resume(home, descriptor, sid):
"""What a browser opens on the gateway's own listener: ``/api/ws`` on its dashboard mount
(``chat_ws.gateway_ws``), with an Origin, as the loopback session-token login; never a native grant."""
from websockets.asyncio.client import connect
from hermes_cli.web_server_boundary import _DESKTOP_ATTACHMENT_WS_MAX_BYTES
origin = descriptor['api_origin']
url = origin.replace('http:', 'ws:') + '/api/ws?token=' + _BROWSER_TOKEN
async with connect(url, origin=origin, proxy=None, max_size=_DESKTOP_ATTACHMENT_WS_MAX_BYTES) as socket:
return await _rpc(socket, 'session.resume', session_id=sid)
@pytest.mark.platforms("linux")
def test_adopting_old_chats_lets_the_browser_login_continue_them(tmp_path, model_peer, monkeypatch, capsys):
with running_owner(tmp_path, model_peer, prepare=_seed, extra_env=_OWNER_ENV) as (home, descriptor, _env):
monkeypatch.setenv('HERMES_HOME', str(home))
assert asyncio.run(_browser_resume(home, descriptor, 'old-desktop'))['error']['message'] == 'not_found'
status, out = _adopt(capsys, '--dry-run')
assert status == 0 and '3 pre-gateway chat(s) to adopt (1 cli, 2 desktop)' in out, out
assert asyncio.run(_browser_resume(home, descriptor, 'old-desktop'))['error']['message'] == 'not_found'
status, out = _adopt(capsys)
assert status == 0 and 'Adopted 3 chat(s).' in out, out
for sid in ('old-desktop', 'old-desktop-nous', 'old-cli'):
resumed = asyncio.run(_browser_resume(home, descriptor, sid))
assert resumed.get('result', {}).get('session_id') == sid, resumed
assert [m['content'] for m in resumed['result']['messages']] == [sid.upper()]
# Another identity's row and a non-local row were never offered, so they stay as they were.
assert asyncio.run(_browser_resume(home, descriptor, 'foreign-desktop'))['error']['message'] == 'not_found'
status, out = _adopt(capsys)
assert status == 0 and 'Nothing to adopt' in out, out
def _seed_long_and_moved(home):
from hermes_state import SessionDB
db = SessionDB(db_path=home / 'state.db')
try:
# The owner's resume reply always carries the whole history: this one is over the 8 MiB a
# default native client accepts in one frame.
db.create_session('long-desktop', source='desktop', model='old-model', cwd=str(home))
db.append_message('long-desktop', role='user', content='x' * (9 * 1024 * 1024))
db.create_session('moved-desktop', source='desktop', model='old-model', cwd=str(home / 'deleted-worktree'))
db.append_message('moved-desktop', role='user', content='MOVED')
for sid in ('long-desktop', 'moved-desktop'):
db.end_session(sid, 'session_switch')
finally:
db.close()
@pytest.mark.platforms("linux")
def test_adopt_survives_a_long_history_and_names_what_the_owner_refused(tmp_path, model_peer, monkeypatch, capsys):
with running_owner(tmp_path, model_peer, prepare=_seed_long_and_moved, extra_env=_OWNER_ENV) as (home, descriptor, _env):
monkeypatch.setenv('HERMES_HOME', str(home))
status, out = _adopt(capsys)
assert status == 0 and 'Adopted 1 chat(s).' in out, out
assert 'Skipped 1: invalid_params (cwd: must be an absolute path to an existing directory).' in out, out
resumed = asyncio.run(_browser_resume(home, descriptor, 'long-desktop'))
assert resumed.get('result', {}).get('session_id') == 'long-desktop', str(resumed)[:300]
@@ -0,0 +1,33 @@
"""Every privileged SPA document uses the same bootstrap policy: uncached, prefixed, and tokenless
whenever the gate or the surface withholds the session token."""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
@pytest.mark.parametrize("surface", ["webapp", "dashboard"])
@pytest.mark.parametrize("prefix", ["", "/hermes"])
@pytest.mark.parametrize("path", ["/", "/chat/session", "/index.html", "/help.html"])
def test_spa_documents_share_the_bootstrap_policy(monkeypatch, tmp_path, surface, prefix, path):
from hermes_cli import web_server as server
from hermes_cli.web_server_dashboard import mount_spa
html = '<html><head><script src="/assets/app.js"></script></head><body>fixture</body></html>'
(tmp_path / "index.html").write_text(html)
(tmp_path / "help.html").write_text("<html><body>help</body></html>")
app = FastAPI()
app.state.ui_surface = surface
app.state.auth_required = True
app.state.web_dist = tmp_path
monkeypatch.setattr(server, "app", app)
monkeypatch.delenv("HERMES_SERVE_HEADLESS", raising=False)
mount_spa(app)
response = TestClient(app).get(path, headers={"X-Forwarded-Prefix": prefix})
assert response.status_code == 200
if path != "/help.html":
# /index.html included: every entry to the app gets the bootstrap, never the raw file.
assert "no-store" in response.headers.get("cache-control", "")
assert f'window.__HERMES_UI_SURFACE__="{surface}"' in response.text
assert f'window.__HERMES_BASE_PATH__="{prefix}"' in response.text
assert f'src="{prefix}/assets/app.js"' in response.text
assert server._SESSION_TOKEN not in response.text
@@ -0,0 +1,89 @@
"""Source ZIP updates preserve the separately built Webapp renderer."""
from pathlib import Path
import subprocess
import zipfile
import pytest
from hermes_cli import update_cmd_zip
from hermes_cli.webapp import webapp_dist_dir
def _git(root, *args):
return subprocess.run(["git", "-C", str(root), *args], capture_output=True,
text=True, check=True).stdout
@pytest.fixture
def clean_install(tmp_path):
# Keep the real ignore rules and tracked root entries without copying a full checkout
# or committing fixture source. Git objects are read-only shared with the test checkout.
root = tmp_path / "install"
repo = Path(__file__).resolve().parents[2]
subprocess.run(["git", "clone", "--quiet", "--shared", "--no-checkout", "--template=",
str(repo), str(root)], check=True)
_git(root, "config", "core.sparseCheckout", "true")
(root / ".git/info").mkdir(exist_ok=True)
(root / ".git/info/sparse-checkout").write_text(
"/.gitignore\n/apps/desktop/package.json\n", encoding="utf-8")
_git(root, "read-tree", "-mu", "HEAD")
assert _git(root, "status", "--porcelain", "--untracked-files=all") == ""
for dist in (root / "apps/desktop/dist", webapp_dist_dir(root)):
dist.mkdir(parents=True)
(dist / "index.html").write_bytes(f"retained {dist.name}".encode())
return root
def test_clean_webapp_output_is_admitted_but_dirty_source_still_refuses(clean_install):
root = clean_install
assert _git(root, "status", "--porcelain", "--untracked-files=all") == ""
assert _git(root, "check-ignore", str(webapp_dist_dir(root) / "index.html")).strip()
for shipped in (None, {"apps", ".gitignore"}):
assert update_cmd_zip._zip_overlay_block_reason(root, shipped=shipped) is None
source = root / "apps/desktop/package.json"
source.write_bytes(source.read_bytes() + b"\n")
for shipped in (None, {"apps", ".gitignore"}):
assert update_cmd_zip._zip_overlay_block_reason(root, shipped=shipped) is not None
def test_local_zip_grafts_both_renderers_and_preswap_guard_keeps_dirty_source(clean_install, tmp_path, monkeypatch):
from hermes_cli import main
root = clean_install
monkeypatch.setattr(main, "PROJECT_ROOT", root)
source = root / "apps/desktop/package.json"
original = source.read_bytes()
outputs = {path: path.read_bytes() for path in (
root / "apps/desktop/dist/index.html", webapp_dist_dir(root) / "index.html")}
extracted = tmp_path / "hermes-agent-main"
new_source = extracted / "apps/desktop/package.json"
new_source.parent.mkdir(parents=True)
new_source.write_bytes(b'{"name":"updated-source"}\n')
archive = tmp_path / "source.zip"
with zipfile.ZipFile(archive, "w") as handle:
handle.write(new_source, "hermes-agent-main/apps/desktop/package.json")
handle.comment = b"8192da90e0afb20010a1c2f5da83db305d05ac5a" # git archive's commit identity
# A failed pre-swap check leaves the live source and both outputs untouched.
source.write_bytes(original + b"\n")
with pytest.raises(SystemExit) as exc:
update_cmd_zip._download_and_swap_zip("main", archive.as_uri())
assert exc.value.code == 1
assert source.read_bytes() == original + b"\n"
assert all(path.read_bytes() == data for path, data in outputs.items())
assert not (root / "apps.hermes-update-staging").exists()
source.write_bytes(original)
# Exercise grafting separately so a missing artifact cannot hide behind a guard refusal.
staged = update_cmd_zip._stage_entries(str(extracted), ["apps"], str(root))
try:
for path, data in outputs.items():
assert (Path(staged[0][0]) / path.relative_to(root / "apps")).read_bytes() == data
finally:
update_cmd_zip._discard_staged(staged)
update_cmd_zip._download_and_swap_zip("main", archive.as_uri())
assert source.read_bytes() == new_source.read_bytes()
assert all(path.read_bytes() == data for path, data in outputs.items())
assert not (root / "apps.hermes-update-staging").exists()
assert not (root / "apps.hermes-update-old").exists()
+151
View File
@@ -0,0 +1,151 @@
"""Webapp host terminal: request policy, the /api/host-terminal gate, and argv resolution."""
import os
from pathlib import Path
import pytest
@pytest.fixture
def web_server(monkeypatch):
import hermes_cli.web_server as ws
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
return ws
@pytest.mark.parametrize(
("surface", "bound_host", "headers"),
[
("dashboard", "testclient", {"host": "testclient"}),
(
"webapp",
"0.0.0.0",
{
"host": "192.168.0.222:9120",
"origin": "http://192.168.0.222:9120",
},
),
],
)
def test_host_terminal_rejection_runs_through_websocket_route(
web_server, monkeypatch, surface, bound_host, headers
):
from starlette.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
monkeypatch.setattr(web_server.app.state, "ui_surface", surface, raising=False)
monkeypatch.setattr(web_server.app.state, "bound_host", bound_host, raising=False)
monkeypatch.setattr(web_server.app.state, "auth_required", False, raising=False)
with pytest.raises(WebSocketDisconnect) as exc:
with TestClient(web_server.app).websocket_connect(
f"/api/host-terminal?token={web_server._SESSION_TOKEN}", headers=headers
):
pass
assert exc.value.code == 4403
def test_host_terminal_policy_rejects_dashboard_and_unauthenticated_public_bind(
web_server, monkeypatch
):
from hermes_cli import web_host_terminal
app = web_server.app
monkeypatch.setattr(app.state, "ui_surface", "dashboard", raising=False)
monkeypatch.setattr(app.state, "bound_host", "127.0.0.1", raising=False)
monkeypatch.setattr(app.state, "auth_required", False, raising=False)
assert web_host_terminal.request_allowed() is False
app.state.ui_surface = "webapp"
app.state.bound_host = "0.0.0.0"
assert web_host_terminal.request_allowed() is False
app.state.auth_required = True
assert web_host_terminal.request_allowed() is True
def _assert_host_terminal_resolver_uses_real_host(tmp_path):
from hermes_cli import web_host_terminal
from hermes_constants import get_hermes_home
argv, cwd, env, shell_name = web_host_terminal.resolve_argv(
home=get_hermes_home(), requested_cwd=str(tmp_path)
)
assert argv
assert argv[0]
assert cwd == str(tmp_path.resolve())
assert shell_name
assert env["TERM"] == "xterm-256color"
assert env["COLORTERM"] == "truecolor"
assert env["HERMES_DESKTOP_TERMINAL"] == "1"
def test_host_terminal_resolver_applies_selected_profile_home_and_config(
tmp_path, monkeypatch
):
from hermes_cli import web_host_terminal
real_home = tmp_path / "real-home"
current = tmp_path / "profiles" / "current"
target = tmp_path / "profiles" / "target"
for path in (real_home, current / "home", target / "home"):
path.mkdir(parents=True)
(target / "config.yaml").write_text(
"terminal:\n home_mode: profile\n",
encoding="utf-8",
)
monkeypatch.setenv("HOME", str(current / "home"))
monkeypatch.setenv("HERMES_HOME", str(current))
monkeypatch.setenv("HERMES_REAL_HOME", str(real_home))
monkeypatch.setenv("TERMINAL_HOME_MODE", "profile")
monkeypatch.setenv("OPENAI_API_KEY", "current-profile-secret")
_argv, _cwd, env, _shell_name = web_host_terminal.resolve_argv(home=target)
assert env["HERMES_HOME"] == str(target)
assert env["HOME"] == str(target / "home")
assert env["TERMINAL_HOME_MODE"] == "profile"
assert "OPENAI_API_KEY" not in env
@pytest.mark.platforms("linux")
def test_host_terminal_resolver_uses_real_linux_shell(tmp_path, monkeypatch):
monkeypatch.setenv("SHELL", "/bin/sh")
_assert_host_terminal_resolver_uses_real_host(tmp_path)
@pytest.mark.platforms("posix")
def test_host_terminal_relative_shell_never_reaches_argv(tmp_path, monkeypatch):
"""The PTY chdirs into the workspace before exec, so the shell it runs is the one checked only if argv[0] is absolute."""
from hermes_cli import web_host_terminal
from hermes_constants import get_hermes_home
server_cwd = tmp_path / "server"
workspace = tmp_path / "workspace"
(server_cwd / "bin").mkdir(parents=True)
workspace.mkdir()
planted = server_cwd / "bin" / "sh"
planted.write_text("#!/bin/sh\n", encoding="utf-8")
planted.chmod(0o755)
monkeypatch.chdir(server_cwd)
monkeypatch.setenv("SHELL", "bin/sh")
argv, cwd, _env, _shell_name = web_host_terminal.resolve_argv(
home=get_hermes_home(), requested_cwd=str(workspace)
)
assert cwd == str(workspace.resolve())
assert os.path.isabs(argv[0])
assert Path(argv[0]).resolve() != planted.resolve()
@pytest.mark.platforms("windows")
def test_host_terminal_resolver_uses_real_windows_shell(tmp_path):
_assert_host_terminal_resolver_uses_real_host(tmp_path)
from hermes_cli import web_host_terminal
argv, shell_name = web_host_terminal.shell_spec()
assert shell_name.startswith(("pwsh", "powershell", "cmd"))
assert argv[1:] == ([] if shell_name.startswith("cmd") else ["-NoLogo"])
@@ -0,0 +1,58 @@
"""Retained host shells must not hold the event loop's default executor.
Every live shell keeps one PTY read in flight. On the loop's shared default pool, the shells a
Webapp retains for ~15 minutes after disconnect would queue every ``asyncio.to_thread`` hop the
process serves behind them: keystroke fences, the reaper's ownership checks, uploads, SessionDB.
"""
import asyncio
from concurrent.futures import ThreadPoolExecutor
import secrets
import threading
import pytest
from hermes_cli.web_host_terminal_sessions import HostTerminalRegistry
from tests.waiting import async_wait_until
class QuietShell:
"""A shell with nothing to print. A real idle shell's read re-enters select every read
timeout, so it holds a thread almost continuously; this one holds it until closed."""
def __init__(self, readers: list):
self._readers = readers
self.closed = threading.Event()
def read(self, timeout):
self._readers.append(threading.current_thread())
self.closed.wait()
def close(self):
self.closed.set()
@pytest.mark.asyncio
async def test_idle_host_shells_leave_the_default_executor_free():
asyncio.get_running_loop().set_default_executor(ThreadPoolExecutor(max_workers=1))
registry = HostTerminalRegistry()
readers: list[threading.Thread] = []
shells: list[QuietShell] = []
def spawn():
shells.append(QuietShell(readers))
return shells[-1]
try:
async with asyncio.timeout(5):
# A full registry: every retained shell reads at once.
for _ in range(registry._max):
await registry.attach_or_spawn(secrets.token_urlsafe(32), spawn=spawn)
await async_wait_until(lambda: len(readers) >= registry._max, timeout=5)
assert await asyncio.to_thread(lambda: "ran") == "ran"
finally:
# Release reads first so a starved default executor can still run teardown.
for shell in shells:
shell.close()
await registry.close_all()
# Closing the registry releases its reader threads; a restarted lifespan gets new ones.
await async_wait_until(lambda: not any(thread.is_alive() for thread in readers), timeout=5)
@@ -0,0 +1,127 @@
"""Overlapping app lifespans must keep requests and shells under a live owner."""
import asyncio
from contextlib import AsyncExitStack, ExitStack
import json
from pathlib import Path
import pytest
from fastapi import FastAPI
from starlette.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
from hermes_cli.web_host_terminal_sessions import get_host_terminals, host_terminal_lifespan
@pytest.mark.asyncio
@pytest.mark.parametrize("older_first", [True, False])
async def test_overlapping_lifespans_close_only_their_owned_registry(older_first, monkeypatch):
app = FastAPI()
async with AsyncExitStack() as older, AsyncExitStack() as newer:
await older.enter_async_context(host_terminal_lifespan(app))
first = get_host_terminals(app)
await newer.enter_async_context(host_terminal_lifespan(app))
second = get_host_terminals(app)
assert second is not first
closing, remaining = (older, newer) if older_first else (newer, older)
retired, live = (first, second) if older_first else (second, first)
await closing.aclose()
assert retired._closed and not live._closed
assert get_host_terminals(app) is live
# Restoring a registry is not enough: its lifespan must still reap it.
reaped = asyncio.Event()
original_reap = live.reap_idle
async def reap_idle():
await original_reap()
reaped.set()
monkeypatch.setattr(live, "reap_idle", reap_idle)
await asyncio.wait_for(reaped.wait(), 3)
await remaining.aclose()
assert first._closed and second._closed
assert not hasattr(app.state, "host_terminals")
with pytest.raises(RuntimeError, match="not running"):
get_host_terminals(app)
assert not hasattr(app.state, "host_terminals")
# A later startup gets a new owner, never a closed predecessor.
async with host_terminal_lifespan(app):
restarted = get_host_terminals(app)
assert restarted is not first and restarted is not second
assert not restarted._closed
assert restarted._closed
@pytest.mark.platforms("linux")
@pytest.mark.parametrize("older_first", [True, False])
def test_real_shells_remain_owned_after_overlapping_lifespan_exit(tmp_path, monkeypatch, older_first):
import psutil
from hermes_cli import web_host_terminal, web_server
from hermes_cli.web_routers.webapp import router
home = tmp_path / ".hermes"
home.mkdir()
(home / "config.yaml").write_text("{}", encoding="utf-8")
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(web_server.app.state, "ui_surface", "webapp", raising=False)
monkeypatch.setattr(web_server.app.state, "bound_host", "localhost", raising=False)
monkeypatch.setattr(web_server.app.state, "auth_required", False, raising=False)
monkeypatch.setattr(web_host_terminal, "shell_spec", lambda: (["/bin/sh", "-i"], "sh"))
app = FastAPI(lifespan=host_terminal_lifespan)
app.include_router(router)
url = f"ws://localhost/api/host-terminal?token={web_server._SESSION_TOKEN}"
bridges = []
processes = []
def open_shell(client):
with client.websocket_connect(url) as ws:
metadata = json.loads(ws.receive_text().removeprefix(web_host_terminal.META_PREFIX))
session = get_host_terminals(app)._sessions[metadata["terminalId"]]
bridges.append(session.bridge)
processes.append(psutil.Process(session.bridge.pid))
ws.send_bytes(b"printf 'LIFE%s=%s\\n' SPAN $$\n")
output = b""
marker = f"LIFESPAN={session.bridge.pid}".encode()
while marker not in output:
output += ws.receive_bytes()
assert session.bridge.is_alive()
return session.bridge
try:
with ExitStack() as older, ExitStack() as newer:
first_client = older.enter_context(TestClient(app, base_url="http://localhost"))
first = open_shell(first_client)
second_client = newer.enter_context(TestClient(app, base_url="http://localhost"))
second = open_shell(second_client)
closing, live_client = (older, second_client) if older_first else (newer, first_client)
retired, live = (first, second) if older_first else (second, first)
closing.close()
assert not retired.is_alive() and live.is_alive()
# This request used to create a third, unowned registry when the
# newer lifespan exited first. Its child escaped the older teardown.
open_shell(live_client)
assert all(not bridge.is_alive() for bridge in bridges)
assert all(not process.is_running() for process in processes)
# An accepted request outside a lifespan must fail, not spawn a shell.
# TestClient without a context deliberately does not run startup.
with TestClient(app).websocket_connect(url) as ws:
assert "terminalId" not in ws.receive_text()
with pytest.raises(WebSocketDisconnect) as error:
ws.receive_bytes()
assert error.value.code == 1011
assert not hasattr(app.state, "host_terminals")
finally:
# Keep the red regression safe even when a child escapes ownership.
registry = getattr(app.state, "host_terminals", None)
if registry is not None:
for session in registry._sessions.values():
session.bridge.close()
for bridge in bridges:
bridge.close()
@@ -0,0 +1,59 @@
"""Webapp host shells start with a locale the host's C library accepts.
glibc rejects the bare charset name ``UTF-8`` that macOS accepts as a locale, and
LC_CTYPE outranks LANG, so forcing ``LC_CTYPE=UTF-8`` on Linux made every host
shell warn and fall back to ASCII even when the user's LANG was valid.
"""
from __future__ import annotations
import os
import subprocess
import pytest
@pytest.mark.parametrize(
("env", "platform", "expected"),
[
# Linux: a set LANG already governs LC_CTYPE; only a missing one needs C.UTF-8.
({"LANG": "en_US.UTF-8"}, "linux", None),
({}, "linux", "C.UTF-8"),
({"LANG": ""}, "linux", "C.UTF-8"),
# An explicit LC_CTYPE or LC_ALL is the user's choice on every platform.
({"LANG": "en_US.UTF-8", "LC_CTYPE": "de_DE.UTF-8"}, "linux", None),
({"LC_ALL": "de_DE.UTF-8"}, "linux", None),
({"LC_CTYPE": "de_DE.UTF-8"}, "darwin", None),
# macOS accepts the bare charset, and a launchd-started backend often has no LANG.
({}, "darwin", "UTF-8"),
({"LANG": "en_US.UTF-8"}, "darwin", "UTF-8"),
# Windows shells take their code page from the console, not LC_CTYPE.
({}, "win32", None),
],
)
def test_lc_ctype_rule(env, platform, expected):
from hermes_cli.web_host_terminal import terminal_lc_ctype
assert terminal_lc_ctype(env, platform) == expected
@pytest.mark.platforms("linux")
@pytest.mark.parametrize("lang", ["C.UTF-8", None], ids=["lang-set", "lang-unset"])
def test_linux_host_shell_env_selects_a_utf8_locale(monkeypatch, lang):
"""The env resolve_argv hands the PTY must pass glibc's setlocale with a UTF-8 codeset."""
from hermes_cli.web_host_terminal import resolve_argv
from hermes_constants import get_hermes_home
for key in [k for k in os.environ if k.startswith("LC_") or k in ("LANG", "LANGUAGE")]:
monkeypatch.delenv(key)
if lang is not None:
monkeypatch.setenv("LANG", lang)
_argv, _cwd, env, _shell = resolve_argv(home=get_hermes_home())
probe = subprocess.run(
["locale", "charmap"], env=env, capture_output=True, text=True, timeout=30, check=False,
)
assert "Cannot set" not in probe.stderr, probe.stderr
assert probe.stdout.strip() == "UTF-8"
if lang is not None:
assert env["LANG"] == lang
@@ -0,0 +1,618 @@
"""Native shell continuity through the real /api/host-terminal route, isolated from user state."""
import asyncio
import json
from pathlib import Path
import pytest
from fastapi import FastAPI
from starlette.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
from hermes_cli import web_host_terminal, web_server, web_server_chat
from hermes_cli.web_routers.webapp import router
from tests.waiting import async_wait_until
@pytest.fixture
def host_app(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
(home / "config.yaml").write_text("{}", encoding="utf-8")
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(web_server.app.state, "ui_surface", "webapp", raising=False)
monkeypatch.setattr(web_server.app.state, "bound_host", "localhost", raising=False)
monkeypatch.setattr(web_server.app.state, "auth_required", False, raising=False)
# Do not run login scripts from the real user's shell.
from hermes_cli import web_host_terminal
monkeypatch.setattr(web_host_terminal, "shell_spec", lambda: (["/bin/sh", "-i"], "sh"))
app = FastAPI()
app.include_router(router)
from hermes_cli.web_host_terminal_sessions import host_terminal_lifespan
app.router.lifespan_context = host_terminal_lifespan
yield app
def url(extra=""):
return f"ws://localhost/api/host-terminal?token={web_server._SESSION_TOKEN}{extra}"
def metadata(ws):
text = ws.receive_text()
assert text.startswith(web_host_terminal.META_PREFIX)
return json.loads(text.removeprefix(web_host_terminal.META_PREFIX))
def output_until(ws, marker):
output = b""
while marker not in output:
output += ws.receive_bytes()
return output
def output_field(ws, name):
"""The digits/hex a command printed as ``NAME=value`` (markers split in source survive echo)."""
import re
pattern = re.compile(re.escape(name) + rb"=([0-9a-f:]+)\r?\n")
output = b""
while (match := pattern.search(output)) is None:
output += ws.receive_bytes()
return match[1]
@pytest.mark.platforms("linux")
@pytest.mark.parametrize("named_launch", [False, True])
def test_shell_scopes_passthrough_after_eager_activation(
host_app, tmp_path, monkeypatch, named_launch,
):
from agent import secret_scope
from hermes_constants import get_hermes_home_override
from tools.terminal_scope import get_terminal_scope
from tui_gateway import launch_profile_policy
root = tmp_path / ".hermes"
launch = root / "profiles" / "launch" if named_launch else root
other = root / "profiles" / "other"
passthrough = "WEBAPP_TEST_TOKEN"
launch_only = "WEBAPP_LAUNCH_ONLY_TOKEN"
other_only = "WEBAPP_OTHER_ONLY_TOKEN"
for home in (launch, other):
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
f"terminal:\n env_passthrough: [{passthrough}, {launch_only}, {other_only}]\n",
encoding="utf-8",
)
(other / ".env").write_text(
f"{passthrough}=other-value\n{other_only}=other-only\n", encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(launch))
monkeypatch.setenv(passthrough, "launch-value")
monkeypatch.setenv(launch_only, "launch-only")
monkeypatch.delenv(other_only, raising=False)
monkeypatch.delenv("GATEWAY_MULTIPLEX_PROFILES", raising=False)
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False)
monkeypatch.setattr(launch_profile_policy, "_snapshot", None)
assert launch_profile_policy.activate_multi_profile_hosting_eagerly()
# A later secondary's process-env residue must not replace the frozen launch value.
monkeypatch.setenv(passthrough, "ambient-poison")
command = (
"printf 'SCO%s=%s:%s:%s\\n' PE "
f'"${{{passthrough}}}" "${{{launch_only}-missing}}" "${{{other_only}-missing}}"\n'
).encode()
launch_name = "launch" if named_launch else "default"
with TestClient(host_app, base_url="http://localhost") as client:
for profile, expected in (
("current", b"launch-value:launch-only:missing"),
("other", b"other-value:missing:other-only"),
(launch_name, b"launch-value:launch-only:missing"),
):
with client.websocket_connect(url(f"&profile={profile}")) as ws:
assert metadata(ws)["shell"] == "sh"
ws.send_bytes(command)
output = output_until(ws, b"SCOPE=")
assert b"SCOPE=" + expected in output
assert b"ambient-poison" not in output
assert secret_scope.current_secret_scope() is None
assert get_hermes_home_override() is None
assert get_terminal_scope() is None
# The fix must bind the caller, not disable the process-wide fail-closed guard.
with pytest.raises(secret_scope.UnscopedSecretError):
secret_scope.get_secret(passthrough)
@pytest.mark.platforms("linux")
def test_real_shell_survives_disconnect_and_explicit_close(host_app, tmp_path):
import shlex
import sys
with TestClient(host_app, base_url="http://localhost") as client:
with client.websocket_connect(url("&cwd=" + str(tmp_path))) as ws:
first = metadata(ws)
token = first["terminalId"]
bridge = host_app.state.host_terminals._sessions[token].bridge
ws.send_bytes(b"stty -echo; continuity=retained; printf 'PID=%s\\n' $$\n")
output_until(ws, b"PID=")
# Echo can contain the command: use a response marker split in source.
ws.send_bytes(b"printf 'REA%s=%s\\n' DY $$\n")
ready = output_until(ws, b"READY=")
pid = ready.split(b"READY=")[-1].splitlines()[0].strip()
program = 'import os,sys; print("CHILD="+str(os.getpid()), flush=True); print("INPUT="+repr(sys.stdin.readline()), flush=True)'
ws.send_bytes(f"{shlex.quote(sys.executable)} -u -c {shlex.quote(program)}\n".encode())
child = output_until(ws, b"CHILD=")
child_pid = child.split(b"CHILD=")[-1].splitlines()[0].strip()
with client.websocket_connect(url("&attach=" + token)) as ws:
resumed = metadata(ws)
assert resumed["reconnected"] is True
assert resumed["terminalId"] == token
assert b"CHILD=" + child_pid in output_until(ws, b"CHILD=" + child_pid)
ws.send_bytes(b"foreground survived\n")
assert b"INPUT='foreground survived\\n'" in output_until(ws, b"INPUT='foreground survived\\n'")
ws.send_bytes(b"printf 'STATE=%s:%s\\n' \"$continuity\" $$\n")
assert b"STATE=retained:" + pid in output_until(ws, b"STATE=retained:" + pid)
program = 'import os,time; print("CLOSING="+str(os.getpid()), flush=True); time.sleep(120)'
ws.send_bytes(f"{shlex.quote(sys.executable)} -u -c {shlex.quote(program)}\n".encode())
running = output_until(ws, b"CLOSING=")
foreground_pid = int(running.split(b"CLOSING=")[-1].splitlines()[0].strip())
with client.websocket_connect(url("&attach=" + token + "&action=close")) as ws:
assert metadata(ws)["closed"] is True
with pytest.raises(WebSocketDisconnect) as closed:
ws.receive_bytes()
assert closed.value.code == 1000
assert not bridge.is_alive()
import psutil
try:
child_process = psutil.Process(foreground_pid)
except psutil.NoSuchProcess:
pass
else:
assert child_process.status() == psutil.STATUS_ZOMBIE
with client.websocket_connect(url("&attach=" + token)) as ws:
with pytest.raises(WebSocketDisconnect) as expired:
ws.receive_bytes()
assert expired.value.code == 4410
@pytest.mark.platforms("posix")
def test_paste_waits_for_a_foreground_program_that_is_not_reading(host_app, tmp_path, monkeypatch):
"""A paste bigger than the PTY input queue (about 1 KB on macOS, 15 KB on Linux)
while ``sleep`` or ``npm install`` runs waits for the program, however long it
takes: the shell survives and the program then reads every byte in order."""
import hashlib
import shlex
import sys
# Shrink the bridge's default write deadline (the chat TUI's 10 s) so a short
# stall outlasts it: a host terminal that writes with the default loses its shell.
bridge_write = web_server_chat.PtyBridge.write
async def write(self, data, *, timeout=0.5, **options):
return await bridge_write(self, data, timeout=timeout, **options)
monkeypatch.setattr(web_server_chat.PtyBridge, "write", write)
paste = b"".join(b"%05d %s\n" % (n, b"x" * 57) for n in range(1024)) # 64 KiB
stall = 2 # longer than the shrunken default deadline
program = (
f"import hashlib,sys,time; print('BUS'+'Y=1', flush=True); time.sleep({stall}); "
f"data = sys.stdin.buffer.read({len(paste)}); "
"print('GOT=%d:%s' % (len(data), hashlib.sha256(data).hexdigest()), flush=True)")
with TestClient(host_app, base_url="http://localhost") as client:
with client.websocket_connect(url("&cwd=" + str(tmp_path))) as ws:
metadata(ws)
ws.send_bytes(b"stty -echo; printf 'SHE%s=%s\\n' LL $$\n")
shell = output_field(ws, b"SHELL")
ws.send_bytes(f"{shlex.quote(sys.executable)} -c {shlex.quote(program)}\n".encode())
output_field(ws, b"BUSY")
ws.send_bytes(paste)
got = output_field(ws, b"GOT")
assert got == b"%d:%s" % (len(paste), hashlib.sha256(paste).hexdigest().encode())
ws.send_bytes(b"printf 'SHE%s=%s\\n' LL $$\n")
assert output_field(ws, b"SHELL") == shell
@pytest.mark.platforms("linux")
@pytest.mark.parametrize("spawn_fails", [False, True])
def test_route_cancelled_spawn_is_owned_through_lifespan(host_app, monkeypatch, spawn_fails):
import threading
original_spawn = web_server_chat.PtyBridge.spawn
entered, release, returned = threading.Event(), threading.Event(), threading.Event()
closing = threading.Event()
bridges = []
releaser = None
def spawn(argv, **kwargs):
entered.set()
assert release.wait(5)
try:
if spawn_fails:
raise OSError("fork failed after disconnect")
bridge = original_spawn(["/bin/sh", "-c", "sleep 120"], **kwargs)
bridges.append(bridge)
return bridge
finally:
returned.set()
def release_on_shutdown():
if closing.wait(3):
release.set()
monkeypatch.setattr(web_server_chat.PtyBridge, "spawn", spawn)
try:
with TestClient(host_app) as client:
registry = host_app.state.host_terminals
original_close_all = registry.close_all
async def close_all():
closing.set()
await original_close_all()
monkeypatch.setattr(registry, "close_all", close_all)
with client.websocket_connect(url()):
assert entered.wait(2)
# TestClient cancels the request in its real AnyIO scope. It must
# not wait for fork/exec or own the cleanup that shutdown awaits.
assert not returned.is_set()
releaser = threading.Thread(target=release_on_shutdown)
releaser.start()
assert closing.is_set() and returned.is_set()
assert not registry._sessions and not registry.owners
assert len(bridges) == (0 if spawn_fails else 1)
assert all(not bridge.is_alive() for bridge in bridges)
finally:
release.set()
if releaser is not None:
releaser.join(3)
for bridge in bridges:
bridge.close()
class IdleBridge:
def __init__(self):
import threading
self.closed = threading.Event()
self.wrote = threading.Event()
self.writes = []
self.resizes = []
def read(self, timeout):
self.closed.wait(timeout)
return None if self.closed.is_set() else b""
async def write(self, data, *, timeout=10.0, fence=None):
def record():
self.writes.append(data)
self.wrote.set()
return True
return record() if fence is None else await asyncio.to_thread(fence, record)
def resize(self, cols, rows):
self.resizes.append((cols, rows))
def close(self):
self.closed.set()
def is_alive(self):
return not self.closed.is_set()
@pytest.fixture
def fake_bridges(monkeypatch):
bridges = []
def spawn(*args, **kwargs):
bridge = IdleBridge()
bridges.append(bridge)
return bridge
monkeypatch.setattr(web_server_chat.PtyBridge, "spawn", spawn)
return bridges
def rejection(client, request, expected):
# Handles pre-accept gate failures and post-accept terminal refusals.
with pytest.raises(WebSocketDisconnect) as error:
with client.websocket_connect(request) as ws:
ws.receive_text()
assert error.value.code == expected
def ticket_url(user="owner", extra=""):
from hermes_cli.dashboard_auth.ws_tickets import mint_ticket
ticket = mint_ticket(user_id=user, provider="test-provider")
return f"ws://localhost/api/host-terminal?ticket={ticket}{extra}"
def test_identity_profile_deletion_and_auth_gate(host_app, fake_bridges, tmp_path, monkeypatch):
from hermes_constants import mark_named_profile_deleted
home = tmp_path / ".hermes"
for name in ("alpha", "beta"):
profile = home / "profiles" / name
profile.mkdir(parents=True)
(profile / "config.yaml").write_text("{}", encoding="utf-8")
monkeypatch.setattr(web_server.app.state, "auth_required", True)
with TestClient(host_app) as client:
with client.websocket_connect(ticket_url(extra="&profile=alpha")) as ws:
token = metadata(ws)["terminalId"]
rejection(client, ticket_url("other", "&profile=alpha&attach=" + token), 4403)
rejection(client, ticket_url(extra="&profile=beta&attach=" + token), 4403)
rejection(client, ticket_url("other", "&profile=alpha&attach=" + token + "&action=close"), 4403)
rejection(client, ticket_url(extra="&profile=beta&attach=" + token + "&action=close"), 4403)
rejection(client, url("&profile=alpha&attach=" + token), 4401)
rejection(client, url("&profile=alpha&attach=" + token + "&action=close"), 4401)
assert len(fake_bridges) == 1 and not fake_bridges[0].closed.is_set()
with client.websocket_connect(ticket_url(extra="&profile=alpha&attach=" + token)) as ws:
assert metadata(ws)["reconnected"]
assert fake_bridges[0].writes == [] # never Ctrl-L into a host shell
mark_named_profile_deleted(home / "profiles" / "alpha")
rejection(client, ticket_url(extra="&profile=alpha&attach=" + token), 4410)
rejection(client, ticket_url(extra="&profile=alpha&attach=" + token + "&action=close"), 4410)
assert len(fake_bridges) == 1
assert fake_bridges[0].closed.wait(3) # live reaper retires a deleted profile's shells
@pytest.mark.platforms("linux")
def test_real_profile_a_b_a_and_process_exit_never_respawns(host_app, tmp_path):
homes = {}
for name in ("alpha", "beta"):
homes[name] = tmp_path / ".hermes" / "profiles" / name
homes[name].mkdir(parents=True)
(homes[name] / "config.yaml").write_text("{}", encoding="utf-8")
with TestClient(host_app) as client:
tokens = {}
for name in ("alpha", "beta", "alpha"):
extra = "&profile=" + name
if name in tokens:
extra += "&attach=" + tokens[name]
with client.websocket_connect(url(extra)) as ws:
tokens[name] = metadata(ws)["terminalId"]
ws.send_bytes(b"printf 'HO%s=%s\\n' ME \"$HERMES_HOME\"\n")
assert b"HOME=" + str(homes[name]).encode() in output_until(ws, b"HOME=" + str(homes[name]).encode())
token = tokens["alpha"]
with client.websocket_connect(url("&profile=alpha&attach=" + token)) as ws:
metadata(ws)
ws.send_bytes(b"exit\n")
with pytest.raises(WebSocketDisconnect) as error:
while True:
ws.receive_bytes()
assert error.value.code == 4410
rejection(client, url("&profile=alpha&attach=" + token), 4410)
assert set(host_app.state.host_terminals._sessions) <= set(tokens.values())
@pytest.mark.platforms("linux")
def test_real_shell_rejects_input_after_profile_deletion(host_app, tmp_path):
import os
import shlex
import subprocess
import sys
profile = tmp_path / ".hermes" / "profiles" / "alpha"
profile.mkdir(parents=True)
(profile / "config.yaml").write_text("{}", encoding="utf-8")
before, after = tmp_path / "before", tmp_path / "after"
with TestClient(host_app) as client:
with client.websocket_connect(url("&profile=alpha")) as ws:
metadata(ws)
ws.send_bytes((
f"stty -echo; printf before > {shlex.quote(str(before))}; printf 'REA%s\\n' DY\n"
).encode())
output_until(ws, b"READY")
assert before.read_text(encoding="utf-8-sig") == "before"
# A different process cannot notify the serving process's registry.
subprocess.run([
sys.executable, "-c",
"import sys; from hermes_constants import mark_named_profile_deleted; "
"mark_named_profile_deleted(sys.argv[1])",
str(profile),
], env={**os.environ, "HOME": str(tmp_path)}, check=True, timeout=10)
ws.send_bytes((
f"printf executed > {shlex.quote(str(after))}; printf 'STA%s\\n' LE\n"
).encode())
with pytest.raises(WebSocketDisconnect) as error:
output_until(ws, b"STALE")
assert error.value.code == 4410
assert not after.exists()
def test_capacity_expiry_close_idempotence_and_lifespan(host_app, fake_bridges):
with TestClient(host_app) as client:
registry = host_app.state.host_terminals
registry._max = 1
with client.websocket_connect(url()) as ws:
token = metadata(ws)["terminalId"]
rejection(client, url(), 1013)
assert not fake_bridges[0].closed.is_set()
detached = registry._sessions[token].last_detached_at
assert detached is not None
registry._sessions[token].last_detached_at = detached - registry._ttl - 1
rejection(client, url("&attach=" + token), 4410)
assert len(fake_bridges) == 1
# A fresh request may create, an expired attach never does.
with client.websocket_connect(url()) as ws:
next_token = metadata(ws)["terminalId"]
assert next_token != token
for _ in range(2):
with client.websocket_connect(url("&attach=" + next_token + "&action=close")) as ws:
assert metadata(ws)["closed"]
with client.websocket_connect(url()) as ws:
metadata(ws)
assert all(bridge.closed.is_set() for bridge in fake_bridges)
assert not registry._sessions and not registry.owners
assert not hasattr(host_app.state, "host_terminals")
def test_simultaneous_viewer_supersedes_without_respawn(host_app, fake_bridges):
with TestClient(host_app) as client:
with client.websocket_connect(url()) as old:
token = metadata(old)["terminalId"]
assert old.receive_bytes() == b"" # explicit empty replay boundary
with client.websocket_connect(url("&attach=" + token)) as new:
assert metadata(new)["reconnected"]
with pytest.raises(WebSocketDisconnect) as error:
old.receive_bytes()
assert error.value.code == 4409
new.send_bytes(b"current")
assert fake_bridges[0].wrote.wait(3)
assert len(fake_bridges) == 1
assert fake_bridges[0].writes == [b"current"]
def test_ownership_checks_never_block_the_event_loop(host_app, fake_bridges, tmp_path, monkeypatch):
"""Ownership reads the profile's home and tombstone; on a slow disk that stalls
every socket the process serves. Create, reattach, input and the reaper all
check off-loop, and a deleted profile still loses its attached shell."""
import asyncio
from hermes_cli.web_host_terminal_sessions import HostOwner
from hermes_constants import mark_named_profile_deleted
profile = tmp_path / ".hermes" / "profiles" / "alpha"
profile.mkdir(parents=True)
(profile / "config.yaml").write_text("{}", encoding="utf-8")
checks = []
original_current = HostOwner.current
def record():
try:
asyncio.get_running_loop()
checks.append("loop")
except RuntimeError:
checks.append("thread")
def current(owner):
record()
return original_current(owner)
monkeypatch.setattr(HostOwner, "current", current)
with TestClient(host_app) as client:
with client.websocket_connect(url("&profile=alpha")) as ws:
token = metadata(ws)["terminalId"]
with client.websocket_connect(url("&profile=alpha&attach=" + token)) as ws:
assert metadata(ws)["reconnected"]
ws.send_bytes(b"input")
assert fake_bridges[0].wrote.wait(3)
mark_named_profile_deleted(profile)
with pytest.raises(WebSocketDisconnect) as error:
while True:
ws.receive_bytes()
assert error.value.code == 4410
assert fake_bridges[0].closed.wait(3)
assert fake_bridges[0].writes == [b"input"]
assert checks and set(checks) == {"thread"}
def test_input_frames_that_arrive_together_share_ownership_checks(tmp_path, monkeypatch):
"""A paste lands as many frames. Frames that arrived together share off-loop
fenced writes, every byte lands in order, and a deleted home receives none."""
from types import SimpleNamespace
from hermes_cli.web_host_terminal_sessions import HostOwner, _pump_input
frames = [f"line {i}\n".encode() for i in range(40)]
checks = []
original_current = HostOwner.current
def current(owner):
try:
asyncio.get_running_loop()
checks.append("loop")
except RuntimeError:
checks.append("thread")
return original_current(owner)
monkeypatch.setattr(HostOwner, "current", current)
class Socket:
def __init__(self):
# Every frame is already buffered when the pump starts reading.
self.inbound = [{"type": "websocket.receive", "bytes": frame} for frame in frames]
self.inbound.append({"type": "websocket.disconnect"})
async def receive(self):
return self.inbound.pop(0)
async def pump(home):
checks.clear()
written, removed = [], []
# Not a named profile home: it stays owned while it exists.
owner = HostOwner(("loopback", "session-token"), home, "sh", str(tmp_path))
async def write(_ws, data, *, fence, **_bridge_options):
return await asyncio.to_thread(fence, lambda: written.append(data) or True)
async def remove(token):
removed.append(token)
ws = Socket()
session = SimpleNamespace(_ws=ws, alive=True, write=write, resize=lambda *args, **kwargs: None)
await _pump_input(ws, SimpleNamespace(remove=remove), "terminal", session, owner)
return written, removed
written, removed = asyncio.run(pump(tmp_path))
assert b"".join(written) == b"".join(frames) and removed == []
assert set(checks) == {"thread"} and len(checks) < len(frames)
written, removed = asyncio.run(pump(tmp_path / "deleted"))
assert written == [] and removed == ["terminal"]
assert set(checks) == {"thread"}
def test_cross_process_deletion_closes_attached_shell(host_app, fake_bridges, tmp_path):
import os
import subprocess
import sys
profile = tmp_path / ".hermes" / "profiles" / "alpha"
profile.mkdir(parents=True)
(profile / "config.yaml").write_text("{}", encoding="utf-8")
with TestClient(host_app) as client:
with client.websocket_connect(url("&profile=alpha")) as ws:
metadata(ws)
assert ws.receive_bytes() == b"" # empty replay precedes deletion
# A different process cannot notify the serving process's registry.
subprocess.run([
sys.executable, "-c",
"import sys; from hermes_constants import mark_named_profile_deleted; "
"mark_named_profile_deleted(sys.argv[1])",
str(profile),
], env={**os.environ, "HOME": str(tmp_path)}, check=True, timeout=5)
with pytest.raises(WebSocketDisconnect) as error:
ws.receive_bytes()
assert error.value.code == 4410
assert fake_bridges[0].closed.wait(3)
@pytest.mark.parametrize("failure", ["metadata", "replay"])
def test_route_lost_initial_delivery_detaches_and_expires(host_app, fake_bridges, monkeypatch, failure):
from starlette.websockets import WebSocket
from hermes_cli.pty_session import PtySession
original_start = PtySession.start
async def start(session):
session.buffer.append(b"startup output")
await original_start(session)
async def broken(*args, **kwargs):
raise RuntimeError("transport lost")
monkeypatch.setattr(PtySession, "start", start)
monkeypatch.setattr(WebSocket, "send_text" if failure == "metadata" else "send_bytes", broken)
with TestClient(host_app) as client:
with client.websocket_connect(url()) as ws:
if failure == "replay":
metadata(ws)
with pytest.raises(WebSocketDisconnect) as error:
ws.receive_bytes()
assert error.value.code == 1001 # reconnect; the shell is retained
registry = host_app.state.host_terminals
session = next(iter(registry._sessions.values()))
assert not session.attached and session.last_detached_at is not None
assert client.portal is not None
client.portal.call(registry.reap_idle, session.last_detached_at + registry._ttl + 1)
assert not registry._sessions and fake_bridges[0].closed.is_set()
def test_host_policy_and_missing_profile_do_not_spawn(host_app, fake_bridges, monkeypatch):
with TestClient(host_app) as client:
rejection(client, url("&profile=missing"), 4410)
rejection(client, url("&attach=" + "x" * 43), 4410)
monkeypatch.setattr(web_server.app.state, "ui_surface", "dashboard")
for extra in ("", "&attach=" + "x" * 43, "&attach=" + "x" * 43 + "&action=close"):
rejection(client, url(extra), 4403)
assert not fake_bridges
@@ -0,0 +1,191 @@
"""Real HTTP file tickets die with the named profile they belong to.
A ticket captures the resolved file and the named profile homes it belongs to (the target's and the
session/profile owner's); once one of them is deleted (main's tombstone rule,
``hermes_constants.assert_named_profile_home_live``) the ticket is refused, and a new one cannot be
minted.
"""
import asyncio
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import secrets
import shutil
import socket
import threading
import time
import httpx
import pytest
import uvicorn
from starlette.responses import FileResponse
from hermes_cli import web_server
from hermes_cli.web_routers import files
from hermes_constants import mark_named_profile_deleted
@pytest.fixture
def file_server(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_DASHBOARD_FILES_ROOT", str(home))
monkeypatch.setattr(web_server, "_SESSION_TOKEN", secrets.token_urlsafe(32))
monkeypatch.setattr(web_server.app.state, "auth_required", False, raising=False)
monkeypatch.setattr(web_server.app.state, "bound_host", "127.0.0.1", raising=False)
monkeypatch.setattr(web_server.app.state, "ui_surface", "serve", raising=False)
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
server = uvicorn.Server(uvicorn.Config(
web_server.app, lifespan="off", access_log=False, log_level="error",
))
thread = threading.Thread(target=server.run, kwargs={"sockets": [sock]}, daemon=True)
thread.start()
deadline = time.monotonic() + 10
while not server.started and thread.is_alive() and time.monotonic() < deadline:
time.sleep(0.01)
assert server.started
with httpx.Client(base_url=f"http://127.0.0.1:{sock.getsockname()[1]}", trust_env=False, timeout=15) as client:
try:
yield client, home
finally:
server.should_exit = True
thread.join(10)
sock.close()
assert not thread.is_alive()
def _profile(home, content):
"""A named profile ``worker`` holding ``clip.mp4``, without service or OS-user operations."""
target = home / "profiles" / "worker"
target.mkdir(parents=True)
(target / "SOUL.md").write_text("Synthetic test profile", encoding="utf-8")
(target / "clip.mp4").write_bytes(content)
return target
def _delete(profile, *, remove=True):
"""What a profile delete leaves: the tombstone first, then (unless still in progress) no home."""
mark_named_profile_deleted(profile)
if remove:
shutil.rmtree(profile)
def _ticket(client, route, query):
minted = client.post("/api/files/ticket", json={"route": route, **query}, headers={
web_server._SESSION_HEADER_NAME: web_server._SESSION_TOKEN,
})
assert minted.status_code == 200, minted.text
assert minted.headers["cache-control"] == "no-store"
return {**query, "ticket": minted.json()["ticket"]}
def _session_owned_file(home, profile, content):
"""Write the file outside the session owner's home: binding only the file's path
would miss the owner's deletion."""
from hermes_state import SessionDB
(home / "clip.mp4").write_bytes(content)
db = SessionDB(db_path=profile / "state.db")
db.create_session("synthetic-session", "desktop", cwd=str(home), profile_name="worker")
db.close()
def _session_scope(home, profile):
_session_owned_file(home, profile, b"old-generation")
return "download", {"path": "clip.mp4", "profile": "worker", "session_id": "synthetic-session"}
# How the ticket names the file: (home, profile) -> (route, query).
_TICKET_SCOPES = {
"profile": lambda home, profile: ("stream", {"path": str(profile / "clip.mp4"), "profile": "worker"}),
"implicit": lambda home, profile: ("stream", {"path": str(profile / "clip.mp4")}),
"managed-relative": lambda home, profile: ("stream", {"path": "profiles/worker/clip.mp4"}),
"file-uri": lambda home, profile: ("stream", {"path": (profile / "clip.mp4").as_uri()}),
"session": _session_scope,
}
@pytest.mark.parametrize("remove", [False, True], ids=["tombstoned", "removed"])
@pytest.mark.parametrize("scope", list(_TICKET_SCOPES))
def test_http_tickets_die_with_their_profile_or_session_owner(file_server, scope, remove):
client, home = file_server
profile = _profile(home, b"old-generation")
route, query = _TICKET_SCOPES[scope](home, profile)
params = _ticket(client, route, query)
unused_download = _ticket(client, "download", query)
endpoint = f"/api/files/{route}"
for _ in range(2 if route == "stream" else 1):
response = client.get(endpoint, params=params, headers={"Range": "bytes=0-2"})
assert (response.status_code, response.content) == (206, b"old")
assert response.headers["content-range"] == "bytes 0-2/14"
if route == "download":
assert client.get(endpoint, params=params).status_code == 401
params = _ticket(client, route, query)
else:
assert client.head(endpoint, params=params).headers["content-length"] == "14"
assert client.get(endpoint, params=params, headers={"Range": "bytes=99-100"}).status_code == 416
multiple = client.get(endpoint, params=params, headers={"Range": "bytes=0-2,4-6"})
assert multiple.status_code == 206 and b"old" in multiple.content and b"gen" in multiple.content
_delete(profile, remove=remove)
stale = client.get(endpoint, params=params, headers={"Range": "bytes=0-2"})
assert stale.status_code in (401, 403, 404), stale.content
assert client.get("/api/files/download", params=unused_download).status_code in (401, 403, 404)
refused = client.post("/api/files/ticket", json={"route": route, **query}, headers={
web_server._SESSION_HEADER_NAME: web_server._SESSION_TOKEN,
})
assert refused.status_code == 404, refused.text
@pytest.mark.parametrize("boundary", [
"before-open",
pytest.param("after-open", marks=pytest.mark.platforms("posix")),
])
def test_http_ticket_resource_open_is_ordered_with_profile_deletion(file_server, monkeypatch, boundary):
client, home = file_server
profile = _profile(home, b"old-generation")
query = {"path": str(profile / "clip.mp4"), "profile": "worker"}
params = _ticket(client, "stream", query)
paused, resume = threading.Event(), threading.Event()
async def barrier():
paused.set()
assert await asyncio.to_thread(resume.wait, 10), "replacement did not release the HTTP request"
if boundary == "before-open":
original = files._managed_file_response
async def before_open(*args, **kwargs):
await barrier()
return await original(*args, **kwargs)
monkeypatch.setattr(files, "_managed_file_response", before_open)
else:
original = FileResponse.__call__
async def before_body(self, scope, receive, send):
# FileResponse dispatch happens after the ticketed response pins its
# descriptor, but before the plain implementation would open a path.
await barrier()
return await original(self, scope, receive, send)
monkeypatch.setattr(FileResponse, "__call__", before_body)
with ThreadPoolExecutor(max_workers=1) as pool:
pending = pool.submit(client.get, "/api/files/stream", params=params, headers={"Range": "bytes=0-2"})
try:
assert paused.wait(10)
# Completing this before releasing the stream also proves nothing the
# deletion waits on is held across response delivery.
_delete(profile)
finally:
resume.set()
response = pending.result(15)
if boundary == "before-open":
assert response.status_code in (401, 403, 404), response.content
else:
assert (response.status_code, response.content) == (206, b"old")
assert client.get("/api/files/stream", params=params).status_code in (401, 403, 404)
+159 -19
View File
@@ -154,7 +154,10 @@ def test_download_authenticates_via_query_token(forced_files_client):
).status_code == 401
def test_download_resolves_paths_in_the_originating_profile_session(local_files_client, monkeypatch):
@pytest.mark.parametrize("route", ["/api/fs/download", "/api/fs/read-data-url", "/api/files/download"])
def test_download_resolves_paths_in_the_originating_profile_session(
local_files_client, monkeypatch, route,
):
from pathlib import Path
from hermes_state import SessionDB
@@ -180,21 +183,43 @@ def test_download_resolves_paths_in_the_originating_profile_session(local_files_
db.create_session(sid, source="gui", cwd=cwd)
finally:
db.close()
for route in ("/api/fs/download", "/api/fs/read-data-url"):
for path in ("./report.txt", "../project/report.txt", str(artifact), artifact.as_uri()):
response = client.get(route, params={
"path": path, "profile": "default", "session_id": "origin-session",
})
assert response.status_code == 200, response.text
data = (base64.b64decode(response.json()["dataUrl"].split(",", 1)[1])
if route.endswith("read-data-url") else response.content)
assert data == artifact.read_bytes()
for profile, session_id in (("other", "origin-session"), ("missing", "origin-session"),
("default", "missing-session"), ("default", "")):
response = client.get(route, params={
"path": str(artifact), "profile": profile, "session_id": session_id,
})
assert response.status_code == 404, response.text
for path in ("./report.txt", "../project/report.txt", str(artifact), artifact.as_uri()):
response = client.get(route, params={
"path": path, "profile": "default", "session_id": "origin-session",
})
assert response.status_code == 200, response.text
data = (base64.b64decode(response.json()["dataUrl"].split(",", 1)[1])
if route.endswith("read-data-url") else response.content)
assert data == artifact.read_bytes()
for profile, session_id in (("other", "origin-session"), ("missing", "origin-session"),
("default", "missing-session"), ("default", "")):
response = client.get(route, params={
"path": str(artifact), "profile": profile, "session_id": session_id,
})
assert response.status_code == 404, response.text
@pytest.mark.parametrize("method", ["get", "head"])
def test_managed_download_accepts_file_uris_without_bypassing_policy(
forced_files_client, monkeypatch, method,
):
client, root = forced_files_client
artifact = _seed_file(client, root, name="out/a b.txt")
request = getattr(client, method)
response = request("/api/files/download", params={"path": artifact.as_uri()})
assert response.status_code == 200
assert response.headers["content-length"] == "5"
assert response.content == (b"hello" if method == "get" else b"")
outside = root.parent / "outside.txt"
outside.write_text("outside")
assert request("/api/files/download", params={"path": outside.as_uri()}).status_code == 403
sensitive = root / ".env"
sensitive.write_text("fixture-only")
assert request("/api/files/download", params={"path": sensitive.as_uri()}).status_code == 403
monkeypatch.setattr(web_server, "_MANAGED_FILE_MAX_BYTES", 1)
assert request("/api/files/download", params={"path": artifact.as_uri()}).status_code == 413
def test_stream_requires_header_auth_and_supports_ranges(forced_files_client):
@@ -202,8 +227,8 @@ def test_stream_requires_header_auth_and_supports_ranges(forced_files_client):
file_path = _seed_file(client, root, name="out/demo.mp4")
# Electron's main-process proxy supplies the connection credential as a
# header. Unlike browser-visible download links, the stream endpoint must
# not accept credentials in its URL.
# header. The session token grants host shells, so browser media elements
# use a path-bound ticket instead: the stream URL never accepts the token.
params = {"path": str(file_path)}
full = client.get("/api/files/stream", params=params)
@@ -239,6 +264,50 @@ def test_stream_requires_header_auth_and_supports_ranges(forced_files_client):
assert client.get("/api/files/stream", params=params).status_code == 401
def test_browser_file_urls_authenticate_only_with_route_and_path_bound_tickets(
forced_files_client, monkeypatch,
):
"""A ticket admits exactly the URL it was minted for: reusable for media
Range requests, single-use for a download, useless for any other file."""
from hermes_cli import web_server_file_tickets
client, root = forced_files_client
video = _seed_file(client, root, name="out/demo.mp4")
other = _seed_file(client, root, name="out/other.mp4")
def ticket(route, **query):
response = client.post("/api/files/ticket", json={"route": route, **query})
assert response.status_code == 200, response.text
assert response.headers["cache-control"] == "no-store"
return response.json()["ticket"]
stream = ticket("stream", path=str(video), profile="default")
download = ticket("download", path=str(video))
monkeypatch.setattr(web_server_file_tickets, "STREAM_TTL_SECONDS", 0)
expired = ticket("stream", path=str(video))
del client.headers[web_server._SESSION_HEADER_NAME]
assert client.post("/api/files/ticket", json={"route": "stream", "path": str(video)}).status_code == 401
params = {"path": str(video), "profile": "default", "ticket": stream}
for _ in range(2):
partial = client.get("/api/files/stream", params=params, headers={"Range": "bytes=1-3"})
assert (partial.status_code, partial.content) == (206, b"ell")
for foreign in (
{**params, "path": str(other)},
{**params, "profile": "other"},
{"path": str(video), "ticket": stream},
{**params, "path": [str(video), str(other)]},
):
assert client.get("/api/files/stream", params=foreign).status_code == 401, foreign
assert client.get("/api/files/download", params=params).status_code == 401
assert client.get("/api/files/stream", params={"path": str(video), "ticket": expired}).status_code == 401
once = {"path": str(video), "ticket": download}
assert client.get("/api/files/stream", params=once).status_code == 401
assert client.get("/api/files/download", params=once).content == b"hello"
assert client.get("/api/files/download", params=once).status_code == 401
def test_stream_rejects_non_media_active_content(forced_files_client):
client, root = forced_files_client
@@ -248,6 +317,77 @@ def test_stream_rejects_non_media_active_content(forced_files_client):
assert response.status_code == 415
@pytest.mark.parametrize("locked", [True, False])
def test_stream_file_uri_uses_host_resolution_without_bypassing_policy(
forced_files_client, monkeypatch, locked,
):
client, root = forced_files_client
artifact = _seed_file(client, root, name="out/a b.mp4")
if not locked:
monkeypatch.delenv("HERMES_DASHBOARD_FILES_ROOT")
params = {"path": artifact.as_uri()}
response = client.get("/api/files/stream", params=params, headers={"Range": "bytes=1-3"})
assert response.status_code == 206, response.text
assert response.content == b"ell"
assert response.headers["content-range"] == "bytes 1-3/5"
assert response.headers["content-disposition"].startswith("inline;")
assert response.headers["x-content-type-options"] == "nosniff"
head = client.head("/api/files/stream", params=params)
assert head.status_code == 200
assert head.content == b""
assert head.headers["content-length"] == "5"
outside = root.parent / "outside.mp4"
outside.write_bytes(b"outside")
assert client.get("/api/files/stream", params={"path": outside.as_uri()}).status_code == (
403 if locked else 200
)
sensitive = root / ".env"
sensitive.write_text("fixture-only")
assert client.get("/api/files/stream", params={"path": sensitive.as_uri()}).status_code == 403
non_media = root / "active.html"
non_media.write_text("<p>not media</p>")
assert client.get("/api/files/stream", params={"path": non_media.as_uri()}).status_code == 415
assert client.get("/api/files/stream", params={"path": (root / "missing.mp4").as_uri()}).status_code == 404
# Ordinary relative managed paths still resolve under the locked root, not cwd.
assert client.get("/api/files/stream", params={"path": "out/a b.mp4"}).status_code == (
200 if locked else 400
)
monkeypatch.setattr(web_server, "_MANAGED_FILE_MAX_BYTES", 1)
assert client.get("/api/files/stream", params=params).status_code == 413
@pytest.mark.platforms("windows")
@pytest.mark.parametrize("uri,expected", [
("file:///C:/Users/Alice/video%20clip.mp4", "C:/Users/Alice/video clip.mp4"),
("file://127.0.0.1/hermes-media-test/video%20clip.mp4", "//127.0.0.1/hermes-media-test/video clip.mp4"),
])
def test_stream_windows_file_uri_reaches_policy_with_absolute_host_path(
local_files_client, monkeypatch, uri, expected,
):
from pathlib import Path
from starlette.responses import Response
client, _home = local_files_client
paths = []
async def capture_response(request, path, *, content_disposition_type, media_only):
paths.append(Path(path))
assert content_disposition_type == "inline"
assert media_only is True
return Response(status_code=204)
# Exercise Windows URI conversion through the route without requiring a
# mounted SMB share; real policy/stream I/O is covered by the test above.
monkeypatch.setattr(_rt_files, "_managed_file_response", capture_response)
response = client.get("/api/files/stream", params={"path": uri})
assert response.status_code == 204
assert paths == [Path(expected).resolve(strict=False)]
assert paths[0].is_absolute()
def test_managed_reads_resolve_docker_mount_inside_locked_root(
forced_files_client, monkeypatch,
):
@@ -308,7 +448,7 @@ def test_query_token_does_not_authenticate_other_endpoints(forced_files_client):
del client.headers[web_server._SESSION_HEADER_NAME]
# The query-token escape hatch is scoped to downloads only; it must not
# The query-token escape hatch is scoped to downloads and playback; it must not
# unlock the rest of the API surface.
leaked = client.get(
"/api/files/read",
+37
View File
@@ -410,6 +410,43 @@ def test_fs_write_text_never_decodes_percent_path(client, tmp_path):
assert response.status_code == 200
assert sibling.read_text() == "existing"
assert (tmp_path / "report%20v2.md").read_text() == "new"
def test_fs_read_data_url_reads_a_browser_paste_from_the_host_for_an_ssh_profile(client, monkeypatch):
"""A browser paste is saved in the profile's host image cache even when its workspace is an
SSH machine. Sending it reads it back through this route, so that one host-owned path must not
go looking on the SSH machine; every other path still does."""
class FakeSshFs:
def read_bytes(self, path, *, max_bytes, read_limit=None):
raise ssh_workspace_fs.SshWorkspaceFsError("ENOENT", f"{path} is not on the SSH machine")
def inspect_file(self, path):
raise ssh_workspace_fs.SshWorkspaceFsError("ENOENT", f"{path} is not on the SSH machine")
monkeypatch.setattr(file_routes, "_fs_backend", lambda profile=None: FakeSshFs())
pasted = base64.b64decode("iVBORw0KGgo=")
upload = client.post(
"/api/chat/image-upload",
json={"data_url": "data:image/png;base64,iVBORw0KGgo=", "filename": "paste.png"},
)
assert upload.status_code == 200, upload.text
host_path = Path(upload.json()["path"])
# The same bytes on this host outside the image cache are still the SSH machine's to answer.
elsewhere = host_path.parent.parent / "elsewhere.png"
elsewhere.write_bytes(pasted)
read_back = client.get("/api/fs/read-data-url", params={"path": str(host_path)})
saved = client.get("/api/fs/download", params={"path": str(host_path)})
not_host_owned = client.get("/api/fs/read-data-url", params={"path": str(elsewhere)})
assert read_back.status_code == 200, read_back.text
assert base64.b64decode(read_back.json()["dataUrl"].split(",", 1)[1]) == pasted
assert (saved.status_code, saved.content) == (200, pasted)
assert not_host_owned.status_code == 404
@pytest.mark.parametrize("endpoint", ["read-text", "read-data-url", "download"])
def test_fs_reads_translate_docker_workspace_path_to_host(
client, docker_volumes, monkeypatch, endpoint,
@@ -0,0 +1,70 @@
"""The Webapp's live socket ticket is minted by the server that serves the page.
``POST /api/webapp/gateway-ticket?profile=<name>`` hands the browser a one-use ticket for
``/api/ws`` on the same origin (offered as the ``hermes-gateway-ticket.`` subprotocol). It names the
caller's login, never a pinned home, and the upgrade gate redeems it exactly once.
"""
from types import SimpleNamespace
import pytest
from fastapi.testclient import TestClient
_TOKEN = "a" * 43
@pytest.fixture
def webapp(monkeypatch, tmp_path):
from hermes_cli import web_server as server
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(server.app.state, "ui_surface", "webapp", raising=False)
monkeypatch.setattr(server.app.state, "auth_required", False, raising=False)
monkeypatch.setattr(server.app.state, "bound_host", "127.0.0.1", raising=False)
monkeypatch.setattr(server, "_SESSION_TOKEN", _TOKEN)
return server, TestClient(server.app, base_url="http://127.0.0.1")
def _socket(ticket):
"""A stand-in for the browser's ``/api/ws`` upgrade, good enough for ``_ws_auth_ok``."""
from hermes_cli import web_server_chat as chat
class _Query(dict):
def get(self, key, default=""):
return super().get(key, default)
protocols = f"{chat._GATEWAY_WS_PROTOCOL}, {chat._GATEWAY_WS_TICKET_PROTOCOL_PREFIX}{ticket}"
return SimpleNamespace(query_params=_Query(native_dial="browser"), headers={"sec-websocket-protocol": protocols},
client=SimpleNamespace(host="127.0.0.1"), url=SimpleNamespace(path="/api/ws"))
def test_loopback_ticket_names_the_session_token_owner_and_is_spent_once(webapp):
from hermes_cli import web_server_chat as chat
_server, client = webapp
assert client.post("/api/webapp/gateway-ticket").status_code == 401
minted = client.post("/api/webapp/gateway-ticket", headers={"X-Hermes-Session-Token": _TOKEN})
assert minted.status_code == 200, minted.text
assert minted.headers["cache-control"] == "no-store"
body = minted.json()
assert set(body) == {"ticket", "ttl_seconds"} and body["ttl_seconds"] > 0
first = _socket(body["ticket"])
assert chat._ws_auth_ok(first) is True
# The same identity ``chat_ws.gateway_ws`` gives a ``?token=`` socket, and only the public protocol.
assert first._hermes_auth_identity == {"user_id": "legacy-token-owner", "provider": "session-token"}
assert first._hermes_ws_subprotocol == chat._GATEWAY_WS_PROTOCOL
assert not hasattr(first, "_hermes_gateway_home")
assert chat._ws_auth_ok(_socket(body["ticket"])) is False
def test_a_missing_profile_is_refused_before_a_ticket_exists(webapp):
_server, client = webapp
refused = client.post("/api/webapp/gateway-ticket?profile=missing", headers={"X-Hermes-Session-Token": _TOKEN})
assert refused.status_code == 404
@pytest.mark.parametrize("surface", ["dashboard", "serve"])
def test_other_surfaces_do_not_mint_webapp_socket_tickets(webapp, monkeypatch, surface):
server, client = webapp
monkeypatch.setattr(server.app.state, "ui_surface", surface)
refused = client.post("/api/webapp/gateway-ticket", headers={"X-Hermes-Session-Token": _TOKEN})
assert refused.status_code == 404
+70
View File
@@ -0,0 +1,70 @@
"""The private launch ticket crosses a browser launcher's argv, so only the server user may spend it.
Minting the link and opening the browser belong to whatever launches the Webapp; these tests cover
the handoff route the ticket is spent at (``/webapp/window-session``).
"""
import os
import pytest
@pytest.mark.platforms("linux")
def test_launch_ticket_is_spent_only_over_the_server_users_own_connection(monkeypatch):
"""Linux publishes every process's argv, so another local user can read a launch ticket
out of the browser launcher before the browser spends it. That user's connection is
refused, and the refusal does not burn the ticket for the operator's browser."""
from fastapi.testclient import TestClient
from hermes_cli import web_server as server
from hermes_cli.web_routers import webapp
monkeypatch.setattr(server.app.state, "ui_surface", "webapp", raising=False)
monkeypatch.setattr(server.app.state, "auth_required", False, raising=False)
monkeypatch.setattr(server.app.state, "bound_host", "127.0.0.1", raising=False)
monkeypatch.setattr(server.app.state, "webapp_window_tickets", {}, raising=False)
monkeypatch.setattr(server, "_SESSION_TOKEN", "a" * 43)
client = TestClient(server.app, base_url="http://127.0.0.1")
headers = {"Origin": "http://127.0.0.1", "X-Hermes-Window-Ticket": webapp.mint_launch_ticket(server.app.state)}
own_uid = os.getuid() # windows-footgun: ok — platforms("linux") test
monkeypatch.setattr(webapp, "_loopback_peer_uid", lambda client, server: own_uid + 1)
stolen = client.post("/webapp/window-session", headers=headers)
assert stolen.status_code == 403 and "a" * 43 not in stolen.text
monkeypatch.setattr(webapp, "_loopback_peer_uid", lambda client, server: own_uid)
granted = client.post("/webapp/window-session", headers=headers)
assert granted.status_code == 200 and granted.json()["token"] == "a" * 43
@pytest.mark.platforms("macos")
def test_macos_launch_handoff_fails_closed_without_consuming_child_window_authority(monkeypatch):
"""An argv launch ticket is unusable until macOS has an authenticated OS handoff.
This native platform contract makes no claim about cross-account argv visibility.
Authenticated child-window tickets never pass through a launcher and still work.
"""
from fastapi.testclient import TestClient
from hermes_cli import web_server as server
from hermes_cli.web_routers import webapp
monkeypatch.setattr(server.app.state, "ui_surface", "webapp", raising=False)
monkeypatch.setattr(server.app.state, "auth_required", False, raising=False)
monkeypatch.setattr(server.app.state, "bound_host", "127.0.0.1", raising=False)
monkeypatch.setattr(server.app.state, "webapp_window_tickets", {}, raising=False)
monkeypatch.setattr(server, "_SESSION_TOKEN", "a" * 43)
client = TestClient(server.app, base_url="http://127.0.0.1")
try:
launch = webapp.mint_launch_ticket(server.app.state)
headers = {"Origin": "http://127.0.0.1", "X-Hermes-Window-Ticket": launch}
for _ in range(2):
denied = client.post("/webapp/window-session", headers=headers)
assert denied.status_code == 403 and server._SESSION_TOKEN not in denied.text
assert launch in server.app.state.webapp_window_tickets
issued = client.post("/api/webapp/window-ticket", headers={
server._SESSION_HEADER_NAME: server._SESSION_TOKEN,
})
assert issued.status_code == 200
headers["X-Hermes-Window-Ticket"] = issued.json()["ticket"]
assert client.post("/webapp/window-session", headers=headers).json() == {"token": server._SESSION_TOKEN}
assert client.post("/webapp/window-session", headers=headers).status_code == 403
finally:
client.close()
@@ -0,0 +1,77 @@
"""Webapp's public HTML must not grant the privileged RPC/host-file credential."""
import json
import re
from urllib.parse import urlencode
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
@pytest.mark.parametrize("surface,gated,headless", [
("webapp", False, False), ("webapp", True, False), ("dashboard", False, False), ("serve", False, True),
])
def test_bootstrap_token_visibility(monkeypatch, tmp_path, surface, gated, headless):
from hermes_cli import web_server as server
from hermes_cli import web_server_profiles
from hermes_cli.web_server_dashboard import mount_spa
serving_profile = "profile</script>"
monkeypatch.setattr(web_server_profiles, "serving_profile_name", lambda: serving_profile)
(tmp_path / "index.html").write_text("<html><head></head><body>Webapp</body></html>")
application = FastAPI()
application.state.ui_surface = surface
application.state.auth_required = gated
monkeypatch.setattr(server, "app", application)
monkeypatch.setattr(server, "WEB_DIST", tmp_path)
monkeypatch.setenv("HERMES_SERVE_HEADLESS", "1" if headless else "0")
mount_spa(application)
client = TestClient(application)
for path in ("/", "/chat", "/settings", "/index.html"):
response = client.get(path)
should_inject = not gated and surface != "webapp" and (not headless or path == "/")
assert (server._SESSION_TOKEN in response.text) is should_inject
assert server._SESSION_TOKEN not in str(response.headers)
if not headless:
profile_js = json.dumps(serving_profile).replace("</", "<\\/")
assert f"window.__HERMES_DASHBOARD_PROFILE__={profile_js};" in response.text
if surface == "webapp" and not headless:
assert 'window.__HERMES_UI_SURFACE__="webapp"' in response.text
@pytest.mark.parametrize("operation", ["shell", "reattach", "close", "rpc", "file"])
def test_html_cannot_authorize_privileged_sibling_routes(monkeypatch, tmp_path, operation):
from hermes_cli import web_server as server
from hermes_cli.web_server_dashboard import mount_spa
(tmp_path / "index.html").write_text("<html><head></head><body>Webapp</body></html>")
# Real middleware, routers and auth gates; don't start the unrelated lifespan.
monkeypatch.setattr(server.app.state, "ui_surface", "webapp", raising=False)
monkeypatch.setattr(server.app.state, "auth_required", False, raising=False)
monkeypatch.setattr(server.app.state, "bound_host", "testclient", raising=False)
spa = FastAPI()
spa.state.ui_surface = "webapp"
spa.state.auth_required = False
monkeypatch.setattr(server, "WEB_DIST", tmp_path)
monkeypatch.delenv("HERMES_SERVE_HEADLESS", raising=False)
mount_spa(spa)
html = TestClient(spa).get("/").text
match = re.search(r'__HERMES_SESSION_TOKEN__\s*=\s*("[^"]*")', html)
exposed = json.loads(match[1]) if match else ""
client = TestClient(server.app, base_url="http://testclient")
if operation == "file":
response = client.get("/api/fs/read-text", params={"path": str(tmp_path / "index.html")},
headers={"X-Hermes-Session-Token": exposed})
assert response.status_code == 401
else:
query = {"token": exposed}
path = "/api/ws" if operation == "rpc" else "/api/host-terminal"
if operation in {"reattach", "close"}:
query["attach"] = "known-shell"
if operation == "close":
query["action"] = "close"
with pytest.raises(WebSocketDisconnect) as exc:
with client.websocket_connect(path + "?" + urlencode(query), headers={"host": "testclient"}):
pass
assert exc.value.code == 4401
@@ -0,0 +1,145 @@
"""The Webapp renderer (``dist-webapp``): verified reuse, one build at a time, rebuilt only when stale.
``hermes_cli/webapp.py`` is what a Webapp launcher calls before handing the browser a link; these
cases come from #93508's ``test_webapp_command.py`` (its renderer-build half).
"""
from __future__ import annotations
from pathlib import Path
import subprocess
import pytest
from hermes_cli import webapp
def _workspace_tree(root: Path) -> None:
(root / "apps" / "desktop").mkdir(parents=True)
(root / "apps" / "shared").mkdir(parents=True)
(root / "web").mkdir()
(root / "ui-tui").mkdir()
(root / "package.json").write_text('{"private":true}', encoding="utf-8")
(root / "package-lock.json").write_text("locked\n", encoding="utf-8")
for path in (
root / "apps" / "desktop" / "package.json",
root / "apps" / "shared" / "package.json",
root / "web" / "package.json",
root / "ui-tui" / "package.json",
):
path.write_text("{}", encoding="utf-8")
def test_skip_build_requires_the_separate_webapp_bundle(tmp_path: Path):
_workspace_tree(tmp_path)
(tmp_path / "apps" / "desktop" / "dist").mkdir()
(tmp_path / "apps" / "desktop" / "dist" / "index.html").write_text(
"native", encoding="utf-8"
)
with pytest.raises(webapp.WebappBuildError, match="dist-webapp"):
webapp.prepare_webapp_renderer(tmp_path, skip_build=True)
def test_skip_build_writes_nothing_into_the_install_tree(tmp_path: Path):
"""A read-only install can reuse a prebuilt renderer only if --skip-build
never writes the install tree, build lock included; a missing renderer is
still refused by its path."""
_workspace_tree(tmp_path)
dist = webapp.webapp_dist_dir(tmp_path)
dist.mkdir()
(dist / "index.html").write_text("<!doctype html>", encoding="utf-8")
install_tree = sorted(tmp_path.rglob("*"))
assert webapp.prepare_webapp_renderer(tmp_path, skip_build=True) == dist
assert sorted(tmp_path.rglob("*")) == install_tree
(dist / "index.html").unlink()
install_tree = sorted(tmp_path.rglob("*"))
with pytest.raises(webapp.WebappBuildError, match="no Webapp renderer exists"):
webapp.prepare_webapp_renderer(tmp_path, skip_build=True)
assert sorted(tmp_path.rglob("*")) == install_tree
def _assert_build_lock_excludes_second_open(tmp_path: Path):
from pm.filesystem import lock_fd, unlock_fd
lock_path = tmp_path / "webapp.lock"
with webapp._exclusive_build_lock(lock_path):
with lock_path.open("a+b") as contender:
assert lock_fd(contender.fileno(), wait=False) is False
with lock_path.open("a+b") as contender:
assert lock_fd(contender.fileno(), wait=False) is True
unlock_fd(contender.fileno())
# Released while its handle stays open, the lock is free for the next open.
with lock_path.open("a+b") as successor:
assert lock_fd(successor.fileno(), wait=False) is True
unlock_fd(successor.fileno())
def test_webapp_build_lock_excludes_a_second_open(tmp_path: Path):
_assert_build_lock_excludes_second_open(tmp_path)
@pytest.mark.platforms("windows")
def test_webapp_build_lock_excludes_a_second_open_on_windows(tmp_path: Path):
_assert_build_lock_excludes_second_open(tmp_path)
def test_launch_rebuilds_only_stale_or_forced_renderers(tmp_path: Path, monkeypatch):
from hermes_cli import source_build
_workspace_tree(tmp_path)
receipt_current = True
builds = []
monkeypatch.setattr(source_build, "source_product_current", lambda *_a: receipt_current)
monkeypatch.setattr(source_build, "source_build_env", lambda *, explicit=False: {})
monkeypatch.setattr(source_build, "build_source_webapp", lambda _root, *, env, explicit: builds.append(explicit))
dist = webapp.prepare_webapp_renderer(tmp_path)
assert dist == tmp_path / "apps" / "desktop" / "dist-webapp"
assert builds == []
webapp.prepare_webapp_renderer(tmp_path, force=True, explicit=True)
receipt_current = False
webapp.prepare_webapp_renderer(tmp_path)
assert builds == [True, False]
@pytest.mark.parametrize("failure", [
PermissionError("workspace node_modules is locked"),
subprocess.CalledProcessError(1, ["node", "build-webapp.mjs"]),
RuntimeError("npm is unavailable and lazy installs are disabled"),
])
def test_renderer_build_failures_are_reported_not_raised_raw(tmp_path: Path, monkeypatch, failure):
from hermes_cli import source_build
_workspace_tree(tmp_path)
def fail(*_a, **_k):
raise failure
monkeypatch.setattr(source_build, "source_product_current", lambda *_a: False)
monkeypatch.setattr(source_build, "source_build_env", lambda *, explicit=False: {})
monkeypatch.setattr(source_build, "build_source_webapp", fail)
with pytest.raises(webapp.WebappBuildError, match="build failed"):
webapp.prepare_webapp_renderer(tmp_path)
def test_dashboard_and_webapp_builds_share_the_workspace_lock(tmp_path: Path, monkeypatch):
"""While the Dashboard builder holds its lock, the Webapp build lock on the same checkout is taken."""
from pm.filesystem import lock_fd
from hermes_cli import main_web_build
_workspace_tree(tmp_path)
webapp_lock_free = []
def build_while_locked(_web_dir, *, fatal=False):
with (tmp_path / webapp._LOCK_NAME).open("ab") as contender:
webapp_lock_free.append(lock_fd(contender.fileno(), wait=False))
return True
monkeypatch.setattr(main_web_build, "_do_build_web_ui", build_while_locked)
assert main_web_build._build_web_ui(tmp_path / "web") is True
assert webapp_lock_free == [False]
@@ -0,0 +1,207 @@
"""Real ASGI regressions for the Webapp's pre-parser upload limit (``hermes_cli/web_upload_limit.py``).
The upload-limit half of #93508's ``test_webapp_upload_security.py``; its cookie Origin gate cases stay
with that separate change.
"""
from __future__ import annotations
import asyncio
import base64
from io import BytesIO
from pathlib import Path
import httpx
import pytest
from starlette import formparsers
from hermes_cli import web_server
from hermes_cli.dashboard_auth import clear_providers, register_provider
from hermes_cli.dashboard_auth.cookies import SESSION_AT_COOKIE
from hermes_constants import WEBAPP_ATTACHMENT_MAX_BYTES
from hermes_cli.web_upload_limit import CHAT_FILE_BODY_MAX_BYTES
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
@pytest.fixture
def upload_app(tmp_path, monkeypatch):
home = tmp_path / "hermes-home"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setattr(web_server.app.state, "auth_required", True, raising=False)
monkeypatch.setattr(web_server.app.state, "bound_host", "0.0.0.0", raising=False)
monkeypatch.setattr(web_server.app.state, "trusted_public_hosts", frozenset(), raising=False)
clear_providers()
register_provider(StubAuthProvider())
# Observe the actual parser's open files, retaining references so GC cannot
# disguise a missing close. No parser or upload implementation is mocked.
spools = []
real_spool = formparsers.SpooledTemporaryFile
def track_spool(*args, **kwargs):
spool = real_spool(*args, dir=tmp_path, **kwargs)
spools.append(spool)
return spool
monkeypatch.setattr(formparsers, "SpooledTemporaryFile", track_spool)
yield home, spools
for spool in spools:
spool.close()
clear_providers()
async def _login(client):
response = await client.get("/auth/login?provider=stub", follow_redirects=True)
assert response.status_code != 401
return next(v for k, v in client.cookies.items() if k.endswith(SESSION_AT_COOKIE))
@pytest.mark.parametrize(
"declared_length,root_path", [(None, ""), ("1", ""), ("oversize", ""), (None, "/hermes")]
)
def test_upload_bound_stops_parser_and_closes_spools(upload_app, monkeypatch, declared_length, root_path):
home, spools = upload_app
# Include a small complete part before the large unfinished file: both open
# handles must be closed when receive refuses a later chunk.
boundary = b"upload-security-boundary"
prefix = (
b"--"
+ boundary
+ b'\r\nContent-Disposition: form-data; name="extra"; filename="first.txt"\r\n\r\nfirst\r\n'
)
prefix += (
b"--" + boundary + b'\r\nContent-Disposition: form-data; name="file"; filename="large.bin"\r\n\r\n'
)
body_cap = CHAT_FILE_BODY_MAX_BYTES
received = 0
async def body():
nonlocal received
received += len(prefix)
yield prefix
while received < body_cap:
chunk = b"x" * min(64 * 1024, body_cap - received)
received += len(chunk)
yield chunk
received += 1
yield b"x"
pytest.fail("body was consumed after the first over-limit byte")
yield b"\r\n--" + boundary + b"--\r\n"
from hermes_cli.web_routers import uploads
def handler_must_not_run(*args, **kwargs):
pytest.fail("upload handler ran after oversized multipart parsing")
monkeypatch.setattr(uploads, "resolve_upload_home", handler_must_not_run)
async def run():
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=web_server.app), base_url="https://dashboard.example.com"
) as client:
token = await _login(client)
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "multipart/form-data; boundary=" + boundary.decode(),
}
if declared_length is not None:
headers["Content-Length"] = (
str(body_cap + 1) if declared_length == "oversize" else declared_length
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=web_server.app, root_path=root_path),
base_url="https://dashboard.example.com",
) as uploader:
response = await uploader.post(
root_path + "/api/chat/file-upload", content=body(), headers=headers
)
assert response.status_code == 413, response.text
assert not (home / "uploads").exists()
if declared_length == "oversize":
assert received == 0
assert not spools
else:
assert received == body_cap + 1
assert len(spools) == 2
assert all(s.closed for s in spools)
assert any(s._rolled for s in spools)
asyncio.run(run())
@pytest.mark.parametrize("chunked", [False, True])
def test_exact_cap_file_survives_multipart_framing(upload_app, chunked):
_, spools = upload_app
async def run():
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=web_server.app), base_url="https://dashboard.example.com"
) as client:
await _login(client)
payload = b"x" * WEBAPP_ATTACHMENT_MAX_BYTES
request = client.build_request(
"POST",
"/api/chat/file-upload",
files={"file": ("exact.bin", payload)},
headers={"Origin": "https://dashboard.example.com"},
)
if chunked:
encoded = request.read()
async def chunks():
for start in range(0, len(encoded), 64 * 1024):
yield encoded[start : start + 64 * 1024]
del request.headers["Content-Length"]
response = await client.post(
"/api/chat/file-upload", content=chunks(), headers=request.headers
)
else:
response = await client.send(request)
assert response.status_code == 200, response.text
assert response.json()["size"] == len(payload)
staged = Path(response.json()["path"])
assert staged.read_bytes() == payload
assert spools and all(s.closed for s in spools)
asyncio.run(run())
def test_other_upload_routes_keep_their_larger_caps(upload_app, monkeypatch):
from hermes_cli.web_routers.files import _CHAT_IMAGE_UPLOAD_MAX_BYTES
home, spools = upload_app
root = home / "managed"
root.mkdir()
monkeypatch.setenv("HERMES_DASHBOARD_FILES_ROOT", str(root))
async def run():
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=web_server.app), base_url="https://dashboard.example.com"
) as client:
token = await _login(client)
headers = {"Authorization": f"Bearer {token}"}
payload = b"x" * web_server._MANAGED_FILE_MAX_BYTES
response = await client.post(
"/api/files/upload-stream",
files={"file": ("large.bin", BytesIO(payload))},
data={"path": str(root / "large.bin")},
headers=headers,
)
assert response.status_code == 200, response.text
assert (root / "large.bin").read_bytes() == payload
payload = b"\x89PNG\r\n\x1a\n" + b"x" * (_CHAT_IMAGE_UPLOAD_MAX_BYTES - 8)
response = await client.post(
"/api/chat/image-upload",
json={
"filename": "large.png",
"data_url": "data:image/png;base64," + base64.b64encode(payload).decode(),
},
headers=headers,
)
assert response.status_code == 200, response.text
assert Path(response.json()["path"]).read_bytes() == payload
assert all(s.closed for s in spools)
asyncio.run(run())
+480
View File
@@ -0,0 +1,480 @@
from __future__ import annotations
import asyncio
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from io import BytesIO
import json
import os
from pathlib import Path
import re
import shutil
import tempfile
import threading
from zipfile import ZIP_DEFLATED, ZipFile
import pytest
from hermes_cli import web_server_profiles
from fastapi import HTTPException, UploadFile
from fastapi.testclient import TestClient
from hermes_cli import profiles, staged_uploads, web_server
from hermes_cli.web_routers import uploads
from hermes_constants import WEBAPP_ATTACHMENT_MAX_BYTES, mark_named_profile_deleted
_SESSION_HEADER = "X-Hermes-Session-Token"
_BARRIER_TIMEOUT_SECONDS = 30
def _client(tmp_path: Path, monkeypatch) -> TestClient:
home = tmp_path / "hermes-home"
home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(web_server, "_SESSION_TOKEN", "webapp-test-token")
monkeypatch.setattr(web_server.app.state, "auth_required", False, raising=False)
monkeypatch.setattr(web_server.app.state, "bound_host", "127.0.0.1", raising=False)
return TestClient(web_server.app, base_url="http://127.0.0.1")
def _docx_upload(text: str) -> bytes:
"""A minimal OOXML package, without optional document-writing dependencies."""
package_ns = "http://schemas.openxmlformats.org/package/2006/relationships"
office_ns = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
main_part = "word/document.xml"
parts = {
main_part: (
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
f"<w:body><w:p><w:r><w:t>{text}</w:t></w:r></w:p></w:body></w:document>"
),
"[Content_Types].xml": (
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
f'<Override PartName="/{main_part}" ContentType="application/vnd.openxmlformats-officedocument.'
'wordprocessingml.document.main+xml"/></Types>'
),
"_rels/.rels": (
f'<Relationships xmlns="{package_ns}"><Relationship Id="rId1" '
f'Type="{office_ns}/officeDocument" Target="{main_part}"/></Relationships>'
),
}
buffer = BytesIO()
with ZipFile(buffer, "w", compression=ZIP_DEFLATED) as archive:
for part, xml in parts.items():
archive.writestr(part, xml)
return buffer.getvalue()
def test_browser_document_upload_remains_extractable_after_file_attach(
tmp_path: Path, monkeypatch
):
"""The upload route's receipt, attached by the profile's gateway (the browser's only route to a
session), leaves a document the file tool still extracts."""
filename = "отчёт.docx"
monkeypatch.setattr(Path, "home", lambda: tmp_path)
with _client(tmp_path, monkeypatch) as client:
from gateway.runtime_bootstrap import TicketStore
from hermes_cli import install_identity
from tests.gateway.test_canonical_file_attach_staged_sources import _attach, _authority_with_old_desktop_chat
from tools.file_tools import read_file_tool
from tools.terminal_tool import clear_task_env_overrides, register_task_env_overrides
from tools.terminal_tool_lifecycle import cleanup_vm
home = tmp_path / "hermes-home"
workspace = tmp_path / "workspace"
workspace.mkdir()
sid = "old-desktop"
monkeypatch.setattr(install_identity, "_INSTALL_ID_CACHE", {"root": None, "value": None})
text = "Browser document content survives upload and attachment"
payload = _docx_upload(text)
response = client.post(
"/api/chat/file-upload",
files={"file": (filename, payload, "application/octet-stream")},
headers={_SESSION_HEADER: "webapp-test-token"},
)
assert response.status_code == 200, response.text
source = Path(response.json()["path"])
async def attach():
authority = await _authority_with_old_desktop_chat(home, monkeypatch, cwd=workspace)
host = _attach(authority, TicketStore("first", frozenset({authority.profile_id})), "interactive", "uid:1000")
await host.dispatch({"id": "resume", "method": "session.resume", "params": {"session_id": sid}})
return await host.dispatch({"id": "attach", "method": "file.attach", "params": {
"session_id": sid, "name": filename, "staged_upload": response.json()["staged_upload"]}})
attached = asyncio.run(attach())
assert "error" not in attached, attached
stored = Path(attached["result"]["path"])
assert source.parent == home / "uploads"
assert stored.parent == home / "attachments"
assert attached["result"]["uploaded"] is True
assert stored.read_bytes() == source.read_bytes() == payload
register_task_env_overrides(sid, {"env_type": "local", "cwd": str(workspace)})
try:
extracted = json.loads(read_file_tool(attached["result"]["ref_path"], task_id=sid))
finally:
cleanup_vm(sid)
clear_task_env_overrides(sid)
assert extracted.get("extracted_document") is True, extracted
assert text in extracted["content"]
assert stored.suffix == source.suffix == Path(filename).suffix
@pytest.mark.parametrize(
("filename", "suffix"),
[
("отчёт.docx", ".docx"),
("../../таблица.xlsx", ".xlsx"),
(r"..\..\таблица.xlsx", ".xlsx"),
("!!!.PDF", ".PDF"),
("long-" * 100 + ".docx", ".docx"),
("report.tar.gz", ".gz"),
("x." + "a" * 200, None),
("../../..", ""),
(None, ""),
],
ids=["cyrillic", "traversal", "backslashes", "punctuation", "long-stem",
"compound", "long-suffix", "dots", "missing"],
)
def test_safe_upload_filename_is_bounded_basename_with_document_suffix(filename, suffix):
cleaned = staged_uploads._safe_filename(filename)
assert 1 <= len(cleaned) <= 120
assert re.fullmatch(r"[A-Za-z0-9_-][A-Za-z0-9._-]*", cleaned)
assert Path(cleaned).name == cleaned
assert Path(cleaned).stem.strip(".-")
if suffix is not None:
assert Path(cleaned).suffix == suffix
def test_browser_upload_stages_bytes_under_hermes_home(tmp_path: Path, monkeypatch):
upload_root = tmp_path / "hermes-home" / "uploads"
upload_root.mkdir(parents=True)
abandoned = upload_root / "web-abandoned-old.txt"
unrelated = upload_root / "keep-me.txt"
abandoned.write_text("old", encoding="utf-8")
unrelated.write_text("keep", encoding="utf-8")
os.utime(abandoned, (1, 1))
with _client(tmp_path, monkeypatch) as client:
response = client.post(
"/api/chat/file-upload",
files={"file": ("../notes from browser.txt", b"browser bytes", "text/plain")},
headers={_SESSION_HEADER: "webapp-test-token"},
)
assert response.status_code == 200
payload = response.json()
staged = Path(payload["path"])
assert staged.parent == tmp_path / "hermes-home" / "uploads"
assert staged.name.endswith("notes-from-browser.txt")
assert staged.read_bytes() == b"browser bytes"
assert not abandoned.exists()
assert unrelated.read_text(encoding="utf-8") == "keep"
@pytest.mark.platforms("linux")
def test_browser_upload_stages_owner_only_file_on_posix(tmp_path: Path, monkeypatch):
with _client(tmp_path, monkeypatch) as client:
response = client.post(
"/api/chat/file-upload",
files={"file": ("private.txt", b"private", "text/plain")},
headers={_SESSION_HEADER: "webapp-test-token"},
)
assert response.status_code == 200
assert Path(response.json()["path"]).stat().st_mode & 0o777 == 0o600
def test_browser_upload_rejects_oversize_without_leaving_partial_file(
tmp_path: Path, monkeypatch
):
with _client(tmp_path, monkeypatch) as client:
response = client.post(
"/api/chat/file-upload",
files={
"file": (
"large.bin",
b"x" * (WEBAPP_ATTACHMENT_MAX_BYTES + 1),
"application/octet-stream",
)
},
headers={_SESSION_HEADER: "webapp-test-token"},
)
assert response.status_code == 413
upload_root = tmp_path / "hermes-home" / "uploads"
assert not upload_root.exists() or list(upload_root.iterdir()) == []
@pytest.mark.parametrize("payload", [b"", b"12345678", b"123456789"])
def test_browser_upload_checks_actual_spooled_size_and_closes_upload(
payload: bytes, tmp_path: Path, monkeypatch
):
home = tmp_path / "hermes-home"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setattr(uploads, "_MAX_UPLOAD_BYTES", 8)
spool = tempfile.SpooledTemporaryFile(max_size=4, mode="w+b")
spool.write(payload)
file = UploadFile(file=spool, size=0, filename="notes.txt")
if len(payload) > 8:
with pytest.raises(HTTPException) as exc_info:
asyncio.run(uploads.upload_chat_file(file=file))
assert exc_info.value.status_code == 413
assert not (home / "uploads").exists()
else:
result = asyncio.run(uploads.upload_chat_file(file=file))
assert result["size"] == len(payload)
assert Path(result["path"]).read_bytes() == payload
assert spool.closed
def test_browser_file_cleanup_failure_preserves_primary_staging_error(
tmp_path: Path, monkeypatch
):
home = tmp_path / "hermes-home"
home.mkdir()
real_open = staged_uploads.os.open
real_unlink = Path.unlink
def fail_target_open(path, *args, **kwargs):
if Path(path).parent == home / "uploads":
raise PermissionError("target open denied")
return real_open(path, *args, **kwargs)
def fail_target_cleanup(path: Path, *args, **kwargs):
if path.parent == home / "uploads":
raise OSError("target cleanup denied")
return real_unlink(path, *args, **kwargs)
monkeypatch.setattr(staged_uploads.os, "open", fail_target_open)
monkeypatch.setattr(Path, "unlink", fail_target_cleanup)
with pytest.raises(HTTPException) as exc_info:
staged_uploads.publish_staged_upload(BytesIO(b"bytes"), home, "notes.txt")
assert exc_info.value.status_code == 500
assert "target open denied" in exc_info.value.detail
def test_largest_browser_upload_is_readable_by_attachment_flow(
tmp_path: Path, monkeypatch
):
payload = b"x" * WEBAPP_ATTACHMENT_MAX_BYTES
with _client(tmp_path, monkeypatch) as client:
upload = client.post(
"/api/chat/file-upload",
files={"file": ("largest.bin", payload, "application/octet-stream")},
headers={_SESSION_HEADER: "webapp-test-token"},
)
assert upload.status_code == 200
read_back = client.get(
"/api/fs/read-data-url",
params={"path": upload.json()["path"]},
headers={_SESSION_HEADER: "webapp-test-token"},
)
assert read_back.status_code == 200
assert read_back.json()["dataUrl"].startswith("data:application/octet-stream;base64,")
def test_browser_upload_requires_the_server_session(tmp_path: Path, monkeypatch):
with _client(tmp_path, monkeypatch) as client:
response = client.post(
"/api/chat/file-upload",
files={"file": ("notes.txt", b"nope", "text/plain")},
headers={_SESSION_HEADER: "wrong-token"},
)
assert response.status_code == 401
def test_browser_file_upload_never_recreates_profile_deleted_after_resolution(
tmp_path: Path, monkeypatch
):
profile_home = tmp_path / "hermes-home" / "profiles" / "worker"
profile_home.mkdir(parents=True)
real_resolve = staged_uploads.resolve_upload_home
def resolve_then_delete(profile):
resolved = real_resolve(profile)
shutil.rmtree(profile_home)
return resolved
monkeypatch.setattr(staged_uploads, "_resolve_profile_dir", lambda _name: profile_home)
monkeypatch.setattr(uploads, "resolve_upload_home", resolve_then_delete)
with _client(tmp_path, monkeypatch) as client:
response = client.post(
"/api/chat/file-upload?profile=worker",
files={"file": ("notes.txt", b"bytes", "text/plain")},
headers={_SESSION_HEADER: "webapp-test-token"},
)
assert response.status_code == 404
assert not profile_home.exists()
@pytest.mark.parametrize(
("write_error", "expected_status"),
[(PermissionError("image write denied"), 403), (OSError("image write failed"), 500)],
)
def test_browser_image_upload_cleanup_failure_does_not_mask_write_status(
tmp_path: Path, monkeypatch, write_error, expected_status
):
image_dir = tmp_path / "hermes-home" / "cache" / "images"
real_write_bytes = Path.write_bytes
real_unlink = Path.unlink
def fail_image_write(path: Path, data: bytes):
if path.parent == image_dir:
raise write_error
return real_write_bytes(path, data)
def fail_image_cleanup(path: Path, *args, **kwargs):
if path.parent == image_dir:
raise OSError("image cleanup denied")
return real_unlink(path, *args, **kwargs)
monkeypatch.setattr(Path, "write_bytes", fail_image_write)
monkeypatch.setattr(Path, "unlink", fail_image_cleanup)
with _client(tmp_path, monkeypatch) as client:
response = client.post(
"/api/chat/image-upload",
json={
"data_url": "data:image/png;base64,iVBORw0KGgo=",
"filename": "image.png",
},
headers={_SESSION_HEADER: "webapp-test-token"},
)
assert response.status_code == expected_status
if expected_status == 500:
assert "image write failed" in response.json()["detail"]
def test_browser_uploads_to_a_profile_do_not_queue_behind_the_skills_lock(
tmp_path: Path, monkeypatch
):
"""Resolving an upload's profile home is a path lookup. A skills request holding the
process-wide skills lock (and the skills-module globals it retargets) must not stall it."""
hermes_home = tmp_path / "hermes-home"
hermes_home.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
profile_home = profiles.create_profile("worker", no_alias=True, no_skills=True)
holding, release = threading.Event(), threading.Event()
def hold_skills_lock():
with web_server_profiles._SKILLS_PROFILE_LOCK:
holding.set()
release.wait()
headers = {_SESSION_HEADER: "webapp-test-token"}
with _client(tmp_path, monkeypatch) as client, ThreadPoolExecutor(max_workers=3) as pool:
try:
pool.submit(hold_skills_lock)
assert holding.wait(timeout=_BARRIER_TIMEOUT_SECONDS)
file_upload = pool.submit(
client.post,
"/api/chat/file-upload?profile=worker",
files={"file": ("notes.txt", b"bytes", "text/plain")},
headers=headers,
)
image_upload = pool.submit(
client.post,
"/api/chat/image-upload?profile=worker",
json={"data_url": "data:image/png;base64,iVBORw0KGgo=", "filename": "image.png"},
headers=headers,
)
file_response = file_upload.result(timeout=_BARRIER_TIMEOUT_SECONDS)
image_response = image_upload.result(timeout=_BARRIER_TIMEOUT_SECONDS)
finally:
release.set()
assert file_response.status_code == 200, file_response.text
assert Path(file_response.json()["path"]).parent == profile_home / "uploads"
assert image_response.status_code == 200, image_response.text
assert Path(image_response.json()["path"]).parent == profile_home / "cache" / "images"
def test_browser_image_upload_never_creates_a_missing_profile_home(
tmp_path: Path, monkeypatch
):
profile_home = tmp_path / "hermes-home" / "profiles" / "worker"
monkeypatch.setattr(staged_uploads, "_resolve_profile_dir", lambda _name: profile_home)
with _client(tmp_path, monkeypatch) as client:
response = client.post(
"/api/chat/image-upload?profile=worker",
json={
"data_url": "data:image/png;base64,iVBORw0KGgo=",
"filename": "image.png",
},
headers={_SESSION_HEADER: "webapp-test-token"},
)
assert response.status_code == 404
assert not profile_home.exists()
def test_browser_uploads_reject_tombstone_before_profile_directory_removal(
tmp_path: Path, monkeypatch
):
profile_home = tmp_path / "hermes-home" / "profiles" / "worker"
profile_home.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home"))
mark_named_profile_deleted(profile_home)
monkeypatch.setattr(staged_uploads, "_resolve_profile_dir", lambda _name: profile_home)
with _client(tmp_path, monkeypatch) as client:
file_response = client.post(
"/api/chat/file-upload?profile=worker",
files={"file": ("notes.txt", b"bytes", "text/plain")},
headers={_SESSION_HEADER: "webapp-test-token"},
)
image_response = client.post(
"/api/chat/image-upload?profile=worker",
json={
"data_url": "data:image/png;base64,iVBORw0KGgo=",
"filename": "image.png",
},
headers={_SESSION_HEADER: "webapp-test-token"},
)
assert file_response.status_code == 404
assert image_response.status_code == 404
assert profile_home.is_dir()
assert not (profile_home / "uploads").exists()
assert not (profile_home / "cache" / "images").exists()
def test_browser_file_upload_does_not_report_a_path_after_tombstone_wins(
tmp_path: Path, monkeypatch
):
profile_home = tmp_path / "hermes-home" / "profiles" / "worker"
profile_home.mkdir(parents=True)
real_fsync = staged_uploads.os.fsync
def tombstone_after_flush(fd):
real_fsync(fd)
mark_named_profile_deleted(profile_home)
monkeypatch.setattr(staged_uploads, "_resolve_profile_dir", lambda _name: profile_home)
monkeypatch.setattr(staged_uploads.os, "fsync", tombstone_after_flush)
with _client(tmp_path, monkeypatch) as client:
response = client.post(
"/api/chat/file-upload?profile=worker",
files={"file": ("race.txt", b"bytes", "text/plain")},
headers={_SESSION_HEADER: "webapp-test-token"},
)
assert response.status_code == 404
upload_root = profile_home / "uploads"
assert not upload_root.exists() or list(upload_root.iterdir()) == []
@@ -0,0 +1,92 @@
"""A child tab may receive local authority only through a one-use handoff."""
from concurrent.futures import ThreadPoolExecutor
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def local_webapp(monkeypatch):
from hermes_cli import web_server as server
monkeypatch.setattr(server.app.state, "ui_surface", "webapp", raising=False)
monkeypatch.setattr(server.app.state, "auth_required", False, raising=False)
monkeypatch.setattr(server.app.state, "bound_host", "127.0.0.1", raising=False)
monkeypatch.setattr(server.app.state, "webapp_window_tickets", {}, raising=False)
monkeypatch.setattr(server, "_SESSION_TOKEN", "a" * 43)
monkeypatch.setattr("hermes_cli.dashboard_auth.prefix.resolve_public_url", lambda: "")
return server, TestClient(server.app, base_url="http://127.0.0.1")
@pytest.mark.parametrize("public_url", ["", "http://localhost", "http://127.0.0.1:8080"])
def test_child_handoff_is_private_single_use_and_authorizes_real_api(local_webapp, tmp_path, monkeypatch, public_url):
monkeypatch.setattr("hermes_cli.dashboard_auth.prefix.resolve_public_url", lambda: public_url)
server, client = local_webapp
token = server._SESSION_TOKEN
headers = {"X-Hermes-Session-Token": token}
assert client.post("/api/webapp/window-ticket").status_code == 401
page = client.get("/webapp/window")
assert page.status_code == 200
assert token not in page.text and token not in str(page.headers)
assert page.headers["cache-control"] == "no-store"
issued = client.post("/api/webapp/window-ticket", headers=headers)
assert issued.status_code == 200
ticket = issued.json()["ticket"]
assert ticket != token
assert issued.headers["cache-control"] == "no-store"
path = tmp_path / "handoff.txt"
path.write_text("child-window-proof", encoding="utf-8")
assert client.get("/api/fs/read-text", params={"path": str(path)},
headers={"X-Hermes-Session-Token": ticket}).status_code == 401
# An opaque/sibling origin cannot consume even a known ticket.
for origin in ("", "null", "http://127.0.0.1:9999", "https://127.0.0.1", "http://evil.example"):
denied = client.post("/webapp/window-session",
headers={"Origin": origin, "X-Hermes-Window-Ticket": ticket})
assert denied.status_code == 403
assert token not in denied.text
rebinding = client.post("/webapp/window-session", headers={
"Host": "evil.example", "Origin": "http://evil.example", "X-Hermes-Window-Ticket": ticket,
})
assert rebinding.status_code == 400
assert client.post("/webapp/window-session", headers=[
("Origin", "http://127.0.0.1"), ("Origin", "http://127.0.0.1"),
("X-Hermes-Window-Ticket", ticket),
]).status_code == 403
def exchange():
return client.post("/webapp/window-session",
headers={"Origin": "http://127.0.0.1", "X-Hermes-Window-Ticket": ticket})
with ThreadPoolExecutor(max_workers=2) as pool:
responses = list(pool.map(lambda _: exchange(), range(2)))
assert sorted(r.status_code for r in responses) == [200, 403]
granted = next(r for r in responses if r.status_code == 200)
assert granted.headers["cache-control"] == "no-store"
assert granted.json()["token"] == token
read = client.get("/api/fs/read-text", params={"path": str(path)},
headers={"X-Hermes-Session-Token": granted.json()["token"]})
assert read.status_code == 200 and "child-window-proof" in read.text
# Replay cannot recover the token, nor can it be obtained through a GET.
assert exchange().status_code == 403
assert token not in client.get("/webapp/window-session", params={"ticket": ticket}).text
@pytest.mark.parametrize("invalidate", ["expiry", "restart", "gated", "dashboard"])
def test_child_handoff_is_bound_to_local_webapp_start(local_webapp, monkeypatch, invalidate):
from hermes_cli.web_routers import webapp
server, client = local_webapp
issued = client.post("/api/webapp/window-ticket", headers={"X-Hermes-Session-Token": server._SESSION_TOKEN})
assert issued.status_code == 200
ticket = issued.json()["ticket"]
if invalidate == "expiry":
now = webapp.time.monotonic()
monkeypatch.setattr(webapp.time, "monotonic", lambda: now + webapp.WINDOW_TICKET_TTL_SECONDS + 1)
elif invalidate == "restart":
monkeypatch.setattr(server, "_SESSION_TOKEN", "b" * 43)
elif invalidate == "gated":
server.app.state.auth_required = True
else:
server.app.state.ui_surface = "dashboard"
denied = client.post("/webapp/window-session",
headers={"Origin": "http://127.0.0.1", "X-Hermes-Window-Ticket": ticket}, follow_redirects=False)
assert denied.status_code in (302, 401, 403, 404)
assert server._SESSION_TOKEN not in denied.text
+204 -1
View File
@@ -29,6 +29,7 @@ import pytest
# must never raise, otherwise the web_server import branch becomes a trap.
from hermes_cli import win_pty_bridge
from hermes_cli.win_pty_bridge import PtyUnavailableError, WinPtyBridge
from tests.waiting import async_wait_until
# ``pytest.mark.platforms("windows")`` rather than a local ``skipif`` alias: the
# dedicated Windows CI job selects its files by grepping for the marker name
@@ -54,6 +55,22 @@ def _read_until(bridge: WinPtyBridge, needle: bytes, timeout: float = 10.0) -> b
return bytes(buf)
class _HealthyProc:
"""A ConPTY whose input never wedges."""
pid = 1
def __init__(self):
self.written = []
self.terminated = threading.Event()
def write(self, text):
self.written.append(text)
def terminate(self, force=False):
self.terminated.set()
# ---------------------------------------------------------------------------
# Cross-platform fallback semantics
# ---------------------------------------------------------------------------
@@ -193,6 +210,156 @@ class TestWinPtyBridgeUnavailable:
proc.release_write.set()
assert any("thread leaked" in r.getMessage() for r in caplog.records)
@pytest.mark.asyncio
async def test_waiting_for_the_fence_does_not_count_against_the_write_deadline(self):
"""Other work holding the fence's profile lease is not a wedged ConPTY: a write that
waits longer than the deadline to enter the fence still lands, and the shell survives."""
proc = _HealthyProc()
bridge = WinPtyBridge(proc)
lease_free = threading.Event()
waiting = threading.Event()
def fence(write):
waiting.set()
lease_free.wait(timeout=5.0)
return write()
task = asyncio.create_task(bridge.write(b"keys", timeout=0.05, fence=fence))
await async_wait_until(waiting.is_set)
done, _ = await asyncio.wait({task}, timeout=0.3)
assert not done
lease_free.set()
assert await task is True
assert proc.written == ["keys"]
assert not proc.terminated.is_set()
@pytest.mark.asyncio
async def test_a_fence_that_raises_propagates_and_keeps_conpty(self):
"""A lease that gives up is the fence refusing, not ConPTY wedging: what it raises
reaches the caller, as on POSIX, and the shell stays up."""
proc = _HealthyProc()
bridge = WinPtyBridge(proc)
def fence(_write):
raise TimeoutError("profile lease busy")
with pytest.raises(TimeoutError, match="profile lease busy"):
await bridge.write(b"keys", fence=fence)
assert proc.written == []
assert not proc.terminated.is_set()
assert bridge._closed is False
@pytest.mark.asyncio
async def test_a_write_wedged_inside_the_fence_terminates_conpty_and_frees_the_fence(self):
"""The deadline still bounds how long a wedged ConPTY can hold the fence."""
class _WedgedProc:
pid = 1
def __init__(self):
self.release_write = threading.Event()
self.terminated = threading.Event()
def write(self, _text):
self.release_write.wait(timeout=5.0)
def terminate(self, force=False):
self.terminated.set()
self.release_write.set()
proc = _WedgedProc()
bridge = WinPtyBridge(proc)
lease = threading.Lock()
def fence(write):
with lease:
return write()
assert await bridge.write(b"x", timeout=0.05, fence=fence) is False
assert proc.terminated.is_set()
assert bridge._closed is True
assert not lease.locked()
@pytest.mark.asyncio
async def test_a_write_cancelled_while_waiting_for_the_fence_lands_and_keeps_conpty(self):
"""A socket leaving while the lease is busy is not a stall: the keystrokes land once
the lease frees, and the shell survives."""
proc = _HealthyProc()
bridge = WinPtyBridge(proc)
lease_free = threading.Event()
waiting = threading.Event()
def fence(write):
waiting.set()
lease_free.wait(timeout=5.0)
return write()
with patch.object(win_pty_bridge, "_WRITE_SHUTDOWN_GRACE", 0.05):
task = asyncio.create_task(bridge.write(b"keys", timeout=0.05, fence=fence))
await async_wait_until(waiting.is_set)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert not proc.terminated.is_set()
lease_free.set()
await async_wait_until(lambda: proc.written == ["keys"] and not bridge._late_writes)
assert not proc.terminated.is_set()
assert bridge._closed is False
@pytest.mark.asyncio
async def test_idle_terminals_leave_the_default_executor_free(self):
"""Every terminal's drain reads in the default executor, which also runs
keystrokes, admission and cleanup. An idle shell's read must return on its
timeout, or a few quiet terminals hold every worker until one prints."""
from concurrent.futures import ThreadPoolExecutor
import socket
from hermes_cli.pty_session import PtySession
class _IdleShell:
"""pywinpty 3.0.5's PtyProcess: a reader thread copies ConPTY output to a
loopback socket, ``read`` is a blocking recv on it, ``fileno`` exposes it."""
pid = 1
def __init__(self):
self.output, self.console = socket.socketpair()
self.polled = threading.Event()
def fileno(self):
self.polled.set() # the bridge's bounded select asks for it on every poll
return self.output.fileno()
def read(self, size):
data = self.output.recv(size)
if not data:
raise EOFError("Pty is closed")
return data.decode("utf-8")
def isalive(self):
return True
def terminate(self, force=False):
self.console.close()
loop = asyncio.get_running_loop()
loop.set_default_executor(ThreadPoolExecutor(max_workers=2))
shells = [_IdleShell() for _ in range(4)]
sessions = [PtySession(f"terminal-{n}", WinPtyBridge(shell), buffer_cap=64, read_timeout=0.05)
for n, shell in enumerate(shells)]
try:
for session in sessions:
await session.start()
# Every idle shell's reader has polled it; unbounded reads on the two-worker
# default pool would hold both workers and leave two shells never polled.
await async_wait_until(lambda: all(shell.polled.is_set() for shell in shells), timeout=3)
assert await asyncio.wait_for(asyncio.to_thread(lambda: "ran"), 3) == "ran"
shells[1].console.sendall("héllo".encode())
await async_wait_until(lambda: sessions[1].buffer.snapshot() == "héllo".encode(), timeout=3)
finally:
for shell in shells:
shell.terminate()
for session in sessions:
await session.close()
@pytest.mark.platforms("posix") # non-Windows only
def test_spawn_raises_unavailable_off_windows(self):
with pytest.raises(PtyUnavailableError):
@@ -243,6 +410,39 @@ class TestWinPtyBridgeIO:
bridge.close()
@pytest.mark.asyncio
async def test_paste_to_a_program_that_is_not_reading_lands_at_once(self):
"""ConPTY buffers input whether or not the child reads it, so a host shell's
paste lands at once and the job keeps running; only a wedged ConPTY can miss
the write deadline, which is why Windows writes keep one."""
script = "import time; time.sleep(1.5); print('SURVI' + 'VED', flush=True)"
bridge = WinPtyBridge.spawn([sys.executable, "-c", script])
try:
paste = b"".join(b"%05d %s\r" % (n, b"x" * 57) for n in range(16384)) # 1 MiB
assert await asyncio.wait_for(bridge.write(paste, timeout=None), timeout=2.0) is True
output = await asyncio.to_thread(_read_until, bridge, b"SURVIVED", 15.0)
assert b"SURVIVED" in output
finally:
bridge.close()
def test_idle_read_returns_within_its_timeout(self):
"""A shell that prints nothing must not hold the reading thread until it
next prints: every terminal's drain shares the default executor."""
script = "import time; print('REA' + 'DY', flush=True); time.sleep(30)"
bridge = WinPtyBridge.spawn([sys.executable, "-c", script])
try:
assert b"READY" in _read_until(bridge, b"READY")
idle, slowest = False, 0.0
deadline = time.monotonic() + 5.0
while not idle and time.monotonic() < deadline:
started = time.monotonic()
idle = bridge.read(timeout=0.5) == b""
slowest = max(slowest, time.monotonic() - started)
assert idle
assert slowest < 2.5
finally:
bridge.close()
def test_read_returns_none_after_child_exits(self):
bridge = WinPtyBridge.spawn(["cmd.exe", "/c", "echo done"])
try:
@@ -251,8 +451,11 @@ class TestWinPtyBridgeIO:
deadline = time.monotonic() + 5.0
while bridge.is_alive() and time.monotonic() < deadline:
bridge.read(timeout=0.1)
# ConPTY reports EOF seconds after the child exits, and read() returns b""
# at its timeout until then, so poll to a deadline rather than a count.
got_none = False
for _ in range(20):
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
if bridge.read(timeout=0.1) is None:
got_none = True
break
@@ -0,0 +1,65 @@
"""A takeover that cancels a keep-alive /api/pty write is not an input stall.
The new viewer's attach cancels the old socket's in-flight write, which then returns False. The
keep-alive writer read that as a stalled child: it logged "recycling terminal session" and sent
the superseded socket a second close, 1013, which only the attach's 4409 kept from landing
(PR #93508 review).
"""
from __future__ import annotations
import asyncio
import logging
import pytest
from hermes_cli.pty_session import PtySession, WS_CLOSE_SUPERSEDED
from hermes_cli.web_routers.chat_ws import _pump_keepalive_input
class BackpressuredBridge:
"""The child is not reading its input: every write waits until it is cancelled."""
def __init__(self):
self.waiting = asyncio.Event()
async def write(self, data, **_options):
self.waiting.set()
await asyncio.Event().wait()
class Viewer:
"""Sends ``frames``, then reads until closed; a second close raises, like Starlette's."""
def __init__(self, *frames):
self.closed = []
self.inbound = asyncio.Queue()
for frame in frames:
self.inbound.put_nowait({"type": "websocket.receive", "bytes": frame})
async def receive(self):
return await self.inbound.get()
async def close(self, code=1000, reason=None):
if self.closed:
raise RuntimeError("Cannot call send once a close message has been sent.")
self.closed.append(code)
self.inbound.put_nowait({"type": "websocket.disconnect"})
@pytest.mark.asyncio
async def test_takeover_during_a_backpressured_write_is_not_reported_as_a_stall(caplog):
bridge = BackpressuredBridge()
session = PtySession("chat", bridge, buffer_cap=32, read_timeout=0.01)
old, new = Viewer(b"a long paste"), Viewer()
assert await session.attach(old)
pump = asyncio.create_task(_pump_keepalive_input(old, session))
await bridge.waiting.wait()
with caplog.at_level(logging.WARNING):
assert await session.attach(new)
async with asyncio.timeout(3):
await pump
assert old.closed == [WS_CLOSE_SUPERSEDED]
assert "stalled" not in caplog.text
assert session.alive and session._ws is new
@@ -0,0 +1,57 @@
"""A Webapp renderer receipt is current only under the renderer environment it was built with.
Vite inlines ``VITE_*`` and ``NODE_ENV`` selects the build mode, so a development or perf-probe
bundle does not satisfy a production launch, and a production bundle does not satisfy either.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
import subprocess
ROOT = Path(__file__).resolve().parents[2]
FRESHNESS = ROOT / "scripts" / "build" / "freshness.mjs"
def _env(**renderer: str) -> dict[str, str]:
env = {k: v for k, v in os.environ.items() if k != "NODE_ENV" and not k.startswith("VITE_")}
env.update(renderer)
return env
def test_webapp_receipt_tracks_the_renderer_environment(tmp_path):
node = shutil.which("node")
assert node, "the freshness contract test requires the build's Node prerequisite"
source = tmp_path / "source"
(source / "apps" / "desktop").mkdir(parents=True)
(source / "apps" / "desktop" / "main.ts").write_text("export {}\n", encoding="utf-8")
out = tmp_path / "dist-webapp"
out.mkdir()
(out / "index.html").write_text("<!doctype html>\n", encoding="utf-8")
record = (
f"import {{ buildInputs, recordProduct }} from {json.dumps(FRESHNESS.as_uri())};"
f"const source = {json.dumps(str(source))};"
f"recordProduct({{ source, product: 'webapp', out: {json.dumps(str(out))},"
" inputs: buildInputs(source, 'webapp') })")
def build(**renderer: str) -> None:
subprocess.run([node, "--input-type=module", "-e", record], env=_env(**renderer), check=True)
def current(**renderer: str) -> bool:
ran = subprocess.run(
[node, str(FRESHNESS), "--source", str(source), "--product", "webapp", "--out", str(out)],
env=_env(**renderer), capture_output=True, text=True, check=True)
return json.loads(ran.stdout)
build(NODE_ENV="development", VITE_PERF_PROBE="1")
assert current(NODE_ENV="development", VITE_PERF_PROBE="1") is True
assert current() is False
assert current(NODE_ENV="production") is False
build()
assert current() is True
assert current(NODE_ENV="production") is True # an unset NODE_ENV builds production
assert current(VITE_PERF_PROBE="1") is False
+1
View File
@@ -61,6 +61,7 @@ _EXEMPT_DIRS = (
"evals",
"website",
"node_modules",
".build",
".cache",
".git",
".venv",
+4 -4
View File
@@ -18748,14 +18748,14 @@ def test_decode_attach_base64_helper():
import base64 as _b64
raw = _b64.b64encode(b"hello").decode("ascii")
assert server._decode_attach_base64(raw, mime_prefix="image/") == b"hello"
assert server._decode_attach_base64(raw, mime_prefix="image/", max_bytes=5) == b"hello"
assert (
server._decode_attach_base64(f"data:image/png;base64,{raw}", mime_prefix="image/")
server._decode_attach_base64(f"data:image/png;base64,{raw}", mime_prefix="image/", max_bytes=5)
== b"hello"
)
# whitespace inside payload is tolerated
assert server._decode_attach_base64(raw[:4] + "\n" + raw[4:], mime_prefix="image/") == b"hello"
assert server._decode_attach_base64("@@@", mime_prefix="image/") is None
assert server._decode_attach_base64(raw[:4] + "\n" + raw[4:], mime_prefix="image/", max_bytes=5) == b"hello"
assert server._decode_attach_base64("@@@", mime_prefix="image/", max_bytes=5) is None
def test_sniff_image_ext_magic_and_filename():
+9
View File
@@ -167,6 +167,14 @@ method("pdf.attach", params=PdfAttachParams, result=PdfAttachResult,
doc="Render a PDF's pages to PNG and queue them as images for the next turn.")
class StagedFileUpload(Params):
"""Source identity returned by the browser upload route, checked against the live profile."""
install_id: str
path: str
profile_home: str
class FileAttachParams(SessionParams):
"""``path`` when the file is gateway-visible, else ``data_url`` carries the bytes; ``name`` labels
an uploaded file."""
@@ -174,6 +182,7 @@ class FileAttachParams(SessionParams):
path: str | None = None
data_url: str | None = None
name: str | None = None
staged_upload: StagedFileUpload | None = None
class FileAttachResult(Result):
+54 -11
View File
@@ -14,6 +14,8 @@ from .method_ctx import HandlerRegistry, bind_module
_registry = HandlerRegistry()
# Match the native desktop file reader; image/browser inputs retain tighter caps.
_ATTACHMENT_MAX_BYTES = 256 * 1024 * 1024
_ATTACH_BYTES_MAX_BYTES = 25 * 1024 * 1024
_PDF_ATTACH_MAX_BYTES = 50 * 1024 * 1024
_PDF_ATTACH_MAX_PAGES = 25
@@ -28,22 +30,31 @@ _ATTACHMENT_REF_NEEDS_QUOTING_RE = _re.compile(r"""[\s()\[\]{}<>"'`]""")
del _re # bodies are rebound onto server globals: import inside functions only
def _b64_payload(raw: str, data_url_re: str, flags: int) -> bytes:
def _b64_payload(raw: str, data_url_re: str, flags: int, *, max_bytes: int) -> bytes:
"""Strip an optional ``data:...;base64,`` wrapper and all whitespace, then strictly decode."""
import base64 as _base64
import re as _re
cleaned = (raw or "").strip()
if m := _re.match(data_url_re, cleaned, flags):
cleaned = m.group(1)
return _base64.b64decode(_re.sub(r"\s+", "", cleaned), validate=True)
cleaned = _re.sub(r"\s+", "", cleaned)
if len(cleaned) > 4 * ((max_bytes + 2) // 3):
raise OverflowError(f"attachment too large; size limit is {max_bytes} bytes")
payload = _base64.b64decode(cleaned, validate=True)
if len(payload) > max_bytes:
raise OverflowError(f"attachment too large; size limit is {max_bytes} bytes")
return payload
def _decode_attach_base64(raw: str, *, mime_prefix: str) -> bytes | None:
def _decode_attach_base64(raw: str, *, mime_prefix: str, max_bytes: int) -> bytes | None:
"""Decode a (``data:<mime_prefix>...;base64,``-wrapped) payload; None when invalid."""
import re as _re
try:
return _b64_payload(
raw, rf"^data:{_re.escape(mime_prefix)}[a-zA-Z0-9.+-]*;base64,(.*)$", _re.DOTALL)
raw, rf"^data:{_re.escape(mime_prefix)}[a-zA-Z0-9.+-]*;base64,(.*)$", _re.DOTALL,
max_bytes=max_bytes)
except OverflowError:
raise
except Exception:
return None
@@ -51,14 +62,14 @@ def _decode_attach_base64(raw: str, *, mime_prefix: str) -> bytes | None:
def _decode_attach_payload(
rid, raw_b64: str, *, mime_prefix: str, max_bytes: int, label: str, empty_msg: str):
"""``(bytes, None)`` or ``(None, error)``: 4017 on bad/empty base64, 4018 over *max_bytes*."""
data = _decode_attach_base64(raw_b64, mime_prefix=mime_prefix)
try:
data = _decode_attach_base64(raw_b64, mime_prefix=mime_prefix, max_bytes=max_bytes)
except OverflowError as exc:
return None, _err(rid, 4018, f"{label} too large: {exc}")
if data is None:
return None, _err(rid, 4017, "data is not valid base64")
if not data:
return None, _err(rid, 4017, empty_msg)
if len(data) > max_bytes:
mb = max_bytes // (1024 * 1024)
return None, _err(rid, 4018, f"{label} too large ({len(data)} bytes; cap is {mb} MB)")
return data, None
@@ -196,12 +207,43 @@ def _sanitize_attachment_name(name: str) -> str:
return candidate.strip().strip(".") or "attachment"
def _read_attachment_bytes(path: Path, max_bytes: int) -> bytes:
# Bound the actual read, not just stat(): the source can grow after inspection.
with path.open("rb") as source:
payload = source.read(max_bytes + 1)
if len(payload) > max_bytes:
raise ValueError(f"attachment too large; size limit is {max_bytes} bytes")
return payload
def _stage_browser_file_attachment(session: dict, staged_upload: object, name: str) -> tuple[Path, bool]:
"""Copy a browser upload (the Webapp upload route's ``staged_upload`` receipt) into this session.
Source and destination may be different profiles on the same installation (an owner-routed
tile can receive a foreground browser pick); the caller decides which source homes this
connection may reach. The receipt is resolved back to exactly the staged layout under its
source home, which must still be a live profile (``hermes_cli/staged_uploads.py``). From there
the copy is the native Desktop's out-of-workspace file attach, capped at the browser limit.
"""
from hermes_constants import WEBAPP_ATTACHMENT_MAX_BYTES
from hermes_cli.staged_uploads import parse_staged_upload, resolve_staged_upload
raw_home, raw_path = parse_staged_upload(staged_upload)
source = resolve_staged_upload(Path(raw_home), raw_path)
# Keep the existing out-of-workspace copy into attachments/, which is visible to
# container/SSH terminal backends through cache mounts.
return _stage_session_file_attachment(
session, raw_path=str(source), data_url="", name=name, max_bytes=WEBAPP_ATTACHMENT_MAX_BYTES)
def _stage_session_file_attachment(
session: dict, *, raw_path: str, data_url: str, name: str) -> tuple[Path, bool]:
session: dict, *, raw_path: str, data_url: str, name: str, max_bytes: int | None = None,
) -> tuple[Path, bool]:
"""Make a desktop file attachment available to the gateway agent: ``(stored_path, uploaded)``.
Inside the workspace -> as-is; gateway-visible but outside -> copied into ``attachments/``
(bind-mounted into container backends so ``@file:`` resolves in the sandbox); not on the
gateway -> ``data_url`` bytes decoded into ``attachments/``."""
max_bytes = min(max_bytes, _ATTACHMENT_MAX_BYTES) if max_bytes is not None else _ATTACHMENT_MAX_BYTES
workspace = Path(_session_cwd(session)).resolve()
resolved = None
if raw_path:
@@ -222,7 +264,7 @@ def _stage_session_file_attachment(
resolved.relative_to(workspace)
return resolved, False
except ValueError:
payload = resolved.read_bytes()
payload = _read_attachment_bytes(resolved, max_bytes)
filename = resolved.name
else:
if not data_url:
@@ -232,7 +274,8 @@ def _stage_session_file_attachment(
import re as _re
try:
payload = _b64_payload(
data_url, r"^data:[^;,]*(?:;[^;,=]+=[^;,]+)*;base64,(.*)$", _re.DOTALL | _re.IGNORECASE)
data_url, r"^data:[^;,]*(?:;[^;,=]+=[^;,]+)*;base64,(.*)$", _re.DOTALL | _re.IGNORECASE,
max_bytes=max_bytes)
except (ValueError, _binascii.Error) as exc:
raise ValueError("invalid data_url payload") from exc
filename = _sanitize_attachment_name(name or Path(str(raw_path or "")).name)
+2
View File
@@ -34,6 +34,8 @@ launches the other. `serve` is the headless backend the desktop app spawns (`hea
`cmd_dashboard` skips `_build_web_ui` and exports `HERMES_SERVE_HEADLESS=1` so `mount_spa()`
disables the SPA even if a stray `web_dist/` exists — only JSON-RPC/WS/API is reachable). The
desktop has no build/runtime dependency on this frontend. Details: `apps/desktop/src/AGENTS.md`.
What each surface (`serve`, `dashboard`, `webapp`) may do is one table,
`hermes_cli/web_server_surface.py`; read it instead of comparing `ui_surface` strings.
## Rules