mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
1 parent
e3cc65d767
commit
b5eb5ee7a8
79 files changed
+6946
-361
No files matched your search
@@ -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).",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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')
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
):
|
||||
|
||||
@@ -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})
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>.
|
||||
|
||||
@@ -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') }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -61,6 +61,7 @@ _EXEMPT_DIRS = (
|
||||
"evals",
|
||||
"website",
|
||||
"node_modules",
|
||||
".build",
|
||||
".cache",
|
||||
".git",
|
||||
".venv",
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user