Compare commits
114 Commits
dependabot
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3042045540 | ||
|
|
9705e7944a | ||
|
|
4ed2f33994 | ||
|
|
0879d5cc8f | ||
|
|
81ff916e57 | ||
|
|
73cd8622f9 | ||
|
|
81eaedd0f5 | ||
|
|
51ee5b2c94 | ||
|
|
07e785d60a | ||
|
|
0fa7d6f660 | ||
|
|
38c8a9c10f | ||
|
|
2fa16ec2d2 | ||
|
|
fd12e59e6b | ||
|
|
c37fdec2d9 | ||
|
|
4af16b5da2 | ||
|
|
5ffbfed193 | ||
|
|
58ad6942d9 | ||
|
|
25c590ccd0 | ||
|
|
f1254c8eaf | ||
|
|
41babc702e | ||
|
|
3c3ac19d9c | ||
|
|
2e5c04aaf7 | ||
|
|
b39ec2fc37 | ||
|
|
646cd1b43e | ||
|
|
ef4b897a18 | ||
|
|
92e6d8c858 | ||
|
|
2f7c4858a7 | ||
|
|
8abdab24c9 | ||
|
|
67316fdc94 | ||
|
|
feff283e17 | ||
|
|
a14bae6bcc | ||
|
|
2a5d51c16e | ||
|
|
426f321e84 | ||
|
|
ca28c630c7 | ||
|
|
9b2f7d2cb1 | ||
|
|
0787ea07c8 | ||
|
|
f4fbaa6cda | ||
|
|
e1d10ec1ed | ||
|
|
860cf5133a | ||
|
|
f6fac60e66 | ||
|
|
b4356135f2 | ||
|
|
40ed67ccfe | ||
|
|
0b54a33a34 | ||
|
|
737007e335 | ||
|
|
6777916068 | ||
|
|
481f0417d8 | ||
|
|
085fc5d001 | ||
|
|
edcde6b26f | ||
|
|
5494c1e9b6 | ||
|
|
832d5967f8 | ||
|
|
eaa0984210 | ||
|
|
1153b42b24 | ||
|
|
c661634537 | ||
|
|
9c3c5da356 | ||
|
|
0ddd21c74e | ||
|
|
4440d77bf3 | ||
|
|
3769dff5dd | ||
|
|
c276b017ad | ||
|
|
fcf6cb3d73 | ||
|
|
c5eb64b9f7 | ||
|
|
6f89e17a33 | ||
|
|
4b7a186003 | ||
|
|
020e59d3cf | ||
|
|
86f2946fbe | ||
|
|
9ba4615db2 | ||
|
|
c1f9eb0ec4 | ||
|
|
acc8916ac7 | ||
|
|
237fa7d29c | ||
|
|
6b03874d07 | ||
|
|
6e20c1992f | ||
|
|
3db9b3e616 | ||
|
|
c28a02b49d | ||
|
|
e74577ed0f | ||
|
|
5feec8b4cf | ||
|
|
c803661cec | ||
|
|
c366466d70 | ||
|
|
ab1a42fcea | ||
|
|
a3cdd8c39d | ||
|
|
d0133fd8e4 | ||
|
|
259e78e175 | ||
|
|
b0999c82f3 | ||
|
|
3db49381d6 | ||
|
|
53d9b98305 | ||
|
|
e9a2ce6585 | ||
|
|
6092be413d | ||
|
|
f8098c6b6f | ||
|
|
016bce1a09 | ||
|
|
fd674af47f | ||
|
|
7fbb8c9df5 | ||
|
|
ee41aa0c1a | ||
|
|
5a00bd1518 | ||
|
|
22b6942fc2 | ||
|
|
394cdf48ce | ||
|
|
c835448908 | ||
|
|
0c1e8d0ba9 | ||
|
|
99a20f8d9a | ||
|
|
3ac6551ba3 | ||
|
|
00c045b43f | ||
|
|
f3b813c027 | ||
|
|
91e9459e10 | ||
|
|
eddbf291a4 | ||
|
|
a30b40c73a | ||
|
|
813a4e3838 | ||
|
|
166d2457b2 | ||
|
|
315fdae5f8 | ||
|
|
2c2ca0443b | ||
|
|
3c76dac4fd | ||
|
|
2b972472ce | ||
|
|
a893d77d8d | ||
|
|
94523764fc | ||
|
|
70f53f36cb | ||
|
|
7f76cf7195 | ||
|
|
b0e25c9cb2 | ||
|
|
2dace37f6b |
BIN
.github/pr-screenshots/45449/billing-confirm.png
vendored
Normal file
BIN
.github/pr-screenshots/45449/billing-confirm.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
BIN
.github/pr-screenshots/45449/billing-overview.png
vendored
Normal file
BIN
.github/pr-screenshots/45449/billing-overview.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
1
.gitignore
vendored
1
.gitignore
vendored
@ -5,6 +5,7 @@
|
|||||||
*.pyc*
|
*.pyc*
|
||||||
__pycache__/
|
__pycache__/
|
||||||
.venv/
|
.venv/
|
||||||
|
.venv
|
||||||
.vscode/
|
.vscode/
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|||||||
57
Dockerfile
57
Dockerfile
@ -9,8 +9,11 @@ FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df228
|
|||||||
FROM node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732 AS node_source
|
FROM node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732 AS node_source
|
||||||
FROM debian:13.4
|
FROM debian:13.4
|
||||||
|
|
||||||
# Disable Python stdout buffering to ensure logs are printed immediately
|
# Disable Python stdout buffering to ensure logs are printed immediately.
|
||||||
|
# Do not write .pyc files at runtime: /opt/hermes is immutable in the
|
||||||
|
# published container and writable state belongs under /opt/data.
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
# Store Playwright browsers outside the volume mount so the build-time
|
# Store Playwright browsers outside the volume mount so the build-time
|
||||||
# install survives the /opt/data volume overlay at runtime.
|
# install survives the /opt/data volume overlay at runtime.
|
||||||
@ -186,36 +189,38 @@ RUN cd web && npm run build && \
|
|||||||
|
|
||||||
# ---------- Source code ----------
|
# ---------- Source code ----------
|
||||||
# .dockerignore excludes node_modules, so the installs above survive.
|
# .dockerignore excludes node_modules, so the installs above survive.
|
||||||
COPY --chown=hermes:hermes . .
|
COPY . .
|
||||||
|
|
||||||
# ---------- Permissions ----------
|
# ---------- Permissions ----------
|
||||||
# Make install dir world-readable so any HERMES_UID can read it at runtime.
|
# Link hermes-agent itself (editable). Deps are already installed in the
|
||||||
# The venv needs to be traversable too.
|
# cached layer above; `--no-deps` makes this a fast egg-link creation with no
|
||||||
# node_modules trees additionally need to be writable by the hermes user
|
# resolution or downloads.
|
||||||
# so the runtime `npm install` triggered by _tui_need_npm_install() in
|
RUN uv pip install --no-cache-dir --no-deps -e "."
|
||||||
# hermes_cli/main.py succeeds (see #18800). /opt/hermes/web is build-time
|
|
||||||
# only (HERMES_WEB_DIST points at hermes_cli/web_dist) and is intentionally
|
# Keep /opt/hermes immutable for the runtime hermes user. Hosted/container
|
||||||
# not chowned here.
|
# instances must not be able to self-edit the installed source or venv; user
|
||||||
# /opt/hermes/gateway is runtime-writable: Python may create __pycache__ and
|
# data, skills, plugins, config, logs, and dashboard uploads live under
|
||||||
# gateway state artifacts beneath the package after services drop privileges,
|
# /opt/data instead. Root can still repair the image during build/boot, but
|
||||||
# especially when the hermes UID is remapped at boot (#27221).
|
# supervised Hermes processes drop to the non-root hermes user.
|
||||||
# The .venv MUST remain hermes-writable so lazy_deps.py can install
|
|
||||||
# remaining optional platform packages and future pin bumps at first use.
|
|
||||||
# Without this, `uv pip install` fails with EACCES and adapters silently
|
|
||||||
# fail to load. See tools/lazy_deps.py.
|
|
||||||
USER root
|
USER root
|
||||||
RUN chmod -R a+rX /opt/hermes && \
|
RUN mkdir -p /opt/hermes/bin && \
|
||||||
chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /opt/hermes/gateway /opt/hermes/node_modules
|
cp /opt/hermes/docker/hermes-exec-shim.sh /opt/hermes/bin/hermes && \
|
||||||
|
chmod 0755 /opt/hermes/bin/hermes && \
|
||||||
|
printf 'docker\n' > /opt/hermes/.install_method && \
|
||||||
|
chown -R root:root /opt/hermes && \
|
||||||
|
chmod -R a+rX /opt/hermes && \
|
||||||
|
chmod -R a-w /opt/hermes
|
||||||
|
# The ``.install_method`` stamp is baked next to the running code (the install
|
||||||
|
# tree), NOT into $HERMES_HOME. $HERMES_HOME (/opt/data) is a shared data
|
||||||
|
# volume that is commonly bind-mounted from the host and even shared with a
|
||||||
|
# host-side Desktop/CLI install; stamping it at boot used to clobber that
|
||||||
|
# host install's marker and wrongly block its ``hermes update``. A code-scoped
|
||||||
|
# stamp is read first by detect_install_method() and is immune to the share.
|
||||||
# Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown
|
# Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown
|
||||||
# the data volume. Each supervised service then drops to the hermes user via
|
# the data volume. Each supervised service then drops to the hermes user via
|
||||||
# `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services
|
# `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services
|
||||||
# run as the default hermes user (UID 10000).
|
# run as the default hermes user (UID 10000).
|
||||||
|
|
||||||
# ---------- Link hermes-agent itself (editable) ----------
|
|
||||||
# Deps are already installed in the cached layer above; `--no-deps` makes
|
|
||||||
# this a fast (~1s) egg-link creation with no resolution or downloads.
|
|
||||||
RUN uv pip install --no-cache-dir --no-deps -e "."
|
|
||||||
|
|
||||||
# ---------- Bake build-time git revision ----------
|
# ---------- Bake build-time git revision ----------
|
||||||
# .dockerignore excludes .git, so `git rev-parse HEAD` from inside the
|
# .dockerignore excludes .git, so `git rev-parse HEAD` from inside the
|
||||||
# container always returns nothing — meaning `hermes dump` reports
|
# container always returns nothing — meaning `hermes dump` reports
|
||||||
@ -235,8 +240,9 @@ RUN uv pip install --no-cache-dir --no-deps -e "."
|
|||||||
# every published image has it.
|
# every published image has it.
|
||||||
ARG HERMES_GIT_SHA=
|
ARG HERMES_GIT_SHA=
|
||||||
RUN if [ -n "${HERMES_GIT_SHA}" ]; then \
|
RUN if [ -n "${HERMES_GIT_SHA}" ]; then \
|
||||||
|
chmod u+w /opt/hermes && \
|
||||||
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha && \
|
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha && \
|
||||||
chown hermes:hermes /opt/hermes/.hermes_build_sha; \
|
chmod a-w /opt/hermes /opt/hermes/.hermes_build_sha; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ---------- s6-overlay service wiring ----------
|
# ---------- s6-overlay service wiring ----------
|
||||||
@ -282,6 +288,8 @@ ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
|
|||||||
# check. (A separate launcher hardening is tracked independently.)
|
# check. (A separate launcher hardening is tracked independently.)
|
||||||
ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
|
ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
|
||||||
ENV HERMES_HOME=/opt/data
|
ENV HERMES_HOME=/opt/data
|
||||||
|
ENV HERMES_WRITE_SAFE_ROOT=/opt/data
|
||||||
|
ENV HERMES_DISABLE_LAZY_INSTALLS=1
|
||||||
|
|
||||||
# `docker exec` privilege-drop shim. When operators run
|
# `docker exec` privilege-drop shim. When operators run
|
||||||
# `docker exec <c> hermes ...` they default to root, and any file the
|
# `docker exec <c> hermes ...` they default to root, and any file the
|
||||||
@ -294,7 +302,6 @@ ENV HERMES_HOME=/opt/data
|
|||||||
# Recursion is impossible because the shim exec's the venv binary by
|
# Recursion is impossible because the shim exec's the venv binary by
|
||||||
# absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for
|
# absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for
|
||||||
# the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1).
|
# the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1).
|
||||||
COPY --chmod=0755 docker/hermes-exec-shim.sh /opt/hermes/bin/hermes
|
|
||||||
|
|
||||||
# Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported
|
# Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported
|
||||||
# the venv bin onto PATH; Architecture B's main-wrapper.sh does the
|
# the venv bin onto PATH; Architecture B's main-wrapper.sh does the
|
||||||
|
|||||||
@ -1156,6 +1156,9 @@ def init_agent(
|
|||||||
"hermes_home": str(get_hermes_home()),
|
"hermes_home": str(get_hermes_home()),
|
||||||
"agent_context": "primary",
|
"agent_context": "primary",
|
||||||
}
|
}
|
||||||
|
if _init_kwargs["platform"] == "cli":
|
||||||
|
_init_kwargs["warning_callback"] = agent._emit_warning
|
||||||
|
_init_kwargs["status_callback"] = agent._emit_status
|
||||||
# Thread session title for memory provider scoping
|
# Thread session title for memory provider scoping
|
||||||
# (e.g. honcho uses this to derive chat-scoped session keys)
|
# (e.g. honcho uses this to derive chat-scoped session keys)
|
||||||
if agent._session_db:
|
if agent._session_db:
|
||||||
@ -1224,6 +1227,12 @@ def init_agent(
|
|||||||
# targets.
|
# targets.
|
||||||
agent._task_completion_guidance = bool(_agent_section.get("task_completion_guidance", True))
|
agent._task_completion_guidance = bool(_agent_section.get("task_completion_guidance", True))
|
||||||
|
|
||||||
|
# Universal parallel-tool-call guidance toggle. Default True. Separate
|
||||||
|
# flag from task_completion_guidance because a user may want one but not
|
||||||
|
# the other. Steers the model to batch independent tool calls into a
|
||||||
|
# single turn; the runtime already executes such batches concurrently.
|
||||||
|
agent._parallel_tool_call_guidance = bool(_agent_section.get("parallel_tool_call_guidance", True))
|
||||||
|
|
||||||
# Local Python toolchain probe toggle. Default True. When False,
|
# Local Python toolchain probe toggle. Default True. When False,
|
||||||
# the probe is skipped entirely (no subprocess calls, no system-prompt
|
# the probe is skipped entirely (no subprocess calls, no system-prompt
|
||||||
# line). Useful for users on exotic setups where the probe heuristics
|
# line). Useful for users on exotic setups where the probe heuristics
|
||||||
|
|||||||
@ -1839,21 +1839,35 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
|||||||
elif function_name == "memory":
|
elif function_name == "memory":
|
||||||
def _execute(next_args: dict) -> Any:
|
def _execute(next_args: dict) -> Any:
|
||||||
target = next_args.get("target", "memory")
|
target = next_args.get("target", "memory")
|
||||||
|
operations = next_args.get("operations")
|
||||||
from tools.memory_tool import memory_tool as _memory_tool
|
from tools.memory_tool import memory_tool as _memory_tool
|
||||||
result = _memory_tool(
|
result = _memory_tool(
|
||||||
action=next_args.get("action"),
|
action=next_args.get("action"),
|
||||||
target=target,
|
target=target,
|
||||||
content=next_args.get("content"),
|
content=next_args.get("content"),
|
||||||
old_text=next_args.get("old_text"),
|
old_text=next_args.get("old_text"),
|
||||||
|
operations=operations,
|
||||||
store=agent._memory_store,
|
store=agent._memory_store,
|
||||||
)
|
)
|
||||||
# Bridge: notify external memory provider of built-in memory writes
|
# Bridge: notify external memory provider of built-in memory writes.
|
||||||
if agent._memory_manager and next_args.get("action") in {"add", "replace"}:
|
# Covers both the single-op shape and each add/replace inside a batch.
|
||||||
|
if agent._memory_manager:
|
||||||
|
if operations:
|
||||||
|
_mem_ops = [
|
||||||
|
op for op in operations
|
||||||
|
if isinstance(op, dict) and op.get("action") in {"add", "replace"}
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
_mem_ops = (
|
||||||
|
[{"action": next_args.get("action"), "content": next_args.get("content")}]
|
||||||
|
if next_args.get("action") in {"add", "replace"} else []
|
||||||
|
)
|
||||||
|
for _op in _mem_ops:
|
||||||
try:
|
try:
|
||||||
agent._memory_manager.on_memory_write(
|
agent._memory_manager.on_memory_write(
|
||||||
next_args.get("action", ""),
|
_op.get("action", ""),
|
||||||
target,
|
target,
|
||||||
next_args.get("content", ""),
|
_op.get("content", "") or "",
|
||||||
metadata=agent._build_memory_write_metadata(
|
metadata=agent._build_memory_write_metadata(
|
||||||
task_id=effective_task_id,
|
task_id=effective_task_id,
|
||||||
tool_call_id=tool_call_id,
|
tool_call_id=tool_call_id,
|
||||||
|
|||||||
@ -300,6 +300,7 @@ def summarize_background_review_actions(
|
|||||||
"target": args.get("target", "memory"),
|
"target": args.get("target", "memory"),
|
||||||
"content": args.get("content", ""),
|
"content": args.get("content", ""),
|
||||||
"old_text": args.get("old_text", ""),
|
"old_text": args.get("old_text", ""),
|
||||||
|
"operations": args.get("operations") or [],
|
||||||
"name": args.get("name", ""),
|
"name": args.get("name", ""),
|
||||||
"old_string": args.get("old_string", ""),
|
"old_string": args.get("old_string", ""),
|
||||||
"new_string": args.get("new_string", ""),
|
"new_string": args.get("new_string", ""),
|
||||||
@ -353,6 +354,7 @@ def summarize_background_review_actions(
|
|||||||
content = detail.get("content", "")
|
content = detail.get("content", "")
|
||||||
old_text = detail.get("old_text", "")
|
old_text = detail.get("old_text", "")
|
||||||
skill_name = detail.get("name", "")
|
skill_name = detail.get("name", "")
|
||||||
|
operations = detail.get("operations") or []
|
||||||
max_preview = 120
|
max_preview = 120
|
||||||
if is_skill:
|
if is_skill:
|
||||||
change = data.get("_change", {})
|
change = data.get("_change", {})
|
||||||
@ -376,6 +378,21 @@ def summarize_background_review_actions(
|
|||||||
actions.append(f"📝 Skill '{skill_name}' rewritten: {description}")
|
actions.append(f"📝 Skill '{skill_name}' rewritten: {description}")
|
||||||
else:
|
else:
|
||||||
actions.append(f"📝 {message}" if message else f"Skill {action}")
|
actions.append(f"📝 {message}" if message else f"Skill {action}")
|
||||||
|
elif operations:
|
||||||
|
for op in operations:
|
||||||
|
op = op or {}
|
||||||
|
op_act = op.get("action", "")
|
||||||
|
op_content = (op.get("content") or "")
|
||||||
|
op_old = (op.get("old_text") or "")
|
||||||
|
if op_act == "add" and op_content:
|
||||||
|
preview = op_content[:max_preview] + ("…" if len(op_content) > max_preview else "")
|
||||||
|
actions.append(f"{label} ➕ {preview}")
|
||||||
|
elif op_act == "replace" and op_content:
|
||||||
|
preview = op_content[:max_preview] + ("…" if len(op_content) > max_preview else "")
|
||||||
|
actions.append(f"{label} ✏️ {preview}")
|
||||||
|
elif op_act == "remove" and op_old:
|
||||||
|
preview = op_old[:60] + ("…" if len(op_old) > 60 else "")
|
||||||
|
actions.append(f"{label} ➖ {preview}")
|
||||||
elif action == "add" and content:
|
elif action == "add" and content:
|
||||||
preview = content[:max_preview] + ("…" if len(content) > max_preview else "")
|
preview = content[:max_preview] + ("…" if len(content) > max_preview else "")
|
||||||
actions.append(f"{label} ➕ {preview}")
|
actions.append(f"{label} ➕ {preview}")
|
||||||
@ -391,6 +408,7 @@ def summarize_background_review_actions(
|
|||||||
"added" in message_lower
|
"added" in message_lower
|
||||||
or "replaced" in message_lower
|
or "replaced" in message_lower
|
||||||
or "removed" in message_lower
|
or "removed" in message_lower
|
||||||
|
or "applied" in message_lower
|
||||||
or (target and "add" in message.lower())
|
or (target and "add" in message.lower())
|
||||||
or "Entry added" in message
|
or "Entry added" in message
|
||||||
):
|
):
|
||||||
|
|||||||
295
agent/billing_view.py
Normal file
295
agent/billing_view.py
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
"""Surface-agnostic core for the Phase 2b terminal-billing screens.
|
||||||
|
|
||||||
|
One fetch/parse per concern, consumed identically by the CLI handler
|
||||||
|
(``cli.py::_show_billing``), the TUI JSON-RPC methods
|
||||||
|
(``tui_gateway/server.py``), and any other surface. Mirrors the proven
|
||||||
|
``agent/account_usage.py::build_credits_view`` pattern: parse the server payload
|
||||||
|
into a frozen dataclass; **fail open** — when not logged in or the portal is
|
||||||
|
unreachable, return a struct with ``logged_in=False`` and let the surface degrade
|
||||||
|
gracefully (never crash).
|
||||||
|
|
||||||
|
Money discipline: the server emits decimal STRINGS (``"142.5"``, not fixed 2dp).
|
||||||
|
We keep them as :class:`decimal.Decimal` end-to-end and only format for display.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Decimal money helpers
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def parse_money(value: Any) -> Optional[Decimal]:
|
||||||
|
"""Parse a server money value (decimal string) into :class:`Decimal`.
|
||||||
|
|
||||||
|
Returns None for missing/invalid input. Never raises. Accepts str/int (and,
|
||||||
|
defensively, float — though the server always sends strings).
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
# Decimal(str(...)) avoids binary-float artifacts if a float ever sneaks in.
|
||||||
|
return Decimal(str(value).strip())
|
||||||
|
except (InvalidOperation, ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def format_money(value: Optional[Decimal]) -> str:
|
||||||
|
"""Format a Decimal as ``$X`` / ``$X.YY`` for display.
|
||||||
|
|
||||||
|
Whole dollars show no decimals; any fractional amount shows exactly 2dp:
|
||||||
|
``Decimal("142.5")`` → ``"$142.50"``, ``Decimal("100")`` → ``"$100"``,
|
||||||
|
``Decimal("0.01")`` → ``"$0.01"``.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return "—"
|
||||||
|
if value == value.to_integral_value():
|
||||||
|
# Whole dollars — no decimal point. format(..., "f") avoids 1E+3 for 1000.
|
||||||
|
return f"${format(value.to_integral_value(), 'f')}"
|
||||||
|
# Fractional — always show 2dp.
|
||||||
|
return f"${format(value.quantize(Decimal('0.01')), 'f')}"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Parsed sub-structures
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CardInfo:
|
||||||
|
brand: str
|
||||||
|
last4: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def masked(self) -> str:
|
||||||
|
return f"{self.brand} ····{self.last4}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MonthlyCap:
|
||||||
|
limit_usd: Optional[Decimal] = None
|
||||||
|
spent_this_month_usd: Optional[Decimal] = None
|
||||||
|
is_default_ceiling: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AutoReload:
|
||||||
|
enabled: bool = False
|
||||||
|
threshold_usd: Optional[Decimal] = None
|
||||||
|
reload_to_usd: Optional[Decimal] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BillingState:
|
||||||
|
"""Parsed ``GET /api/billing/state`` — the overview screen's data.
|
||||||
|
|
||||||
|
Fail-open: ``logged_in=False`` (and empty fields) when not logged in or the
|
||||||
|
portal is unreachable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
logged_in: bool
|
||||||
|
org_id: Optional[str] = None
|
||||||
|
org_slug: Optional[str] = None
|
||||||
|
org_name: Optional[str] = None
|
||||||
|
role: Optional[str] = None # "OWNER" | "ADMIN" | "MEMBER"
|
||||||
|
balance_usd: Optional[Decimal] = None
|
||||||
|
cli_billing_enabled: bool = False
|
||||||
|
charge_presets: tuple[Decimal, ...] = ()
|
||||||
|
min_usd: Optional[Decimal] = None
|
||||||
|
max_usd: Optional[Decimal] = None
|
||||||
|
card: Optional[CardInfo] = None
|
||||||
|
monthly_cap: Optional[MonthlyCap] = None
|
||||||
|
auto_reload: Optional[AutoReload] = None
|
||||||
|
portal_url: Optional[str] = None
|
||||||
|
# When the fetch failed (vs cleanly not-logged-in), the message for the surface.
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_admin(self) -> bool:
|
||||||
|
"""True for OWNER/ADMIN — the roles that can manage billing."""
|
||||||
|
return (self.role or "").upper() in ("OWNER", "ADMIN")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_charge(self) -> bool:
|
||||||
|
"""True when the UI should offer charge/auto-reload actions.
|
||||||
|
|
||||||
|
Admin role AND the per-org kill-switch on. (The server still enforces;
|
||||||
|
this is just for graying out actions the user can't take.)
|
||||||
|
"""
|
||||||
|
return self.is_admin and self.cli_billing_enabled
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_card(raw: Any) -> Optional[CardInfo]:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
brand = raw.get("brand")
|
||||||
|
last4 = raw.get("last4")
|
||||||
|
if isinstance(brand, str) and isinstance(last4, str):
|
||||||
|
return CardInfo(brand=brand, last4=last4)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
return MonthlyCap(
|
||||||
|
limit_usd=parse_money(raw.get("limitUsd")),
|
||||||
|
spent_this_month_usd=parse_money(raw.get("spentThisMonthUsd")),
|
||||||
|
is_default_ceiling=bool(raw.get("isDefaultCeiling")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_auto_reload(raw: Any) -> Optional[AutoReload]:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
return AutoReload(
|
||||||
|
enabled=bool(raw.get("enabled")),
|
||||||
|
threshold_usd=parse_money(raw.get("thresholdUsd")),
|
||||||
|
reload_to_usd=parse_money(raw.get("reloadToUsd")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def billing_state_from_payload(
|
||||||
|
payload: dict[str, Any], *, portal_url: Optional[str] = None
|
||||||
|
) -> BillingState:
|
||||||
|
"""Map a raw ``/api/billing/state`` JSON dict into :class:`BillingState`."""
|
||||||
|
raw_org = payload.get("org")
|
||||||
|
org: dict[str, Any] = raw_org if isinstance(raw_org, dict) else {}
|
||||||
|
raw_bounds = payload.get("bounds")
|
||||||
|
bounds: dict[str, Any] = raw_bounds if isinstance(raw_bounds, dict) else {}
|
||||||
|
|
||||||
|
presets: list[Decimal] = []
|
||||||
|
for item in payload.get("chargePresets") or ():
|
||||||
|
parsed = parse_money(item)
|
||||||
|
if parsed is not None:
|
||||||
|
presets.append(parsed)
|
||||||
|
|
||||||
|
return BillingState(
|
||||||
|
logged_in=True,
|
||||||
|
org_id=org.get("id"),
|
||||||
|
org_slug=org.get("slug"),
|
||||||
|
org_name=org.get("name"),
|
||||||
|
role=org.get("role"),
|
||||||
|
balance_usd=parse_money(payload.get("balanceUsd")),
|
||||||
|
cli_billing_enabled=bool(payload.get("cliBillingEnabled")),
|
||||||
|
charge_presets=tuple(presets),
|
||||||
|
min_usd=parse_money(bounds.get("minUsd")),
|
||||||
|
max_usd=parse_money(bounds.get("maxUsd")),
|
||||||
|
card=_parse_card(payload.get("card")),
|
||||||
|
monthly_cap=_parse_monthly_cap(payload.get("monthlyCap")),
|
||||||
|
auto_reload=_parse_auto_reload(payload.get("autoReload")),
|
||||||
|
portal_url=portal_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Fail-open builders (the surface front doors)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def build_billing_state(*, timeout: float = 15.0) -> BillingState:
|
||||||
|
"""Fetch + parse ``/api/billing/state``. Fail-open.
|
||||||
|
|
||||||
|
Returns ``BillingState(logged_in=False)`` when not logged in. On a portal/HTTP
|
||||||
|
failure, returns ``logged_in=False`` with ``error`` set so the surface can show
|
||||||
|
a clear message rather than crashing.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from hermes_cli.nous_billing import (
|
||||||
|
BillingAuthError,
|
||||||
|
BillingError,
|
||||||
|
_absolutize_portal_url,
|
||||||
|
get_billing_state,
|
||||||
|
resolve_portal_base_url,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return BillingState(logged_in=False, error="billing client unavailable")
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = get_billing_state(timeout=timeout)
|
||||||
|
except BillingAuthError:
|
||||||
|
return BillingState(logged_in=False)
|
||||||
|
except BillingError as exc:
|
||||||
|
logger.debug("billing ▸ /state fetch failed (fail-open)", exc_info=True)
|
||||||
|
return BillingState(logged_in=False, error=str(exc))
|
||||||
|
except Exception:
|
||||||
|
logger.debug("billing ▸ /state unexpected error (fail-open)", exc_info=True)
|
||||||
|
return BillingState(logged_in=False, error="could not load billing state")
|
||||||
|
|
||||||
|
# Prefer a server-supplied portalUrl if present (resolved to absolute in case
|
||||||
|
# it's relative); else build the standard one.
|
||||||
|
raw_portal = payload.get("portalUrl") if isinstance(payload, dict) else None
|
||||||
|
portal_url = _absolutize_portal_url(raw_portal) if raw_portal else None
|
||||||
|
if not portal_url:
|
||||||
|
try:
|
||||||
|
portal_url = _fallback_portal_url(resolve_portal_base_url())
|
||||||
|
except Exception:
|
||||||
|
portal_url = None
|
||||||
|
|
||||||
|
return billing_state_from_payload(payload, portal_url=portal_url)
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_portal_url(base: str) -> str:
|
||||||
|
"""Standard billing deep-link when the server omits ``portalUrl``."""
|
||||||
|
return f"{base.rstrip('/')}/billing?topup=open"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Idempotency
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def new_idempotency_key() -> str:
|
||||||
|
"""Fresh UUID for a user-confirmed purchase (reuse on retry of the SAME buy).
|
||||||
|
|
||||||
|
The ``Idempotency-Key`` header is mandatory on ``POST /charge``; generate one
|
||||||
|
per confirmed purchase and reuse it across retries so a double-submit collapses
|
||||||
|
to a single charge. Never reuse a key across different amounts (the server
|
||||||
|
returns 409 idempotency_conflict).
|
||||||
|
"""
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Amount validation (Screen 3 custom input)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AmountValidation:
|
||||||
|
ok: bool
|
||||||
|
amount: Optional[Decimal] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_charge_amount(
|
||||||
|
raw: str, *, min_usd: Optional[Decimal], max_usd: Optional[Decimal]
|
||||||
|
) -> AmountValidation:
|
||||||
|
"""Validate a custom charge amount against bounds + 2dp (multipleOf 0.01).
|
||||||
|
|
||||||
|
Mirrors the server's accept/reject so the UI can give instant feedback rather
|
||||||
|
than round-tripping a sure-to-fail charge. The server is still authoritative.
|
||||||
|
"""
|
||||||
|
cleaned = (raw or "").strip().lstrip("$").strip()
|
||||||
|
amount = parse_money(cleaned)
|
||||||
|
if amount is None:
|
||||||
|
return AmountValidation(ok=False, error="Enter a dollar amount, e.g. 100")
|
||||||
|
if amount <= 0:
|
||||||
|
return AmountValidation(ok=False, error="Amount must be greater than $0")
|
||||||
|
# multipleOf 0.01 — reject sub-cent precision.
|
||||||
|
if amount != amount.quantize(Decimal("0.01")):
|
||||||
|
return AmountValidation(ok=False, error="Amount can't be smaller than a cent")
|
||||||
|
if min_usd is not None and amount < min_usd:
|
||||||
|
return AmountValidation(ok=False, error=f"Minimum is {format_money(min_usd)}")
|
||||||
|
if max_usd is not None and amount > max_usd:
|
||||||
|
return AmountValidation(ok=False, error=f"Maximum is {format_money(max_usd)}")
|
||||||
|
return AmountValidation(ok=True, amount=amount)
|
||||||
@ -262,6 +262,26 @@ def _responses_tools(tools: Optional[List[Dict[str, Any]]] = None) -> Optional[L
|
|||||||
return converted or None
|
return converted or None
|
||||||
|
|
||||||
|
|
||||||
|
# Provider-executed built-in tool *declaration* types accepted on the
|
||||||
|
# Responses ``tools`` array. These are declared by ``type`` alone (no
|
||||||
|
# client-side name/parameters schema) and run server-side — the provider
|
||||||
|
# owns the implementation and reports progress via the matching ``*_call``
|
||||||
|
# output items. Hermes injects xAI's native ``web_search`` for the xAI
|
||||||
|
# transport (see agent/transports/codex.py); the rest are listed so the
|
||||||
|
# preflight validator passes them through rather than rejecting them as
|
||||||
|
# "unsupported type". Mirrors the ``*_call`` item-type set used in
|
||||||
|
# _normalize_codex_response.
|
||||||
|
_RESPONSES_BUILTIN_TOOL_TYPES = {
|
||||||
|
"web_search",
|
||||||
|
"web_search_preview",
|
||||||
|
"file_search",
|
||||||
|
"code_interpreter",
|
||||||
|
"image_generation",
|
||||||
|
"computer_use_preview",
|
||||||
|
"local_shell",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Message format conversion
|
# Message format conversion
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -802,7 +822,22 @@ def _preflight_codex_api_kwargs(
|
|||||||
for idx, tool in enumerate(tools):
|
for idx, tool in enumerate(tools):
|
||||||
if not isinstance(tool, dict):
|
if not isinstance(tool, dict):
|
||||||
raise ValueError(f"Codex Responses tools[{idx}] must be an object.")
|
raise ValueError(f"Codex Responses tools[{idx}] must be an object.")
|
||||||
if tool.get("type") != "function":
|
|
||||||
|
tool_type = tool.get("type")
|
||||||
|
|
||||||
|
# Provider-executed built-in tools (xAI native web_search, code
|
||||||
|
# interpreter, etc.) are declared by ``type`` alone and carry no
|
||||||
|
# ``name``/``parameters`` schema — the provider owns the
|
||||||
|
# implementation. Pass them through verbatim instead of forcing
|
||||||
|
# them through the function-tool validation below (which would
|
||||||
|
# otherwise reject them with "unsupported type"). See
|
||||||
|
# agent/transports/codex.py for where xAI's native web_search is
|
||||||
|
# injected.
|
||||||
|
if tool_type in _RESPONSES_BUILTIN_TOOL_TYPES:
|
||||||
|
normalized_tools.append(dict(tool))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if tool_type != "function":
|
||||||
raise ValueError(f"Codex Responses tools[{idx}] has unsupported type {tool.get('type')!r}.")
|
raise ValueError(f"Codex Responses tools[{idx}] has unsupported type {tool.get('type')!r}.")
|
||||||
|
|
||||||
name = tool.get("name")
|
name = tool.get("name")
|
||||||
@ -1086,6 +1121,33 @@ def _normalize_codex_response(
|
|||||||
saw_final_answer_phase = False
|
saw_final_answer_phase = False
|
||||||
saw_reasoning_item = False
|
saw_reasoning_item = False
|
||||||
|
|
||||||
|
# Server-side built-in tool calls (xAI's native web_search, code
|
||||||
|
# interpreter, etc.) are executed by the provider and reported as
|
||||||
|
# discrete ``*_call`` output items. xAI's /v1/responses surface
|
||||||
|
# (e.g. grok-composer-2.5-fast on SuperGrok OAuth) routinely leaves
|
||||||
|
# these items at ``status="in_progress"`` even when the overall
|
||||||
|
# ``response.status == "completed"`` — the search ran to completion
|
||||||
|
# server-side, the per-item status simply isn't reconciled. These
|
||||||
|
# are NOT a signal that the model's turn is unfinished, so they must
|
||||||
|
# not flip ``has_incomplete_items``. Only the response-level status
|
||||||
|
# and genuine model output items (message/reasoning/function_call)
|
||||||
|
# govern the incomplete verdict. Without this guard, any turn where
|
||||||
|
# grok-composer invokes server-side search is misclassified as
|
||||||
|
# ``finish_reason="incomplete"`` and burns 3 fruitless continuation
|
||||||
|
# retries before failing with "Codex response remained incomplete
|
||||||
|
# after 3 continuation attempts". client-side function/custom tool
|
||||||
|
# calls keep their own in_progress handling below (they are skipped,
|
||||||
|
# not awaited).
|
||||||
|
_SERVER_SIDE_TOOL_CALL_TYPES = {
|
||||||
|
"web_search_call",
|
||||||
|
"file_search_call",
|
||||||
|
"code_interpreter_call",
|
||||||
|
"image_generation_call",
|
||||||
|
"computer_call",
|
||||||
|
"local_shell_call",
|
||||||
|
"mcp_call",
|
||||||
|
}
|
||||||
|
|
||||||
for item in output:
|
for item in output:
|
||||||
item_type = getattr(item, "type", None)
|
item_type = getattr(item, "type", None)
|
||||||
item_status = getattr(item, "status", None)
|
item_status = getattr(item, "status", None)
|
||||||
@ -1094,7 +1156,10 @@ def _normalize_codex_response(
|
|||||||
else:
|
else:
|
||||||
item_status = None
|
item_status = None
|
||||||
|
|
||||||
if item_status in {"queued", "in_progress", "incomplete"}:
|
if (
|
||||||
|
item_status in {"queued", "in_progress", "incomplete"}
|
||||||
|
and item_type not in _SERVER_SIDE_TOOL_CALL_TYPES
|
||||||
|
):
|
||||||
has_incomplete_items = True
|
has_incomplete_items = True
|
||||||
saw_streaming_or_item_incomplete = True
|
saw_streaming_or_item_incomplete = True
|
||||||
|
|
||||||
|
|||||||
@ -512,6 +512,16 @@ def compress_context(
|
|||||||
old_title = agent._session_db.get_session_title(agent.session_id)
|
old_title = agent._session_db.get_session_title(agent.session_id)
|
||||||
# Trigger memory extraction on the old session before it rotates.
|
# Trigger memory extraction on the old session before it rotates.
|
||||||
agent.commit_memory_session(messages)
|
agent.commit_memory_session(messages)
|
||||||
|
# Flush any un-persisted messages from the current turn to the
|
||||||
|
# old session *before* rotating. compress_context() can be
|
||||||
|
# called mid-turn (auto-compress when context exceeds threshold)
|
||||||
|
# at a point when _flush_messages_to_session_db() has not yet
|
||||||
|
# run. Without this, messages generated during the current turn
|
||||||
|
# are silently lost on session rotation (#47202).
|
||||||
|
try:
|
||||||
|
agent._flush_messages_to_session_db(messages)
|
||||||
|
except Exception:
|
||||||
|
pass # best-effort — don't block compression on a flush error
|
||||||
agent._session_db.end_session(agent.session_id, "compression")
|
agent._session_db.end_session(agent.session_id, "compression")
|
||||||
old_session_id = agent.session_id
|
old_session_id = agent.session_id
|
||||||
agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
|
agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
|
||||||
|
|||||||
@ -3756,8 +3756,30 @@ def run_conversation(
|
|||||||
assistant_msg = agent._build_assistant_message(assistant_message, finish_reason)
|
assistant_msg = agent._build_assistant_message(assistant_message, finish_reason)
|
||||||
messages.append(assistant_msg)
|
messages.append(assistant_msg)
|
||||||
for tc in assistant_message.tool_calls:
|
for tc in assistant_message.tool_calls:
|
||||||
if tc.function.name not in agent.valid_tool_names:
|
_tc_name = tc.function.name
|
||||||
content = f"Tool '{tc.function.name}' does not exist. Available tools: {available}"
|
if _tc_name not in agent.valid_tool_names:
|
||||||
|
# A blank/whitespace-only name is not a typo the
|
||||||
|
# model can fuzzy-correct toward a real tool — it is
|
||||||
|
# almost always a weak open model echoing tool-call
|
||||||
|
# XML/JSON it saw in file or tool output (#47967:
|
||||||
|
# <tool_call>/<invoke name=...> payloads in a file
|
||||||
|
# prime mimo/nemotron-class models to emit empty
|
||||||
|
# structured calls). Dumping the full tool catalog
|
||||||
|
# in that case feeds the priming loop more names to
|
||||||
|
# mimic and inflates context 3-4x across retries, so
|
||||||
|
# send a terse error that tells the model in-context
|
||||||
|
# tool-call syntax is DATA, not a call to make.
|
||||||
|
if not (_tc_name or "").strip():
|
||||||
|
content = (
|
||||||
|
"Tool call rejected: the tool name was empty. "
|
||||||
|
"If tool-call XML or JSON appeared in file "
|
||||||
|
"contents or tool output, that is data — do "
|
||||||
|
"not re-emit it as a tool call. To call a "
|
||||||
|
"tool, use a valid name from your tool list; "
|
||||||
|
"otherwise reply in plain text."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
content = f"Tool '{_tc_name}' does not exist. Available tools: {available}"
|
||||||
else:
|
else:
|
||||||
content = "Skipped: another tool call in this turn used an invalid name. Please retry this tool call."
|
content = "Skipped: another tool call in this turn used an invalid name. Please retry this tool call."
|
||||||
messages.append({
|
messages.append({
|
||||||
|
|||||||
@ -275,6 +275,10 @@ DEFAULT_CONTEXT_LENGTHS = {
|
|||||||
# via a custom provider. Values sourced from models.dev (2026-04).
|
# via a custom provider. Values sourced from models.dev (2026-04).
|
||||||
# Keys use substring matching (longest-first), so e.g. "grok-4.20"
|
# Keys use substring matching (longest-first), so e.g. "grok-4.20"
|
||||||
# matches "grok-4.20-0309-reasoning" / "-non-reasoning" / "-multi-agent-0309".
|
# matches "grok-4.20-0309-reasoning" / "-non-reasoning" / "-multi-agent-0309".
|
||||||
|
# OAuth-only slug; absent from GET /v1/models. xAI publishes a 200k
|
||||||
|
# usable context window for Composer 2.5 on Grok Build (SuperGrok /
|
||||||
|
# Premium+); /v1/responses additionally enforces a ~262144 input+output
|
||||||
|
# budget, but the usable context (what we track here) is 200k.
|
||||||
"grok-composer": 200000, # grok-composer-2.5-fast (Grok Build CLI)
|
"grok-composer": 200000, # grok-composer-2.5-fast (Grok Build CLI)
|
||||||
"grok-build": 256000, # grok-build-0.1
|
"grok-build": 256000, # grok-build-0.1
|
||||||
"grok-code-fast": 256000, # grok-code-fast-1
|
"grok-code-fast": 256000, # grok-code-fast-1
|
||||||
|
|||||||
@ -305,6 +305,47 @@ TASK_COMPLETION_GUIDANCE = (
|
|||||||
"is always better than inventing a result."
|
"is always better than inventing a result."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Universal parallel-tool-call guidance — applied to ALL models.
|
||||||
|
#
|
||||||
|
# Why this matters for cost: every assistant turn resends the entire
|
||||||
|
# accumulated conversation (and, on cache-friendly providers, re-reads the
|
||||||
|
# cached prefix and pays for the newly-appended turn). A model that issues
|
||||||
|
# one tool call per turn multiplies the number of round-trips — and therefore
|
||||||
|
# the resent context — for any task that needs several independent reads,
|
||||||
|
# searches, or safe lookups. Batching independent calls into a single
|
||||||
|
# assistant response collapses N turns into one, cutting both latency and the
|
||||||
|
# resent-context cost that compounds over a long conversation.
|
||||||
|
#
|
||||||
|
# The hermes-agent runtime already executes a batch of tool calls
|
||||||
|
# concurrently when they are independent (read-only tools always; path-scoped
|
||||||
|
# file ops when their targets don't overlap — see
|
||||||
|
# run_agent._execute_tool_calls / tool_dispatch_helpers). The missing piece
|
||||||
|
# was telling the *model* to emit those calls together in the first place.
|
||||||
|
# Until now the only batching steer in the prompt lived in
|
||||||
|
# GOOGLE_MODEL_OPERATIONAL_GUIDANCE — Gemini/Gemma got it, every other model
|
||||||
|
# got nothing. This block makes the steer universal; the now-redundant
|
||||||
|
# Google-only bullet has been dropped so no model receives it twice.
|
||||||
|
#
|
||||||
|
# Short on purpose — shipped in the cached system prompt to every user, every
|
||||||
|
# session. Token cost is paid once at install and amortised across all
|
||||||
|
# sessions via prefix caching. Keep it tight.
|
||||||
|
#
|
||||||
|
# Ported from cline/cline#11514 ("encourage parallel tool calls"), adapted
|
||||||
|
# from Cline's TypeScript tool-surface guidance to hermes-agent's Python
|
||||||
|
# prompt-assembly architecture.
|
||||||
|
PARALLEL_TOOL_CALL_GUIDANCE = (
|
||||||
|
"# Parallel tool calls\n"
|
||||||
|
"When you need several pieces of information that don't depend on each "
|
||||||
|
"other, request them together in a single response instead of one tool "
|
||||||
|
"call per turn. Independent reads, searches, web fetches, and read-only "
|
||||||
|
"commands should be batched into the same assistant turn — the runtime "
|
||||||
|
"executes independent calls concurrently, and batching avoids resending "
|
||||||
|
"the whole conversation on every extra round-trip.\n"
|
||||||
|
"Only serialize calls when a later call genuinely depends on an earlier "
|
||||||
|
"call's result (e.g. you must read a file before you can patch it). When "
|
||||||
|
"in doubt and the calls are independent, batch them."
|
||||||
|
)
|
||||||
|
|
||||||
# OpenAI GPT/Codex-specific execution guidance. Addresses known failure modes
|
# OpenAI GPT/Codex-specific execution guidance. Addresses known failure modes
|
||||||
# where GPT models abandon work on partial results, skip prerequisite lookups,
|
# where GPT models abandon work on partial results, skip prerequisite lookups,
|
||||||
# hallucinate instead of using tools, and declare "done" without verification.
|
# hallucinate instead of using tools, and declare "done" without verification.
|
||||||
@ -386,9 +427,10 @@ GOOGLE_MODEL_OPERATIONAL_GUIDANCE = (
|
|||||||
"package.json, requirements.txt, Cargo.toml, etc. before importing.\n"
|
"package.json, requirements.txt, Cargo.toml, etc. before importing.\n"
|
||||||
"- **Conciseness:** Keep explanatory text brief — a few sentences, not "
|
"- **Conciseness:** Keep explanatory text brief — a few sentences, not "
|
||||||
"paragraphs. Focus on actions and results over narration.\n"
|
"paragraphs. Focus on actions and results over narration.\n"
|
||||||
"- **Parallel tool calls:** When you need to perform multiple independent "
|
# Parallel-tool-call steering now lives in the universal
|
||||||
"operations (e.g. reading several files), make all the tool calls in a "
|
# PARALLEL_TOOL_CALL_GUIDANCE block (injected for all models), so it is no
|
||||||
"single response rather than sequentially.\n"
|
# longer duplicated here — keeping it would send Gemini/Gemma the same
|
||||||
|
# instruction twice.
|
||||||
"- **Non-interactive commands:** Use flags like -y, --yes, --non-interactive "
|
"- **Non-interactive commands:** Use flags like -y, --yes, --non-interactive "
|
||||||
"to prevent CLI tools from hanging on prompts.\n"
|
"to prevent CLI tools from hanging on prompts.\n"
|
||||||
"- **Keep going:** Work autonomously until the task is fully resolved. "
|
"- **Keep going:** Work autonomously until the task is fully resolved. "
|
||||||
|
|||||||
@ -33,6 +33,7 @@ from agent.prompt_builder import (
|
|||||||
KANBAN_GUIDANCE,
|
KANBAN_GUIDANCE,
|
||||||
MEMORY_GUIDANCE,
|
MEMORY_GUIDANCE,
|
||||||
OPENAI_MODEL_EXECUTION_GUIDANCE,
|
OPENAI_MODEL_EXECUTION_GUIDANCE,
|
||||||
|
PARALLEL_TOOL_CALL_GUIDANCE,
|
||||||
PLATFORM_HINTS,
|
PLATFORM_HINTS,
|
||||||
SESSION_SEARCH_GUIDANCE,
|
SESSION_SEARCH_GUIDANCE,
|
||||||
SKILLS_GUIDANCE,
|
SKILLS_GUIDANCE,
|
||||||
@ -123,6 +124,17 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
|||||||
if getattr(agent, "_task_completion_guidance", True) and agent.valid_tool_names:
|
if getattr(agent, "_task_completion_guidance", True) and agent.valid_tool_names:
|
||||||
stable_parts.append(TASK_COMPLETION_GUIDANCE)
|
stable_parts.append(TASK_COMPLETION_GUIDANCE)
|
||||||
|
|
||||||
|
# Universal parallel-tool-call guidance. Tells the model to batch
|
||||||
|
# independent tool calls into one assistant turn rather than emitting one
|
||||||
|
# call per turn — the runtime already runs independent calls concurrently
|
||||||
|
# (read-only tools always; non-overlapping path-scoped file ops), so the
|
||||||
|
# only thing missing was steering the model to produce the batch. Cuts
|
||||||
|
# round-trips and the resent-context cost that compounds over a long
|
||||||
|
# conversation. Gated by config.yaml ``agent.parallel_tool_call_guidance``
|
||||||
|
# (default True) and only injected when tools are actually loaded.
|
||||||
|
if getattr(agent, "_parallel_tool_call_guidance", True) and agent.valid_tool_names:
|
||||||
|
stable_parts.append(PARALLEL_TOOL_CALL_GUIDANCE)
|
||||||
|
|
||||||
# Tool-aware behavioral guidance: only inject when the tools are loaded
|
# Tool-aware behavioral guidance: only inject when the tools are loaded
|
||||||
tool_guidance = []
|
tool_guidance = []
|
||||||
if "memory" in agent.valid_tool_names:
|
if "memory" in agent.valid_tool_names:
|
||||||
|
|||||||
@ -1012,21 +1012,35 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||||||
elif function_name == "memory":
|
elif function_name == "memory":
|
||||||
def _execute(next_args: dict) -> Any:
|
def _execute(next_args: dict) -> Any:
|
||||||
target = next_args.get("target", "memory")
|
target = next_args.get("target", "memory")
|
||||||
|
operations = next_args.get("operations")
|
||||||
from tools.memory_tool import memory_tool as _memory_tool
|
from tools.memory_tool import memory_tool as _memory_tool
|
||||||
result = _memory_tool(
|
result = _memory_tool(
|
||||||
action=next_args.get("action"),
|
action=next_args.get("action"),
|
||||||
target=target,
|
target=target,
|
||||||
content=next_args.get("content"),
|
content=next_args.get("content"),
|
||||||
old_text=next_args.get("old_text"),
|
old_text=next_args.get("old_text"),
|
||||||
|
operations=operations,
|
||||||
store=agent._memory_store,
|
store=agent._memory_store,
|
||||||
)
|
)
|
||||||
# Bridge: notify external memory provider of built-in memory writes
|
# Bridge: notify external memory provider of built-in memory writes.
|
||||||
if agent._memory_manager and next_args.get("action") in {"add", "replace"}:
|
# Covers both the single-op shape and each add/replace inside a batch.
|
||||||
|
if agent._memory_manager:
|
||||||
|
if operations:
|
||||||
|
_mem_ops = [
|
||||||
|
op for op in operations
|
||||||
|
if isinstance(op, dict) and op.get("action") in {"add", "replace"}
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
_mem_ops = (
|
||||||
|
[{"action": next_args.get("action"), "content": next_args.get("content")}]
|
||||||
|
if next_args.get("action") in {"add", "replace"} else []
|
||||||
|
)
|
||||||
|
for _op in _mem_ops:
|
||||||
try:
|
try:
|
||||||
agent._memory_manager.on_memory_write(
|
agent._memory_manager.on_memory_write(
|
||||||
next_args.get("action", ""),
|
_op.get("action", ""),
|
||||||
target,
|
target,
|
||||||
next_args.get("content", ""),
|
_op.get("content", "") or "",
|
||||||
metadata=agent._build_memory_write_metadata(
|
metadata=agent._build_memory_write_metadata(
|
||||||
task_id=effective_task_id,
|
task_id=effective_task_id,
|
||||||
tool_call_id=getattr(tool_call, "id", None),
|
tool_call_id=getattr(tool_call, "id", None),
|
||||||
|
|||||||
@ -128,6 +128,65 @@ class ResponsesApiTransport(ProviderTransport):
|
|||||||
reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort)
|
reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort)
|
||||||
|
|
||||||
response_tools = _responses_tools(tools)
|
response_tools = _responses_tools(tools)
|
||||||
|
|
||||||
|
# xAI server-side web search.
|
||||||
|
#
|
||||||
|
# grok models on xAI's /v1/responses surface (notably
|
||||||
|
# grok-composer-2.5-fast on SuperGrok OAuth) have a *native*,
|
||||||
|
# server-executed web search. When the model is handed a
|
||||||
|
# client-side function literally named ``web_search``, it routes
|
||||||
|
# the intent to that native engine — but because the tool is
|
||||||
|
# declared as a plain ``function`` rather than xAI's first-class
|
||||||
|
# ``{"type": "web_search"}`` built-in, the server-side search is
|
||||||
|
# dispatched but never reconciled: the response streams reasoning
|
||||||
|
# + ``web_search_call`` progress items, the searches never reach
|
||||||
|
# ``status="completed"`` in the assembled output, no final
|
||||||
|
# message is emitted, and ``_normalize_codex_response`` correctly
|
||||||
|
# sees reasoning-with-no-answer and reports ``incomplete``. The
|
||||||
|
# turn then burns 3 continuation retries and fails with "Codex
|
||||||
|
# response remained incomplete after 3 continuation attempts".
|
||||||
|
# Verified live against grok-composer-2.5-fast (2026-06).
|
||||||
|
#
|
||||||
|
# Fix: when the agent HAS a client-side ``web_search`` function (i.e.
|
||||||
|
# the user enabled the web toolset), declare xAI's native
|
||||||
|
# ``web_search`` built-in instead so the search actually runs to
|
||||||
|
# completion server-side and the model streams a real answer. The
|
||||||
|
# Responses API rejects two tools sharing the name ``web_search``
|
||||||
|
# (HTTP 400 "Duplicate tool names"), so we drop the client-side
|
||||||
|
# ``web_search`` function for the xAI path and let the native tool
|
||||||
|
# satisfy it. All other client-side tools (read_file, terminal,
|
||||||
|
# web_extract, MCP tools, …) are untouched and continue to dispatch
|
||||||
|
# through Hermes's agent loop.
|
||||||
|
#
|
||||||
|
# Scope: we ONLY swap in the native built-in when the client
|
||||||
|
# ``web_search`` was actually present. We do NOT force-enable Grok
|
||||||
|
# server-side search on turns where the user never had web enabled —
|
||||||
|
# that would silently route around Hermes's web-provider config and
|
||||||
|
# tool-trace/citation plumbing for every xai-oauth turn. The swap is
|
||||||
|
# a 1:1 replacement of an already-requested capability, not an
|
||||||
|
# additive grant.
|
||||||
|
#
|
||||||
|
# NOTE: for the swapped case this routes ``web_search`` to Grok's
|
||||||
|
# native search engine for xAI sessions instead of Hermes's
|
||||||
|
# configured web provider (Tavily/etc.), and those results bypass
|
||||||
|
# Hermes's tool-trace / citation plumbing (they arrive baked into the
|
||||||
|
# model's answer rather than as a tool result the loop observes).
|
||||||
|
# Scoped to ``is_xai_responses`` deliberately; narrow to specific
|
||||||
|
# models if a future grok variant should keep the client-side
|
||||||
|
# function.
|
||||||
|
if is_xai_responses and response_tools:
|
||||||
|
has_client_web_search = any(
|
||||||
|
isinstance(t, dict) and t.get("name") == "web_search"
|
||||||
|
for t in response_tools
|
||||||
|
)
|
||||||
|
if has_client_web_search:
|
||||||
|
filtered = [
|
||||||
|
t for t in response_tools
|
||||||
|
if not (isinstance(t, dict) and t.get("name") == "web_search")
|
||||||
|
]
|
||||||
|
filtered.append({"type": "web_search"})
|
||||||
|
response_tools = filtered
|
||||||
|
|
||||||
# ``tools`` MUST be omitted entirely when there are no functions to
|
# ``tools`` MUST be omitted entirely when there are no functions to
|
||||||
# expose: the openai SDK's ``responses.stream()`` / ``responses.parse()``
|
# expose: the openai SDK's ``responses.stream()`` / ``responses.parse()``
|
||||||
# eagerly call ``_make_tools(tools)`` which does ``for tool in tools``
|
# eagerly call ``_make_tools(tools)`` which does ``for tool in tools``
|
||||||
|
|||||||
@ -286,7 +286,7 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
|||||||
emit_stage(&app, "rebuild", StageState::Running, None, None);
|
emit_stage(&app, "rebuild", StageState::Running, None, None);
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let rebuild_args: Vec<String> = vec!["desktop".into(), "--build-only".into()];
|
let rebuild_args: Vec<String> = vec!["desktop".into(), "--build-only".into()];
|
||||||
let rebuild = run_streamed(
|
let mut rebuild = run_streamed(
|
||||||
&app,
|
&app,
|
||||||
&hermes,
|
&hermes,
|
||||||
&rebuild_args,
|
&rebuild_args,
|
||||||
@ -295,6 +295,33 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
|||||||
Some("rebuild"),
|
Some("rebuild"),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// Retry-once: the first `--build-only` can return nonzero on a still-settling
|
||||||
|
// post-update tree or a network-blocked Electron fetch that our self-heal
|
||||||
|
// repaired mid-run. A second attempt then builds clean off the healed dist
|
||||||
|
// (the content-hash stamp makes it a near-no-op when the first actually
|
||||||
|
// succeeded). Without this the updater bails here and never reaches the
|
||||||
|
// relaunch below — the app updates but doesn't restart. Matches the
|
||||||
|
// retry-once `hermes update` already does above, and `hermes update`'s own
|
||||||
|
// desktop rebuild in cmd_update.
|
||||||
|
if rebuild_needs_retry(rebuild.exit_code) {
|
||||||
|
emit_log(
|
||||||
|
&app,
|
||||||
|
Some("rebuild"),
|
||||||
|
LogStream::Stdout,
|
||||||
|
"[rebuild] first desktop rebuild failed; retrying once (a self-healed \
|
||||||
|
Electron download builds clean on the second run)…",
|
||||||
|
);
|
||||||
|
rebuild = run_streamed(
|
||||||
|
&app,
|
||||||
|
&hermes,
|
||||||
|
&rebuild_args,
|
||||||
|
&install_root,
|
||||||
|
&child_env,
|
||||||
|
Some("rebuild"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
let rebuild_ms = started.elapsed().as_millis() as u64;
|
let rebuild_ms = started.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
if rebuild.exit_code != Some(0) {
|
if rebuild.exit_code != Some(0) {
|
||||||
@ -533,6 +560,14 @@ fn is_locked(path: &Path) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the `desktop --build-only` rebuild should be retried once. Any
|
||||||
|
/// non-success exit qualifies: the common cause is a transient first-attempt
|
||||||
|
/// failure (still-settling tree / self-healed Electron download) that a clean
|
||||||
|
/// second run resolves.
|
||||||
|
fn rebuild_needs_retry(exit_code: Option<i32>) -> bool {
|
||||||
|
exit_code != Some(0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn `hermes <args>` from `cwd`, stream stdout/stderr as Log events on the
|
/// Spawn `hermes <args>` from `cwd`, stream stdout/stderr as Log events on the
|
||||||
/// bootstrap channel, and return the exit code. Mirrors powershell::run_script
|
/// bootstrap channel, and return the exit code. Mirrors powershell::run_script
|
||||||
/// but for an arbitrary command (no install.ps1 -File wrapping).
|
/// but for an arbitrary command (no install.ps1 -File wrapping).
|
||||||
@ -970,6 +1005,16 @@ mod tests {
|
|||||||
assert_eq!(update_branch_from_args(["--update"]), None);
|
assert_eq!(update_branch_from_args(["--update"]), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rebuild_retries_only_on_failure() {
|
||||||
|
assert!(!rebuild_needs_retry(Some(0)), "a clean rebuild must not retry");
|
||||||
|
assert!(rebuild_needs_retry(Some(1)), "a failed rebuild retries once");
|
||||||
|
assert!(
|
||||||
|
rebuild_needs_retry(None),
|
||||||
|
"a killed/signalled rebuild (no exit code) retries once"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_only_app_targets() {
|
fn parses_only_app_targets() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@ -45,6 +45,7 @@ const { readDirForIpc } = require('./fs-read-dir.cjs')
|
|||||||
const { gitRootForIpc } = require('./git-root.cjs')
|
const { gitRootForIpc } = require('./git-root.cjs')
|
||||||
const { worktreesForIpc } = require('./git-worktrees.cjs')
|
const { worktreesForIpc } = require('./git-worktrees.cjs')
|
||||||
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
|
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
|
||||||
|
const { runRebuildWithRetry } = require('./update-rebuild.cjs')
|
||||||
const {
|
const {
|
||||||
buildPosixCleanupScript,
|
buildPosixCleanupScript,
|
||||||
buildWindowsCleanupScript,
|
buildWindowsCleanupScript,
|
||||||
@ -2009,10 +2010,14 @@ async function applyUpdatesPosixInApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
emitUpdateProgress({ stage: 'rebuild', message: 'Rebuilding the desktop app…', percent: 60 })
|
emitUpdateProgress({ stage: 'rebuild', message: 'Rebuilding the desktop app…', percent: 60 })
|
||||||
const rebuilt = await runStreamedUpdate(hermes, ['desktop', '--build-only'], {
|
// Retry-once: a first rebuild can fail on a still-settling tree or a
|
||||||
cwd: updateRoot,
|
// self-healed (network-blocked) Electron download; a second run builds clean
|
||||||
env,
|
// off the healed dist so we reach the swap+relaunch below instead of bailing.
|
||||||
stage: 'rebuild'
|
const rebuilt = await runRebuildWithRetry(attempt => {
|
||||||
|
if (attempt > 0) {
|
||||||
|
emitUpdateProgress({ stage: 'rebuild', message: 'Retrying the desktop rebuild…', percent: 60 })
|
||||||
|
}
|
||||||
|
return runStreamedUpdate(hermes, ['desktop', '--build-only'], { cwd: updateRoot, env, stage: 'rebuild' })
|
||||||
})
|
})
|
||||||
if (rebuilt.code !== 0) {
|
if (rebuilt.code !== 0) {
|
||||||
emitUpdateProgress({
|
emitUpdateProgress({
|
||||||
@ -6546,6 +6551,12 @@ app.on('before-quit', () => {
|
|||||||
flushDesktopLogBufferSync()
|
flushDesktopLogBufferSync()
|
||||||
closePreviewWatchers()
|
closePreviewWatchers()
|
||||||
|
|
||||||
|
// Kill open PTYs before environment teardown to avoid the node-pty#904
|
||||||
|
// ThreadSafeFunction SIGABRT race.
|
||||||
|
for (const id of [...terminalSessions.keys()]) {
|
||||||
|
disposeTerminalSession(id)
|
||||||
|
}
|
||||||
|
|
||||||
if (hermesProcess && !hermesProcess.killed) {
|
if (hermesProcess && !hermesProcess.killed) {
|
||||||
hermesProcess.kill('SIGTERM')
|
hermesProcess.kill('SIGTERM')
|
||||||
}
|
}
|
||||||
|
|||||||
29
apps/desktop/electron/update-rebuild.cjs
Normal file
29
apps/desktop/electron/update-rebuild.cjs
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry-once policy for the desktop `--build-only` rebuild during self-update.
|
||||||
|
*
|
||||||
|
* The first rebuild can return nonzero on a still-settling post-update tree or a
|
||||||
|
* network-blocked Electron fetch that the installer's self-heal repaired mid-run.
|
||||||
|
* A second attempt then builds clean off the healed dist (the content-hash stamp
|
||||||
|
* makes it a near-no-op when the first actually succeeded). Without the retry the
|
||||||
|
* updater bails before the relaunch step — the app updates but doesn't restart.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function shouldRetryRebuild(code) {
|
||||||
|
return code !== 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `rebuild()` (async, resolves `{ code, ... }`), retrying once on failure.
|
||||||
|
* Returns the final result.
|
||||||
|
*/
|
||||||
|
async function runRebuildWithRetry(rebuild) {
|
||||||
|
let result = await rebuild(0)
|
||||||
|
if (shouldRetryRebuild(result.code)) {
|
||||||
|
result = await rebuild(1)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { shouldRetryRebuild, runRebuildWithRetry }
|
||||||
55
apps/desktop/electron/update-rebuild.test.cjs
Normal file
55
apps/desktop/electron/update-rebuild.test.cjs
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Tests for electron/update-rebuild.cjs — the retry-once policy for the desktop
|
||||||
|
* `--build-only` rebuild during self-update.
|
||||||
|
*
|
||||||
|
* Run with: node --test electron/update-rebuild.test.cjs
|
||||||
|
* (Wired into npm test:desktop:platforms in package.json.)
|
||||||
|
*
|
||||||
|
* Why this matters: a first rebuild can return nonzero on a still-settling tree
|
||||||
|
* or a self-healed (network-blocked) Electron download. Without a second attempt
|
||||||
|
* the updater bails before the relaunch step — the app updates but never restarts
|
||||||
|
* (the field report behind this fix). The retry must fire on failure, not on
|
||||||
|
* success, and must run at most twice.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const test = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const { shouldRetryRebuild, runRebuildWithRetry } = require('./update-rebuild.cjs')
|
||||||
|
|
||||||
|
test('shouldRetryRebuild retries only on a non-success exit', () => {
|
||||||
|
assert.equal(shouldRetryRebuild(0), false)
|
||||||
|
assert.equal(shouldRetryRebuild(1), true)
|
||||||
|
assert.equal(shouldRetryRebuild(null), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a clean first rebuild runs once and does not retry', async () => {
|
||||||
|
const codes = []
|
||||||
|
const result = await runRebuildWithRetry(attempt => {
|
||||||
|
codes.push(attempt)
|
||||||
|
return Promise.resolve({ code: 0 })
|
||||||
|
})
|
||||||
|
assert.deepEqual(codes, [0])
|
||||||
|
assert.equal(result.code, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a failed first rebuild retries once and succeeds', async () => {
|
||||||
|
const codes = []
|
||||||
|
const result = await runRebuildWithRetry(attempt => {
|
||||||
|
codes.push(attempt)
|
||||||
|
return Promise.resolve({ code: attempt === 0 ? 1 : 0 })
|
||||||
|
})
|
||||||
|
assert.deepEqual(codes, [0, 1])
|
||||||
|
assert.equal(result.code, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a rebuild that keeps failing runs at most twice and reports the failure', async () => {
|
||||||
|
const codes = []
|
||||||
|
const result = await runRebuildWithRetry(attempt => {
|
||||||
|
codes.push(attempt)
|
||||||
|
return Promise.resolve({ code: 1, error: 'rebuild-failed' })
|
||||||
|
})
|
||||||
|
assert.deepEqual(codes, [0, 1])
|
||||||
|
assert.equal(result.code, 1)
|
||||||
|
assert.equal(result.error, 'rebuild-failed')
|
||||||
|
})
|
||||||
@ -21,7 +21,7 @@
|
|||||||
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild",
|
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild",
|
||||||
"postbuild": "node scripts/assert-dist-built.cjs",
|
"postbuild": "node scripts/assert-dist-built.cjs",
|
||||||
"prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs",
|
"prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs",
|
||||||
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
|
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.cjs",
|
||||||
"pack": "npm run build && npm run builder -- --dir",
|
"pack": "npm run build && npm run builder -- --dir",
|
||||||
"dist": "npm run build && npm run builder",
|
"dist": "npm run build && npm run builder",
|
||||||
"dist:mac": "npm run build && npm run builder -- --mac",
|
"dist:mac": "npm run build && npm run builder -- --mac",
|
||||||
@ -37,7 +37,7 @@
|
|||||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/windows-user-env.test.cjs",
|
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/windows-user-env.test.cjs",
|
||||||
"typecheck": "tsc -p . --noEmit",
|
"typecheck": "tsc -p . --noEmit",
|
||||||
"lint": "eslint src/ electron/",
|
"lint": "eslint src/ electron/",
|
||||||
"lint:fix": "eslint src/ electron/ --fix",
|
"lint:fix": "eslint src/ electron/ --fix",
|
||||||
@ -135,7 +135,6 @@
|
|||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"electronVersion": "40.10.2",
|
"electronVersion": "40.10.2",
|
||||||
"electronDist": "../../node_modules/electron/dist",
|
|
||||||
"appId": "com.nousresearch.hermes",
|
"appId": "com.nousresearch.hermes",
|
||||||
"productName": "Hermes",
|
"productName": "Hermes",
|
||||||
"executableName": "Hermes",
|
"executableName": "Hermes",
|
||||||
|
|||||||
@ -24,6 +24,11 @@ const replacement = ` // ${marker}: electron-builder 26.8.x can sometimes cop
|
|||||||
if (!fs.existsSync(bundledElectronBinary)) {
|
if (!fs.existsSync(bundledElectronBinary)) {
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(packager.info.framework.distMacOsAppName, "Contents", "MacOS", electronBranding.productName),
|
path.join(packager.info.framework.distMacOsAppName, "Contents", "MacOS", electronBranding.productName),
|
||||||
|
// npm may nest the workspace-only electron devDep under
|
||||||
|
// apps/desktop/node_modules (process.cwd() during pack), or hoist
|
||||||
|
// it to the repo root. Try the workspace-local install first, then
|
||||||
|
// the root hoist, so the fallback works under either layout.
|
||||||
|
path.join(process.cwd(), "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
|
||||||
path.join(process.cwd(), "..", "..", "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
|
path.join(process.cwd(), "..", "..", "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
|
||||||
];
|
];
|
||||||
const sourceBinary = candidates.find(candidate => fs.existsSync(candidate));
|
const sourceBinary = candidates.find(candidate => fs.existsSync(candidate));
|
||||||
|
|||||||
57
apps/desktop/scripts/run-electron-builder.cjs
Normal file
57
apps/desktop/scripts/run-electron-builder.cjs
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
"use strict"
|
||||||
|
|
||||||
|
// Resolve electronDist at runtime (#38673, #47917): electron-builder 26.8.x can
|
||||||
|
// re-unpack a broken Electron.app; reusing the installed dist dodges that.
|
||||||
|
// npm workspace hoisting is non-deterministic — require.resolve finds electron
|
||||||
|
// wherever it landed. Dist present → -c.electronDist=<abs>/dist; absent → let
|
||||||
|
// electron-builder fetch via @electron/get (electronVersion + ELECTRON_MIRROR).
|
||||||
|
|
||||||
|
const fs = require("node:fs")
|
||||||
|
const path = require("node:path")
|
||||||
|
const { spawnSync } = require("node:child_process")
|
||||||
|
|
||||||
|
function electronDistDir() {
|
||||||
|
try {
|
||||||
|
return path.join(path.dirname(require.resolve("electron/package.json")), "dist")
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function distBinary(dist) {
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
return path.join(dist, "Electron.app", "Contents", "MacOS", "Electron")
|
||||||
|
}
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return path.join(dist, "electron.exe")
|
||||||
|
}
|
||||||
|
return path.join(dist, "electron")
|
||||||
|
}
|
||||||
|
|
||||||
|
function electronBuilderCli() {
|
||||||
|
const pkgJson = require.resolve("electron-builder/package.json")
|
||||||
|
const bin = require(pkgJson).bin
|
||||||
|
const rel = typeof bin === "string" ? bin : bin["electron-builder"]
|
||||||
|
return path.join(path.dirname(pkgJson), rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dist = electronDistDir()
|
||||||
|
const args = []
|
||||||
|
if (dist && fs.existsSync(distBinary(dist))) {
|
||||||
|
args.push(`-c.electronDist=${dist}`)
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
"[run-electron-builder] no local electron dist; electron-builder will fetch " +
|
||||||
|
"via @electron/get (electronVersion + ELECTRON_MIRROR)."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
args.push(...process.argv.slice(2))
|
||||||
|
|
||||||
|
const result = spawnSync(process.execPath, [electronBuilderCli(), ...args], {
|
||||||
|
stdio: "inherit",
|
||||||
|
})
|
||||||
|
if (result.error) {
|
||||||
|
console.error(`[run-electron-builder] spawn failed: ${result.error.message}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
process.exit(result.status == null ? 1 : result.status)
|
||||||
@ -15,7 +15,9 @@ import { Backdrop } from '@/components/Backdrop'
|
|||||||
import { PromptOverlays } from '@/components/prompt-overlays'
|
import { PromptOverlays } from '@/components/prompt-overlays'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
|
import { ErrorState } from '@/components/ui/error-state'
|
||||||
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
|
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import type { ChatMessage } from '@/lib/chat-messages'
|
import type { ChatMessage } from '@/lib/chat-messages'
|
||||||
import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime'
|
import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime'
|
||||||
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
|
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
|
||||||
@ -38,6 +40,7 @@ import {
|
|||||||
$lastVisibleMessageIsUser,
|
$lastVisibleMessageIsUser,
|
||||||
$messages,
|
$messages,
|
||||||
$messagesEmpty,
|
$messagesEmpty,
|
||||||
|
$resumeExhaustedSessionId,
|
||||||
$selectedStoredSessionId,
|
$selectedStoredSessionId,
|
||||||
$sessions,
|
$sessions,
|
||||||
sessionPinId
|
sessionPinId
|
||||||
@ -86,7 +89,9 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
|||||||
onEdit: (message: AppendMessage) => Promise<void>
|
onEdit: (message: AppendMessage) => Promise<void>
|
||||||
onReload: (parentId: string | null) => Promise<void>
|
onReload: (parentId: string | null) => Promise<void>
|
||||||
onRestoreToMessage?: (messageId: string) => Promise<void>
|
onRestoreToMessage?: (messageId: string) => Promise<void>
|
||||||
|
onRetryResume: (sessionId: string) => void
|
||||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||||
|
onDismissError?: (messageId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChatHeaderProps {
|
interface ChatHeaderProps {
|
||||||
@ -272,9 +277,12 @@ export function ChatView({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onReload,
|
onReload,
|
||||||
onRestoreToMessage,
|
onRestoreToMessage,
|
||||||
onTranscribeAudio
|
onRetryResume,
|
||||||
|
onTranscribeAudio,
|
||||||
|
onDismissError
|
||||||
}: ChatViewProps) {
|
}: ChatViewProps) {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const { t } = useI18n()
|
||||||
const activeSessionId = useStore($activeSessionId)
|
const activeSessionId = useStore($activeSessionId)
|
||||||
const awaitingResponse = useStore($awaitingResponse)
|
const awaitingResponse = useStore($awaitingResponse)
|
||||||
const busy = useStore($busy)
|
const busy = useStore($busy)
|
||||||
@ -296,6 +304,7 @@ export function ChatView({
|
|||||||
const messagesEmpty = useStore($messagesEmpty)
|
const messagesEmpty = useStore($messagesEmpty)
|
||||||
const lastVisibleIsUser = useStore($lastVisibleMessageIsUser)
|
const lastVisibleIsUser = useStore($lastVisibleMessageIsUser)
|
||||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||||
|
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
|
||||||
const routedSessionId = routeSessionId(location.pathname)
|
const routedSessionId = routeSessionId(location.pathname)
|
||||||
const isRoutedSessionView = Boolean(routedSessionId)
|
const isRoutedSessionView = Boolean(routedSessionId)
|
||||||
|
|
||||||
@ -315,9 +324,21 @@ export function ChatView({
|
|||||||
// session exists — even if it has zero messages (a brand-new routed
|
// session exists — even if it has zero messages (a brand-new routed
|
||||||
// session). The flicker where `busy` flips true briefly during hydrate
|
// session). The flicker where `busy` flips true briefly during hydrate
|
||||||
// is handled by `threadLoadingState`'s last-visible-user gate.
|
// is handled by `threadLoadingState`'s last-visible-user gate.
|
||||||
const loadingSession = isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId))
|
//
|
||||||
|
// resumeExhausted: the bounded auto-retry in use-route-resume gave up on this
|
||||||
|
// routed session (gateway RPC + REST fallback failed through every attempt).
|
||||||
|
// Suppress the loader and show an explicit error + manual Retry instead of
|
||||||
|
// spinning forever. Gated on the route matching so a stale latch from another
|
||||||
|
// session can't blank the current one.
|
||||||
|
const resumeExhausted = isRoutedSessionView && resumeExhaustedSessionId === routedSessionId
|
||||||
|
|
||||||
|
const loadingSession =
|
||||||
|
!resumeExhausted && isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId))
|
||||||
|
|
||||||
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleIsUser)
|
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleIsUser)
|
||||||
const showChatBar = !loadingSession
|
// Hide the composer in the exhausted error state too: there's no live runtime
|
||||||
|
// to send to until a retry rebinds one.
|
||||||
|
const showChatBar = !loadingSession && !resumeExhausted
|
||||||
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
|
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
|
||||||
|
|
||||||
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
|
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
|
||||||
@ -432,6 +453,7 @@ export function ChatView({
|
|||||||
loading={threadLoading}
|
loading={threadLoading}
|
||||||
onBranchInNewChat={onBranchInNewChat}
|
onBranchInNewChat={onBranchInNewChat}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
|
onDismissError={onDismissError}
|
||||||
onRestoreToMessage={onRestoreToMessage}
|
onRestoreToMessage={onRestoreToMessage}
|
||||||
sessionId={activeSessionId}
|
sessionId={activeSessionId}
|
||||||
sessionKey={threadKey}
|
sessionKey={threadKey}
|
||||||
@ -465,6 +487,21 @@ export function ChatView({
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</ChatRuntimeBoundary>
|
</ChatRuntimeBoundary>
|
||||||
|
{resumeExhausted && routedSessionId && (
|
||||||
|
<div className="absolute inset-0 z-10 grid place-items-center bg-(--ui-chat-surface-background) px-8 py-10">
|
||||||
|
<ErrorState
|
||||||
|
className="max-w-sm"
|
||||||
|
description={t.desktop.resumeStrandedBody}
|
||||||
|
title={t.desktop.resumeStrandedTitle}
|
||||||
|
>
|
||||||
|
<div className="grid justify-items-center">
|
||||||
|
<Button onClick={() => onRetryResume(routedSessionId)} size="sm" variant="outline">
|
||||||
|
{t.desktop.resumeRetry}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ErrorState>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{showChatBar && <ScrollToBottomButton />}
|
{showChatBar && <ScrollToBottomButton />}
|
||||||
<ChatDropOverlay kind={dragKind} />
|
<ChatDropOverlay kind={dragKind} />
|
||||||
<ChatSwapOverlay profile={gatewaySwapTarget} />
|
<ChatSwapOverlay profile={gatewaySwapTarget} />
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import { useSkinCommand } from '@/themes/use-skin-command'
|
|||||||
|
|
||||||
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
||||||
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
|
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
|
||||||
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
|
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
|
||||||
import {
|
import {
|
||||||
isMessagingSource,
|
isMessagingSource,
|
||||||
LOCAL_SESSION_SOURCE_IDS,
|
LOCAL_SESSION_SOURCE_IDS,
|
||||||
@ -52,7 +52,10 @@ import {
|
|||||||
$currentCwd,
|
$currentCwd,
|
||||||
$freshDraftReady,
|
$freshDraftReady,
|
||||||
$gatewayState,
|
$gatewayState,
|
||||||
|
$messages,
|
||||||
$messagingSessions,
|
$messagingSessions,
|
||||||
|
$resumeFailedSessionId,
|
||||||
|
$resumeExhaustedSessionId,
|
||||||
$selectedStoredSessionId,
|
$selectedStoredSessionId,
|
||||||
$sessions,
|
$sessions,
|
||||||
$workingSessionIds,
|
$workingSessionIds,
|
||||||
@ -199,6 +202,8 @@ export function DesktopController() {
|
|||||||
const activeSessionId = useStore($activeSessionId)
|
const activeSessionId = useStore($activeSessionId)
|
||||||
const currentCwd = useStore($currentCwd)
|
const currentCwd = useStore($currentCwd)
|
||||||
const freshDraftReady = useStore($freshDraftReady)
|
const freshDraftReady = useStore($freshDraftReady)
|
||||||
|
const resumeFailedSessionId = useStore($resumeFailedSessionId)
|
||||||
|
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
|
||||||
const filePreviewTarget = useStore($filePreviewTarget)
|
const filePreviewTarget = useStore($filePreviewTarget)
|
||||||
const previewTarget = useStore($previewTarget)
|
const previewTarget = useStore($previewTarget)
|
||||||
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
||||||
@ -736,6 +741,49 @@ export function DesktopController() {
|
|||||||
[branchCurrentSession, refreshSessions]
|
[branchCurrentSession, refreshSessions]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Clear a failed turn's red error banner from the transcript. Errors are
|
||||||
|
// renderer-local state (never persisted), so dismissing is purely a view +
|
||||||
|
// session-cache edit. A message that errored before emitting any visible
|
||||||
|
// text is a bare error placeholder → drop it entirely; one that streamed
|
||||||
|
// partial output then failed keeps its content and just sheds the error.
|
||||||
|
// Both the per-runtime cache AND the live $messages view must be updated:
|
||||||
|
// `preserveLocalAssistantErrors` re-grafts any still-errored message it
|
||||||
|
// finds in the view onto the next session.info flush, so clearing only the
|
||||||
|
// cache would let the heartbeat resurrect the banner.
|
||||||
|
const dismissError = useCallback(
|
||||||
|
(messageId: string) => {
|
||||||
|
const runtimeSessionId = activeSessionIdRef.current
|
||||||
|
|
||||||
|
if (!runtimeSessionId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearErrorIn = (messages: ChatMessage[]): ChatMessage[] =>
|
||||||
|
messages.flatMap(message => {
|
||||||
|
if (message.id !== messageId || !message.error) {
|
||||||
|
return [message]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chatMessageText(message).trim() && !message.parts.some(part => part.type !== 'text')) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ ...message, error: undefined, pending: false }]
|
||||||
|
})
|
||||||
|
|
||||||
|
// View first: the flush below reads $messages as the "current" baseline
|
||||||
|
// for error preservation, so the banner must be gone from it before the
|
||||||
|
// cache update triggers a re-sync.
|
||||||
|
setMessages(clearErrorIn($messages.get()))
|
||||||
|
|
||||||
|
updateSessionState(runtimeSessionId, state => ({
|
||||||
|
...state,
|
||||||
|
messages: clearErrorIn(state.messages)
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
[activeSessionIdRef, updateSessionState]
|
||||||
|
)
|
||||||
|
|
||||||
const startSessionInWorkspace = useCallback(
|
const startSessionInWorkspace = useCallback(
|
||||||
(path: null | string) => {
|
(path: null | string) => {
|
||||||
startFreshSessionDraft()
|
startFreshSessionDraft()
|
||||||
@ -845,6 +893,8 @@ export function DesktopController() {
|
|||||||
gatewayState,
|
gatewayState,
|
||||||
locationPathname: location.pathname,
|
locationPathname: location.pathname,
|
||||||
resumeSession,
|
resumeSession,
|
||||||
|
resumeFailedSessionId,
|
||||||
|
resumeExhaustedSessionId,
|
||||||
routedSessionId,
|
routedSessionId,
|
||||||
runtimeIdByStoredSessionIdRef,
|
runtimeIdByStoredSessionIdRef,
|
||||||
selectedStoredSessionId,
|
selectedStoredSessionId,
|
||||||
@ -994,6 +1044,7 @@ export function DesktopController() {
|
|||||||
void removeSession(selectedStoredSessionId)
|
void removeSession(selectedStoredSessionId)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onDismissError={dismissError}
|
||||||
onEdit={editMessage}
|
onEdit={editMessage}
|
||||||
onPasteClipboardImage={() => void composer.pasteClipboardImage()}
|
onPasteClipboardImage={() => void composer.pasteClipboardImage()}
|
||||||
onPickFiles={() => void composer.pickContextPaths('file')}
|
onPickFiles={() => void composer.pickContextPaths('file')}
|
||||||
@ -1002,6 +1053,7 @@ export function DesktopController() {
|
|||||||
onReload={reloadFromMessage}
|
onReload={reloadFromMessage}
|
||||||
onRemoveAttachment={id => void composer.removeAttachment(id)}
|
onRemoveAttachment={id => void composer.removeAttachment(id)}
|
||||||
onRestoreToMessage={restoreToMessage}
|
onRestoreToMessage={restoreToMessage}
|
||||||
|
onRetryResume={sessionId => void resumeSession(sessionId, true)}
|
||||||
onSteer={steerPrompt}
|
onSteer={steerPrompt}
|
||||||
onSubmit={submitText}
|
onSubmit={submitText}
|
||||||
onThreadMessagesChange={handleThreadMessagesChange}
|
onThreadMessagesChange={handleThreadMessagesChange}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
type GatewayEventPayload,
|
type GatewayEventPayload,
|
||||||
reasoningPart,
|
reasoningPart,
|
||||||
renderMediaTags,
|
renderMediaTags,
|
||||||
|
textPart,
|
||||||
upsertToolPart
|
upsertToolPart
|
||||||
} from '@/lib/chat-messages'
|
} from '@/lib/chat-messages'
|
||||||
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
|
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||||
@ -1080,6 +1081,32 @@ export function useMessageStream({
|
|||||||
// completions / watch matches here — re-sync the status stack.
|
// completions / watch matches here — re-sync the status stack.
|
||||||
void refreshBackgroundProcesses(sessionId)
|
void refreshBackgroundProcesses(sessionId)
|
||||||
}
|
}
|
||||||
|
} else if (event.type === 'review.summary') {
|
||||||
|
// Self-improvement background review saved something to memory/skills
|
||||||
|
// and emitted a persistent summary (Python formats it as
|
||||||
|
// "💾 Self-improvement review: …"). The CLI prints this via
|
||||||
|
// prompt_toolkit and the Ink TUI renders it as a system line; the
|
||||||
|
// desktop has neither, so without this handler the skill/memory
|
||||||
|
// change happens silently. Surface it as a persistent system message
|
||||||
|
// in the transcript so the user is always informed — it must not be a
|
||||||
|
// transient toast that can be missed.
|
||||||
|
const text = coerceGatewayText(payload?.text).trim()
|
||||||
|
|
||||||
|
if (text && sessionId) {
|
||||||
|
flushQueuedDeltas(sessionId)
|
||||||
|
updateSessionState(sessionId, state => ({
|
||||||
|
...state,
|
||||||
|
messages: [
|
||||||
|
...state.messages,
|
||||||
|
{
|
||||||
|
id: `review-summary-${Date.now()}`,
|
||||||
|
role: 'system',
|
||||||
|
parts: [textPart(text)],
|
||||||
|
timestamp: Math.floor(Date.now() / 1000)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
}
|
||||||
} else if (event.type === 'error') {
|
} else if (event.type === 'error') {
|
||||||
const errorMessage = payload?.message || 'Hermes reported an error'
|
const errorMessage = payload?.message || 'Hermes reported an error'
|
||||||
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
|
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { cleanup, render } from '@testing-library/react'
|
|||||||
import type { MutableRefObject } from 'react'
|
import type { MutableRefObject } from 'react'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { $resumeExhaustedSessionId, setResumeExhaustedSessionId } from '@/store/session'
|
||||||
|
|
||||||
import { useRouteResume } from './use-route-resume'
|
import { useRouteResume } from './use-route-resume'
|
||||||
|
|
||||||
interface HarnessProps {
|
interface HarnessProps {
|
||||||
@ -13,6 +15,8 @@ interface HarnessProps {
|
|||||||
gatewayState: string
|
gatewayState: string
|
||||||
locationPathname: string
|
locationPathname: string
|
||||||
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
|
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
|
||||||
|
resumeFailedSessionId?: null | string
|
||||||
|
resumeExhaustedSessionId?: null | string
|
||||||
routedSessionId: null | string
|
routedSessionId: null | string
|
||||||
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
|
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
|
||||||
selectedStoredSessionId: null | string
|
selectedStoredSessionId: null | string
|
||||||
@ -20,8 +24,12 @@ interface HarnessProps {
|
|||||||
startFreshSessionDraft: (focus: boolean) => unknown
|
startFreshSessionDraft: (focus: boolean) => unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
function RouteResumeHarness(props: HarnessProps) {
|
function RouteResumeHarness({
|
||||||
useRouteResume(props)
|
resumeFailedSessionId = null,
|
||||||
|
resumeExhaustedSessionId = null,
|
||||||
|
...props
|
||||||
|
}: HarnessProps) {
|
||||||
|
useRouteResume({ ...props, resumeExhaustedSessionId, resumeFailedSessionId })
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@ -256,3 +264,212 @@ describe('useRouteResume', () => {
|
|||||||
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
|
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('useRouteResume bounded auto-retry after a failed resume', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
vi.useRealTimers()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
setResumeExhaustedSessionId(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Common stranded-window props: gateway open, route on the session, no runtime
|
||||||
|
// yet, and the ref already synced to the route (resumeSession sets it at entry
|
||||||
|
// before failing) — the exact state that defeats the main effect's self-heal.
|
||||||
|
function strandedProps(resumeSession: (sid: string, focus: boolean) => Promise<unknown>) {
|
||||||
|
return {
|
||||||
|
activeSessionId: null,
|
||||||
|
activeSessionIdRef: { current: null } as MutableRefObject<null | string>,
|
||||||
|
creatingSessionRef: { current: false },
|
||||||
|
currentView: 'chat',
|
||||||
|
freshDraftReady: false,
|
||||||
|
gatewayState: 'open',
|
||||||
|
locationPathname: '/session-1',
|
||||||
|
resumeSession,
|
||||||
|
routedSessionId: 'session-1',
|
||||||
|
runtimeIdByStoredSessionIdRef: { current: new Map<string, string>() },
|
||||||
|
selectedStoredSessionId: 'session-1',
|
||||||
|
// Synced to the route by the failed resume's synchronous entry-write.
|
||||||
|
selectedStoredSessionIdRef: { current: 'session-1' } as MutableRefObject<null | string>,
|
||||||
|
startFreshSessionDraft: vi.fn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('retries the resume on backoff when the routed session is flagged as failed', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const resumeSession = vi.fn(async () => undefined)
|
||||||
|
|
||||||
|
render(<RouteResumeHarness {...strandedProps(resumeSession)} resumeFailedSessionId="session-1" />)
|
||||||
|
|
||||||
|
// The main effect fires one resume on mount (pathname-changed). Clear it so
|
||||||
|
// we assert purely the bounded-retry effect's scheduled retry below.
|
||||||
|
resumeSession.mockClear()
|
||||||
|
|
||||||
|
// No immediate fire — the retry is scheduled behind the backoff timer.
|
||||||
|
expect(resumeSession).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
// First backoff window (1s) elapses → one retry.
|
||||||
|
vi.advanceTimersByTime(1_000)
|
||||||
|
expect(resumeSession).toHaveBeenCalledTimes(1)
|
||||||
|
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT retry a failed session that is not the routed one', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const resumeSession = vi.fn(async () => undefined)
|
||||||
|
|
||||||
|
// The failure flag points at a different session than the route.
|
||||||
|
render(<RouteResumeHarness {...strandedProps(resumeSession)} resumeFailedSessionId="other-session" />)
|
||||||
|
resumeSession.mockClear() // drop the mount resume
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(10_000)
|
||||||
|
expect(resumeSession).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips the scheduled retry if the session already recovered when the timer fires', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const resumeSession = vi.fn(async () => undefined)
|
||||||
|
const props = strandedProps(resumeSession)
|
||||||
|
|
||||||
|
render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
|
||||||
|
resumeSession.mockClear() // drop the mount resume
|
||||||
|
|
||||||
|
// A resume landed while we waited: runtime is now bound.
|
||||||
|
props.activeSessionIdRef.current = 'runtime-1'
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(8_000)
|
||||||
|
expect(resumeSession).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops retrying after MAX_RESUME_RETRIES consecutive failures', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const resumeSession = vi.fn(async () => undefined)
|
||||||
|
const props = strandedProps(resumeSession)
|
||||||
|
|
||||||
|
// Model the real re-arm loop: resumeSession clears $resumeFailedSessionId at
|
||||||
|
// entry (null) and a repeat failure re-sets it ('session-1'). That null->id
|
||||||
|
// toggle is what re-runs the effect and advances the bounded counter. The
|
||||||
|
// routed session never changes, so the counter is NOT reset between cycles.
|
||||||
|
const { rerender } = render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
|
||||||
|
resumeSession.mockClear() // drop the mount resume; count only the retries
|
||||||
|
|
||||||
|
for (let i = 0; i < 8; i += 1) {
|
||||||
|
vi.advanceTimersByTime(8_000) // fire the scheduled retry (if any)
|
||||||
|
rerender(<RouteResumeHarness {...props} resumeFailedSessionId={null} />) // cleared at entry
|
||||||
|
rerender(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />) // re-armed on failure
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capped at MAX_RESUME_RETRIES (4): a persistently dead backend can't
|
||||||
|
// hot-loop the resume forever.
|
||||||
|
expect(resumeSession.mock.calls.length).toBe(4)
|
||||||
|
|
||||||
|
// Once auto-retry gives up, the exhausted latch is armed for the routed
|
||||||
|
// session so the chat view can swap the perpetual loader for an explicit
|
||||||
|
// error + manual Retry instead of spinning forever.
|
||||||
|
expect($resumeExhaustedSessionId.get()).toBe('session-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not arm the exhausted latch while retries remain', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const resumeSession = vi.fn(async () => undefined)
|
||||||
|
const props = strandedProps(resumeSession)
|
||||||
|
|
||||||
|
const { rerender } = render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
|
||||||
|
resumeSession.mockClear()
|
||||||
|
|
||||||
|
// Two failure cycles — still under the 4-retry cap, so the latch must stay
|
||||||
|
// clear and the loader keeps spinning (auto-recovery hasn't given up yet).
|
||||||
|
for (let i = 0; i < 2; i += 1) {
|
||||||
|
vi.advanceTimersByTime(8_000)
|
||||||
|
rerender(<RouteResumeHarness {...props} resumeFailedSessionId={null} />)
|
||||||
|
rerender(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($resumeExhaustedSessionId.get()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears a stale exhausted latch when the route moves off the stranded session', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const resumeSession = vi.fn(async () => undefined)
|
||||||
|
const props = strandedProps(resumeSession)
|
||||||
|
|
||||||
|
// Pre-arm the latch as if this session had exhausted its retries.
|
||||||
|
setResumeExhaustedSessionId('session-1')
|
||||||
|
|
||||||
|
// Route is now on a different, healthy session that is not flagged as
|
||||||
|
// failed — the retry effect's "route moved off" branch clears the latch.
|
||||||
|
render(
|
||||||
|
<RouteResumeHarness
|
||||||
|
{...props}
|
||||||
|
activeSessionId="runtime-2"
|
||||||
|
activeSessionIdRef={{ current: 'runtime-2' }}
|
||||||
|
locationPathname="/session-2"
|
||||||
|
resumeFailedSessionId={null}
|
||||||
|
routedSessionId="session-2"
|
||||||
|
selectedStoredSessionId="session-2"
|
||||||
|
selectedStoredSessionIdRef={{ current: 'session-2' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect($resumeExhaustedSessionId.get()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets the retry counter for a fresh backoff cycle when the exhausted latch clears (manual retry, same session)', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const resumeSession = vi.fn(async () => undefined)
|
||||||
|
const props = strandedProps(resumeSession)
|
||||||
|
|
||||||
|
// Phase A — exhaust the bounded auto-retry (counter → MAX) like a dead
|
||||||
|
// backend. The resumeExhaustedSessionId prop stays null here: the hook sets
|
||||||
|
// the store, which doesn't feed back into the prop in this harness.
|
||||||
|
const { rerender } = render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
|
||||||
|
resumeSession.mockClear()
|
||||||
|
for (let i = 0; i < 8; i += 1) {
|
||||||
|
vi.advanceTimersByTime(8_000)
|
||||||
|
rerender(<RouteResumeHarness {...props} resumeFailedSessionId={null} />)
|
||||||
|
rerender(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
|
||||||
|
}
|
||||||
|
expect(resumeSession.mock.calls.length).toBe(4) // capped
|
||||||
|
expect($resumeExhaustedSessionId.get()).toBe('session-1')
|
||||||
|
|
||||||
|
// Phase B — user clicks Retry on the SAME stranded session. resumeSession
|
||||||
|
// clears both latches at entry; the exhausted latch's armed->cleared edge
|
||||||
|
// must reset the attempt counter so a fresh bounded cycle runs, not a single
|
||||||
|
// one-shot attempt that immediately re-arms the error. Model the prop
|
||||||
|
// transitions: reflect the armed latch, then clear it (retry), then re-arm
|
||||||
|
// the failure latch on the fresh failure.
|
||||||
|
resumeSession.mockClear()
|
||||||
|
rerender(<RouteResumeHarness {...props} resumeExhaustedSessionId="session-1" resumeFailedSessionId="session-1" />)
|
||||||
|
rerender(<RouteResumeHarness {...props} resumeExhaustedSessionId={null} resumeFailedSessionId={null} />)
|
||||||
|
rerender(<RouteResumeHarness {...props} resumeExhaustedSessionId={null} resumeFailedSessionId="session-1" />)
|
||||||
|
|
||||||
|
// A real retry fires again instead of staying pinned at MAX (which would
|
||||||
|
// dispatch nothing). Without the reset the counter stays >= MAX and this
|
||||||
|
// advance dispatches zero resumes.
|
||||||
|
vi.advanceTimersByTime(8_000)
|
||||||
|
expect(resumeSession.mock.calls.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not burn retry attempts on unrelated re-renders during the backoff window', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const props = strandedProps(vi.fn())
|
||||||
|
|
||||||
|
// Mount schedules the first backoff timer. Then re-render repeatedly with a
|
||||||
|
// fresh resumeSession identity (referential instability — a real dep change
|
||||||
|
// for the retry effect) WITHOUT ever letting the timer fire. The old code
|
||||||
|
// incremented the attempt counter at schedule time, so >= MAX re-renders
|
||||||
|
// armed the exhausted error with zero resumes actually dispatched. The fix
|
||||||
|
// only advances the counter when a timer truly fires, so the latch stays
|
||||||
|
// clear no matter how many spurious re-renders happen mid-backoff.
|
||||||
|
const { rerender } = render(
|
||||||
|
<RouteResumeHarness {...props} resumeFailedSessionId="session-1" resumeSession={vi.fn(async () => undefined)} />
|
||||||
|
)
|
||||||
|
for (let j = 0; j < 8; j += 1) {
|
||||||
|
rerender(
|
||||||
|
<RouteResumeHarness {...props} resumeFailedSessionId="session-1" resumeSession={vi.fn(async () => undefined)} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($resumeExhaustedSessionId.get()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { type MutableRefObject, useEffect, useRef } from 'react'
|
import { type MutableRefObject, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
import { isNewChatRoute } from '@/app/routes'
|
import { isNewChatRoute } from '@/app/routes'
|
||||||
|
import { setResumeExhaustedSessionId } from '@/store/session'
|
||||||
|
|
||||||
interface RouteResumeOptions {
|
interface RouteResumeOptions {
|
||||||
activeSessionId: string | null
|
activeSessionId: string | null
|
||||||
@ -11,6 +12,17 @@ interface RouteResumeOptions {
|
|||||||
gatewayState: string | undefined
|
gatewayState: string | undefined
|
||||||
locationPathname: string
|
locationPathname: string
|
||||||
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
|
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
|
||||||
|
// Stored-session id whose most recent resume failed terminally (set by
|
||||||
|
// useSessionActions, mirrored from $resumeFailedSessionId). While this equals
|
||||||
|
// routedSessionId the window would otherwise latch on the loader forever, so
|
||||||
|
// the bounded-retry effect below re-attempts the resume.
|
||||||
|
resumeFailedSessionId: string | null
|
||||||
|
// Stored-session id whose bounded auto-retry has EXHAUSTED (mirrored from
|
||||||
|
// $resumeExhaustedSessionId). Only resumeSession clears this latch (manual
|
||||||
|
// Retry / reconnect / reselect) — the auto-retry loop never does — so its
|
||||||
|
// armed->cleared edge is an unambiguous "give me a fresh backoff cycle"
|
||||||
|
// signal the effect below uses to reset the attempt counter.
|
||||||
|
resumeExhaustedSessionId: string | null
|
||||||
routedSessionId: string | null
|
routedSessionId: string | null
|
||||||
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
|
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
|
||||||
selectedStoredSessionId: string | null
|
selectedStoredSessionId: string | null
|
||||||
@ -18,6 +30,19 @@ interface RouteResumeOptions {
|
|||||||
startFreshSessionDraft: (focus: boolean) => unknown
|
startFreshSessionDraft: (focus: boolean) => unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bounded auto-retry for a stranded session window. A resume can fail terminally
|
||||||
|
// (gateway RPC reject + REST fallback failure) on a transiently wedged backend —
|
||||||
|
// dead provider key, a runaway turn hogging the dispatcher, flaky DNS. Without a
|
||||||
|
// retry the loader latches forever. We retry with backoff, capped, so a
|
||||||
|
// genuinely dead backend doesn't hot-loop the resume.
|
||||||
|
const MAX_RESUME_RETRIES = 4
|
||||||
|
const RESUME_RETRY_BASE_MS = 1_000
|
||||||
|
const RESUME_RETRY_MAX_MS = 8_000
|
||||||
|
|
||||||
|
function resumeRetryDelayMs(attempt: number): number {
|
||||||
|
return Math.min(RESUME_RETRY_MAX_MS, RESUME_RETRY_BASE_MS * 2 ** attempt)
|
||||||
|
}
|
||||||
|
|
||||||
// HashRouter boot edge case: pathname briefly reads `/` before the hash is
|
// HashRouter boot edge case: pathname briefly reads `/` before the hash is
|
||||||
// parsed. If the hash references a real session, defer; resume picks it up
|
// parsed. If the hash references a real session, defer; resume picks it up
|
||||||
// next tick. Without this, ctrl+R on `#/:sessionId` flashes 5 loading states.
|
// next tick. Without this, ctrl+R on `#/:sessionId` flashes 5 loading states.
|
||||||
@ -49,6 +74,8 @@ export function useRouteResume({
|
|||||||
gatewayState,
|
gatewayState,
|
||||||
locationPathname,
|
locationPathname,
|
||||||
resumeSession,
|
resumeSession,
|
||||||
|
resumeFailedSessionId,
|
||||||
|
resumeExhaustedSessionId,
|
||||||
routedSessionId,
|
routedSessionId,
|
||||||
runtimeIdByStoredSessionIdRef,
|
runtimeIdByStoredSessionIdRef,
|
||||||
selectedStoredSessionId,
|
selectedStoredSessionId,
|
||||||
@ -58,6 +85,16 @@ export function useRouteResume({
|
|||||||
const lastPathnameRef = useRef<string | null>(null)
|
const lastPathnameRef = useRef<string | null>(null)
|
||||||
const seenGatewayStateRef = useRef(false)
|
const seenGatewayStateRef = useRef(false)
|
||||||
const wasGatewayOpenRef = useRef(false)
|
const wasGatewayOpenRef = useRef(false)
|
||||||
|
// Per-session retry bookkeeping for the bounded auto-retry effect below. Keyed
|
||||||
|
// by the session id we're retrying so switching chats resets the counter.
|
||||||
|
const retrySessionIdRef = useRef<string | null>(null)
|
||||||
|
const retryAttemptRef = useRef(0)
|
||||||
|
// Tracks the previous exhausted-latch value so we can detect its armed->cleared
|
||||||
|
// edge. resumeSession clears $resumeExhaustedSessionId on a manual Retry /
|
||||||
|
// reconnect / reselect; that transition is our cue to reset the attempt counter
|
||||||
|
// for a fresh backoff cycle on the SAME session (the auto-retry loop itself
|
||||||
|
// never touches this latch, so it can't spuriously trigger the reset).
|
||||||
|
const prevResumeExhaustedRef = useRef<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const gatewayOpen = gatewayState === 'open'
|
const gatewayOpen = gatewayState === 'open'
|
||||||
@ -139,4 +176,111 @@ export function useRouteResume({
|
|||||||
selectedStoredSessionIdRef,
|
selectedStoredSessionIdRef,
|
||||||
startFreshSessionDraft
|
startFreshSessionDraft
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// Bounded auto-retry: when the routed session's resume failed terminally
|
||||||
|
// (resumeFailedSessionId matches the route), schedule a backoff retry so the
|
||||||
|
// window recovers on its own instead of latching the loader forever. This is
|
||||||
|
// the safety net the main effect above can't provide: after a failed resume,
|
||||||
|
// selectedStoredSessionIdRef.current already equals the route (resumeSession
|
||||||
|
// sets it synchronously at entry) and the pathname/gateway are unchanged, so
|
||||||
|
// none of stuckOnRoutedSession / pathnameChanged / gatewayBecameOpen fire
|
||||||
|
// again. resumeSession clears resumeFailedSessionId on its next attempt; a
|
||||||
|
// success keeps it clear (the effect's guard then no-ops), a repeat failure
|
||||||
|
// re-arms it and we back off further, capped at MAX_RESUME_RETRIES.
|
||||||
|
useEffect(() => {
|
||||||
|
// Detect the exhausted-latch armed->cleared edge for the current route. Only
|
||||||
|
// resumeSession clears $resumeExhaustedSessionId (manual Retry / reconnect /
|
||||||
|
// reselect) — the auto-retry loop never touches it — so this transition
|
||||||
|
// uniquely means "the user asked for another go." Reset the attempt counter
|
||||||
|
// for a fresh bounded backoff cycle on the SAME session. Without this,
|
||||||
|
// retryAttemptRef stays pinned at MAX after exhaustion (the !stranded reset
|
||||||
|
// below only fires on a route CHANGE to a different session), so a manual
|
||||||
|
// retry on the same stranded session would get exactly ONE attempt and then
|
||||||
|
// immediately re-arm the exhausted error — never the renewed backoff cycle
|
||||||
|
// the store/session.ts + use-session-actions.ts comments promise. (Point 2)
|
||||||
|
const wasExhausted = prevResumeExhaustedRef.current
|
||||||
|
prevResumeExhaustedRef.current = resumeExhaustedSessionId
|
||||||
|
if (wasExhausted && wasExhausted === routedSessionId && resumeExhaustedSessionId !== wasExhausted) {
|
||||||
|
retrySessionIdRef.current = routedSessionId
|
||||||
|
retryAttemptRef.current = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentView !== 'chat' || gatewayState !== 'open') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const stranded =
|
||||||
|
Boolean(routedSessionId) &&
|
||||||
|
resumeFailedSessionId === routedSessionId &&
|
||||||
|
!creatingSessionRef.current
|
||||||
|
|
||||||
|
if (!stranded) {
|
||||||
|
// Route moved off the stranded session (or it recovered) — reset the
|
||||||
|
// counter so a future failure on another session starts fresh, and clear
|
||||||
|
// any exhausted-latch armed for a session we're no longer viewing (never
|
||||||
|
// the current route: that's the error state we want to keep showing).
|
||||||
|
// resumeSession also clears it on a fresh attempt; this covers a plain
|
||||||
|
// route-change away from the stranded window.
|
||||||
|
if (retrySessionIdRef.current !== routedSessionId) {
|
||||||
|
retrySessionIdRef.current = null
|
||||||
|
retryAttemptRef.current = 0
|
||||||
|
setResumeExhaustedSessionId(current => (current && current !== routedSessionId ? null : current))
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// New stranded session id → reset the attempt counter.
|
||||||
|
if (retrySessionIdRef.current !== routedSessionId) {
|
||||||
|
retrySessionIdRef.current = routedSessionId
|
||||||
|
retryAttemptRef.current = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retryAttemptRef.current >= MAX_RESUME_RETRIES) {
|
||||||
|
// Give up auto-retrying a persistently dead backend; the user can still
|
||||||
|
// reconnect / reselect (which resets the counter via the branch above).
|
||||||
|
// Surface an explicit error + manual Retry in the chat view instead of
|
||||||
|
// spinning the loader forever — resumeSession (manual Retry / reconnect /
|
||||||
|
// reselect) clears this latch and resets the counter for a fresh cycle.
|
||||||
|
setResumeExhaustedSessionId(routedSessionId)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const attempt = retryAttemptRef.current
|
||||||
|
const sessionId = routedSessionId as string
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
// Re-check liveness at fire time: a resume may have landed while we waited.
|
||||||
|
if (
|
||||||
|
creatingSessionRef.current ||
|
||||||
|
selectedStoredSessionIdRef.current !== sessionId ||
|
||||||
|
activeSessionIdRef.current !== null
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume an attempt ONLY now that a resume is actually dispatching.
|
||||||
|
// Incrementing at schedule time (the old behavior) let unrelated dep
|
||||||
|
// changes during the 1s–8s backoff window — a transient gatewayState
|
||||||
|
// flip, a non-referentially-stable resumeSession — clear the pending
|
||||||
|
// timer and re-run the effect, burning an attempt without any resume
|
||||||
|
// having fired. A flapping backend could then hit MAX in a couple of
|
||||||
|
// re-renders with far fewer than MAX real attempts. (Point 3)
|
||||||
|
retryAttemptRef.current += 1
|
||||||
|
void resumeSession(sessionId, true)
|
||||||
|
}, resumeRetryDelayMs(attempt))
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [
|
||||||
|
activeSessionIdRef,
|
||||||
|
creatingSessionRef,
|
||||||
|
currentView,
|
||||||
|
gatewayState,
|
||||||
|
resumeSession,
|
||||||
|
resumeFailedSessionId,
|
||||||
|
resumeExhaustedSessionId,
|
||||||
|
routedSessionId,
|
||||||
|
selectedStoredSessionIdRef
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,9 @@ import type { MutableRefObject } from 'react'
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { getSessionMessages } from '@/hermes'
|
||||||
import { $activeGatewayProfile, $newChatProfile } from '@/store/profile'
|
import { $activeGatewayProfile, $newChatProfile } from '@/store/profile'
|
||||||
import { $currentCwd } from '@/store/session'
|
import { $currentCwd, $messages, $resumeFailedSessionId, setMessages, setResumeFailedSessionId } from '@/store/session'
|
||||||
|
|
||||||
import type { ClientSessionState } from '../../types'
|
import type { ClientSessionState } from '../../types'
|
||||||
|
|
||||||
@ -117,3 +118,142 @@ describe('createBackendSessionForSend profile routing', () => {
|
|||||||
expect(params).toMatchObject({ profile: 'default' })
|
expect(params).toMatchObject({ profile: 'default' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Resume failure recovery (the "stuck loading session window" bug) ──────────
|
||||||
|
// When session.resume rejects AND the REST transcript fallback ALSO fails, the
|
||||||
|
// hook must (a) not throw out of the fallback (which stranded the loader), and
|
||||||
|
// (b) arm $resumeFailedSessionId so use-route-resume can retry. A resume that
|
||||||
|
// succeeds must NOT leave the flag armed.
|
||||||
|
function ResumeHarness({
|
||||||
|
onReady,
|
||||||
|
requestGateway
|
||||||
|
}: {
|
||||||
|
onReady: (resume: (storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) => void
|
||||||
|
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||||
|
}) {
|
||||||
|
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
|
||||||
|
|
||||||
|
const actions = useSessionActions({
|
||||||
|
activeSessionId: null,
|
||||||
|
activeSessionIdRef: ref<string | null>(null),
|
||||||
|
busyRef: ref(false),
|
||||||
|
creatingSessionRef: ref(false),
|
||||||
|
ensureSessionState: () => ({}) as ClientSessionState,
|
||||||
|
getRouteToken: () => 'token',
|
||||||
|
navigate: vi.fn() as never,
|
||||||
|
requestGateway,
|
||||||
|
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
|
||||||
|
selectedStoredSessionId: null,
|
||||||
|
selectedStoredSessionIdRef: ref<string | null>(null),
|
||||||
|
sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),
|
||||||
|
syncSessionStateToView: vi.fn(),
|
||||||
|
updateSessionState: (_sessionId, updater) => updater({} as ClientSessionState)
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onReady(actions.resumeSession)
|
||||||
|
}, [actions.resumeSession, onReady])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resumeSession failure recovery', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
setResumeFailedSessionId(null)
|
||||||
|
setMessages([])
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function runResume(
|
||||||
|
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||||
|
): Promise<void> {
|
||||||
|
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
|
||||||
|
render(<ResumeHarness onReady={r => (resume = r)} requestGateway={requestGateway} />)
|
||||||
|
await waitFor(() => expect(resume).not.toBeNull())
|
||||||
|
await resume!('stored-1', true)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('arms $resumeFailedSessionId when resume RPC and REST fallback both fail', async () => {
|
||||||
|
// session.resume rejects (e.g. timeout against a wedged backend)...
|
||||||
|
const requestGateway = vi.fn(async (method: string) => {
|
||||||
|
if (method === 'session.resume') {
|
||||||
|
throw new Error('request timed out: session.resume')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
// ...and the REST transcript fallback also rejects (backend unreachable).
|
||||||
|
vi.mocked(getSessionMessages).mockRejectedValue(new Error('network down'))
|
||||||
|
|
||||||
|
await runResume(requestGateway)
|
||||||
|
|
||||||
|
// The window is no longer silently stranded: the failure latch is armed for
|
||||||
|
// the stored session, which use-route-resume consumes to retry.
|
||||||
|
expect($resumeFailedSessionId.get()).toBe('stored-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT arm the failure latch when the resume RPC fails but the REST fallback paints history', async () => {
|
||||||
|
// session.resume rejects, but the REST transcript fallback succeeds and
|
||||||
|
// hydrates a readable transcript — the window is NOT stranded.
|
||||||
|
const requestGateway = vi.fn(async (method: string) => {
|
||||||
|
if (method === 'session.resume') {
|
||||||
|
throw new Error('request timed out: session.resume')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mocked(getSessionMessages).mockResolvedValue({
|
||||||
|
messages: [
|
||||||
|
{ content: 'hello', role: 'user', timestamp: 1 },
|
||||||
|
{ content: 'hi there', role: 'assistant', timestamp: 2 }
|
||||||
|
],
|
||||||
|
session_id: 'stored-1'
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
await runResume(requestGateway)
|
||||||
|
|
||||||
|
// Arming here would auto-retry a window that already shows history and,
|
||||||
|
// on exhaustion, blank that transcript behind the error overlay — a
|
||||||
|
// regression vs. plain fallback-success. The latch must stay clear.
|
||||||
|
expect($resumeFailedSessionId.get()).toBeNull()
|
||||||
|
// The fallback transcript is visible.
|
||||||
|
expect($messages.get().length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT throw out of the fallback when REST also fails (no unhandled rejection)', async () => {
|
||||||
|
const requestGateway = vi.fn(async (method: string) => {
|
||||||
|
if (method === 'session.resume') {
|
||||||
|
throw new Error('request timed out: session.resume')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mocked(getSessionMessages).mockRejectedValue(new Error('network down'))
|
||||||
|
|
||||||
|
// resumeSession must resolve (swallow the fallback failure), not reject.
|
||||||
|
await expect(runResume(requestGateway)).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves the failure latch clear when resume succeeds', async () => {
|
||||||
|
// Pre-arm to prove a successful resume clears it (entry-clear path).
|
||||||
|
setResumeFailedSessionId('stored-1')
|
||||||
|
|
||||||
|
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||||
|
if (method === 'session.resume') {
|
||||||
|
return { session_id: 'runtime-1', resumed: params?.session_id, messages: [], info: {} } as never
|
||||||
|
}
|
||||||
|
|
||||||
|
return {} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mocked(getSessionMessages).mockResolvedValue({ messages: [] } as never)
|
||||||
|
|
||||||
|
await runResume(requestGateway)
|
||||||
|
|
||||||
|
expect($resumeFailedSessionId.get()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@ -38,6 +38,8 @@ import {
|
|||||||
setFreshDraftReady,
|
setFreshDraftReady,
|
||||||
setIntroSeed,
|
setIntroSeed,
|
||||||
setMessages,
|
setMessages,
|
||||||
|
setResumeExhaustedSessionId,
|
||||||
|
setResumeFailedSessionId,
|
||||||
setSelectedStoredSessionId,
|
setSelectedStoredSessionId,
|
||||||
setSessions,
|
setSessions,
|
||||||
setSessionStartedAt,
|
setSessionStartedAt,
|
||||||
@ -579,6 +581,15 @@ export function useSessionActions({
|
|||||||
clearNotifications()
|
clearNotifications()
|
||||||
setSelectedStoredSessionId(storedSessionId)
|
setSelectedStoredSessionId(storedSessionId)
|
||||||
selectedStoredSessionIdRef.current = storedSessionId
|
selectedStoredSessionIdRef.current = storedSessionId
|
||||||
|
// Optimistically clear any prior resume-failure latch for this session:
|
||||||
|
// we're attempting a fresh resume, so the self-heal in use-route-resume
|
||||||
|
// must not keep treating it as stranded. It's re-armed below only if THIS
|
||||||
|
// attempt fails terminally (RPC reject + REST fallback failure).
|
||||||
|
setResumeFailedSessionId(current => (current === storedSessionId ? null : current))
|
||||||
|
// Also clear the exhausted-latch: a fresh attempt (manual Retry, reconnect,
|
||||||
|
// reselect) gives the bounded auto-retry counter a clean cycle, so the
|
||||||
|
// chat view drops the error state and shows the loader again.
|
||||||
|
setResumeExhaustedSessionId(current => (current === storedSessionId ? null : current))
|
||||||
|
|
||||||
const warmRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
const warmRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||||
|
|
||||||
@ -769,6 +780,15 @@ export function useSessionActions({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The gateway resume RPC failed. Try the REST transcript as a fallback
|
||||||
|
// so the window at least shows history. CRITICAL: this fallback must be
|
||||||
|
// wrapped in its own try — if it ALSO throws (wedged/unreachable backend,
|
||||||
|
// the common case when resume failed in the first place), an unguarded
|
||||||
|
// throw here skips setMessages AND leaves activeSessionId null with an
|
||||||
|
// empty transcript. That is the exact state the thread loader latches on
|
||||||
|
// forever (messagesEmpty && !activeSessionId) with no recovery path —
|
||||||
|
// the "open in new window stays stuck loading, even after a nap" bug.
|
||||||
|
try {
|
||||||
const fallback = await getSessionMessages(storedSessionId, sessionProfile)
|
const fallback = await getSessionMessages(storedSessionId, sessionProfile)
|
||||||
|
|
||||||
if (!isCurrentResume()) {
|
if (!isCurrentResume()) {
|
||||||
@ -776,6 +796,25 @@ export function useSessionActions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
|
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
|
||||||
|
} catch {
|
||||||
|
// Fallback also failed: nothing to paint. Leave whatever messages are
|
||||||
|
// already shown and fall through to arm the resume-failure latch so
|
||||||
|
// use-route-resume re-attempts the resume on the next render / window
|
||||||
|
// focus / gateway reconnect instead of stranding the loader.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCurrentResume() && $messages.get().length === 0) {
|
||||||
|
// Arm the self-heal ONLY when the window is still empty: the gateway
|
||||||
|
// resume rejected AND the REST fallback failed to paint a transcript.
|
||||||
|
// That is the exact stranded state the loader latches on
|
||||||
|
// (messagesEmpty && !activeSessionId), and matches $resumeFailedSessionId's
|
||||||
|
// documented contract. If the REST fallback DID paint history, the
|
||||||
|
// window is readable — arming here would needlessly auto-retry and,
|
||||||
|
// once retries exhaust, blank that visible transcript behind the
|
||||||
|
// exhausted-state error overlay (a regression vs. plain fallback success).
|
||||||
|
setResumeFailedSessionId(storedSessionId)
|
||||||
|
}
|
||||||
|
|
||||||
notifyError(err, copy.resumeFailed)
|
notifyError(err, copy.resumeFailed)
|
||||||
} finally {
|
} finally {
|
||||||
if (isCurrentResume()) {
|
if (isCurrentResume()) {
|
||||||
|
|||||||
@ -378,6 +378,20 @@ function IntroHarness() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DismissibleErrorHarness({ onDismissError }: { onDismissError: (messageId: string) => void }) {
|
||||||
|
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||||
|
messages: [assistantErrorMessage('OpenRouter rejected the request (403).')],
|
||||||
|
isRunning: false,
|
||||||
|
onNew: async () => {}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AssistantRuntimeProvider runtime={runtime}>
|
||||||
|
<Thread onDismissError={onDismissError} />
|
||||||
|
</AssistantRuntimeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
describe('assistant-ui streaming renderer', () => {
|
describe('assistant-ui streaming renderer', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resizeObservers.clear()
|
resizeObservers.clear()
|
||||||
@ -421,6 +435,23 @@ describe('assistant-ui streaming renderer', () => {
|
|||||||
expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).')
|
expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('omits the dismiss control when no onDismissError handler is supplied', () => {
|
||||||
|
render(<MessageHarness message={assistantErrorMessage('OpenRouter rejected the request (403).')} />)
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: 'Dismiss error' })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('invokes onDismissError with the errored message id when the dismiss control is clicked', () => {
|
||||||
|
const onDismissError = vi.fn()
|
||||||
|
render(<DismissibleErrorHarness onDismissError={onDismissError} />)
|
||||||
|
|
||||||
|
const dismiss = screen.getByRole('button', { name: 'Dismiss error' })
|
||||||
|
fireEvent.click(dismiss)
|
||||||
|
|
||||||
|
expect(onDismissError).toHaveBeenCalledTimes(1)
|
||||||
|
expect(onDismissError).toHaveBeenCalledWith('assistant-error-1')
|
||||||
|
})
|
||||||
|
|
||||||
// Scroll behavior (follow-at-bottom, escape-on-scroll-up, re-engage) is owned
|
// Scroll behavior (follow-at-bottom, escape-on-scroll-up, re-engage) is owned
|
||||||
// by the use-stick-to-bottom library and covered by its own test suite. We
|
// by the use-stick-to-bottom library and covered by its own test suite. We
|
||||||
// don't re-assert its scrollTop mechanics here — doing so in jsdom (no real
|
// don't re-assert its scrollTop mechanics here — doing so in jsdom (no real
|
||||||
|
|||||||
@ -91,7 +91,7 @@ import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runti
|
|||||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||||
import { LinkifiedText } from '@/lib/external-link'
|
import { LinkifiedText } from '@/lib/external-link'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons'
|
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon, XIcon } from '@/lib/icons'
|
||||||
import { extractPreviewTargets } from '@/lib/preview-targets'
|
import { extractPreviewTargets } from '@/lib/preview-targets'
|
||||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@ -169,6 +169,7 @@ export const Thread: FC<{
|
|||||||
loading?: ThreadLoadingState
|
loading?: ThreadLoadingState
|
||||||
onBranchInNewChat?: (messageId: string) => void
|
onBranchInNewChat?: (messageId: string) => void
|
||||||
onCancel?: () => Promise<void> | void
|
onCancel?: () => Promise<void> | void
|
||||||
|
onDismissError?: (messageId: string) => void
|
||||||
onRestoreToMessage?: (messageId: string) => Promise<void> | void
|
onRestoreToMessage?: (messageId: string) => Promise<void> | void
|
||||||
sessionId?: string | null
|
sessionId?: string | null
|
||||||
sessionKey?: string | null
|
sessionKey?: string | null
|
||||||
@ -180,18 +181,19 @@ export const Thread: FC<{
|
|||||||
loading,
|
loading,
|
||||||
onBranchInNewChat,
|
onBranchInNewChat,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
onDismissError,
|
||||||
onRestoreToMessage,
|
onRestoreToMessage,
|
||||||
sessionId = null,
|
sessionId = null,
|
||||||
sessionKey
|
sessionKey
|
||||||
}) => {
|
}) => {
|
||||||
const messageComponents = useMemo(
|
const messageComponents = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
|
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} onDismissError={onDismissError} />,
|
||||||
SystemMessage,
|
SystemMessage,
|
||||||
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
|
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
|
||||||
UserMessage: () => <UserMessage onCancel={onCancel} onRestoreToMessage={onRestoreToMessage} />
|
UserMessage: () => <UserMessage onCancel={onCancel} onRestoreToMessage={onRestoreToMessage} />
|
||||||
}),
|
}),
|
||||||
[cwd, gateway, onBranchInNewChat, onCancel, onRestoreToMessage, sessionId]
|
[cwd, gateway, onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage, sessionId]
|
||||||
)
|
)
|
||||||
|
|
||||||
const emptyPlaceholder = intro ? (
|
const emptyPlaceholder = intro ? (
|
||||||
@ -245,9 +247,13 @@ const CenteredThreadSpinner: FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
|
const AssistantMessage: FC<{
|
||||||
|
onBranchInNewChat?: (messageId: string) => void
|
||||||
|
onDismissError?: (messageId: string) => void
|
||||||
|
}> = ({ onBranchInNewChat, onDismissError }) => {
|
||||||
const messageId = useAuiState(s => s.message.id)
|
const messageId = useAuiState(s => s.message.id)
|
||||||
const messageRuntime = useMessageRuntime()
|
const messageRuntime = useMessageRuntime()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
// PERF: this component must NOT subscribe to the streaming text. Every
|
// PERF: this component must NOT subscribe to the streaming text. Every
|
||||||
// selector here returns a value that stays referentially stable across
|
// selector here returns a value that stays referentially stable across
|
||||||
@ -306,10 +312,20 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
|||||||
)}
|
)}
|
||||||
<MessagePrimitive.Error>
|
<MessagePrimitive.Error>
|
||||||
<ErrorPrimitive.Root
|
<ErrorPrimitive.Root
|
||||||
className="mt-1.5 text-[0.78rem] leading-5 text-[color-mix(in_srgb,var(--dt-destructive)_78%,var(--ui-text-secondary))]"
|
className="mt-1.5 flex items-start gap-1.5 text-[0.78rem] leading-5 text-[color-mix(in_srgb,var(--dt-destructive)_78%,var(--ui-text-secondary))]"
|
||||||
role="alert"
|
role="alert"
|
||||||
>
|
>
|
||||||
<ErrorPrimitive.Message />
|
<ErrorPrimitive.Message className="min-w-0 flex-1" />
|
||||||
|
{onDismissError && (
|
||||||
|
<TooltipIconButton
|
||||||
|
className="-my-0.5 shrink-0 text-current opacity-70 hover:opacity-100"
|
||||||
|
onClick={() => onDismissError(messageId)}
|
||||||
|
side="top"
|
||||||
|
tooltip={t.assistant.thread.dismissError}
|
||||||
|
>
|
||||||
|
<XIcon className="size-3.5" />
|
||||||
|
</TooltipIconButton>
|
||||||
|
)}
|
||||||
</ErrorPrimitive.Root>
|
</ErrorPrimitive.Root>
|
||||||
</MessagePrimitive.Error>
|
</MessagePrimitive.Error>
|
||||||
</div>
|
</div>
|
||||||
@ -811,7 +827,7 @@ function StickyHumanMessageContainer({ attachments, children }: { attachments?:
|
|||||||
// so without the carve-out, clicking a stuck bubble drags the window instead of
|
// so without the carve-out, clicking a stuck bubble drags the window instead of
|
||||||
// opening the edit composer.
|
// opening the edit composer.
|
||||||
const USER_BUBBLE_BASE_CLASS =
|
const USER_BUBBLE_BASE_CLASS =
|
||||||
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]'
|
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-y-auto rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]'
|
||||||
|
|
||||||
const USER_ACTION_ICON_BUTTON_CLASS =
|
const USER_ACTION_ICON_BUTTON_CLASS =
|
||||||
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
|
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
|
||||||
|
|||||||
@ -1733,6 +1733,7 @@ export const en: Translations = {
|
|||||||
refresh: 'Refresh',
|
refresh: 'Refresh',
|
||||||
moreActions: 'More actions',
|
moreActions: 'More actions',
|
||||||
branchNewChat: 'Branch in new chat',
|
branchNewChat: 'Branch in new chat',
|
||||||
|
dismissError: 'Dismiss error',
|
||||||
readAloudFailed: 'Read aloud failed',
|
readAloudFailed: 'Read aloud failed',
|
||||||
preparingAudio: 'Preparing audio...',
|
preparingAudio: 'Preparing audio...',
|
||||||
stopReading: 'Stop reading',
|
stopReading: 'Stop reading',
|
||||||
@ -1842,6 +1843,9 @@ export const en: Translations = {
|
|||||||
regenerateFailed: 'Regenerate failed',
|
regenerateFailed: 'Regenerate failed',
|
||||||
editFailed: 'Edit failed',
|
editFailed: 'Edit failed',
|
||||||
resumeFailed: 'Resume failed',
|
resumeFailed: 'Resume failed',
|
||||||
|
resumeStrandedTitle: "Couldn't load this session",
|
||||||
|
resumeStrandedBody: 'The connection to this session failed and automatic retries gave up. Check that the gateway is running, then try again.',
|
||||||
|
resumeRetry: 'Retry',
|
||||||
nothingToBranch: 'Nothing to branch',
|
nothingToBranch: 'Nothing to branch',
|
||||||
branchNeedsChat: 'Start or resume a chat before branching.',
|
branchNeedsChat: 'Start or resume a chat before branching.',
|
||||||
sessionBusy: 'Session busy',
|
sessionBusy: 'Session busy',
|
||||||
|
|||||||
@ -1864,6 +1864,7 @@ export const ja = defineLocale({
|
|||||||
refresh: '更新',
|
refresh: '更新',
|
||||||
moreActions: 'その他のアクション',
|
moreActions: 'その他のアクション',
|
||||||
branchNewChat: '新しいチャットでブランチ',
|
branchNewChat: '新しいチャットでブランチ',
|
||||||
|
dismissError: 'エラーを閉じる',
|
||||||
readAloudFailed: '読み上げに失敗しました',
|
readAloudFailed: '読み上げに失敗しました',
|
||||||
preparingAudio: '音声を準備中...',
|
preparingAudio: '音声を準備中...',
|
||||||
stopReading: '読み上げを停止',
|
stopReading: '読み上げを停止',
|
||||||
@ -1973,6 +1974,9 @@ export const ja = defineLocale({
|
|||||||
regenerateFailed: '再生成に失敗しました',
|
regenerateFailed: '再生成に失敗しました',
|
||||||
editFailed: '編集に失敗しました',
|
editFailed: '編集に失敗しました',
|
||||||
resumeFailed: '再開に失敗しました',
|
resumeFailed: '再開に失敗しました',
|
||||||
|
resumeStrandedTitle: 'このセッションを読み込めませんでした',
|
||||||
|
resumeStrandedBody: 'このセッションへの接続に失敗し、自動再試行も停止しました。ゲートウェイが実行中か確認してから、もう一度お試しください。',
|
||||||
|
resumeRetry: '再試行',
|
||||||
nothingToBranch: 'ブランチするものがありません',
|
nothingToBranch: 'ブランチするものがありません',
|
||||||
branchNeedsChat: 'ブランチする前にチャットを開始または再開してください。',
|
branchNeedsChat: 'ブランチする前にチャットを開始または再開してください。',
|
||||||
sessionBusy: 'セッションが使用中',
|
sessionBusy: 'セッションが使用中',
|
||||||
|
|||||||
@ -1373,6 +1373,7 @@ export interface Translations {
|
|||||||
refresh: string
|
refresh: string
|
||||||
moreActions: string
|
moreActions: string
|
||||||
branchNewChat: string
|
branchNewChat: string
|
||||||
|
dismissError: string
|
||||||
readAloudFailed: string
|
readAloudFailed: string
|
||||||
preparingAudio: string
|
preparingAudio: string
|
||||||
stopReading: string
|
stopReading: string
|
||||||
@ -1480,6 +1481,9 @@ export interface Translations {
|
|||||||
regenerateFailed: string
|
regenerateFailed: string
|
||||||
editFailed: string
|
editFailed: string
|
||||||
resumeFailed: string
|
resumeFailed: string
|
||||||
|
resumeStrandedTitle: string
|
||||||
|
resumeStrandedBody: string
|
||||||
|
resumeRetry: string
|
||||||
nothingToBranch: string
|
nothingToBranch: string
|
||||||
branchNeedsChat: string
|
branchNeedsChat: string
|
||||||
sessionBusy: string
|
sessionBusy: string
|
||||||
|
|||||||
@ -1806,6 +1806,7 @@ export const zhHant = defineLocale({
|
|||||||
refresh: '重新整理',
|
refresh: '重新整理',
|
||||||
moreActions: '更多動作',
|
moreActions: '更多動作',
|
||||||
branchNewChat: '在新聊天中分支',
|
branchNewChat: '在新聊天中分支',
|
||||||
|
dismissError: '关闭错误',
|
||||||
readAloudFailed: '朗讀失敗',
|
readAloudFailed: '朗讀失敗',
|
||||||
preparingAudio: '正在準備音訊...',
|
preparingAudio: '正在準備音訊...',
|
||||||
stopReading: '停止朗讀',
|
stopReading: '停止朗讀',
|
||||||
@ -1913,6 +1914,9 @@ export const zhHant = defineLocale({
|
|||||||
regenerateFailed: '重新生成失敗',
|
regenerateFailed: '重新生成失敗',
|
||||||
editFailed: '編輯失敗',
|
editFailed: '編輯失敗',
|
||||||
resumeFailed: '繼續失敗',
|
resumeFailed: '繼續失敗',
|
||||||
|
resumeStrandedTitle: '無法載入此工作階段',
|
||||||
|
resumeStrandedBody: '與此工作階段的連線失敗,自動重試已停止。請確認閘道正在執行,然後重試。',
|
||||||
|
resumeRetry: '重試',
|
||||||
nothingToBranch: '沒有可分支的內容',
|
nothingToBranch: '沒有可分支的內容',
|
||||||
branchNeedsChat: '分支前請先開始或繼續一個聊天。',
|
branchNeedsChat: '分支前請先開始或繼續一個聊天。',
|
||||||
sessionBusy: '工作階段忙碌中',
|
sessionBusy: '工作階段忙碌中',
|
||||||
|
|||||||
@ -1912,6 +1912,7 @@ export const zh: Translations = {
|
|||||||
refresh: '刷新',
|
refresh: '刷新',
|
||||||
moreActions: '更多操作',
|
moreActions: '更多操作',
|
||||||
branchNewChat: '在新对话中分支',
|
branchNewChat: '在新对话中分支',
|
||||||
|
dismissError: '关闭错误',
|
||||||
readAloudFailed: '朗读失败',
|
readAloudFailed: '朗读失败',
|
||||||
preparingAudio: '正在准备音频...',
|
preparingAudio: '正在准备音频...',
|
||||||
stopReading: '停止朗读',
|
stopReading: '停止朗读',
|
||||||
@ -2020,6 +2021,9 @@ export const zh: Translations = {
|
|||||||
regenerateFailed: '重新生成失败',
|
regenerateFailed: '重新生成失败',
|
||||||
editFailed: '编辑失败',
|
editFailed: '编辑失败',
|
||||||
resumeFailed: '恢复失败',
|
resumeFailed: '恢复失败',
|
||||||
|
resumeStrandedTitle: '无法加载此会话',
|
||||||
|
resumeStrandedBody: '与此会话的连接失败,自动重试已停止。请确认网关正在运行,然后重试。',
|
||||||
|
resumeRetry: '重试',
|
||||||
nothingToBranch: '没有可分支的内容',
|
nothingToBranch: '没有可分支的内容',
|
||||||
branchNeedsChat: '分支前请先开始或恢复一个对话。',
|
branchNeedsChat: '分支前请先开始或恢复一个对话。',
|
||||||
sessionBusy: '会话忙碌中',
|
sessionBusy: '会话忙碌中',
|
||||||
|
|||||||
@ -218,6 +218,23 @@ export const $lastVisibleMessageIsUser = computed($messages, lastVisibleMessageI
|
|||||||
export const $freshDraftReady = atom(false)
|
export const $freshDraftReady = atom(false)
|
||||||
export const $busy = atom(false)
|
export const $busy = atom(false)
|
||||||
export const $awaitingResponse = atom(false)
|
export const $awaitingResponse = atom(false)
|
||||||
|
// Stored-session id whose most recent resume FAILED terminally (the gateway RPC
|
||||||
|
// rejected AND the REST transcript fallback also failed), leaving the window
|
||||||
|
// with no runtime and an empty transcript. Drives use-route-resume's self-heal:
|
||||||
|
// while this matches the routed session the loader would otherwise latch
|
||||||
|
// forever (messagesEmpty && !activeSessionId), so the hook re-attempts the
|
||||||
|
// resume on the next render/focus/reconnect instead of stranding the window.
|
||||||
|
// Null whenever the active route has a healthy (or in-flight) resume.
|
||||||
|
export const $resumeFailedSessionId = atom<string | null>(null)
|
||||||
|
// Stored-session id whose resume has EXHAUSTED its bounded auto-retries (the
|
||||||
|
// terminal-failure latch above kept failing through all MAX_RESUME_RETRIES
|
||||||
|
// attempts). Distinct from $resumeFailedSessionId, which is armed *during* the
|
||||||
|
// backoff window too: this fires only once auto-recovery has given up, so the
|
||||||
|
// chat view can swap the perpetual loader for an explicit error + manual Retry
|
||||||
|
// affordance. A fresh resumeSession() (manual Retry, reconnect, reselect)
|
||||||
|
// clears it and resets the retry counter. Null whenever the active route has a
|
||||||
|
// healthy, in-flight, or still-auto-retrying resume.
|
||||||
|
export const $resumeExhaustedSessionId = atom<string | null>(null)
|
||||||
export const $currentModel = atom(storedString(COMPOSER_MODEL_KEY) ?? '')
|
export const $currentModel = atom(storedString(COMPOSER_MODEL_KEY) ?? '')
|
||||||
export const $currentProvider = atom(storedString(COMPOSER_PROVIDER_KEY) ?? '')
|
export const $currentProvider = atom(storedString(COMPOSER_PROVIDER_KEY) ?? '')
|
||||||
export const $currentReasoningEffort = atom(storedString(COMPOSER_EFFORT_KEY) ?? '')
|
export const $currentReasoningEffort = atom(storedString(COMPOSER_EFFORT_KEY) ?? '')
|
||||||
@ -262,6 +279,8 @@ export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($
|
|||||||
export const setSelectedStoredSessionId = (next: Updater<string | null>) => updateAtom($selectedStoredSessionId, next)
|
export const setSelectedStoredSessionId = (next: Updater<string | null>) => updateAtom($selectedStoredSessionId, next)
|
||||||
export const setMessages = (next: Updater<ChatMessage[]>) => updateAtom($messages, next)
|
export const setMessages = (next: Updater<ChatMessage[]>) => updateAtom($messages, next)
|
||||||
export const setFreshDraftReady = (next: Updater<boolean>) => updateAtom($freshDraftReady, next)
|
export const setFreshDraftReady = (next: Updater<boolean>) => updateAtom($freshDraftReady, next)
|
||||||
|
export const setResumeFailedSessionId = (next: Updater<string | null>) => updateAtom($resumeFailedSessionId, next)
|
||||||
|
export const setResumeExhaustedSessionId = (next: Updater<string | null>) => updateAtom($resumeExhaustedSessionId, next)
|
||||||
export const setBusy = (next: Updater<boolean>) => updateAtom($busy, next)
|
export const setBusy = (next: Updater<boolean>) => updateAtom($busy, next)
|
||||||
export const setAwaitingResponse = (next: Updater<boolean>) => updateAtom($awaitingResponse, next)
|
export const setAwaitingResponse = (next: Updater<boolean>) => updateAtom($awaitingResponse, next)
|
||||||
|
|
||||||
|
|||||||
671
cli.py
671
cli.py
@ -1984,6 +1984,24 @@ _ACCENT = _SkinAwareAnsi("response_border", "#FFD700", bold=True)
|
|||||||
_DIM = "\x1b[2;3m"
|
_DIM = "\x1b[2;3m"
|
||||||
|
|
||||||
|
|
||||||
|
def _b(s: str) -> str:
|
||||||
|
"""Bold if stdout is a real TTY; plain text otherwise (slash-worker safe)."""
|
||||||
|
import sys as _sys
|
||||||
|
try:
|
||||||
|
return f"\x1b[1m{s}\x1b[0m" if _sys.stdout.isatty() else str(s)
|
||||||
|
except Exception:
|
||||||
|
return str(s)
|
||||||
|
|
||||||
|
|
||||||
|
def _d(s: str) -> str:
|
||||||
|
"""Dim-italic if stdout is a real TTY; plain text otherwise."""
|
||||||
|
import sys as _sys
|
||||||
|
try:
|
||||||
|
return f"\x1b[2;3m{s}\x1b[0m" if _sys.stdout.isatty() else str(s)
|
||||||
|
except Exception:
|
||||||
|
return str(s)
|
||||||
|
|
||||||
|
|
||||||
def _accent_hex() -> str:
|
def _accent_hex() -> str:
|
||||||
"""Return the active skin accent color for legacy CLI output lines."""
|
"""Return the active skin accent color for legacy CLI output lines."""
|
||||||
try:
|
try:
|
||||||
@ -3664,7 +3682,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||||||
if getattr(self, "_resize_recovery_pending", False):
|
if getattr(self, "_resize_recovery_pending", False):
|
||||||
return
|
return
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if hasattr(self, "_app") and self._app and (now - self._last_invalidate) >= min_interval:
|
if hasattr(self, "_app") and self._app and (now - getattr(self, "_last_invalidate", 0.0)) >= min_interval:
|
||||||
self._last_invalidate = now
|
self._last_invalidate = now
|
||||||
self._app.invalidate()
|
self._app.invalidate()
|
||||||
|
|
||||||
@ -5957,6 +5975,18 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||||||
|
|
||||||
old_session_id = self.session_id
|
old_session_id = self.session_id
|
||||||
if self._session_db and old_session_id:
|
if self._session_db and old_session_id:
|
||||||
|
# Flush any un-persisted messages from the current turn to the
|
||||||
|
# old session *before* rotating. /new can be called mid-turn
|
||||||
|
# when _flush_messages_to_session_db() has not yet run — without
|
||||||
|
# this, messages generated during the current turn are silently
|
||||||
|
# lost on session rotation (#47202).
|
||||||
|
if self.agent:
|
||||||
|
try:
|
||||||
|
self.agent._flush_messages_to_session_db(
|
||||||
|
self.conversation_history
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # best-effort
|
||||||
try:
|
try:
|
||||||
self._session_db.end_session(old_session_id, "new_session")
|
self._session_db.end_session(old_session_id, "new_session")
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -6359,6 +6389,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||||||
|
|
||||||
in_main_thread = threading.current_thread() is threading.main_thread()
|
in_main_thread = threading.current_thread() is threading.main_thread()
|
||||||
|
|
||||||
|
# Slash-worker guard (#23185 / billing auto-reload hang): when a
|
||||||
|
# prompt_toolkit app is running but we're on a non-main thread (the
|
||||||
|
# process_loop / TUI slash-worker daemon thread), stdin is owned by the
|
||||||
|
# event loop / JSON-RPC pipe. A bare input() there blocks forever until
|
||||||
|
# the worker's 45s timeout fires. We cannot safely prompt off the main
|
||||||
|
# thread, so cancel cleanly (None) instead of hanging — mirrors the
|
||||||
|
# _stdin_fallback discipline in _prompt_text_input_modal.
|
||||||
|
if self._app and not in_main_thread:
|
||||||
|
self._invalidate()
|
||||||
|
return None
|
||||||
|
|
||||||
if self._app and in_main_thread:
|
if self._app and in_main_thread:
|
||||||
from prompt_toolkit.application import run_in_terminal
|
from prompt_toolkit.application import run_in_terminal
|
||||||
was_visible = self._status_bar_visible
|
was_visible = self._status_bar_visible
|
||||||
@ -6930,7 +6971,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||||||
try:
|
try:
|
||||||
if ctx is None:
|
if ctx is None:
|
||||||
raise RuntimeError("inventory context unavailable")
|
raise RuntimeError("inventory context unavailable")
|
||||||
providers = build_models_payload(ctx, max_models=50)["providers"]
|
providers = build_models_payload(ctx)["providers"]
|
||||||
except Exception:
|
except Exception:
|
||||||
providers = []
|
providers = []
|
||||||
|
|
||||||
@ -7506,6 +7547,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||||||
self._show_usage()
|
self._show_usage()
|
||||||
elif canonical == "credits":
|
elif canonical == "credits":
|
||||||
self._show_credits()
|
self._show_credits()
|
||||||
|
elif canonical == "billing":
|
||||||
|
self._show_billing(cmd_original)
|
||||||
elif canonical == "insights":
|
elif canonical == "insights":
|
||||||
self._show_insights(cmd_original)
|
self._show_insights(cmd_original)
|
||||||
elif canonical == "copy":
|
elif canonical == "copy":
|
||||||
@ -8425,7 +8468,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||||||
|
|
||||||
if not view.logged_in:
|
if not view.logged_in:
|
||||||
print()
|
print()
|
||||||
print(f" 💳 {_DIM}Not logged into Nous Portal.{_RST}")
|
_cprint(f" 💳 {_d('Not logged into Nous Portal.')}")
|
||||||
print(" Run `hermes portal` to log in, then /credits.")
|
print(" Run `hermes portal` to log in, then /credits.")
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -8487,6 +8530,628 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||||||
else:
|
else:
|
||||||
print(" 🟡 Cancelled. No credits added.")
|
print(" 🟡 Cancelled. No credits added.")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# /billing — Phase 2b terminal billing (CLI surface, all 5 screens)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _show_billing(self, command: str = "/billing"):
|
||||||
|
"""`/billing` — terminal billing for Nous (one interactive modal).
|
||||||
|
|
||||||
|
ZERO sub-commands: any argument is ignored. Bare ``/billing`` always
|
||||||
|
opens the Overview (Screen 1), whose numbered menu is the *only* way to
|
||||||
|
reach the Buy / Auto-reload / Monthly-limit sub-screens. (Per the unified
|
||||||
|
UX spec §0.4 — ``/billing buy`` etc. are gone; we don't error on a stray
|
||||||
|
arg, we just open the menu.)
|
||||||
|
|
||||||
|
Interactive CLI uses the prompt_toolkit modal; non-interactive contexts
|
||||||
|
(TUI slash-worker / no live app) render text + the portal deep-link, never
|
||||||
|
prompting (the URL is the affordance), same discipline as ``_show_credits``.
|
||||||
|
All money is Decimal end-to-end; the terminal never collects card details.
|
||||||
|
"""
|
||||||
|
from agent.billing_view import build_billing_state
|
||||||
|
|
||||||
|
state = build_billing_state()
|
||||||
|
if not state.logged_in:
|
||||||
|
print()
|
||||||
|
if state.error:
|
||||||
|
_msg = f"Couldn't load billing: {state.error}"
|
||||||
|
_cprint(f" 💳 {_d(_msg)}")
|
||||||
|
else:
|
||||||
|
_cprint(f" 💳 {_d('Not logged into Nous Portal.')}")
|
||||||
|
print(" Run `hermes portal` to log in, then /billing.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Any sub-arg is intentionally ignored — always open the menu.
|
||||||
|
self._billing_overview(state)
|
||||||
|
|
||||||
|
def _billing_portal_hint(self, state, *, reason: str = "") -> None:
|
||||||
|
"""Print a portal deep-link line (the funnel for portal-only actions)."""
|
||||||
|
url = getattr(state, "portal_url", None)
|
||||||
|
if not url:
|
||||||
|
return
|
||||||
|
if reason:
|
||||||
|
print(f" {reason}")
|
||||||
|
print(f" Manage on portal: {url}")
|
||||||
|
|
||||||
|
def _billing_overview(self, state):
|
||||||
|
"""Screen 1 — overview: balance, spend bar, role-gated action menu."""
|
||||||
|
from agent.billing_view import format_money
|
||||||
|
|
||||||
|
print()
|
||||||
|
_cprint(f" 💳 {_b('Usage credits')}")
|
||||||
|
print(f" {'─' * 41}")
|
||||||
|
|
||||||
|
cap = state.monthly_cap
|
||||||
|
if cap is not None and cap.limit_usd is not None:
|
||||||
|
spent = format_money(cap.spent_this_month_usd)
|
||||||
|
limit = format_money(cap.limit_usd)
|
||||||
|
ceiling = " (default ceiling)" if cap.is_default_ceiling else ""
|
||||||
|
bar, pct = self._billing_spend_bar(
|
||||||
|
cap.spent_this_month_usd, cap.limit_usd
|
||||||
|
)
|
||||||
|
print(f" {spent} of {limit} used{ceiling} {bar} {pct}%")
|
||||||
|
|
||||||
|
print(f" Balance: {format_money(state.balance_usd)}")
|
||||||
|
|
||||||
|
ar = state.auto_reload
|
||||||
|
if ar is not None:
|
||||||
|
if ar.enabled:
|
||||||
|
print(
|
||||||
|
f" Auto-reload: on — below {format_money(ar.threshold_usd)} "
|
||||||
|
f"→ reload to {format_money(ar.reload_to_usd)}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(" Auto-reload: off")
|
||||||
|
|
||||||
|
if state.org_name:
|
||||||
|
role = (state.role or "").title()
|
||||||
|
_org_line = f"Org: {state.org_name}{f' · {role}' if role else ''}"
|
||||||
|
_cprint(f" {_d(_org_line)}")
|
||||||
|
print(f" {'─' * 41}")
|
||||||
|
|
||||||
|
# Action gating: admin + kill-switch for charge/auto-reload; everyone gets portal.
|
||||||
|
if not state.is_admin:
|
||||||
|
_cprint(f" {_d('Billing actions require an org admin/owner.')}")
|
||||||
|
self._billing_portal_hint(state)
|
||||||
|
return
|
||||||
|
if not state.cli_billing_enabled:
|
||||||
|
_cprint(f" {_d('Terminal billing is turned off for this org.')}")
|
||||||
|
self._billing_portal_hint(state, reason="Enable it on the portal to buy credits here.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Optimistic funnel: no card on file → a charge will 403 no_payment_method.
|
||||||
|
# Surface that up front (with the portal link) but DON'T hide Buy — /state.card
|
||||||
|
# can't fully prove CLI-chargeability, so we advise rather than gate.
|
||||||
|
if state.card is None:
|
||||||
|
_cprint(
|
||||||
|
f" {_d('No saved card for terminal charges yet — set one up on the portal first.')}"
|
||||||
|
)
|
||||||
|
self._billing_portal_hint(state)
|
||||||
|
|
||||||
|
# Non-interactive (slash-worker / no live app): no modal, no sub-command
|
||||||
|
# advertising — just the portal funnel (the URL is the affordance).
|
||||||
|
if not getattr(self, "_app", None):
|
||||||
|
self._billing_portal_hint(state)
|
||||||
|
return
|
||||||
|
|
||||||
|
choices = [
|
||||||
|
("buy", "Buy credits", "purchase a one-time credit top-up"),
|
||||||
|
("auto", "Adjust auto-reload", "configure automatic top-ups"),
|
||||||
|
("limit", "Adjust monthly limit", "show the monthly spend cap (read-only)"),
|
||||||
|
("portal", "Manage on portal", "open the billing page in your browser"),
|
||||||
|
("cancel", "Cancel", "do nothing"),
|
||||||
|
]
|
||||||
|
# The overview summary is already printed above; the modal only needs to
|
||||||
|
# present the action menu — repeating the title/balance reads as a dupe.
|
||||||
|
raw = self._prompt_text_input_modal(
|
||||||
|
title="💳 Choose an action", detail="",
|
||||||
|
choices=choices,
|
||||||
|
)
|
||||||
|
choice = self._normalize_slash_confirm_choice(raw, choices)
|
||||||
|
if choice == "buy":
|
||||||
|
self._billing_buy_flow(state)
|
||||||
|
elif choice == "auto":
|
||||||
|
self._billing_auto_reload_flow(state)
|
||||||
|
elif choice == "limit":
|
||||||
|
self._billing_limit_screen(state)
|
||||||
|
elif choice == "portal":
|
||||||
|
self._billing_open_portal(state)
|
||||||
|
else:
|
||||||
|
print(" 🟡 Cancelled.")
|
||||||
|
|
||||||
|
def _billing_spend_bar(self, spent, limit, *, cells: int = 10):
|
||||||
|
"""Render a 10-cell `█`/`░` spend bar + integer percent from spent/limit.
|
||||||
|
|
||||||
|
Returns ``(bar, pct)`` where ``bar`` is like ``[████░░░░░░]`` and ``pct``
|
||||||
|
is the spent/limit percentage clamped to 0..100. Box-drawing glyphs are
|
||||||
|
not SGR codes, so this is leak-safe even without ``_b()``/``_d()``.
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
try:
|
||||||
|
s = Decimal(str(spent)) if spent is not None else Decimal("0")
|
||||||
|
l = Decimal(str(limit)) if limit is not None else Decimal("0")
|
||||||
|
except Exception:
|
||||||
|
s, l = Decimal("0"), Decimal("0")
|
||||||
|
if l <= 0:
|
||||||
|
pct = 0
|
||||||
|
else:
|
||||||
|
pct = int((s / l) * 100)
|
||||||
|
pct = max(0, min(100, pct))
|
||||||
|
filled = int(round(pct / 100 * cells))
|
||||||
|
filled = max(0, min(cells, filled))
|
||||||
|
bar = ("█" * filled) + ("░" * (cells - filled))
|
||||||
|
return bar, pct
|
||||||
|
|
||||||
|
def _billing_open_portal(self, state):
|
||||||
|
url = getattr(state, "portal_url", None)
|
||||||
|
if not url:
|
||||||
|
print(" No portal URL available.")
|
||||||
|
return
|
||||||
|
opened = False
|
||||||
|
try:
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
opened = webbrowser.open(url)
|
||||||
|
except Exception:
|
||||||
|
opened = False
|
||||||
|
if not opened:
|
||||||
|
print(f" Open this URL: {url}")
|
||||||
|
print(" Complete billing changes in the browser.")
|
||||||
|
|
||||||
|
def _billing_require_admin(self, state) -> bool:
|
||||||
|
"""Guard charge/auto-reload entry points; print + return False if blocked."""
|
||||||
|
if not state.is_admin:
|
||||||
|
print()
|
||||||
|
_cprint(f" 💳 {_d('Billing actions require an org admin/owner.')}")
|
||||||
|
self._billing_portal_hint(state)
|
||||||
|
return False
|
||||||
|
if not state.cli_billing_enabled:
|
||||||
|
print()
|
||||||
|
_cprint(f" 💳 {_d('Terminal billing is turned off for this org.')}")
|
||||||
|
self._billing_portal_hint(state, reason="Enable it on the portal first.")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _billing_buy_flow(self, state):
|
||||||
|
"""Screen 2 (preset select) → Screen 3 (confirm + charge + poll)."""
|
||||||
|
from agent.billing_view import format_money, validate_charge_amount
|
||||||
|
|
||||||
|
if not self._billing_require_admin(state):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Screen 3 — preset selection.
|
||||||
|
if not getattr(self, "_app", None):
|
||||||
|
presets = ", ".join(format_money(p) for p in state.charge_presets)
|
||||||
|
print()
|
||||||
|
_cprint(f" 💳 {_b('Buy usage credits')}")
|
||||||
|
print(f" Presets: {presets}")
|
||||||
|
print(" Run this in the interactive CLI to complete a purchase.")
|
||||||
|
self._billing_portal_hint(state)
|
||||||
|
return
|
||||||
|
|
||||||
|
preset_choices = []
|
||||||
|
for p in state.charge_presets:
|
||||||
|
preset_choices.append((str(p), format_money(p), "one-time credit purchase"))
|
||||||
|
preset_choices.append(("custom", "Custom amount…", "enter your own amount"))
|
||||||
|
preset_choices.append(("cancel", "Cancel", "do nothing"))
|
||||||
|
|
||||||
|
card = state.card
|
||||||
|
detail = f"Payment: {card.masked}" if card else "No saved card on file"
|
||||||
|
raw = self._prompt_text_input_modal(
|
||||||
|
title="💳 Buy usage credits", detail=detail, choices=preset_choices,
|
||||||
|
)
|
||||||
|
choice = self._normalize_slash_confirm_choice(raw, preset_choices)
|
||||||
|
if not choice or choice == "cancel":
|
||||||
|
print(" 🟡 Cancelled. No credits added.")
|
||||||
|
return
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
if choice == "custom":
|
||||||
|
entered = self._prompt_text_input(" Amount (USD): ")
|
||||||
|
if entered is None:
|
||||||
|
# None = cancelled (e.g. slash-worker can't prompt off-thread).
|
||||||
|
print(" 🟡 Cancelled. No credits added.")
|
||||||
|
return
|
||||||
|
v = validate_charge_amount(
|
||||||
|
entered or "", min_usd=state.min_usd, max_usd=state.max_usd
|
||||||
|
)
|
||||||
|
if not v.ok:
|
||||||
|
print(f" 🔴 {v.error}")
|
||||||
|
return
|
||||||
|
amount = v.amount
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
amount = Decimal(choice)
|
||||||
|
except Exception:
|
||||||
|
print(" 🔴 Invalid selection.")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._billing_confirm_and_charge(state, amount)
|
||||||
|
|
||||||
|
def _billing_confirm_and_charge(self, state, amount):
|
||||||
|
"""Screen 3 — confirm total + consent, charge, then poll to settlement."""
|
||||||
|
from agent.billing_view import format_money, new_idempotency_key
|
||||||
|
|
||||||
|
card = state.card
|
||||||
|
print()
|
||||||
|
_cprint(f" 💳 {_b('Confirm purchase')}")
|
||||||
|
print(f" {'─' * 41}")
|
||||||
|
print(f" Total: {format_money(amount)}")
|
||||||
|
if card:
|
||||||
|
print(f" Payment: {card.masked}")
|
||||||
|
print(f" {'─' * 41}")
|
||||||
|
_consent = (
|
||||||
|
"By confirming, you allow Nous Research to charge your card."
|
||||||
|
)
|
||||||
|
_cprint(f" {_d(_consent)}")
|
||||||
|
|
||||||
|
confirm_choices = [
|
||||||
|
("pay", f"Pay {format_money(amount)} now", "submit the charge"),
|
||||||
|
("cancel", "Go back", "do not charge"),
|
||||||
|
]
|
||||||
|
if not getattr(self, "_app", None):
|
||||||
|
print(" Run in the interactive CLI to confirm a purchase.")
|
||||||
|
return
|
||||||
|
raw = self._prompt_text_input_modal(
|
||||||
|
title=f"💳 Pay {format_money(amount)}?",
|
||||||
|
detail=(card.masked if card else "no saved card"),
|
||||||
|
choices=confirm_choices,
|
||||||
|
)
|
||||||
|
choice = self._normalize_slash_confirm_choice(raw, confirm_choices)
|
||||||
|
if choice != "pay":
|
||||||
|
print(" 🟡 Cancelled. No credits added.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Submit the charge with a fresh idempotency key (reused on retry).
|
||||||
|
from hermes_cli.nous_billing import (
|
||||||
|
BillingError,
|
||||||
|
BillingScopeRequired,
|
||||||
|
post_charge,
|
||||||
|
)
|
||||||
|
|
||||||
|
key = new_idempotency_key()
|
||||||
|
try:
|
||||||
|
result = post_charge(amount_usd=amount, idempotency_key=key)
|
||||||
|
except BillingScopeRequired:
|
||||||
|
self._billing_handle_scope_required(state)
|
||||||
|
return
|
||||||
|
except BillingError as exc:
|
||||||
|
self._billing_render_charge_error(state, exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
charge_id = result.get("chargeId")
|
||||||
|
if not charge_id:
|
||||||
|
print(" 🔴 No charge id returned; please check the portal.")
|
||||||
|
return
|
||||||
|
_cprint(f" {_d('Charge submitted — confirming settlement…')}")
|
||||||
|
self._billing_poll_charge(state, charge_id, amount)
|
||||||
|
|
||||||
|
def _billing_poll_charge(self, state, charge_id, amount):
|
||||||
|
"""Poll loop: 2s interval, 5-min cap, cancellable. settled = ledger truth."""
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
from agent.billing_view import format_money
|
||||||
|
from hermes_cli.nous_billing import (
|
||||||
|
BillingError,
|
||||||
|
BillingRateLimited,
|
||||||
|
get_charge_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
deadline = _time.time() + 300 # 5-minute cap
|
||||||
|
interval = 2.0
|
||||||
|
while _time.time() < deadline:
|
||||||
|
try:
|
||||||
|
status = get_charge_status(charge_id)
|
||||||
|
except BillingRateLimited as exc:
|
||||||
|
# Retry-after, NOT a failure — back off and keep polling.
|
||||||
|
wait = exc.retry_after or 5
|
||||||
|
_time.sleep(min(wait, 30))
|
||||||
|
continue
|
||||||
|
except BillingError as exc:
|
||||||
|
print(f" 🔴 Could not check the charge: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
state_str = status.get("status")
|
||||||
|
if state_str == "settled":
|
||||||
|
amt = status.get("amountUsd")
|
||||||
|
from agent.billing_view import parse_money
|
||||||
|
|
||||||
|
shown = format_money(parse_money(amt)) if amt else format_money(amount)
|
||||||
|
print(f" ✅ {shown} in credits added.")
|
||||||
|
return
|
||||||
|
if state_str == "failed":
|
||||||
|
self._billing_render_charge_failed(state, status.get("reason"))
|
||||||
|
return
|
||||||
|
# pending → wait and poll again
|
||||||
|
_time.sleep(interval)
|
||||||
|
|
||||||
|
# Past the cap with no terminal state = timeout (not an error).
|
||||||
|
print(f" 🟡 Still processing after 5 minutes — this is a timeout, not a "
|
||||||
|
f"failure. Check /billing or the portal shortly.")
|
||||||
|
self._billing_portal_hint(state)
|
||||||
|
|
||||||
|
def _billing_render_charge_failed(self, state, reason):
|
||||||
|
"""Branch the poll `failed` reasons to the right copy + portal funnel."""
|
||||||
|
reason = (reason or "").strip()
|
||||||
|
if reason == "authentication_required":
|
||||||
|
print(" 🔴 Your bank requires verification (3DS). Complete it on the "
|
||||||
|
"portal to finish this purchase.")
|
||||||
|
elif reason == "payment_method_expired":
|
||||||
|
print(" 🔴 Your card has expired. Update it on the portal.")
|
||||||
|
elif reason == "card_declined":
|
||||||
|
print(" 🔴 Your card was declined. Try another card on the portal.")
|
||||||
|
else:
|
||||||
|
print(f" 🔴 The charge didn't go through ({reason or 'processing_error'}).")
|
||||||
|
self._billing_portal_hint(state)
|
||||||
|
|
||||||
|
def _billing_render_charge_error(self, state, exc):
|
||||||
|
"""Render a typed BillingError at submit time (pre-poll)."""
|
||||||
|
from hermes_cli.nous_billing import BillingRateLimited
|
||||||
|
|
||||||
|
code = getattr(exc, "error", None)
|
||||||
|
portal_url = getattr(exc, "portal_url", None) or getattr(state, "portal_url", None)
|
||||||
|
if code == "no_payment_method":
|
||||||
|
print(" 💳 No saved card for terminal charges yet. Set one up on the "
|
||||||
|
"portal (one-time credit buys don't save a reusable card).")
|
||||||
|
elif code == "cli_billing_disabled":
|
||||||
|
print(" 🔴 Terminal billing is turned off for this org — an admin must enable it on the portal.")
|
||||||
|
elif code == "monthly_cap_exceeded":
|
||||||
|
remaining = (getattr(exc, "payload", {}) or {}).get("remainingUsd")
|
||||||
|
if remaining is not None:
|
||||||
|
print(f" 🔴 Monthly spend cap reached — ${remaining} headroom left.")
|
||||||
|
else:
|
||||||
|
print(" 🔴 Monthly spend cap reached.")
|
||||||
|
elif isinstance(exc, BillingRateLimited):
|
||||||
|
wait = getattr(exc, "retry_after", None)
|
||||||
|
mins = f" (try again in ~{max(1, round(wait / 60))} min)" if wait else ""
|
||||||
|
print(f" 🟡 Too many charges right now{mins}. This isn't a payment failure.")
|
||||||
|
else:
|
||||||
|
print(f" 🔴 {exc}")
|
||||||
|
if portal_url:
|
||||||
|
print(f" Portal: {portal_url}")
|
||||||
|
|
||||||
|
def _billing_handle_scope_required(self, state):
|
||||||
|
"""403 insufficient_scope → lazy step-up re-auth (plan D-A)."""
|
||||||
|
print()
|
||||||
|
print(" 💳 Terminal billing needs an extra permission (billing:manage).")
|
||||||
|
_scope_msg = (
|
||||||
|
"An org admin/owner must tick \"Allow terminal billing\" during "
|
||||||
|
"login."
|
||||||
|
)
|
||||||
|
_cprint(f" {_d(_scope_msg)}")
|
||||||
|
if not getattr(self, "_app", None):
|
||||||
|
print(" Run `hermes portal` and approve terminal billing, then retry.")
|
||||||
|
return
|
||||||
|
confirm_choices = [
|
||||||
|
("yes", "Re-authorize now", "open the portal to grant billing access"),
|
||||||
|
("no", "Not now", "cancel"),
|
||||||
|
]
|
||||||
|
raw = self._prompt_text_input_modal(
|
||||||
|
title="💳 Grant terminal billing access?",
|
||||||
|
detail="Opens the portal device-authorization page.",
|
||||||
|
choices=confirm_choices,
|
||||||
|
)
|
||||||
|
choice = self._normalize_slash_confirm_choice(raw, confirm_choices)
|
||||||
|
if choice != "yes":
|
||||||
|
print(" 🟡 Cancelled.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from hermes_cli.auth import step_up_nous_billing_scope
|
||||||
|
|
||||||
|
granted = step_up_nous_billing_scope(open_browser=True)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" 🔴 Re-authorization failed: {exc}")
|
||||||
|
return
|
||||||
|
if granted:
|
||||||
|
print(" ✅ Billing permission granted.")
|
||||||
|
# Step-up only grants the billing:manage TOKEN scope; the ORG
|
||||||
|
# kill-switch (cli_billing_enabled) is a separate gate. Re-fetch
|
||||||
|
# /state so we don't over-promise when a charge would still hit
|
||||||
|
# cli_billing_disabled.
|
||||||
|
from agent.billing_view import build_billing_state
|
||||||
|
|
||||||
|
fresh = build_billing_state()
|
||||||
|
if fresh.logged_in and fresh.cli_billing_enabled:
|
||||||
|
print(" Run /billing buy again to continue.")
|
||||||
|
else:
|
||||||
|
print(" 🟡 Permission granted, but terminal billing is still turned "
|
||||||
|
"off for this org. Enable it in the portal, then run /billing again.")
|
||||||
|
self._billing_portal_hint(fresh)
|
||||||
|
else:
|
||||||
|
print(" 🟡 Terminal billing was not granted (an admin must tick the box).")
|
||||||
|
|
||||||
|
def _billing_auto_reload_flow(self, state):
|
||||||
|
"""Screen 4 — auto-reload config: threshold + reload-to → PATCH.
|
||||||
|
|
||||||
|
Prefills the current values from ``state.auto_reload``. Validates both
|
||||||
|
amounts (2dp, within bounds, ``reload_to > threshold``). When auto-reload
|
||||||
|
is already on, offers a "Turn off" path (PATCH ``enabled:false``).
|
||||||
|
"""
|
||||||
|
from agent.billing_view import format_money, validate_charge_amount
|
||||||
|
|
||||||
|
if not self._billing_require_admin(state):
|
||||||
|
return
|
||||||
|
|
||||||
|
card = state.card
|
||||||
|
ar = state.auto_reload
|
||||||
|
currently_on = bool(ar and ar.enabled)
|
||||||
|
|
||||||
|
print()
|
||||||
|
_cprint(f" 💳 {_b('Auto-reload')}")
|
||||||
|
print(f" {'─' * 41}")
|
||||||
|
_cprint(f" {_d('Automatically buy more credits when your balance is low.')}")
|
||||||
|
if card:
|
||||||
|
print(f" Card on file: {card.masked}")
|
||||||
|
else:
|
||||||
|
print(" No saved card — set one up on the portal first.")
|
||||||
|
self._billing_portal_hint(state)
|
||||||
|
return
|
||||||
|
if currently_on:
|
||||||
|
print(
|
||||||
|
f" Currently: below {format_money(ar.threshold_usd)} → "
|
||||||
|
f"reload to {format_money(ar.reload_to_usd)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not getattr(self, "_app", None):
|
||||||
|
print(" Run in the interactive CLI to configure auto-reload.")
|
||||||
|
self._billing_portal_hint(state)
|
||||||
|
return
|
||||||
|
|
||||||
|
# When already enabled, let the user turn it off without re-entering values.
|
||||||
|
if currently_on:
|
||||||
|
top_choices = [
|
||||||
|
("edit", "Edit thresholds", "change when / how much to reload"),
|
||||||
|
("off", "Turn off", "disable auto-reload"),
|
||||||
|
("cancel", "Cancel", "do nothing"),
|
||||||
|
]
|
||||||
|
raw = self._prompt_text_input_modal(
|
||||||
|
title="💳 Auto-reload",
|
||||||
|
detail=(
|
||||||
|
f"On — below {format_money(ar.threshold_usd)} → "
|
||||||
|
f"reload to {format_money(ar.reload_to_usd)}"
|
||||||
|
),
|
||||||
|
choices=top_choices,
|
||||||
|
)
|
||||||
|
top = self._normalize_slash_confirm_choice(raw, top_choices)
|
||||||
|
if top == "off":
|
||||||
|
self._billing_auto_reload_disable(state)
|
||||||
|
return
|
||||||
|
if top != "edit":
|
||||||
|
print(" 🟡 Cancelled.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Field 1 — threshold (prefilled when editing an existing config).
|
||||||
|
cur_thr = format_money(ar.threshold_usd) if currently_on else None
|
||||||
|
thr_prompt = " When balance falls below (USD)"
|
||||||
|
thr_prompt += f" [{cur_thr}]: " if cur_thr else ": "
|
||||||
|
threshold_raw = self._prompt_text_input(thr_prompt)
|
||||||
|
if threshold_raw is None:
|
||||||
|
# None = cancelled (e.g. slash-worker can't prompt off-thread).
|
||||||
|
print(" 🟡 Cancelled.")
|
||||||
|
return
|
||||||
|
if not (threshold_raw or "").strip() and currently_on:
|
||||||
|
threshold_amt = ar.threshold_usd # keep current value on empty input
|
||||||
|
else:
|
||||||
|
tv = validate_charge_amount(
|
||||||
|
threshold_raw or "", min_usd=state.min_usd, max_usd=state.max_usd
|
||||||
|
)
|
||||||
|
if not tv.ok or tv.amount is None:
|
||||||
|
print(f" 🔴 {tv.error}")
|
||||||
|
return
|
||||||
|
threshold_amt = tv.amount
|
||||||
|
|
||||||
|
# Field 2 — reload-to (prefilled when editing an existing config).
|
||||||
|
cur_rel = format_money(ar.reload_to_usd) if currently_on else None
|
||||||
|
rel_prompt = " Reload balance to (USD)"
|
||||||
|
rel_prompt += f" [{cur_rel}]: " if cur_rel else ": "
|
||||||
|
reload_raw = self._prompt_text_input(rel_prompt)
|
||||||
|
if reload_raw is None:
|
||||||
|
print(" 🟡 Cancelled.")
|
||||||
|
return
|
||||||
|
if not (reload_raw or "").strip() and currently_on:
|
||||||
|
reload_amt = ar.reload_to_usd # keep current value on empty input
|
||||||
|
else:
|
||||||
|
rv = validate_charge_amount(
|
||||||
|
reload_raw or "", min_usd=state.min_usd, max_usd=state.max_usd
|
||||||
|
)
|
||||||
|
if not rv.ok or rv.amount is None:
|
||||||
|
print(f" 🔴 {rv.error}")
|
||||||
|
return
|
||||||
|
reload_amt = rv.amount
|
||||||
|
|
||||||
|
if reload_amt is None or threshold_amt is None or reload_amt <= threshold_amt:
|
||||||
|
print(" 🔴 Reload-to amount must be greater than the threshold.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print()
|
||||||
|
_ar_consent = (
|
||||||
|
f"By confirming, you authorize Nous Research to charge {card.masked} "
|
||||||
|
f"whenever your balance reaches {format_money(threshold_amt)}. "
|
||||||
|
f"Turn off any time here or on the portal."
|
||||||
|
)
|
||||||
|
_cprint(f" {_d(_ar_consent)}")
|
||||||
|
confirm_choices = [
|
||||||
|
("agree", "Agree and turn on", "enable auto-reload"),
|
||||||
|
("cancel", "Cancel", "do nothing"),
|
||||||
|
]
|
||||||
|
raw = self._prompt_text_input_modal(
|
||||||
|
title="💳 Turn on auto-reload?",
|
||||||
|
detail=f"Below {format_money(threshold_amt)} → reload to {format_money(reload_amt)}",
|
||||||
|
choices=confirm_choices,
|
||||||
|
)
|
||||||
|
choice = self._normalize_slash_confirm_choice(raw, confirm_choices)
|
||||||
|
if choice != "agree":
|
||||||
|
print(" 🟡 Cancelled.")
|
||||||
|
return
|
||||||
|
|
||||||
|
from hermes_cli.nous_billing import (
|
||||||
|
BillingError,
|
||||||
|
BillingScopeRequired,
|
||||||
|
patch_auto_top_up,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
patch_auto_top_up(
|
||||||
|
enabled=True, threshold=float(threshold_amt), top_up_amount=float(reload_amt)
|
||||||
|
)
|
||||||
|
except BillingScopeRequired:
|
||||||
|
self._billing_handle_scope_required(state)
|
||||||
|
return
|
||||||
|
except BillingError as exc:
|
||||||
|
self._billing_render_charge_error(state, exc)
|
||||||
|
return
|
||||||
|
print(f" ✅ Auto-reload on: below {format_money(threshold_amt)} → "
|
||||||
|
f"reload to {format_money(reload_amt)}.")
|
||||||
|
|
||||||
|
def _billing_auto_reload_disable(self, state):
|
||||||
|
"""Turn off auto-reload (PATCH ``enabled:false``).
|
||||||
|
|
||||||
|
The endpoint requires ``threshold``/``topUpAmount`` in the body even when
|
||||||
|
disabling, so we echo back the current values (falling back to 0).
|
||||||
|
"""
|
||||||
|
from hermes_cli.nous_billing import (
|
||||||
|
BillingError,
|
||||||
|
BillingScopeRequired,
|
||||||
|
patch_auto_top_up,
|
||||||
|
)
|
||||||
|
|
||||||
|
ar = state.auto_reload
|
||||||
|
thr = float(ar.threshold_usd) if ar and ar.threshold_usd is not None else 0.0
|
||||||
|
rel = float(ar.reload_to_usd) if ar and ar.reload_to_usd is not None else 0.0
|
||||||
|
try:
|
||||||
|
patch_auto_top_up(enabled=False, threshold=thr, top_up_amount=rel)
|
||||||
|
except BillingScopeRequired:
|
||||||
|
self._billing_handle_scope_required(state)
|
||||||
|
return
|
||||||
|
except BillingError as exc:
|
||||||
|
self._billing_render_charge_error(state, exc)
|
||||||
|
return
|
||||||
|
print(" ✅ Auto-reload turned off.")
|
||||||
|
|
||||||
|
def _billing_limit_screen(self, state):
|
||||||
|
"""Screen 5 — monthly spend limit (read-only; cap is portal-only)."""
|
||||||
|
from agent.billing_view import format_money
|
||||||
|
|
||||||
|
print()
|
||||||
|
_cprint(f" 💳 {_b('Monthly spend limit')}")
|
||||||
|
print(f" {'─' * 41}")
|
||||||
|
cap = state.monthly_cap
|
||||||
|
if cap is None or cap.limit_usd is None:
|
||||||
|
_cprint(f" {_d('No monthly cap visible (managed on the portal).')}")
|
||||||
|
else:
|
||||||
|
spent = format_money(cap.spent_this_month_usd)
|
||||||
|
limit = format_money(cap.limit_usd)
|
||||||
|
ceiling = " (default ceiling)" if cap.is_default_ceiling else ""
|
||||||
|
print(f" {spent} of {limit} used this month{ceiling}")
|
||||||
|
_limit_note = (
|
||||||
|
"The monthly limit is set on the portal — the terminal shows "
|
||||||
|
"it read-only."
|
||||||
|
)
|
||||||
|
_cprint(f" {_d(_limit_note)}")
|
||||||
|
self._billing_portal_hint(state)
|
||||||
|
|
||||||
def _show_insights(self, command: str = "/insights"):
|
def _show_insights(self, command: str = "/insights"):
|
||||||
"""Show usage insights and analytics from session history."""
|
"""Show usage insights and analytics from session history."""
|
||||||
# Parse optional --days flag
|
# Parse optional --days flag
|
||||||
|
|||||||
@ -28,14 +28,13 @@ as_hermes() { [ "$(id -u)" = 0 ] || { "$@"; return; }; s6-setuidgid hermes "$@";
|
|||||||
# arbitrary host UID (the classic `--user $(id -u):$(id -g)` invocation people
|
# arbitrary host UID (the classic `--user $(id -u):$(id -g)` invocation people
|
||||||
# used in the tini era to make container-written files match their host user).
|
# used in the tini era to make container-written files match their host user).
|
||||||
#
|
#
|
||||||
# Under s6-overlay this no longer works: the bootstrap (UID remap, volume +
|
# Under s6-overlay this no longer works: the bootstrap (UID remap, data-volume
|
||||||
# build-tree chown, config seeding) all require root, and they're skipped when
|
# ownership, config seeding) requires root, and it is skipped when the container
|
||||||
# the container starts non-root. The baked image trees (/opt/data, /opt/hermes/
|
# starts non-root. The baked install tree under /opt/hermes is intentionally
|
||||||
# .venv, ui-tui, node_modules) stay owned by the hermes build UID (10000), so an
|
# root-owned and non-writable; mutable runtime state must live under
|
||||||
# arbitrary `--user` UID can't write them — the runtime then fails with EACCES
|
# $HERMES_HOME. An arbitrary `--user` UID therefore cannot repair or populate
|
||||||
# on a bind mount, or hard-crashes on a named volume (Docker initialises the
|
# the data volume, and startup fails with EACCES. See #34837 for the
|
||||||
# volume from the image as UID 10000, and the non-root start can't even `cd`
|
# supervision-tree side of this.
|
||||||
# into $HERMES_HOME). See #34837 for the supervision-tree side of this.
|
|
||||||
#
|
#
|
||||||
# The supported way to match host-side ownership is to start as root (the image
|
# The supported way to match host-side ownership is to start as root (the image
|
||||||
# default) and pass HERMES_UID/HERMES_GID — or the PUID/PGID aliases — which the
|
# default) and pass HERMES_UID/HERMES_GID — or the PUID/PGID aliases — which the
|
||||||
@ -53,9 +52,10 @@ if [ "$cur_uid" != 0 ] && [ "$cur_uid" != "$(id -u hermes)" ]; then
|
|||||||
[stage2] ERROR: container started with --user $cur_uid (an arbitrary, non-hermes UID).
|
[stage2] ERROR: container started with --user $cur_uid (an arbitrary, non-hermes UID).
|
||||||
|
|
||||||
This is not supported under the s6-overlay image. The container bootstrap
|
This is not supported under the s6-overlay image. The container bootstrap
|
||||||
(UID remap, volume ownership, dependency installs) needs to start as root,
|
(UID remap, data-volume ownership, config seeding) needs to start as root,
|
||||||
and the baked image directories are owned by the hermes user (UID $(id -u hermes)),
|
and the baked /opt/hermes install tree is intentionally root-owned and
|
||||||
so a pinned --user UID cannot write them — startup will fail.
|
non-writable, so a pinned --user UID cannot repair startup state — startup
|
||||||
|
will fail.
|
||||||
|
|
||||||
To make container-written files match your HOST user, DON'T use --user.
|
To make container-written files match your HOST user, DON'T use --user.
|
||||||
Start the container as root (the default) and pass your host UID/GID instead:
|
Start the container as root (the default) and pass your host UID/GID instead:
|
||||||
@ -207,49 +207,13 @@ if [ "$needs_chown" = true ]; then
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Fix ownership of build trees under $INSTALL_DIR ---
|
# --- Immutable install tree ---
|
||||||
# Hermes-owned trees under $INSTALL_DIR must be re-chowned whenever the
|
# Do not chown runtime code or dependency trees under $INSTALL_DIR back to the
|
||||||
# runtime hermes UID no longer owns them — otherwise:
|
# hermes user. Hosted/container instances keep mutable state under
|
||||||
# - .venv: lazy_deps.py cannot install platform packages (discord.py,
|
# $HERMES_HOME (/opt/data) and run with PYTHONDONTWRITEBYTECODE plus
|
||||||
# telegram, slack, etc.) with EACCES (#15012, #21100)
|
# HERMES_DISABLE_LAZY_INSTALLS=1. Keeping /opt/hermes root-owned and
|
||||||
# - ui-tui: esbuild rebuilds dist/entry.js on every TUI launch (when
|
# non-writable prevents an agent session from self-modifying the installed
|
||||||
# the source mtime is newer than dist/ or when HERMES_TUI_FORCE_BUILD
|
# source, venv, TUI bundle, or node_modules and bricking the gateway.
|
||||||
# is set) and writes to ui-tui/dist/. Without this chown the new
|
|
||||||
# hermes UID can't write the build output (#28851).
|
|
||||||
# - gateway: Python writes __pycache__ and runtime artifacts beneath the
|
|
||||||
# gateway package on first import. After a UID remap those source-owned
|
|
||||||
# paths still belong to the build-time UID (10000) unless repaired here,
|
|
||||||
# producing EACCES for the supervised gateway (#27221).
|
|
||||||
# - node_modules: root-level dependencies (puppeteer, web tooling)
|
|
||||||
# that runtime code may walk/update.
|
|
||||||
# The set mirrors the build-time `chown -R hermes:hermes` line in the
|
|
||||||
# Dockerfile — keep them in sync if the Dockerfile chown set changes.
|
|
||||||
# These are under $INSTALL_DIR (not $HERMES_HOME), so the bind-mount
|
|
||||||
# concern doesn't apply — recursive is fine.
|
|
||||||
#
|
|
||||||
# This MUST be gated independently of the $HERMES_HOME ownership check
|
|
||||||
# above. `usermod -u <new> hermes` re-chowns the hermes home dir
|
|
||||||
# ($HERMES_HOME == /opt/data) to the new UID as a side effect, so after a
|
|
||||||
# HERMES_UID/PUID remap `stat $HERMES_HOME` always already matches the new
|
|
||||||
# UID and `needs_chown` is false — but the build trees under /opt/hermes
|
|
||||||
# are NOT touched by usermod and remain owned by the build-time UID
|
|
||||||
# (10000). Gating them on $HERMES_HOME ownership (as #35027 did) silently
|
|
||||||
# skipped this chown on the common PUID/NAS path, regressing lazy installs
|
|
||||||
# and TUI rebuilds. Probe the build trees directly instead: chown only
|
|
||||||
# when the venv is not already owned by the runtime hermes UID. Idempotent
|
|
||||||
# and skips the expensive recursive chown on every restart once ownership
|
|
||||||
# is settled.
|
|
||||||
venv_owner=$(stat -c %u "$INSTALL_DIR/.venv" 2>/dev/null || echo "")
|
|
||||||
if [ -n "$venv_owner" ] && [ "$venv_owner" != "$actual_hermes_uid" ]; then
|
|
||||||
echo "[stage2] Fixing ownership of build trees under $INSTALL_DIR to hermes ($actual_hermes_uid)"
|
|
||||||
chown -R hermes:hermes \
|
|
||||||
"$INSTALL_DIR/.venv" \
|
|
||||||
"$INSTALL_DIR/ui-tui" \
|
|
||||||
"$INSTALL_DIR/gateway" \
|
|
||||||
"$INSTALL_DIR/node_modules" \
|
|
||||||
2>/dev/null || \
|
|
||||||
echo "[stage2] Warning: chown of build trees failed (rootless container?) — continuing"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Always reset ownership of $HERMES_HOME/profiles to hermes on every
|
# Always reset ownership of $HERMES_HOME/profiles to hermes on every
|
||||||
# boot. Profile dirs and files can land owned by root when commands
|
# boot. Profile dirs and files can land owned by root when commands
|
||||||
@ -327,13 +291,25 @@ as_hermes mkdir -p \
|
|||||||
"$HERMES_HOME/pairing" \
|
"$HERMES_HOME/pairing" \
|
||||||
"$HERMES_HOME/platforms/pairing"
|
"$HERMES_HOME/platforms/pairing"
|
||||||
|
|
||||||
# --- Install-method stamp (read by detect_install_method() in hermes status) ---
|
# --- Install-method stamp ---
|
||||||
# Preserved from the tini-era entrypoint (PR #27843). Must be written as
|
# The 'docker' stamp is baked into the immutable install tree at
|
||||||
# the hermes user so ownership matches the file's documented owner.
|
# /opt/hermes/.install_method (see Dockerfile), NOT written here into
|
||||||
# tee is invoked directly via s6-setuidgid (no `sh -c` wrapper) for the
|
# $HERMES_HOME. detect_install_method() reads the code-scoped stamp first.
|
||||||
# same shell-metacharacter safety described above.
|
#
|
||||||
printf 'docker\n' | as_hermes tee "$HERMES_HOME/.install_method" >/dev/null \
|
# Why we no longer stamp $HERMES_HOME: it is a shared DATA volume, commonly
|
||||||
|| true
|
# bind-mounted from the host (~/.hermes:/opt/data) and sometimes shared with a
|
||||||
|
# host-side Desktop/CLI install. Stamping 'docker' here clobbered that host
|
||||||
|
# install's marker, so its in-app updater read 'docker' and refused to run
|
||||||
|
# 'hermes update'. To heal homes already poisoned by older images, remove a
|
||||||
|
# stale 'docker' stamp from $HERMES_HOME if one is present (the host install's
|
||||||
|
# own installer re-creates its code-scoped stamp; a genuine container relies on
|
||||||
|
# the baked /opt/hermes stamp, so deleting the data-dir copy is safe).
|
||||||
|
if [ -f "$HERMES_HOME/.install_method" ]; then
|
||||||
|
stamped="$(tr -d '[:space:]' < "$HERMES_HOME/.install_method" 2>/dev/null || true)"
|
||||||
|
if [ "$stamped" = "docker" ]; then
|
||||||
|
rm -f "$HERMES_HOME/.install_method" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# --- Seed config files (only on first boot) ---
|
# --- Seed config files (only on first boot) ---
|
||||||
seed_one() {
|
seed_one() {
|
||||||
|
|||||||
260
docs/relay-connector-contract.md
Normal file
260
docs/relay-connector-contract.md
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
# Relay ↔ Connector Contract (v1, EXPERIMENTAL)
|
||||||
|
|
||||||
|
> **Status:** EXPERIMENTAL. This contract MAY CHANGE without a deprecation
|
||||||
|
> cycle until at least two real Class-1 platforms (Discord + Telegram) have
|
||||||
|
> validated it. Evolution during the experimental phase is **additive-only**,
|
||||||
|
> gated by `contract_version`. A breaking change updates both repos in lockstep.
|
||||||
|
|
||||||
|
This document is the formal interface between the **Hermes gateway** (Python,
|
||||||
|
`gateway/relay/`) and the **connector** (Node/TypeScript,
|
||||||
|
`NousResearch/gateway-gateway`). The connector implementer's first action is to
|
||||||
|
read this file.
|
||||||
|
|
||||||
|
The gateway runs a generic `RelayAdapter` that dials **out** to the connector,
|
||||||
|
receives a `CapabilityDescriptor` at handshake, then exchanges normalized
|
||||||
|
`MessageEvent`s (inbound) and actions (outbound) over a per-turn bidirectional
|
||||||
|
WebSocket. The gateway never learns which concrete platform is fronting it; the
|
||||||
|
connector owns all platform-specific socket/identity logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Handshake
|
||||||
|
|
||||||
|
1. Gateway opens the transport (`connect`).
|
||||||
|
2. Gateway calls `handshake()`; connector returns a `CapabilityDescriptor`
|
||||||
|
(section 2) describing the platform this adapter instance fronts.
|
||||||
|
3. Gateway configures the adapter from the descriptor (char limit, length unit,
|
||||||
|
draft/edit/thread/markdown capabilities) and registers an inbound handler.
|
||||||
|
4. Connector then streams inbound events and accepts outbound actions.
|
||||||
|
|
||||||
|
`contract_version` (currently `1`) is carried in the descriptor. The gateway
|
||||||
|
ignores unknown descriptor fields (forward-compat) and fills missing optional
|
||||||
|
fields from defaults.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. CapabilityDescriptor (handshake payload)
|
||||||
|
|
||||||
|
JSON object. Source of truth: `gateway/relay/descriptor.py`.
|
||||||
|
|
||||||
|
| Field | Type | Required | Meaning |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `contract_version` | int | yes | Contract version (additive-only within a version). |
|
||||||
|
| `platform` | string | yes | Platform name (e.g. `"discord"`, `"telegram"`). |
|
||||||
|
| `label` | string | yes | Human-readable label. |
|
||||||
|
| `max_message_length` | int | yes | Char limit; gateway exposes as `MAX_MESSAGE_LENGTH`. 0 → treat as 4096. |
|
||||||
|
| `supports_draft_streaming` | bool | yes | Native draft-streaming preview support. |
|
||||||
|
| `supports_edit` | bool | yes | Edit-based streaming possible; if false, consumer degrades to one-message-per-segment. |
|
||||||
|
| `supports_threads` | bool | yes | `create_handoff_thread` capability. |
|
||||||
|
| `markdown_dialect` | string | yes | `"plain"`, `"markdown_v2"`, `"discord"`, … (drives `supports_code_blocks`). |
|
||||||
|
| `len_unit` | string | yes | `"chars"` (builtin len) or `"utf16"` (Telegram UTF-16 code units). |
|
||||||
|
| `emoji` | string | no | Display emoji (default 🔌). |
|
||||||
|
| `platform_hint` | string | no | System-prompt platform hint. |
|
||||||
|
| `pii_safe` | bool | no | Redact PII in session descriptions. |
|
||||||
|
|
||||||
|
Most fields are a projection of the gateway's existing `PlatformEntry`; the
|
||||||
|
runtime-only fields (`len_unit`, `supports_*`, `markdown_dialect`) come from the
|
||||||
|
live platform adapter's capability methods.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Inbound: `MessageEvent` envelope
|
||||||
|
|
||||||
|
The connector normalizes each platform wire event into a `MessageEvent`
|
||||||
|
(`gateway/platforms/base.py`) and delivers it to the gateway. **Inbound is
|
||||||
|
delivered over a signed HTTP POST, not the outbound `/relay` WebSocket** (see
|
||||||
|
the transport note below). The gateway keys the session via `build_session_key()`
|
||||||
|
from the embedded `SessionSource` — so populating the right discriminators is
|
||||||
|
the single highest-correctness responsibility of the connector.
|
||||||
|
|
||||||
|
### Inbound transport (signed HTTP POST, not the outbound WS)
|
||||||
|
|
||||||
|
The gateway dials **out** to the connector's `/relay` WebSocket for the
|
||||||
|
handshake + outbound actions (§4) + its own `/stop` egress (§5). Inbound,
|
||||||
|
however, is delivered the other way: the connector **POSTs** the normalized
|
||||||
|
event to the gateway's inbound endpoint (`HttpGatewayDelivery` on the connector;
|
||||||
|
`gateway/relay/inbound_receiver.py` on the gateway). The reason is
|
||||||
|
multi-instance: the connector instance that owns a platform's socket (and thus
|
||||||
|
produces inbound events) is generally **not** the instance a given gateway
|
||||||
|
dialed its outbound WS into, so inbound must target a tenant **endpoint** (which
|
||||||
|
may load-balance across gateway instances) rather than ride one gateway's
|
||||||
|
outbound socket. Each delivery is HMAC-signed with the per-tenant **delivery
|
||||||
|
key** (§6.1); the gateway verifies the signature over the exact raw bytes before
|
||||||
|
accepting the event. Two POST targets:
|
||||||
|
|
||||||
|
- `POST {gatewayEndpoint}` → `{"type":"message", "event": <MessageEvent>}`
|
||||||
|
- `POST {gatewayEndpoint}/interrupt` → `{"type":"interrupt", "session_key", "reason"?}` (§5)
|
||||||
|
|
||||||
|
> An earlier draft of this contract delivered inbound over the WS `inbound`
|
||||||
|
> frame. That only works single-instance and predates the multi-instance
|
||||||
|
> socket-ownership + channel-auth model; the signed-HTTP path above is the
|
||||||
|
> shipped design.
|
||||||
|
|
||||||
|
### SessionSource fields (the wire surface)
|
||||||
|
|
||||||
|
Source of truth: `SessionSource.to_dict()` in `gateway/session.py`. These are
|
||||||
|
every key the gateway accepts on the wire. `platform`, `chat_id`, `chat_type`,
|
||||||
|
`user_id`, `user_name`, `thread_id`, `chat_name`, and `chat_topic` are always
|
||||||
|
present (may be `null`); the rest are included only when set.
|
||||||
|
|
||||||
|
| Field | Type | Always sent | Meaning |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `platform` | string | yes | Platform name (matches the descriptor's `platform`). |
|
||||||
|
| `chat_id` | string | yes | Primary conversation id (channel/chat). Session-key discriminator. |
|
||||||
|
| `chat_type` | string | yes | `dm` / `group` / `channel` / `thread` / `forum`. |
|
||||||
|
| `chat_name` | string\|null | yes | Human-readable chat name. |
|
||||||
|
| `user_id` | string\|null | yes | Message author id. Session-key discriminator. |
|
||||||
|
| `user_name` | string\|null | yes | Author display name. |
|
||||||
|
| `thread_id` | string\|null | yes | Thread/forum-topic id when in a thread. Session-key discriminator. |
|
||||||
|
| `chat_topic` | string\|null | yes | Channel topic/description (Discord, Slack). |
|
||||||
|
| `user_id_alt` | string | no | Platform-specific stable alt id (Signal UUID, Feishu union_id). |
|
||||||
|
| `chat_id_alt` | string | no | Alternate chat id (e.g. Signal group internal id). |
|
||||||
|
| `guild_id` | string | no | Discord guild / Slack workspace / Matrix server scope. **REQUIRED for Discord server isolation.** Session-key discriminator. |
|
||||||
|
| `parent_chat_id` | string | no | Parent channel when `chat_id` refers to a thread. |
|
||||||
|
| `message_id` | string | no | Id of the triggering message (for pin/reply/react). |
|
||||||
|
|
||||||
|
> `is_bot` (author-is-a-bot/webhook classification) exists on the gateway-side
|
||||||
|
> dataclass but is **intentionally NOT on the wire** in v1 — it is not part of
|
||||||
|
> `to_dict()`. Do not add it to the connector's `SessionSource` until it is
|
||||||
|
> first added here and to `to_dict()` (additive bump).
|
||||||
|
|
||||||
|
### SessionSource discriminators per platform
|
||||||
|
|
||||||
|
| Platform | chat_id | chat_type | user_id | thread_id | guild_id |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| **Discord** | channel id | `dm`/`group`/`thread` | author id | thread channel id (threads) | **guild id** (REQUIRED for server isolation) |
|
||||||
|
| **Telegram** | chat id | `dm`/`group`/`forum` | from id | forum topic id (forums) | — |
|
||||||
|
|
||||||
|
**Get Discord's `guild_id` wrong and two servers collide into one session.**
|
||||||
|
This is the #1 High-severity risk. The gateway's `build_session_key()` is the
|
||||||
|
conformance oracle: for a given `SessionSource`, the connector's normalization
|
||||||
|
must produce the same key the Python adapter would. (The Phase-1 stub tests
|
||||||
|
assert known-input → known-key.)
|
||||||
|
|
||||||
|
### Bot identity vs tenant (single-bot consolidation, Appendix A)
|
||||||
|
|
||||||
|
The envelope carries the **originating bot identity** as a field **distinct from
|
||||||
|
tenant**. Tenant is resolved from the event's own discriminator (Discord
|
||||||
|
`guild_id`, Telegram `chat_id`, webhook path/subdomain) — **never** from which
|
||||||
|
token/socket/process delivered it. This keeps one shared bot able to front many
|
||||||
|
tenants (Phase 6) without overloading an existing field.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Outbound: action set
|
||||||
|
|
||||||
|
The gateway calls the transport with action dicts. Source of truth:
|
||||||
|
`gateway/relay/transport.py` + `gateway/relay/adapter.py`.
|
||||||
|
|
||||||
|
| `op` | Fields | Result |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `send` | `chat_id`, `content`, `reply_to?`, `metadata?` | `{success: bool, message_id?, error?}` |
|
||||||
|
| `edit` | `chat_id`, `message_id`, `content`, `metadata?` | `{success: bool, error?}` |
|
||||||
|
| `typing` | `chat_id` | `{success: bool}` |
|
||||||
|
| `follow_up` | `session_key`, `kind`, `content`, `metadata?` | `{success: bool, message_id?, error?}` |
|
||||||
|
|
||||||
|
`get_chat_info(chat_id)` is a separate proxied call returning at least
|
||||||
|
`{name, type}`. Media actions follow the same envelope shape (deferred to a
|
||||||
|
later contract revision; additive).
|
||||||
|
|
||||||
|
**`follow_up` (A2 capability action).** Some inbound payloads carry a credential
|
||||||
|
that acts on the **shared** bot identity (e.g. a Discord interaction follow-up
|
||||||
|
token). Per §6 the connector strips that at the edge and binds it in its
|
||||||
|
capability vault keyed by the session; it **never reaches the gateway**. To use
|
||||||
|
it, the gateway issues `follow_up` naming the **session it is already in**
|
||||||
|
(`session_key`) plus the capability `kind` (e.g. `discord.interaction_token`) —
|
||||||
|
**never a token**. The connector resolves the real value from its vault,
|
||||||
|
enforces the tenant match (tenant B can never wield tenant A's capability), and
|
||||||
|
egresses. `success: false` when the capability is absent/expired or the tenant
|
||||||
|
doesn't match — the gateway has nothing to retry with, by design (a leaked
|
||||||
|
gateway holds zero capability material). Source of truth:
|
||||||
|
`gateway/relay/transport.py` (`send_follow_up`) + `gateway/relay/adapter.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Interrupt (`/stop`) routing
|
||||||
|
|
||||||
|
- **Gateway → connector:** `send_interrupt(session_key, reason?)` egresses a
|
||||||
|
mid-turn `/stop` over the outbound WS. The connector MUST forward it to the
|
||||||
|
gateway instance running that `session_key` (the routing invariant).
|
||||||
|
- **Connector → gateway:** an inbound interrupt for a `session_key` is delivered
|
||||||
|
as a **signed HTTP POST** to `{gatewayEndpoint}/interrupt` (§3 transport note),
|
||||||
|
and bridged by the adapter's `on_interrupt(session_key, chat_id)` into the
|
||||||
|
existing per-session interrupt mechanism, cancelling exactly that turn
|
||||||
|
(siblings untouched).
|
||||||
|
|
||||||
|
The gateway→connector `/stop` rides the outbound WS; the connector→gateway
|
||||||
|
interrupt rides the same signed-HTTP inbound path as a normalized event.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Trust boundary & signed-body handling (A2)
|
||||||
|
|
||||||
|
**The connector is the sole crypto/identity boundary. The gateway re-validates
|
||||||
|
nothing.**
|
||||||
|
|
||||||
|
Webhook signatures (Discord ed25519, Twilio HMAC, WeCom BizMsgCrypt) are
|
||||||
|
computed over exact raw bytes, and some payloads are *encrypted* with a shared
|
||||||
|
secret. The connector fronts a **shared** bot for many tenants and holds every
|
||||||
|
tenant's platform secrets, so it:
|
||||||
|
|
||||||
|
- **verifies / decrypts at the edge** (the only place the secrets live),
|
||||||
|
- **normalizes** the payload into a tenant-scoped `MessageEvent` (§3),
|
||||||
|
- **strips any shared-identity capability** out of the payload and binds it in
|
||||||
|
its capability vault, keyed by the session (see §4 `follow_up`),
|
||||||
|
- **forwards only the sanitized `MessageEvent`** — never the raw signed body.
|
||||||
|
|
||||||
|
The gateway therefore performs **no** platform signature/crypto verification on
|
||||||
|
the relay path; it trusts the normalized event. This is an enforced invariant on
|
||||||
|
the gateway side (`tests/gateway/relay/test_relay_sheds_crypto.py`: the relay
|
||||||
|
package imports/calls no platform-crypto).
|
||||||
|
|
||||||
|
**Why not "forward the signed body byte-for-byte so the gateway re-validates"?**
|
||||||
|
That earlier model is incoherent under an untrusted, disposable tenant gateway:
|
||||||
|
|
||||||
|
- Re-validating Twilio HMAC / WeCom crypto would require handing the gateway the
|
||||||
|
**shared signing secret** — which is itself the leak, and on a shared bot it's
|
||||||
|
a *cross-tenant* leak.
|
||||||
|
- WeCom payloads are encrypted with the shared secret; the connector must decrypt
|
||||||
|
at the edge just to route, so forwarding ciphertext would again require giving
|
||||||
|
the gateway the secret.
|
||||||
|
- A Discord interaction token lives **inside** the signed JSON body — you cannot
|
||||||
|
both preserve the bytes and strip the credential; they are the same bytes.
|
||||||
|
|
||||||
|
So byte-preservation is abandoned deliberately: the connector re-serializes the
|
||||||
|
sanitized event and the gateway trusts it. This also unifies the passthrough and
|
||||||
|
relay planes — both are "verify at the edge → emit a normalized event," differing
|
||||||
|
only in transport. See `docs/capability-trust-boundary.md` (connector repo:
|
||||||
|
`gateway-gateway`) for the full A2 rationale and the connector-side vault.
|
||||||
|
|
||||||
|
### 6.1 Channel authentication (the connector⇄gateway link itself)
|
||||||
|
|
||||||
|
A2 makes the connector the sole holder of platform secrets while the gateway may
|
||||||
|
be **customer-managed and internet-exposed**, so the connector⇄gateway channel
|
||||||
|
is itself authenticated. The gateway holds two enrollment-issued credentials
|
||||||
|
(`hermes gateway enroll` → connector `/relay/enroll`): a **per-gateway secret**
|
||||||
|
and a **per-tenant delivery key**. Both are HMAC-SHA256 schemes with a
|
||||||
|
multi-secret rotation verify list (gateway side: `gateway/relay/auth.py`;
|
||||||
|
connector side: `src/core/relayAuthToken.ts` + `src/core/deliverySigning.ts`).
|
||||||
|
|
||||||
|
| Leg | Credential | Mechanism |
|
||||||
|
|-----|-----------|-----------|
|
||||||
|
| Gateway → connector WS upgrade | per-gateway secret | An `Authorization` bearer header on the `/relay` upgrade. The token is `base64url(payload:exp:sig)` where `payload = gatewayId` and `sig = HMAC(payload:exp, secret)`. Connector verifies and rejects the upgrade (**close 4401**) on mismatch/absence/revocation. The authenticated tenant comes from the connector's store, never the `hello` frame. |
|
||||||
|
| Connector → gateway inbound POST | per-tenant delivery key | Two headers: `x-relay-timestamp` (unix seconds) and `x-relay-signature` (hex `HMAC(ts.rawBody, deliveryKey)`). Gateway verifies over the **exact raw bytes** within a ±300s replay window before accepting the event; rejects **401** otherwise. |
|
||||||
|
|
||||||
|
This is the **channel** authenticator — distinct from platform crypto, which the
|
||||||
|
relay path still sheds entirely (§6). The gateway holds zero platform secrets;
|
||||||
|
these two keys authenticate only the connector link. Full threat model +
|
||||||
|
enrollment/rotation/kill-switch design: `docs/connector-gateway-auth-design.md`
|
||||||
|
(connector repo).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Versioning policy
|
||||||
|
|
||||||
|
- `contract_version` is an int; bump **only** for additive changes during the
|
||||||
|
experimental phase (new optional fields, new `op`s).
|
||||||
|
- A breaking change (renamed/removed field, changed semantics) requires a
|
||||||
|
coordinated update of both repos and a version bump.
|
||||||
|
- The connector's first PR references the commit SHA of this file it implements
|
||||||
|
against.
|
||||||
@ -164,6 +164,7 @@ class Platform(Enum):
|
|||||||
BLUEBUBBLES = "bluebubbles"
|
BLUEBUBBLES = "bluebubbles"
|
||||||
QQBOT = "qqbot"
|
QQBOT = "qqbot"
|
||||||
YUANBAO = "yuanbao"
|
YUANBAO = "yuanbao"
|
||||||
|
RELAY = "relay" # generic relay adapter fronted by the connector (EXPERIMENTAL)
|
||||||
@classmethod
|
@classmethod
|
||||||
def _missing_(cls, value):
|
def _missing_(cls, value):
|
||||||
"""Accept unknown platform names only for known plugin adapters.
|
"""Accept unknown platform names only for known plugin adapters.
|
||||||
@ -492,6 +493,13 @@ _PLATFORM_CONNECTED_CHECKERS: dict[Platform, Callable[[PlatformConfig], bool]] =
|
|||||||
(cfg.extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID"))
|
(cfg.extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID"))
|
||||||
and (cfg.extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET"))
|
and (cfg.extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET"))
|
||||||
),
|
),
|
||||||
|
# Relay dials OUT to a connector; it is "connected" once an endpoint URL is
|
||||||
|
# configured (extra["relay_url"] or extra["url"]). The capability descriptor
|
||||||
|
# is negotiated at handshake time, so the URL is the only config-level
|
||||||
|
# signal in the experimental phase. EXPERIMENTAL — may change.
|
||||||
|
Platform.RELAY: lambda cfg: bool(
|
||||||
|
cfg.extra.get("relay_url") or cfg.extra.get("url")
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
398
gateway/relay/__init__.py
Normal file
398
gateway/relay/__init__.py
Normal file
@ -0,0 +1,398 @@
|
|||||||
|
"""Relay/connector support package for the Hermes gateway.
|
||||||
|
|
||||||
|
EXPERIMENTAL. This package implements the gateway side of the "Gateway Gateway"
|
||||||
|
relay design: a generic ``RelayAdapter`` plus the wire-serializable
|
||||||
|
``CapabilityDescriptor`` the connector hands it at handshake time, and the
|
||||||
|
production ``WebSocketRelayTransport`` that dials the connector. The public API
|
||||||
|
(module names, descriptor field set, transport protocol) MAY CHANGE without a
|
||||||
|
deprecation cycle until at least two real Class-1 platforms (Discord + Telegram)
|
||||||
|
have shaken out the schema.
|
||||||
|
|
||||||
|
See ``docs/relay-connector-contract.md`` for the formal cross-repo interface.
|
||||||
|
|
||||||
|
Activation is driven by configuration, not a separate feature flag: the relay
|
||||||
|
platform is registered when a connector relay URL is configured
|
||||||
|
(``GATEWAY_RELAY_URL`` env or ``gateway.relay_url`` in config.yaml). Deployments
|
||||||
|
that don't set it are unaffected — exactly the same shape as ``gateway.proxy_url``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def relay_url() -> Optional[str]:
|
||||||
|
"""The connector relay endpoint URL, or None when relay is not configured.
|
||||||
|
|
||||||
|
Checks ``GATEWAY_RELAY_URL`` (convenient for Docker) first, then
|
||||||
|
``gateway.relay_url`` in config.yaml. A non-empty value activates the relay
|
||||||
|
platform; absence means a normal direct/single-tenant gateway.
|
||||||
|
"""
|
||||||
|
url = os.environ.get("GATEWAY_RELAY_URL", "").strip()
|
||||||
|
if url:
|
||||||
|
return url.rstrip("/")
|
||||||
|
try:
|
||||||
|
from gateway.run import _load_gateway_config # late import to avoid cycle
|
||||||
|
|
||||||
|
cfg = _load_gateway_config()
|
||||||
|
url = (cfg.get("gateway") or {}).get("relay_url", "").strip()
|
||||||
|
if url:
|
||||||
|
return url.rstrip("/")
|
||||||
|
except Exception: # noqa: BLE001 - config absence/parse must never crash registration
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def relay_platform_identity() -> tuple[str, str]:
|
||||||
|
"""Platform + bot id this gateway fronts over the relay (for the handshake hello).
|
||||||
|
|
||||||
|
Defaults to ``("relay", "")``; overridable via ``GATEWAY_RELAY_PLATFORM`` /
|
||||||
|
``GATEWAY_RELAY_BOT_ID`` so one connector can front several platforms.
|
||||||
|
"""
|
||||||
|
platform = os.environ.get("GATEWAY_RELAY_PLATFORM", "relay").strip() or "relay"
|
||||||
|
bot_id = os.environ.get("GATEWAY_RELAY_BOT_ID", "").strip()
|
||||||
|
return platform, bot_id
|
||||||
|
|
||||||
|
|
||||||
|
def relay_connection_auth() -> tuple[Optional[str], Optional[str]]:
|
||||||
|
"""The (gateway_id, upgrade_secret) this gateway authenticates the WS upgrade with.
|
||||||
|
|
||||||
|
Both come from enrollment (``hermes gateway enroll`` writes them to
|
||||||
|
``~/.hermes/.env``): ``GATEWAY_RELAY_ID`` identifies the enrolled instance,
|
||||||
|
``GATEWAY_RELAY_SECRET`` is the per-gateway signing secret. Either absent ->
|
||||||
|
``(None, None)`` and the transport dials unauthenticated (dev/test, or a
|
||||||
|
connector that doesn't enforce auth). Checks env first (Docker), then
|
||||||
|
``gateway.relay_id`` / ``gateway.relay_secret`` in config.yaml.
|
||||||
|
"""
|
||||||
|
gateway_id = os.environ.get("GATEWAY_RELAY_ID", "").strip()
|
||||||
|
secret = os.environ.get("GATEWAY_RELAY_SECRET", "").strip()
|
||||||
|
if not (gateway_id and secret):
|
||||||
|
try:
|
||||||
|
from gateway.run import _load_gateway_config # late import to avoid cycle
|
||||||
|
|
||||||
|
cfg = (_load_gateway_config().get("gateway") or {})
|
||||||
|
gateway_id = gateway_id or str(cfg.get("relay_id", "") or "").strip()
|
||||||
|
secret = secret or str(cfg.get("relay_secret", "") or "").strip()
|
||||||
|
except Exception: # noqa: BLE001 - config absence/parse must never crash registration
|
||||||
|
pass
|
||||||
|
return (gateway_id or None, secret or None)
|
||||||
|
|
||||||
|
|
||||||
|
def relay_inbound_config() -> tuple[Optional[str], Optional[str], int]:
|
||||||
|
"""Resolve (delivery_key, bind_host, bind_port) for the inbound receiver.
|
||||||
|
|
||||||
|
The connector delivers normalized inbound events to this gateway over a
|
||||||
|
SIGNED HTTP POST (not the outbound WS), verified with the per-tenant delivery
|
||||||
|
key issued at enrollment (``GATEWAY_RELAY_DELIVERY_KEY``). The receiver only
|
||||||
|
starts when a delivery key AND a bind port are configured — a gateway with no
|
||||||
|
public inbound URL (e.g. a purely outbound dev run) simply doesn't run it.
|
||||||
|
|
||||||
|
Env first (Docker), then ``gateway.relay_delivery_key`` /
|
||||||
|
``gateway.relay_inbound_host`` / ``gateway.relay_inbound_port`` in config.yaml.
|
||||||
|
Port 0 (default/unset) -> receiver disabled.
|
||||||
|
"""
|
||||||
|
key = os.environ.get("GATEWAY_RELAY_DELIVERY_KEY", "").strip()
|
||||||
|
host = os.environ.get("GATEWAY_RELAY_INBOUND_HOST", "").strip()
|
||||||
|
port_raw = os.environ.get("GATEWAY_RELAY_INBOUND_PORT", "").strip()
|
||||||
|
if not (key and port_raw):
|
||||||
|
try:
|
||||||
|
from gateway.run import _load_gateway_config # late import to avoid cycle
|
||||||
|
|
||||||
|
cfg = (_load_gateway_config().get("gateway") or {})
|
||||||
|
key = key or str(cfg.get("relay_delivery_key", "") or "").strip()
|
||||||
|
host = host or str(cfg.get("relay_inbound_host", "") or "").strip()
|
||||||
|
if not port_raw:
|
||||||
|
port_raw = str(cfg.get("relay_inbound_port", "") or "").strip()
|
||||||
|
except Exception: # noqa: BLE001 - config absence/parse must never crash registration
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
port = int(port_raw) if port_raw else 0
|
||||||
|
except ValueError:
|
||||||
|
port = 0
|
||||||
|
return (key or None, host or "0.0.0.0", port)
|
||||||
|
|
||||||
|
|
||||||
|
def relay_endpoint() -> Optional[str]:
|
||||||
|
"""The gateway's own PUBLIC inbound URL, asserted to the connector at provision.
|
||||||
|
|
||||||
|
The connector delivers signed inbound POSTs to this URL and stores it on the
|
||||||
|
tenant's route rows. It is gateway-asserted (the connector scopes it to the
|
||||||
|
verified tenant, so a dishonest gateway can only misdirect its OWN inbound).
|
||||||
|
The *source* of the value differs by deployment but the code path is uniform:
|
||||||
|
a self-hosted operator sets ``GATEWAY_RELAY_ENDPOINT`` (mirrors how they set
|
||||||
|
``HERMES_DASHBOARD_PUBLIC_URL``); a hosted/NAS container has the same var
|
||||||
|
stamped in (NAS knows the public URL only in that case). Absent -> the
|
||||||
|
gateway provisions outbound-only (no inbound routes written).
|
||||||
|
|
||||||
|
Env first (Docker), then ``gateway.relay_endpoint`` in config.yaml.
|
||||||
|
"""
|
||||||
|
url = os.environ.get("GATEWAY_RELAY_ENDPOINT", "").strip()
|
||||||
|
if not url:
|
||||||
|
try:
|
||||||
|
from gateway.run import _load_gateway_config # late import to avoid cycle
|
||||||
|
|
||||||
|
cfg = (_load_gateway_config().get("gateway") or {})
|
||||||
|
url = str(cfg.get("relay_endpoint", "") or "").strip()
|
||||||
|
except Exception: # noqa: BLE001 - config absence/parse must never crash boot
|
||||||
|
url = ""
|
||||||
|
return url.rstrip("/") or None
|
||||||
|
|
||||||
|
|
||||||
|
def relay_route_keys() -> list[str]:
|
||||||
|
"""Discriminators (guild_ids / chat_ids / paths) this gateway's tenant owns.
|
||||||
|
|
||||||
|
Gateway-provided config, paired with ``relay_endpoint()``: the connector
|
||||||
|
writes one route row per (routeKey -> tenant, endpoint), so route keys only
|
||||||
|
take effect alongside an endpoint. Empty -> outbound-only provisioning (the
|
||||||
|
connector accepts an empty set and writes no route rows).
|
||||||
|
|
||||||
|
``GATEWAY_RELAY_ROUTE_KEYS`` is comma-separated; config.yaml
|
||||||
|
``gateway.relay_route_keys`` may be a list or a comma string.
|
||||||
|
"""
|
||||||
|
raw = os.environ.get("GATEWAY_RELAY_ROUTE_KEYS", "").strip()
|
||||||
|
if not raw:
|
||||||
|
try:
|
||||||
|
from gateway.run import _load_gateway_config # late import to avoid cycle
|
||||||
|
|
||||||
|
cfg = (_load_gateway_config().get("gateway") or {})
|
||||||
|
val = cfg.get("relay_route_keys", "")
|
||||||
|
if isinstance(val, (list, tuple)):
|
||||||
|
return [str(k).strip() for k in val if str(k).strip()]
|
||||||
|
raw = str(val or "").strip()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
raw = ""
|
||||||
|
return [k.strip() for k in raw.split(",") if k.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _provision_url(relay_dial_url: str) -> str:
|
||||||
|
"""Map the ``ws(s)://…/relay`` dial URL to the ``http(s)://…/relay/provision`` POST URL."""
|
||||||
|
raw = relay_dial_url.rstrip("/")
|
||||||
|
if raw.startswith("ws://"):
|
||||||
|
raw = "http://" + raw[len("ws://"):]
|
||||||
|
elif raw.startswith("wss://"):
|
||||||
|
raw = "https://" + raw[len("wss://"):]
|
||||||
|
if raw.endswith("/relay"):
|
||||||
|
raw = raw[: -len("/relay")]
|
||||||
|
return f"{raw}/relay/provision"
|
||||||
|
|
||||||
|
|
||||||
|
def _post_provision(
|
||||||
|
*,
|
||||||
|
provision_url: str,
|
||||||
|
access_token: str,
|
||||||
|
gateway_id: str,
|
||||||
|
platform: str,
|
||||||
|
bot_id: str,
|
||||||
|
gateway_endpoint: Optional[str],
|
||||||
|
route_keys: list[str],
|
||||||
|
timeout: float = 15.0,
|
||||||
|
) -> dict:
|
||||||
|
"""POST to the connector's ``/relay/provision`` and return the JSON body.
|
||||||
|
|
||||||
|
The connector validates ``access_token`` against NAS, derives the
|
||||||
|
authoritative tenant, mints the per-gateway secret + per-tenant delivery key,
|
||||||
|
upserts the tenant's route rows, and returns
|
||||||
|
``{secret, deliveryKey, tenant, gatewayId, routeKeys}``. Raises RuntimeError
|
||||||
|
with a user-facing message on any non-2xx / transport failure.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
body: dict = {
|
||||||
|
"gatewayId": gateway_id,
|
||||||
|
"platform": platform,
|
||||||
|
"botId": bot_id,
|
||||||
|
"gatewayEndpoint": gateway_endpoint or "",
|
||||||
|
"routeKeys": route_keys,
|
||||||
|
}
|
||||||
|
data = json.dumps(body).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
provision_url,
|
||||||
|
data=data,
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
payload = json.loads(resp.read().decode())
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = ""
|
||||||
|
try:
|
||||||
|
detail = (json.loads(exc.read().decode()) or {}).get("error", "")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise RuntimeError(
|
||||||
|
f"connector returned HTTP {exc.code}" + (f": {detail}" if detail else "")
|
||||||
|
) from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise RuntimeError(f"could not reach connector: {exc.reason}") from exc
|
||||||
|
|
||||||
|
if not isinstance(payload, dict) or not payload.get("secret"):
|
||||||
|
raise RuntimeError("connector returned an unexpected response (no secret)")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def self_provision_if_managed() -> bool:
|
||||||
|
"""Managed-boot self-provision: mint relay creds in-process, no human, no disk.
|
||||||
|
|
||||||
|
Fires only on a MANAGED boot (``is_managed()``) with relay configured
|
||||||
|
(``relay_url()`` set) and NO per-gateway secret already present. In that case
|
||||||
|
the runtime resolves the agent's own Nous access token (the same
|
||||||
|
``resolve_nous_access_token()`` the enroll CLI / dashboard register use),
|
||||||
|
POSTs ``/relay/provision`` asserting its own endpoint + route keys, and sets
|
||||||
|
``GATEWAY_RELAY_ID`` / ``GATEWAY_RELAY_SECRET`` / ``GATEWAY_RELAY_DELIVERY_KEY``
|
||||||
|
into ``os.environ`` so the subsequent ``register_relay_adapter()`` picks them
|
||||||
|
up. The creds live ONLY in process memory — never written to ``~/.hermes/.env``
|
||||||
|
(``save_env_value`` refuses under managed anyway, and keeping the secret off
|
||||||
|
any volume is the stronger posture).
|
||||||
|
|
||||||
|
Stateless: process-env creds don't survive a restart, so a managed container
|
||||||
|
re-provisions every boot; the connector's rotation window covers a still-
|
||||||
|
connected prior instance. An explicitly-pinned ``GATEWAY_RELAY_SECRET`` (env
|
||||||
|
or config) is RESPECTED — self-provision skips so an operator pin isn't
|
||||||
|
stomped.
|
||||||
|
|
||||||
|
Returns True if it provisioned, False otherwise. NEVER raises: a provision
|
||||||
|
failure logs and returns False so the gateway still boots (and
|
||||||
|
``register_relay_adapter`` will simply dial unauthenticated / be rejected,
|
||||||
|
rather than the whole gateway crashing).
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger("gateway.relay")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from hermes_cli.config import is_managed
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not is_managed():
|
||||||
|
return False
|
||||||
|
dial_url = relay_url()
|
||||||
|
if not dial_url:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Respect an already-present (pinned/stamped) secret — don't stomp it.
|
||||||
|
existing_id, existing_secret = relay_connection_auth()
|
||||||
|
if existing_id and existing_secret:
|
||||||
|
logger.info("relay self-provision skipped: GATEWAY_RELAY_SECRET already set")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from hermes_cli.auth import resolve_nous_access_token
|
||||||
|
|
||||||
|
access_token = resolve_nous_access_token()
|
||||||
|
except Exception as exc: # noqa: BLE001 - boot must survive a token failure
|
||||||
|
logger.warning("relay self-provision skipped: could not resolve Nous token (%s)", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
platform, bot_id = relay_platform_identity()
|
||||||
|
# gatewayId default mirrors the enroll CLI's hostname-based slug.
|
||||||
|
import socket
|
||||||
|
|
||||||
|
try:
|
||||||
|
host = socket.gethostname().strip()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
host = ""
|
||||||
|
gateway_id = os.environ.get("GATEWAY_RELAY_ID", "").strip() or f"gw-{host or 'hermes'}"
|
||||||
|
endpoint = relay_endpoint()
|
||||||
|
route_keys = relay_route_keys()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = _post_provision(
|
||||||
|
provision_url=_provision_url(dial_url),
|
||||||
|
access_token=access_token,
|
||||||
|
gateway_id=gateway_id,
|
||||||
|
platform=platform,
|
||||||
|
bot_id=bot_id,
|
||||||
|
gateway_endpoint=endpoint,
|
||||||
|
route_keys=route_keys,
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logger.warning("relay self-provision failed (%s); gateway will boot without relay auth", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Set creds in-process so register_relay_adapter() + relay_inbound_config()
|
||||||
|
# read them from os.environ. Never logged.
|
||||||
|
os.environ["GATEWAY_RELAY_ID"] = str(result.get("gatewayId") or gateway_id)
|
||||||
|
os.environ["GATEWAY_RELAY_SECRET"] = str(result.get("secret") or "")
|
||||||
|
os.environ["GATEWAY_RELAY_DELIVERY_KEY"] = str(result.get("deliveryKey") or "")
|
||||||
|
tenant = str(result.get("tenant") or "")
|
||||||
|
logger.info(
|
||||||
|
"relay self-provisioned (gateway_id=%s tenant=%s routes=%d inbound=%s)",
|
||||||
|
os.environ["GATEWAY_RELAY_ID"],
|
||||||
|
tenant or "?",
|
||||||
|
len(route_keys),
|
||||||
|
"yes" if endpoint else "outbound-only",
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def register_relay_adapter(force: bool = False, url: Optional[str] = None) -> bool:
|
||||||
|
"""Register the generic ``relay`` platform via the platform registry.
|
||||||
|
|
||||||
|
Registers when a relay URL is configured (or ``force=True`` for tests, which
|
||||||
|
builds a transport-less adapter — the unit-test posture). Returns True if
|
||||||
|
registration happened. Additive: uses the same registry path as plugin
|
||||||
|
adapters, so no core dispatch changes are needed.
|
||||||
|
|
||||||
|
When a URL is present the factory builds a live ``WebSocketRelayTransport``;
|
||||||
|
the ``RelayAdapter`` negotiates the real ``CapabilityDescriptor`` at
|
||||||
|
``connect()`` time via ``transport.handshake()``.
|
||||||
|
"""
|
||||||
|
resolved_url = url if url is not None else relay_url()
|
||||||
|
if not (force or resolved_url):
|
||||||
|
return False
|
||||||
|
|
||||||
|
from gateway.platform_registry import PlatformEntry, platform_registry
|
||||||
|
from gateway.relay.adapter import RelayAdapter
|
||||||
|
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
|
||||||
|
|
||||||
|
platform, bot_id = relay_platform_identity()
|
||||||
|
|
||||||
|
def _factory(config):
|
||||||
|
# Placeholder descriptor; replaced by the negotiated one at connect time
|
||||||
|
# when a transport is present. With no URL (force/test) the adapter is
|
||||||
|
# transport-less and keeps the placeholder.
|
||||||
|
placeholder = CapabilityDescriptor(
|
||||||
|
contract_version=CONTRACT_VERSION,
|
||||||
|
platform=platform,
|
||||||
|
label="Relay",
|
||||||
|
max_message_length=4096,
|
||||||
|
supports_draft_streaming=False,
|
||||||
|
supports_edit=True,
|
||||||
|
supports_threads=False,
|
||||||
|
markdown_dialect="plain",
|
||||||
|
len_unit="chars",
|
||||||
|
)
|
||||||
|
transport = None
|
||||||
|
if resolved_url:
|
||||||
|
from gateway.relay.ws_transport import WebSocketRelayTransport
|
||||||
|
|
||||||
|
gateway_id, upgrade_secret = relay_connection_auth()
|
||||||
|
transport = WebSocketRelayTransport(
|
||||||
|
resolved_url,
|
||||||
|
platform,
|
||||||
|
bot_id,
|
||||||
|
gateway_id=gateway_id,
|
||||||
|
upgrade_secret=upgrade_secret,
|
||||||
|
)
|
||||||
|
return RelayAdapter(config, placeholder, transport=transport)
|
||||||
|
|
||||||
|
platform_registry.register(
|
||||||
|
PlatformEntry(
|
||||||
|
name="relay",
|
||||||
|
label="Relay",
|
||||||
|
adapter_factory=_factory,
|
||||||
|
check_fn=lambda: True,
|
||||||
|
source="builtin",
|
||||||
|
emoji="\U0001f50c",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return True
|
||||||
220
gateway/relay/adapter.py
Normal file
220
gateway/relay/adapter.py
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
"""RelayAdapter — one generic gateway adapter fronted by the connector. EXPERIMENTAL.
|
||||||
|
|
||||||
|
A single ``BasePlatformAdapter`` subclass that, at handshake, receives a
|
||||||
|
``CapabilityDescriptor`` from the connector telling it which platform it is
|
||||||
|
fronting and which capabilities to advertise to the ``GatewayStreamConsumer``.
|
||||||
|
It implements the four abstract methods (``connect`` / ``disconnect`` / ``send``
|
||||||
|
/ ``get_chat_info``) plus the capability surface (``MAX_MESSAGE_LENGTH``,
|
||||||
|
``message_len_fn``, ``supports_draft_streaming``) by delegating wire I/O to an
|
||||||
|
injected transport and reading capabilities off the descriptor.
|
||||||
|
|
||||||
|
There is NO per-platform gateway code: the connector is the only side that knows
|
||||||
|
"this chat_id maps to a Discord channel, send it via the Discord websocket."
|
||||||
|
The gateway sees an ordinary ``MessageEvent`` in and calls ``adapter.send`` out.
|
||||||
|
|
||||||
|
EXPERIMENTAL: the transport protocol and descriptor schema may change without a
|
||||||
|
deprecation cycle until >=2 Class-1 platforms validate them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
|
from gateway.config import Platform, PlatformConfig
|
||||||
|
from gateway.platforms.base import BasePlatformAdapter, SendResult
|
||||||
|
from gateway.relay.descriptor import CapabilityDescriptor
|
||||||
|
from gateway.relay.transport import RelayTransport
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _utf16_len(text: str) -> int:
|
||||||
|
"""Count UTF-16 code units (Telegram's length unit)."""
|
||||||
|
return len(text.encode("utf-16-le")) // 2
|
||||||
|
|
||||||
|
|
||||||
|
# Table-driven length-unit selection from the descriptor's ``len_unit``.
|
||||||
|
_LEN_FNS: Dict[str, Callable[[str], int]] = {
|
||||||
|
"chars": len,
|
||||||
|
"utf16": _utf16_len,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RelayAdapter(BasePlatformAdapter):
|
||||||
|
"""Generic relay adapter advertising a connector-negotiated capability profile."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: PlatformConfig,
|
||||||
|
descriptor: CapabilityDescriptor,
|
||||||
|
transport: Optional[RelayTransport] = None,
|
||||||
|
) -> None:
|
||||||
|
# The relay adapter fronts many platforms but presents as a single
|
||||||
|
# logical platform to the runner; Platform.RELAY identifies it.
|
||||||
|
super().__init__(config, Platform.RELAY)
|
||||||
|
self.descriptor = descriptor
|
||||||
|
self._transport = transport
|
||||||
|
# Capability surface read by stream_consumer (getattr(..., 4096)).
|
||||||
|
self.MAX_MESSAGE_LENGTH = descriptor.max_message_length
|
||||||
|
self.supports_code_blocks = descriptor.markdown_dialect not in ("", "plain")
|
||||||
|
# Inbound delivery receiver (signed connector→gateway HTTP POSTs). Built
|
||||||
|
# lazily in connect() when a delivery key + bind port are configured; a
|
||||||
|
# purely-outbound dev gateway runs without it. See inbound_receiver.py.
|
||||||
|
self._inbound_runner: Any = None
|
||||||
|
|
||||||
|
# ── capability surface (from descriptor) ─────────────────────────────
|
||||||
|
@property
|
||||||
|
def message_len_fn(self) -> Callable[[str], int]:
|
||||||
|
return _LEN_FNS.get(self.descriptor.len_unit, len)
|
||||||
|
|
||||||
|
def supports_draft_streaming(
|
||||||
|
self,
|
||||||
|
chat_type: Optional[str] = None,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> bool:
|
||||||
|
return self.descriptor.supports_draft_streaming
|
||||||
|
|
||||||
|
# ── abstract methods (delegated to the transport) ────────────────────
|
||||||
|
async def connect(self) -> bool:
|
||||||
|
if self._transport is None:
|
||||||
|
raise RuntimeError("RelayAdapter has no transport configured")
|
||||||
|
self._transport.set_inbound_handler(self._on_inbound)
|
||||||
|
ok = await self._transport.connect()
|
||||||
|
if not ok:
|
||||||
|
return False
|
||||||
|
# Negotiate the real capability descriptor from the connector and adopt
|
||||||
|
# it — the placeholder passed at construction is replaced by what the
|
||||||
|
# connector advertises for the platform this gateway actually fronts.
|
||||||
|
try:
|
||||||
|
descriptor = await self._transport.handshake()
|
||||||
|
except Exception as exc: # noqa: BLE001 - a failed handshake = a failed connect
|
||||||
|
logger.warning("relay handshake failed: %s", exc)
|
||||||
|
return False
|
||||||
|
self._apply_descriptor(descriptor)
|
||||||
|
# Start the signed inbound-delivery receiver if configured (the connector
|
||||||
|
# POSTs normalized events to it over HTTP, verified with the tenant
|
||||||
|
# delivery key). Non-fatal: a receiver bind failure must not fail the
|
||||||
|
# outbound connection — the gateway can still send.
|
||||||
|
await self._maybe_start_inbound_receiver()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _maybe_start_inbound_receiver(self) -> None:
|
||||||
|
"""Start the inbound HTTP receiver when a delivery key + port are set."""
|
||||||
|
from gateway.relay import relay_inbound_config
|
||||||
|
|
||||||
|
delivery_key, host, port = relay_inbound_config()
|
||||||
|
if not (delivery_key and port):
|
||||||
|
return # no inbound URL configured -> outbound-only gateway
|
||||||
|
try:
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from gateway.relay.inbound_receiver import InboundDeliveryReceiver
|
||||||
|
|
||||||
|
receiver = InboundDeliveryReceiver(
|
||||||
|
delivery_key_verify_list=lambda: [delivery_key],
|
||||||
|
on_message=self._on_inbound,
|
||||||
|
on_interrupt=self.on_interrupt,
|
||||||
|
)
|
||||||
|
runner = web.AppRunner(receiver.build_app(), access_log=None)
|
||||||
|
await runner.setup()
|
||||||
|
site = web.TCPSite(runner, host, port)
|
||||||
|
await site.start()
|
||||||
|
self._inbound_runner = runner
|
||||||
|
logger.info("relay inbound receiver listening on http://%s:%s", host, port)
|
||||||
|
except Exception as exc: # noqa: BLE001 - inbound bind failure must not kill outbound
|
||||||
|
logger.warning("relay inbound receiver failed to start: %s", exc)
|
||||||
|
self._inbound_runner = None
|
||||||
|
|
||||||
|
def _apply_descriptor(self, descriptor: CapabilityDescriptor) -> None:
|
||||||
|
"""Adopt a (re)negotiated descriptor into the live capability surface."""
|
||||||
|
self.descriptor = descriptor
|
||||||
|
self.MAX_MESSAGE_LENGTH = descriptor.max_message_length
|
||||||
|
self.supports_code_blocks = descriptor.markdown_dialect not in ("", "plain")
|
||||||
|
|
||||||
|
async def _on_inbound(self, event) -> None:
|
||||||
|
"""Bridge a connector-delivered MessageEvent into the normal adapter path."""
|
||||||
|
await self.handle_message(event)
|
||||||
|
|
||||||
|
async def on_interrupt(self, session_key: str, chat_id: str) -> None:
|
||||||
|
"""Bridge a connector-delivered /stop into the adapter's interrupt path.
|
||||||
|
|
||||||
|
The connector forwards a mid-turn interrupt down the socket owned by
|
||||||
|
the gateway instance running ``session_key``; this routes it to the
|
||||||
|
existing per-session interrupt mechanism (sets the
|
||||||
|
``_active_sessions[session_key]`` Event and clears typing), cancelling
|
||||||
|
the right turn without touching sibling sessions.
|
||||||
|
"""
|
||||||
|
await self.interrupt_session_activity(session_key, chat_id)
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
if self._inbound_runner is not None:
|
||||||
|
try:
|
||||||
|
await self._inbound_runner.cleanup()
|
||||||
|
except Exception: # noqa: BLE001 - best-effort teardown
|
||||||
|
pass
|
||||||
|
self._inbound_runner = None
|
||||||
|
if self._transport is not None:
|
||||||
|
await self._transport.disconnect()
|
||||||
|
|
||||||
|
async def send(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
content: str,
|
||||||
|
reply_to: Optional[str] = None,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> SendResult:
|
||||||
|
if self._transport is None:
|
||||||
|
return SendResult(success=False, error="no transport")
|
||||||
|
result = await self._transport.send_outbound(
|
||||||
|
{
|
||||||
|
"op": "send",
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"content": content,
|
||||||
|
"reply_to": reply_to,
|
||||||
|
"metadata": metadata or {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return SendResult(
|
||||||
|
success=bool(result.get("success")),
|
||||||
|
message_id=result.get("message_id"),
|
||||||
|
error=result.get("error"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||||
|
# Proxied to the connector (it owns the platform connection / cache).
|
||||||
|
if self._transport is None:
|
||||||
|
return {"name": chat_id, "type": "dm"}
|
||||||
|
return await self._transport.get_chat_info(chat_id)
|
||||||
|
|
||||||
|
async def send_follow_up(
|
||||||
|
self,
|
||||||
|
session_key: str,
|
||||||
|
kind: str,
|
||||||
|
content: str,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> SendResult:
|
||||||
|
"""Send via a shared-identity capability bound to a session (A2 outbound).
|
||||||
|
|
||||||
|
The gateway never holds the credential: it names the session it is
|
||||||
|
already in plus the capability ``kind``, and the connector resolves the
|
||||||
|
real value from its vault and egresses (enforcing the tenant match). Used
|
||||||
|
e.g. to post a Discord interaction follow-up as the shared bot without
|
||||||
|
the token ever reaching the gateway. See RelayTransport.send_follow_up.
|
||||||
|
"""
|
||||||
|
if self._transport is None:
|
||||||
|
return SendResult(success=False, error="no transport")
|
||||||
|
result = await self._transport.send_follow_up(
|
||||||
|
{
|
||||||
|
"op": "follow_up",
|
||||||
|
"session_key": session_key,
|
||||||
|
"kind": kind,
|
||||||
|
"content": content,
|
||||||
|
"metadata": metadata or {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return SendResult(
|
||||||
|
success=bool(result.get("success")),
|
||||||
|
message_id=result.get("message_id"),
|
||||||
|
error=result.get("error"),
|
||||||
|
)
|
||||||
168
gateway/relay/auth.py
Normal file
168
gateway/relay/auth.py
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
"""Gateway-side relay authentication primitives. EXPERIMENTAL.
|
||||||
|
|
||||||
|
The connector⇄gateway channel is authenticated because a gateway may be
|
||||||
|
customer-managed and internet-exposed (see the connector repo
|
||||||
|
``docs/connector-gateway-auth-design.md``). This module is the **gateway half**
|
||||||
|
of two HMAC schemes whose wire bytes must match the connector's TypeScript
|
||||||
|
exactly:
|
||||||
|
|
||||||
|
1. **WS upgrade auth** (gateway → connector): the gateway presents
|
||||||
|
``Authorization: Bearer <token>`` on the ``/relay`` WebSocket upgrade, where
|
||||||
|
``token = make_upgrade_token(gateway_id, secret)``. Mirrors the connector's
|
||||||
|
``relayAuthToken.ts`` ``makeToken`` (``src/core/relayAuthToken.ts``):
|
||||||
|
``base64url(f"{payload}:{exp}:{sig}")`` with
|
||||||
|
``sig = HMAC_SHA256(f"{payload}:{exp}", secret).hexdigest()`` and
|
||||||
|
``payload == gateway_id``.
|
||||||
|
|
||||||
|
2. **Inbound delivery signature** (connector → gateway): the connector signs
|
||||||
|
each inbound POST with the per-tenant *delivery key*, carried as
|
||||||
|
``x-relay-timestamp`` + ``x-relay-signature`` headers; the gateway verifies
|
||||||
|
before accepting the event. Mirrors the connector's ``deliverySigning.ts``:
|
||||||
|
``sig = HMAC_SHA256(f"{ts}.{body_json}", key).hexdigest()`` over the EXACT
|
||||||
|
request body bytes, with a replay-window skew check.
|
||||||
|
|
||||||
|
Both schemes use a **multi-secret verify list** (primary first, then a secondary
|
||||||
|
during a rotation window), exactly like ``api/src/handlers/stats_oauth.ts`` — so
|
||||||
|
a secret rotation doesn't invalidate outstanding tokens.
|
||||||
|
|
||||||
|
EXPERIMENTAL: may change without a deprecation cycle until ≥2 Class-1 platforms
|
||||||
|
validate the relay contract.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
from typing import Optional, Sequence
|
||||||
|
|
||||||
|
# Header names the connector uses for inbound delivery signatures
|
||||||
|
# (connector ``src/core/deliverySigning.ts`` — DELIVERY_TS_HEADER / SIG_HEADER).
|
||||||
|
DELIVERY_TS_HEADER = "x-relay-timestamp"
|
||||||
|
DELIVERY_SIG_HEADER = "x-relay-signature"
|
||||||
|
|
||||||
|
# Default replay window for an inbound delivery signature (connector default).
|
||||||
|
_DEFAULT_MAX_SKEW_SECONDS = 300
|
||||||
|
# Default TTL for an upgrade token (connector ``makeUpgradeToken`` default).
|
||||||
|
_DEFAULT_UPGRADE_TTL_SECONDS = 300
|
||||||
|
|
||||||
|
|
||||||
|
def _hmac_hex(payload: str, secret: str) -> str:
|
||||||
|
"""HMAC-SHA256 hex digest of ``payload`` under ``secret`` (UTF-8)."""
|
||||||
|
return hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def sign(payload: str, secret: str) -> str:
|
||||||
|
"""HMAC-SHA256 hex digest — the connector's ``sign`` (relayAuthToken.ts)."""
|
||||||
|
return _hmac_hex(payload, secret)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_signature(payload: str, sig_hex: str, secrets: Sequence[str]) -> bool:
|
||||||
|
"""Constant-time check that ``sig_hex`` is a valid HMAC of ``payload`` under
|
||||||
|
ANY of ``secrets`` (rotation window). Length-mismatched candidates are
|
||||||
|
skipped without a timing leak. Mirrors ``verifySignature``.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sig_buf = bytes.fromhex(sig_hex)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
if len(sig_buf) == 0:
|
||||||
|
return False
|
||||||
|
for secret in secrets:
|
||||||
|
if not secret:
|
||||||
|
continue
|
||||||
|
expected = bytes.fromhex(_hmac_hex(payload, secret))
|
||||||
|
if len(expected) != len(sig_buf):
|
||||||
|
continue
|
||||||
|
if hmac.compare_digest(sig_buf, expected):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def make_token(payload: str, secret: str, ttl_seconds: int = 0) -> str:
|
||||||
|
"""Build a signed, optionally-expiring token — the connector's ``makeToken``.
|
||||||
|
|
||||||
|
``base64url(f"{payload}:{exp}:{sig}")`` where ``exp`` is a unix-seconds
|
||||||
|
expiry (0 = never) and ``sig = HMAC_SHA256(f"{payload}:{exp}", secret)``.
|
||||||
|
base64url is unpadded to match Node's ``Buffer.toString("base64url")``.
|
||||||
|
"""
|
||||||
|
exp = int(time.time()) + ttl_seconds if ttl_seconds > 0 else 0
|
||||||
|
signed = f"{payload}:{exp}"
|
||||||
|
sig = _hmac_hex(signed, secret)
|
||||||
|
raw = f"{signed}:{sig}".encode("utf-8")
|
||||||
|
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def make_upgrade_token(
|
||||||
|
gateway_id: str, secret: str, ttl_seconds: int = _DEFAULT_UPGRADE_TTL_SECONDS
|
||||||
|
) -> str:
|
||||||
|
"""The WS-upgrade bearer token a gateway sends: ``payload = gateway_id``.
|
||||||
|
|
||||||
|
The connector peeks ``gateway_id`` (the payload head) to index its secret
|
||||||
|
verify list, then verifies the signature against that gateway's stored
|
||||||
|
secret(s). Mirrors the connector's ``makeUpgradeToken``.
|
||||||
|
"""
|
||||||
|
return make_token(gateway_id, secret, ttl_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_token(token: str, secrets: Sequence[str]) -> Optional[str]:
|
||||||
|
"""Verify a token built by ``make_token``; return the payload or None.
|
||||||
|
|
||||||
|
Splits from the right so a payload may itself contain colons (mirrors the
|
||||||
|
connector's ``verifyToken``). Rejects an expired token and any signature
|
||||||
|
that doesn't match a secret in the verify list.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# base64url decode with padding restored.
|
||||||
|
padded = token + "=" * (-len(token) % 4)
|
||||||
|
decoded = base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
parts = decoded.split(":")
|
||||||
|
if len(parts) < 3:
|
||||||
|
return None
|
||||||
|
sig = parts[-1]
|
||||||
|
try:
|
||||||
|
exp = int(parts[-2])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
payload = ":".join(parts[:-2])
|
||||||
|
if exp != 0 and int(time.time()) > exp:
|
||||||
|
return None
|
||||||
|
signed = f"{payload}:{exp}"
|
||||||
|
return payload if verify_signature(signed, sig, secrets) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _delivery_payload(ts: int, body_json: str) -> str:
|
||||||
|
"""Signed material for an inbound delivery: ``f"{ts}.{body_json}"``."""
|
||||||
|
return f"{ts}.{body_json}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_delivery_signature(
|
||||||
|
body_json: str,
|
||||||
|
timestamp: Optional[str],
|
||||||
|
signature: Optional[str],
|
||||||
|
verify_keys: Sequence[str],
|
||||||
|
max_skew_seconds: int = _DEFAULT_MAX_SKEW_SECONDS,
|
||||||
|
*,
|
||||||
|
now: Optional[int] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Verify a connector→gateway inbound delivery signature.
|
||||||
|
|
||||||
|
``body_json`` MUST be the exact request body bytes decoded as UTF-8 — the
|
||||||
|
connector signs over the literal serialized body, so the gateway verifies
|
||||||
|
over the literal received body (no re-serialization). Checks the timestamp
|
||||||
|
is within ``max_skew_seconds`` of now and the HMAC matches any key in the
|
||||||
|
rotation verify list. Mirrors the connector's ``verifyDeliverySignature``.
|
||||||
|
"""
|
||||||
|
if not timestamp or not signature:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
ts = int(timestamp)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
current = now if now is not None else int(time.time())
|
||||||
|
if abs(current - ts) > max_skew_seconds:
|
||||||
|
return False
|
||||||
|
return verify_signature(_delivery_payload(ts, body_json), signature, verify_keys)
|
||||||
118
gateway/relay/descriptor.py
Normal file
118
gateway/relay/descriptor.py
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
"""CapabilityDescriptor — the relay handshake payload. EXPERIMENTAL.
|
||||||
|
|
||||||
|
The connector hands a ``CapabilityDescriptor`` to the gateway's ``RelayAdapter``
|
||||||
|
at handshake time; it tells the adapter which platform it is fronting and which
|
||||||
|
capabilities to advertise to the ``GatewayStreamConsumer`` (char limit,
|
||||||
|
draft-streaming, edit/threading support, markdown dialect, length unit). It is
|
||||||
|
the linchpin of the generalization: one gateway adapter serves Discord,
|
||||||
|
Telegram, Matrix, Signal, ... without per-platform branching.
|
||||||
|
|
||||||
|
EXPERIMENTAL: this schema MAY CHANGE without a deprecation cycle until at least
|
||||||
|
two real Class-1 platforms have validated it. Evolution during the experimental
|
||||||
|
phase is additive-only, gated by ``contract_version`` (see
|
||||||
|
docs/relay-connector-contract.md).
|
||||||
|
|
||||||
|
Field origins (most are a wire-serializable projection of ``PlatformEntry`` plus
|
||||||
|
the per-instance capability methods on ``BasePlatformAdapter``):
|
||||||
|
|
||||||
|
- ``max_message_length`` -> ``PlatformEntry.max_message_length`` / adapter
|
||||||
|
``MAX_MESSAGE_LENGTH`` attribute (read by stream_consumer).
|
||||||
|
- ``len_unit`` -> selects which ``message_len_fn`` the adapter installs
|
||||||
|
("chars" = builtin len; "utf16" = Telegram-style UTF-16 code-unit counting).
|
||||||
|
- ``supports_draft_streaming`` -> adapter ``supports_draft_streaming()`` probe.
|
||||||
|
- ``supports_edit`` -> whether edit-based streaming is possible (Discord/
|
||||||
|
Telegram yes; Signal/SMS no -> consumer degrades to one-message-per-segment).
|
||||||
|
- ``supports_threads`` -> ``create_handoff_thread`` capability flag.
|
||||||
|
- ``markdown_dialect`` -> presentation hint (e.g. "markdown_v2", "discord").
|
||||||
|
- ``emoji`` / ``platform_hint`` / ``pii_safe`` -> ``PlatformEntry`` fields of the
|
||||||
|
same name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
|
||||||
|
# Bump additively (never reinterpret an existing field) during the experimental
|
||||||
|
# phase; a breaking change requires updating both repos in lockstep.
|
||||||
|
CONTRACT_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CapabilityDescriptor:
|
||||||
|
"""Immutable capability descriptor negotiated at relay handshake.
|
||||||
|
|
||||||
|
Frozen so a descriptor cannot be mutated after handshake — the adapter
|
||||||
|
advertises a fixed capability profile for the life of the connection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
contract_version: int
|
||||||
|
platform: str
|
||||||
|
label: str
|
||||||
|
max_message_length: int
|
||||||
|
supports_draft_streaming: bool
|
||||||
|
supports_edit: bool
|
||||||
|
supports_threads: bool
|
||||||
|
markdown_dialect: str
|
||||||
|
len_unit: str # "chars" | "utf16"
|
||||||
|
emoji: str = "\U0001f50c" # 🔌 default (matches PlatformEntry default)
|
||||||
|
platform_hint: str = ""
|
||||||
|
pii_safe: bool = False
|
||||||
|
|
||||||
|
def to_json(self) -> str:
|
||||||
|
"""Serialize to a compact, stable JSON string for the handshake frame."""
|
||||||
|
return json.dumps(asdict(self), sort_keys=True, ensure_ascii=False)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json(cls, data: str) -> "CapabilityDescriptor":
|
||||||
|
"""Deserialize from a handshake JSON string.
|
||||||
|
|
||||||
|
Unknown keys are ignored (forward-compat: a newer connector may send
|
||||||
|
fields this gateway does not know yet); missing optional keys fall back
|
||||||
|
to dataclass defaults.
|
||||||
|
"""
|
||||||
|
raw = json.loads(data)
|
||||||
|
known = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined]
|
||||||
|
filtered = {k: v for k, v in raw.items() if k in known}
|
||||||
|
return cls(**filtered)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_platform_entry(
|
||||||
|
cls,
|
||||||
|
entry,
|
||||||
|
*,
|
||||||
|
len_unit: str = "chars",
|
||||||
|
supports_draft_streaming: bool = False,
|
||||||
|
supports_edit: bool = True,
|
||||||
|
supports_threads: bool = False,
|
||||||
|
markdown_dialect: str = "plain",
|
||||||
|
) -> "CapabilityDescriptor":
|
||||||
|
"""Project a ``gateway.platform_registry.PlatformEntry`` into a descriptor.
|
||||||
|
|
||||||
|
Demonstrates the descriptor is a *subset/projection* of what
|
||||||
|
``PlatformEntry`` already encodes, not a parallel concept: ``label``,
|
||||||
|
``max_message_length``, ``emoji``, ``platform_hint``, ``pii_safe`` and
|
||||||
|
the platform name come straight off the entry. The runtime capability
|
||||||
|
bits that ``PlatformEntry`` does NOT encode (length unit, draft/edit/
|
||||||
|
thread/markdown behavior) are supplied by the caller — in production
|
||||||
|
the connector fills these from the live adapter's capability methods.
|
||||||
|
|
||||||
|
``max_message_length`` of 0 on a ``PlatformEntry`` means "no limit";
|
||||||
|
we map that to the stream_consumer default of 4096 so the descriptor
|
||||||
|
always carries a concrete chunking bound.
|
||||||
|
"""
|
||||||
|
max_len = getattr(entry, "max_message_length", 0) or 4096
|
||||||
|
return cls(
|
||||||
|
contract_version=CONTRACT_VERSION,
|
||||||
|
platform=entry.name,
|
||||||
|
label=entry.label,
|
||||||
|
max_message_length=max_len,
|
||||||
|
supports_draft_streaming=supports_draft_streaming,
|
||||||
|
supports_edit=supports_edit,
|
||||||
|
supports_threads=supports_threads,
|
||||||
|
markdown_dialect=markdown_dialect,
|
||||||
|
len_unit=len_unit,
|
||||||
|
emoji=getattr(entry, "emoji", "\U0001f50c"),
|
||||||
|
platform_hint=getattr(entry, "platform_hint", ""),
|
||||||
|
pii_safe=getattr(entry, "pii_safe", False),
|
||||||
|
)
|
||||||
204
gateway/relay/inbound_receiver.py
Normal file
204
gateway/relay/inbound_receiver.py
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
"""Gateway-side inbound delivery receiver. EXPERIMENTAL.
|
||||||
|
|
||||||
|
The connector delivers normalized inbound events to a tenant's gateway over a
|
||||||
|
**signed HTTP POST** (connector ``src/relay/httpGatewayDelivery.ts``), NOT over
|
||||||
|
the gateway's outbound ``/relay`` WebSocket: the connector instance that owns a
|
||||||
|
platform socket is generally not the instance a given gateway dialed out to, so
|
||||||
|
inbound is delivered to a tenant ENDPOINT (which may load-balance across gateway
|
||||||
|
instances). Each delivery is HMAC-signed with the per-tenant **delivery key**
|
||||||
|
(``gateway/relay/auth.py``); this receiver verifies the signature over the EXACT
|
||||||
|
raw request bytes before accepting the event.
|
||||||
|
|
||||||
|
Two routes (mirroring the connector's two POST targets):
|
||||||
|
POST {base} {"type":"message", "event": <MessageEvent>, ...}
|
||||||
|
POST {base}/interrupt {"type":"interrupt","session_key": ..., "reason"?}
|
||||||
|
|
||||||
|
The receiver:
|
||||||
|
1. reads the RAW body bytes (never a reparsed/re-serialized form — the HMAC is
|
||||||
|
over the literal bytes the connector signed),
|
||||||
|
2. verifies ``x-relay-signature`` / ``x-relay-timestamp`` against the delivery
|
||||||
|
key verify list (primary + secondary during rotation), within the replay
|
||||||
|
window — rejects 401 on any failure,
|
||||||
|
3. parses the JSON and dispatches: a ``message`` to the inbound handler (the
|
||||||
|
RelayAdapter's ``handle_message`` via the transport's normal path), an
|
||||||
|
``interrupt`` to the interrupt handler.
|
||||||
|
|
||||||
|
EXPERIMENTAL: the transport protocol may change without a deprecation cycle
|
||||||
|
until ≥2 Class-1 platforms validate it. See docs/relay-connector-contract.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any, Awaitable, Callable, Optional, Sequence
|
||||||
|
|
||||||
|
from gateway.platforms.base import MessageEvent
|
||||||
|
from gateway.relay.auth import (
|
||||||
|
DELIVERY_SIG_HEADER,
|
||||||
|
DELIVERY_TS_HEADER,
|
||||||
|
verify_delivery_signature,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Callbacks the receiver dispatches verified deliveries to.
|
||||||
|
InboundMessageHandler = Callable[[MessageEvent], Awaitable[None]]
|
||||||
|
InboundInterruptHandler = Callable[[str, str], Awaitable[None]]
|
||||||
|
|
||||||
|
try: # lazy/optional dep — mirrors the other HTTP-receiving adapters
|
||||||
|
from aiohttp import web
|
||||||
|
except ImportError: # pragma: no cover - exercised only when the extra is absent
|
||||||
|
web = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
AIOHTTP_AVAILABLE = web is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _event_from_wire(raw: dict) -> MessageEvent:
|
||||||
|
"""Rebuild a MessageEvent from the connector's normalized inbound payload.
|
||||||
|
|
||||||
|
Identical mapping to the WS transport's ``_event_from_wire`` (the wire shape
|
||||||
|
is the same; only the transport differs). Kept here so the HTTP receiver has
|
||||||
|
no import dependency on the WS transport module.
|
||||||
|
"""
|
||||||
|
from gateway.config import Platform
|
||||||
|
from gateway.platforms.base import MessageType
|
||||||
|
from gateway.session import SessionSource
|
||||||
|
|
||||||
|
src = raw.get("source", {}) or {}
|
||||||
|
platform = src.get("platform", "relay")
|
||||||
|
try:
|
||||||
|
platform_enum = Platform(platform)
|
||||||
|
except ValueError:
|
||||||
|
platform_enum = Platform.RELAY
|
||||||
|
|
||||||
|
source = SessionSource(
|
||||||
|
platform=platform_enum,
|
||||||
|
chat_id=src.get("chat_id", ""),
|
||||||
|
chat_type=src.get("chat_type", "dm"),
|
||||||
|
chat_name=src.get("chat_name"),
|
||||||
|
user_id=src.get("user_id"),
|
||||||
|
user_name=src.get("user_name"),
|
||||||
|
thread_id=src.get("thread_id"),
|
||||||
|
chat_topic=src.get("chat_topic"),
|
||||||
|
user_id_alt=src.get("user_id_alt"),
|
||||||
|
chat_id_alt=src.get("chat_id_alt"),
|
||||||
|
guild_id=src.get("guild_id"),
|
||||||
|
parent_chat_id=src.get("parent_chat_id"),
|
||||||
|
message_id=src.get("message_id"),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
msg_type = MessageType(raw.get("message_type", "text"))
|
||||||
|
except ValueError:
|
||||||
|
msg_type = MessageType.TEXT
|
||||||
|
|
||||||
|
return MessageEvent(
|
||||||
|
text=raw.get("text", ""),
|
||||||
|
message_type=msg_type,
|
||||||
|
source=source,
|
||||||
|
message_id=raw.get("message_id"),
|
||||||
|
reply_to_message_id=raw.get("reply_to_message_id"),
|
||||||
|
media_urls=raw.get("media_urls") or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InboundDeliveryReceiver:
|
||||||
|
"""Verifies + dispatches signed connector→gateway inbound deliveries.
|
||||||
|
|
||||||
|
Transport-agnostic core: ``handle_raw`` takes the raw body bytes + headers +
|
||||||
|
which route was hit and returns ``(status, body)``. The aiohttp wiring
|
||||||
|
(``build_app`` / ``serve``) is a thin shell so the verify+dispatch logic is
|
||||||
|
unit-testable without a live socket.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
delivery_key_verify_list: Callable[[], Sequence[str]],
|
||||||
|
on_message: InboundMessageHandler,
|
||||||
|
on_interrupt: Optional[InboundInterruptHandler] = None,
|
||||||
|
max_skew_seconds: int = 300,
|
||||||
|
) -> None:
|
||||||
|
# A callable (not a static list) so a rotated delivery key is picked up
|
||||||
|
# without rebuilding the receiver — mirrors the connector's verify list.
|
||||||
|
self._verify_list = delivery_key_verify_list
|
||||||
|
self._on_message = on_message
|
||||||
|
self._on_interrupt = on_interrupt
|
||||||
|
self._max_skew_seconds = max_skew_seconds
|
||||||
|
|
||||||
|
async def handle_raw(
|
||||||
|
self, *, raw_body: bytes, timestamp: Optional[str], signature: Optional[str], is_interrupt: bool
|
||||||
|
) -> tuple[int, dict]:
|
||||||
|
"""Verify the signature over ``raw_body`` and dispatch. Returns (status, json).
|
||||||
|
|
||||||
|
401 on a missing/invalid/expired signature (never dispatches unverified).
|
||||||
|
400 on malformed JSON. 200 on a verified, dispatched delivery.
|
||||||
|
"""
|
||||||
|
verify_keys = list(self._verify_list() or [])
|
||||||
|
if not verify_keys:
|
||||||
|
# No delivery key provisioned -> we cannot verify -> reject. A gateway
|
||||||
|
# that hasn't enrolled must not accept inbound (fail closed).
|
||||||
|
logger.warning("relay inbound: no delivery key configured; rejecting")
|
||||||
|
return 401, {"error": "no delivery key configured"}
|
||||||
|
|
||||||
|
# Verify over the EXACT raw bytes the connector signed. Decode to text
|
||||||
|
# with the same UTF-8 the connector's JSON.stringify produced; a single
|
||||||
|
# differing byte breaks the HMAC (raw-body-preservation discipline).
|
||||||
|
body_text = raw_body.decode("utf-8", errors="strict")
|
||||||
|
if not verify_delivery_signature(
|
||||||
|
body_text, timestamp, signature, verify_keys, self._max_skew_seconds
|
||||||
|
):
|
||||||
|
return 401, {"error": "invalid delivery signature"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(body_text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return 400, {"error": "invalid JSON body"}
|
||||||
|
|
||||||
|
if is_interrupt or payload.get("type") == "interrupt":
|
||||||
|
session_key = str(payload.get("session_key", ""))
|
||||||
|
chat_id = str(payload.get("chat_id", "") or payload.get("reason", "") or "")
|
||||||
|
if self._on_interrupt is not None and session_key:
|
||||||
|
await self._on_interrupt(session_key, chat_id)
|
||||||
|
return 200, {"ok": True}
|
||||||
|
|
||||||
|
# Default: a normalized inbound message event.
|
||||||
|
event_raw = payload.get("event")
|
||||||
|
if not isinstance(event_raw, dict):
|
||||||
|
return 400, {"error": "missing event"}
|
||||||
|
event = _event_from_wire(event_raw)
|
||||||
|
await self._on_message(event)
|
||||||
|
return 200, {"ok": True}
|
||||||
|
|
||||||
|
# ── aiohttp wiring (thin shell over handle_raw) ──────────────────────
|
||||||
|
def build_app(self) -> Any:
|
||||||
|
"""Build an aiohttp Application exposing the delivery + interrupt routes."""
|
||||||
|
if not AIOHTTP_AVAILABLE:
|
||||||
|
raise RuntimeError(
|
||||||
|
"InboundDeliveryReceiver requires the 'aiohttp' package "
|
||||||
|
"(install the messaging extra)."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _deliver(request: Any) -> Any:
|
||||||
|
return await self._respond(request, is_interrupt=False)
|
||||||
|
|
||||||
|
async def _interrupt(request: Any) -> Any:
|
||||||
|
return await self._respond(request, is_interrupt=True)
|
||||||
|
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_get("/healthz", lambda _: web.Response(text="ok"))
|
||||||
|
app.router.add_post("/", _deliver)
|
||||||
|
app.router.add_post("/interrupt", _interrupt)
|
||||||
|
return app
|
||||||
|
|
||||||
|
async def _respond(self, request: Any, *, is_interrupt: bool) -> Any:
|
||||||
|
# Read the RAW bytes — do NOT use request.json() (it reparses and we'd
|
||||||
|
# verify over a re-serialized form, breaking the HMAC).
|
||||||
|
raw_body = await request.read()
|
||||||
|
status, body = await self.handle_raw(
|
||||||
|
raw_body=raw_body,
|
||||||
|
timestamp=request.headers.get(DELIVERY_TS_HEADER),
|
||||||
|
signature=request.headers.get(DELIVERY_SIG_HEADER),
|
||||||
|
is_interrupt=is_interrupt,
|
||||||
|
)
|
||||||
|
return web.json_response(body, status=status)
|
||||||
101
gateway/relay/transport.py
Normal file
101
gateway/relay/transport.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
"""Relay transport protocol — the gateway<->connector wire contract. EXPERIMENTAL.
|
||||||
|
|
||||||
|
The ``RelayAdapter`` (gateway side) delegates all wire I/O to a ``RelayTransport``.
|
||||||
|
The gateway dials OUT to the connector, so a production transport is a WebSocket
|
||||||
|
client; in tests it is an in-memory stub (``tests/gateway/relay/stub_connector.py``).
|
||||||
|
|
||||||
|
This module defines the protocol surface only — no concrete transport. The
|
||||||
|
contract has four concerns:
|
||||||
|
|
||||||
|
1. Lifecycle: ``connect`` / ``disconnect``.
|
||||||
|
2. Handshake: ``handshake`` returns the ``CapabilityDescriptor`` the connector
|
||||||
|
advertises for the platform this adapter fronts.
|
||||||
|
3. Inbound: ``set_inbound_handler`` registers a callback the transport invokes
|
||||||
|
with each normalized ``MessageEvent`` the connector delivers.
|
||||||
|
4. Outbound: ``send_outbound`` carries send/edit/typing actions back to the
|
||||||
|
connector; ``get_chat_info`` proxies a chat-info lookup; ``send_interrupt``
|
||||||
|
routes a mid-turn /stop down the socket that owns the session_key.
|
||||||
|
|
||||||
|
EXPERIMENTAL: may change without a deprecation cycle until >=2 Class-1 platforms
|
||||||
|
validate it. See docs/relay-connector-contract.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Awaitable, Callable, Dict, Optional, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from gateway.platforms.base import MessageEvent
|
||||||
|
from gateway.relay.descriptor import CapabilityDescriptor
|
||||||
|
|
||||||
|
# Callback the transport invokes for each inbound normalized event.
|
||||||
|
InboundHandler = Callable[[MessageEvent], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class RelayTransport(Protocol):
|
||||||
|
"""Full gateway<->connector transport contract."""
|
||||||
|
|
||||||
|
async def connect(self) -> bool:
|
||||||
|
"""Open the connection to the connector; return True on success."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
"""Close the connection."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def handshake(self) -> CapabilityDescriptor:
|
||||||
|
"""Return the capability descriptor the connector advertises."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def set_inbound_handler(self, handler: InboundHandler) -> None:
|
||||||
|
"""Register the callback invoked with each inbound MessageEvent."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def send_outbound(self, action: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Carry an outbound action (send/edit/typing) to the connector.
|
||||||
|
|
||||||
|
Returns a result dict; for ``op == "send"`` it carries
|
||||||
|
``success`` and optionally ``message_id`` / ``error``.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||||
|
"""Proxy a chat-info lookup to the connector."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def send_interrupt(self, session_key: str, reason: Optional[str] = None) -> None:
|
||||||
|
"""Route a mid-turn /stop to the connector for ``session_key``.
|
||||||
|
|
||||||
|
The connector forwards it down the socket owned by the gateway
|
||||||
|
instance running that session (the /stop routing invariant). On the
|
||||||
|
gateway side this is the OUTBOUND direction; the actual task
|
||||||
|
cancellation happens when the connector echoes an interrupt inbound
|
||||||
|
(handled in Task 1.4).
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def send_follow_up(self, action: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Act on a shared-identity capability bound to a session (A2 outbound).
|
||||||
|
|
||||||
|
Some platforms hand the connector a credential that acts on the SHARED
|
||||||
|
bot identity (e.g. a Discord interaction follow-up token, valid ~15min).
|
||||||
|
Under A2 that credential NEVER reaches the gateway — the connector
|
||||||
|
stripped it at the edge and bound it in its capability vault keyed by
|
||||||
|
the session. To use it, the gateway issues a SEMANTIC action against the
|
||||||
|
session it is already in; it never names or holds a token.
|
||||||
|
|
||||||
|
The action dict carries:
|
||||||
|
``op`` == ``"follow_up"``
|
||||||
|
``session_key`` the session whose bound capability to wield
|
||||||
|
``kind`` the capability kind (e.g. ``"discord.interaction_token"``)
|
||||||
|
``content`` the message content to send via that capability
|
||||||
|
``metadata?`` optional extras
|
||||||
|
|
||||||
|
The connector resolves the real capability (``resolveOutboundCapability``
|
||||||
|
on its side), enforces the tenant match (tenant B can never wield tenant
|
||||||
|
A's capability), and egresses. Returns ``{success, message_id?, error?}``;
|
||||||
|
``success`` is False when the capability is absent/expired or the tenant
|
||||||
|
doesn't match — the gateway then has nothing to retry with (by design: a
|
||||||
|
leaked gateway holds zero capability material).
|
||||||
|
"""
|
||||||
|
...
|
||||||
298
gateway/relay/ws_transport.py
Normal file
298
gateway/relay/ws_transport.py
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
"""Production WebSocket RelayTransport — the gateway's live link to the connector.
|
||||||
|
|
||||||
|
The gateway dials OUT to the connector's relay endpoint over a WebSocket and
|
||||||
|
speaks the newline-delimited JSON frame protocol defined in the connector repo
|
||||||
|
(``gateway-gateway`` ``src/relay/protocol.ts``) and mirrored in
|
||||||
|
``docs/relay-connector-contract.md``:
|
||||||
|
|
||||||
|
gateway -> connector : hello, outbound, interrupt
|
||||||
|
connector -> gateway : descriptor, inbound, outbound_result, interrupt_inbound
|
||||||
|
|
||||||
|
Frames:
|
||||||
|
hello {type, platform, botId}
|
||||||
|
descriptor {type, descriptor} (handshake reply)
|
||||||
|
inbound {type, event, bufferId?} (a normalized MessageEvent)
|
||||||
|
outbound {type, requestId, action} (send/edit/typing/follow_up)
|
||||||
|
outbound_result {type, requestId, result}
|
||||||
|
interrupt {type, session_key, reason?} (gateway egresses /stop)
|
||||||
|
interrupt_inbound{type, session_key, chat_id} (connector -> owning gateway)
|
||||||
|
|
||||||
|
This is the concrete transport behind the ``RelayTransport`` Protocol; the
|
||||||
|
``RelayAdapter`` delegates all wire I/O to it. Outbound calls block on a
|
||||||
|
per-request future keyed by ``requestId`` until the matching ``outbound_result``
|
||||||
|
arrives. A background reader task pumps inbound frames to the registered handler
|
||||||
|
and resolves pending outbound futures.
|
||||||
|
|
||||||
|
EXPERIMENTAL: the frame schema may change without a deprecation cycle until at
|
||||||
|
least two Class-1 platforms validate it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from gateway.platforms.base import MessageEvent, MessageType
|
||||||
|
from gateway.session import SessionSource
|
||||||
|
from gateway.relay.descriptor import CapabilityDescriptor
|
||||||
|
from gateway.relay.transport import InboundHandler
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try: # lazy/optional dep — mirrors gateway/platforms/feishu.py
|
||||||
|
import websockets
|
||||||
|
except ImportError: # pragma: no cover - exercised only when the extra is absent
|
||||||
|
websockets = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
WEBSOCKETS_AVAILABLE = websockets is not None
|
||||||
|
|
||||||
|
# How long to wait for the handshake descriptor and for each outbound result.
|
||||||
|
_HANDSHAKE_TIMEOUT_S = 30.0
|
||||||
|
_OUTBOUND_TIMEOUT_S = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent:
|
||||||
|
"""Rebuild a MessageEvent from the connector's normalized inbound payload.
|
||||||
|
|
||||||
|
The connector emits SessionSource as the snake_case wire form (§3); map it
|
||||||
|
back onto the gateway dataclasses. Unknown message types fall back to TEXT.
|
||||||
|
"""
|
||||||
|
src = raw.get("source", {}) or {}
|
||||||
|
from gateway.config import Platform
|
||||||
|
|
||||||
|
platform = src.get("platform", "relay")
|
||||||
|
try:
|
||||||
|
platform_enum = Platform(platform)
|
||||||
|
except ValueError:
|
||||||
|
platform_enum = Platform.RELAY
|
||||||
|
|
||||||
|
source = SessionSource(
|
||||||
|
platform=platform_enum,
|
||||||
|
chat_id=src.get("chat_id", ""),
|
||||||
|
chat_type=src.get("chat_type", "dm"),
|
||||||
|
chat_name=src.get("chat_name"),
|
||||||
|
user_id=src.get("user_id"),
|
||||||
|
user_name=src.get("user_name"),
|
||||||
|
thread_id=src.get("thread_id"),
|
||||||
|
chat_topic=src.get("chat_topic"),
|
||||||
|
user_id_alt=src.get("user_id_alt"),
|
||||||
|
chat_id_alt=src.get("chat_id_alt"),
|
||||||
|
guild_id=src.get("guild_id"),
|
||||||
|
parent_chat_id=src.get("parent_chat_id"),
|
||||||
|
message_id=src.get("message_id"),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
msg_type = MessageType(raw.get("message_type", "text"))
|
||||||
|
except ValueError:
|
||||||
|
msg_type = MessageType.TEXT
|
||||||
|
|
||||||
|
return MessageEvent(
|
||||||
|
text=raw.get("text", ""),
|
||||||
|
message_type=msg_type,
|
||||||
|
source=source,
|
||||||
|
message_id=raw.get("message_id"),
|
||||||
|
reply_to_message_id=raw.get("reply_to_message_id"),
|
||||||
|
media_urls=raw.get("media_urls") or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketRelayTransport:
|
||||||
|
"""RelayTransport over a WebSocket connection the gateway dials to the connector."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
platform: str,
|
||||||
|
bot_id: str,
|
||||||
|
*,
|
||||||
|
connect_timeout_s: float = _HANDSHAKE_TIMEOUT_S,
|
||||||
|
outbound_timeout_s: float = _OUTBOUND_TIMEOUT_S,
|
||||||
|
gateway_id: Optional[str] = None,
|
||||||
|
upgrade_secret: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
if not WEBSOCKETS_AVAILABLE:
|
||||||
|
raise RuntimeError(
|
||||||
|
"WebSocketRelayTransport requires the 'websockets' package "
|
||||||
|
"(install the messaging extra)."
|
||||||
|
)
|
||||||
|
self._url = url
|
||||||
|
self._platform = platform
|
||||||
|
self._bot_id = bot_id
|
||||||
|
self._connect_timeout_s = connect_timeout_s
|
||||||
|
self._outbound_timeout_s = outbound_timeout_s
|
||||||
|
# Connection auth (Phase 2): when a per-gateway secret is configured the
|
||||||
|
# gateway presents an HMAC bearer on the WS upgrade so the connector can
|
||||||
|
# authenticate it (reject 4401 otherwise). gateway_id identifies the
|
||||||
|
# enrolled instance — the connector peeks it to index its secret verify
|
||||||
|
# list, then verifies the signature. Absent -> unauthenticated upgrade
|
||||||
|
# (dev/test, or a connector that doesn't enforce auth).
|
||||||
|
self._gateway_id = gateway_id
|
||||||
|
self._upgrade_secret = upgrade_secret
|
||||||
|
|
||||||
|
self._ws: Any = None
|
||||||
|
self._reader: Optional[asyncio.Task[None]] = None
|
||||||
|
self._inbound: Optional[InboundHandler] = None
|
||||||
|
self._descriptor: Optional[CapabilityDescriptor] = None
|
||||||
|
self._descriptor_ready: asyncio.Future[CapabilityDescriptor] | None = None
|
||||||
|
# requestId -> future awaiting the matching outbound_result.
|
||||||
|
self._pending: Dict[str, asyncio.Future[Dict[str, Any]]] = {}
|
||||||
|
self._closing = False
|
||||||
|
|
||||||
|
# ── lifecycle ────────────────────────────────────────────────────────
|
||||||
|
async def connect(self) -> bool:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
self._descriptor_ready = loop.create_future()
|
||||||
|
headers = self._upgrade_headers()
|
||||||
|
if headers:
|
||||||
|
self._ws = await websockets.connect(self._url, additional_headers=headers) # type: ignore[union-attr]
|
||||||
|
else:
|
||||||
|
self._ws = await websockets.connect(self._url) # type: ignore[union-attr]
|
||||||
|
self._reader = asyncio.create_task(self._read_loop(), name="relay-ws-reader")
|
||||||
|
# Send hello; the descriptor arrives via the reader and resolves handshake().
|
||||||
|
await self._send({"type": "hello", "platform": self._platform, "botId": self._bot_id})
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _upgrade_headers(self) -> Dict[str, str]:
|
||||||
|
"""Auth headers for the WS upgrade, or {} when no secret is configured.
|
||||||
|
|
||||||
|
Presents ``Authorization: Bearer *** where the token is a signed
|
||||||
|
bearer built with the per-gateway secret (``gateway/relay/auth.py``
|
||||||
|
``make_upgrade_token``), keyed by ``gateway_id`` so the connector can
|
||||||
|
index its verify list. The connector rejects the upgrade (close 4401)
|
||||||
|
when this is missing/invalid/revoked; an unauthenticated connector
|
||||||
|
ignores it.
|
||||||
|
"""
|
||||||
|
if not (self._upgrade_secret and self._gateway_id):
|
||||||
|
return {}
|
||||||
|
from gateway.relay.auth import make_upgrade_token
|
||||||
|
|
||||||
|
token = make_upgrade_token(self._gateway_id, self._upgrade_secret)
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
self._closing = True
|
||||||
|
if self._reader is not None:
|
||||||
|
self._reader.cancel()
|
||||||
|
try:
|
||||||
|
await self._reader
|
||||||
|
except (asyncio.CancelledError, Exception): # noqa: BLE001 - best-effort teardown
|
||||||
|
pass
|
||||||
|
self._reader = None
|
||||||
|
if self._ws is not None:
|
||||||
|
try:
|
||||||
|
await self._ws.close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
self._ws = None
|
||||||
|
# Fail any in-flight outbound waiters so callers don't hang.
|
||||||
|
for fut in self._pending.values():
|
||||||
|
if not fut.done():
|
||||||
|
fut.set_exception(RuntimeError("relay transport closed"))
|
||||||
|
self._pending.clear()
|
||||||
|
|
||||||
|
async def handshake(self) -> CapabilityDescriptor:
|
||||||
|
if self._descriptor is not None:
|
||||||
|
return self._descriptor
|
||||||
|
if self._descriptor_ready is None:
|
||||||
|
raise RuntimeError("handshake() called before connect()")
|
||||||
|
return await asyncio.wait_for(self._descriptor_ready, timeout=self._connect_timeout_s)
|
||||||
|
|
||||||
|
def set_inbound_handler(self, handler: InboundHandler) -> None:
|
||||||
|
self._inbound = handler
|
||||||
|
|
||||||
|
# ── outbound ─────────────────────────────────────────────────────────
|
||||||
|
async def send_outbound(self, action: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
return await self._request_response(action)
|
||||||
|
|
||||||
|
async def send_follow_up(self, action: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
# follow_up rides the same outbound frame; the connector dispatches by
|
||||||
|
# action.op. Kept as a distinct method to satisfy the transport Protocol
|
||||||
|
# and to make the A2 call site explicit.
|
||||||
|
return await self._request_response(action)
|
||||||
|
|
||||||
|
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||||
|
result = await self._request_response(
|
||||||
|
{"op": "get_chat_info", "chat_id": chat_id}, frame_type="outbound"
|
||||||
|
)
|
||||||
|
# The connector answers chat-info inside the outbound_result envelope.
|
||||||
|
info = result.get("chat_info") or result
|
||||||
|
return {"name": info.get("name", chat_id), "type": info.get("type", "dm")}
|
||||||
|
|
||||||
|
async def send_interrupt(self, session_key: str, reason: Optional[str] = None) -> None:
|
||||||
|
await self._send({"type": "interrupt", "session_key": session_key, "reason": reason})
|
||||||
|
|
||||||
|
async def _request_response(
|
||||||
|
self, action: Dict[str, Any], frame_type: str = "outbound"
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
if self._ws is None:
|
||||||
|
return {"success": False, "error": "relay transport not connected"}
|
||||||
|
request_id = uuid.uuid4().hex
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
fut: asyncio.Future[Dict[str, Any]] = loop.create_future()
|
||||||
|
self._pending[request_id] = fut
|
||||||
|
try:
|
||||||
|
await self._send({"type": frame_type, "requestId": request_id, "action": action})
|
||||||
|
return await asyncio.wait_for(fut, timeout=self._outbound_timeout_s)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return {"success": False, "error": "relay outbound timed out"}
|
||||||
|
finally:
|
||||||
|
self._pending.pop(request_id, None)
|
||||||
|
|
||||||
|
# ── wire I/O ─────────────────────────────────────────────────────────
|
||||||
|
async def _send(self, frame: Dict[str, Any]) -> None:
|
||||||
|
if self._ws is None:
|
||||||
|
raise RuntimeError("relay transport not connected")
|
||||||
|
await self._ws.send(json.dumps(frame) + "\n")
|
||||||
|
|
||||||
|
async def _read_loop(self) -> None:
|
||||||
|
assert self._ws is not None
|
||||||
|
buf = ""
|
||||||
|
try:
|
||||||
|
async for chunk in self._ws:
|
||||||
|
buf += chunk if isinstance(chunk, str) else chunk.decode("utf-8")
|
||||||
|
# Newline-delimited frames; keep any trailing partial line.
|
||||||
|
*lines, buf = buf.split("\n")
|
||||||
|
for line in lines:
|
||||||
|
if line.strip():
|
||||||
|
await self._handle_frame(line)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001 - log + let the task end; reconnection is caller policy
|
||||||
|
if not self._closing:
|
||||||
|
logger.warning("relay ws read loop ended: %s", exc)
|
||||||
|
|
||||||
|
async def _handle_frame(self, line: str) -> None:
|
||||||
|
try:
|
||||||
|
frame = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning("relay: skipping malformed frame")
|
||||||
|
return
|
||||||
|
ftype = frame.get("type")
|
||||||
|
if ftype == "descriptor":
|
||||||
|
descriptor = CapabilityDescriptor.from_json(json.dumps(frame.get("descriptor", {})))
|
||||||
|
self._descriptor = descriptor
|
||||||
|
if self._descriptor_ready is not None and not self._descriptor_ready.done():
|
||||||
|
self._descriptor_ready.set_result(descriptor)
|
||||||
|
elif ftype == "inbound":
|
||||||
|
if self._inbound is not None:
|
||||||
|
event = _event_from_wire(frame.get("event", {}))
|
||||||
|
await self._inbound(event)
|
||||||
|
elif ftype == "outbound_result":
|
||||||
|
fut = self._pending.get(frame.get("requestId", ""))
|
||||||
|
if fut is not None and not fut.done():
|
||||||
|
fut.set_result(frame.get("result", {}))
|
||||||
|
elif ftype == "interrupt_inbound":
|
||||||
|
# Bridged into the adapter's interrupt path by the runner wiring.
|
||||||
|
handler = getattr(self, "_interrupt_inbound_handler", None)
|
||||||
|
if handler is not None:
|
||||||
|
await handler(frame.get("session_key", ""), frame.get("chat_id", ""))
|
||||||
|
else:
|
||||||
|
# hello/outbound/interrupt are gateway->connector; ignore if echoed.
|
||||||
|
pass
|
||||||
|
|
||||||
|
def set_interrupt_inbound_handler(self, handler: Any) -> None:
|
||||||
|
"""Register the callback for connector->gateway interrupt_inbound frames."""
|
||||||
|
self._interrupt_inbound_handler = handler
|
||||||
@ -5110,6 +5110,31 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||||||
"plugin discovery failed at gateway startup", exc_info=True,
|
"plugin discovery failed at gateway startup", exc_info=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Register the generic relay adapter when a connector relay URL is
|
||||||
|
# configured (GATEWAY_RELAY_URL / gateway.relay_url). No URL -> no-op, so
|
||||||
|
# direct/single-tenant deployments are unaffected. When configured, the
|
||||||
|
# adapter dials the connector over a WebSocket, negotiates its capability
|
||||||
|
# descriptor at handshake, and bridges inbound/outbound like any platform.
|
||||||
|
try:
|
||||||
|
from gateway.relay import (
|
||||||
|
register_relay_adapter,
|
||||||
|
relay_url,
|
||||||
|
self_provision_if_managed,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Managed boot: self-provision relay creds in-process (resolve the
|
||||||
|
# agent's NAS token -> POST /relay/provision -> set GATEWAY_RELAY_* in
|
||||||
|
# os.environ) BEFORE registration reads them. No-op when not managed,
|
||||||
|
# relay unconfigured, or a secret is already pinned. Never raises.
|
||||||
|
self_provision_if_managed()
|
||||||
|
|
||||||
|
if register_relay_adapter():
|
||||||
|
logger.info("relay adapter registered (connector at %s)", relay_url())
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"relay adapter registration failed at gateway startup", exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
# Register declarative shell hooks from cli-config.yaml. Gateway
|
# Register declarative shell hooks from cli-config.yaml. Gateway
|
||||||
# has no TTY, so consent has to come from one of the three opt-in
|
# has no TTY, so consent has to come from one of the three opt-in
|
||||||
# channels (--accept-hooks on launch, HERMES_ACCEPT_HOOKS env var,
|
# channels (--accept-hooks on launch, HERMES_ACCEPT_HOOKS env var,
|
||||||
|
|||||||
@ -2214,7 +2214,9 @@ class GatewaySlashCommandsMixin:
|
|||||||
stranded).
|
stranded).
|
||||||
|
|
||||||
``diff`` output is truncated for chat bubbles — the full diff lives in
|
``diff`` output is truncated for chat bubbles — the full diff lives in
|
||||||
the CLI (``/skills diff <id>``) and the pending JSON file.
|
the pending JSON file under ``~/.hermes/pending/skills/``. (Note this is
|
||||||
|
the write-approval ``diff <id>``; the CLI also has an unrelated
|
||||||
|
``hermes skills diff <name>`` that diffs a bundled skill vs stock.)
|
||||||
"""
|
"""
|
||||||
from gateway.run import _hermes_home
|
from gateway.run import _hermes_home
|
||||||
from hermes_cli.write_approval_commands import handle_pending_subcommand
|
from hermes_cli.write_approval_commands import handle_pending_subcommand
|
||||||
@ -2252,12 +2254,14 @@ class GatewaySlashCommandsMixin:
|
|||||||
"(Search/install are CLI-only.)")
|
"(Search/install are CLI-only.)")
|
||||||
|
|
||||||
# Chat bubbles can't hold a full skill diff — truncate and point at
|
# Chat bubbles can't hold a full skill diff — truncate and point at
|
||||||
# the real review surfaces.
|
# the real review surface. (Note: `hermes skills diff <name>` is a
|
||||||
|
# *different* command — it diffs a bundled skill against its stock
|
||||||
|
# version — so we point at the pending JSON file, not that command.)
|
||||||
if args and args[0].lower() == "diff" and len(out) > 3000:
|
if args and args[0].lower() == "diff" and len(out) > 3000:
|
||||||
pending_id = args[1] if len(args) > 1 else "<id>"
|
pending_id = args[1] if len(args) > 1 else "<id>"
|
||||||
out = (out[:3000]
|
out = (out[:3000]
|
||||||
+ f"\n… (truncated — full diff: `/skills diff {pending_id}` "
|
+ "\n… (truncated — full diff in "
|
||||||
f"on the CLI, or ~/.hermes/pending/skills/{pending_id}.json)")
|
f"~/.hermes/pending/skills/{pending_id}.json)")
|
||||||
return out
|
return out
|
||||||
|
|
||||||
async def _handle_fast_command(self, event: MessageEvent) -> str:
|
async def _handle_fast_command(self, event: MessageEvent) -> str:
|
||||||
@ -2584,14 +2588,29 @@ class GatewaySlashCommandsMixin:
|
|||||||
# session_id for the continuation. Write the compressed messages
|
# session_id for the continuation. Write the compressed messages
|
||||||
# into the NEW session so the original history stays searchable.
|
# into the NEW session so the original history stays searchable.
|
||||||
new_session_id = tmp_agent.session_id
|
new_session_id = tmp_agent.session_id
|
||||||
if new_session_id != session_entry.session_id:
|
rotated = new_session_id != session_entry.session_id
|
||||||
|
if rotated:
|
||||||
session_entry.session_id = new_session_id
|
session_entry.session_id = new_session_id
|
||||||
self.session_store._save()
|
self.session_store._save()
|
||||||
self._sync_telegram_topic_binding(
|
self._sync_telegram_topic_binding(
|
||||||
source, session_entry, reason="compress-command",
|
source, session_entry, reason="compress-command",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Only rewrite the transcript when rotation actually produced a
|
||||||
|
# NEW session id. If _compress_context could not rotate (e.g.
|
||||||
|
# _session_db unavailable, or the DB split raised), session_id
|
||||||
|
# is unchanged and rewrite_transcript() would DELETE the
|
||||||
|
# original messages and replace them with only the compressed
|
||||||
|
# summary — permanent data loss (#44794, #39704). In that case
|
||||||
|
# leave the original transcript intact.
|
||||||
|
if rotated:
|
||||||
self.session_store.rewrite_transcript(new_session_id, compressed)
|
self.session_store.rewrite_transcript(new_session_id, compressed)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Manual /compress: session rotation did not occur "
|
||||||
|
"(session_id unchanged) — preserving original transcript "
|
||||||
|
"instead of overwriting it (#44794)."
|
||||||
|
)
|
||||||
# Reset stored token count — transcript changed, old value is stale
|
# Reset stored token count — transcript changed, old value is stale
|
||||||
self.session_store.update_session(
|
self.session_store.update_session(
|
||||||
session_entry.session_key, last_prompt_tokens=0
|
session_entry.session_key, last_prompt_tokens=0
|
||||||
|
|||||||
@ -71,6 +71,7 @@ DEFAULT_NOUS_PORTAL_URL = "https://portal.nousresearch.com"
|
|||||||
DEFAULT_NOUS_INFERENCE_URL = "https://inference-api.nousresearch.com/v1"
|
DEFAULT_NOUS_INFERENCE_URL = "https://inference-api.nousresearch.com/v1"
|
||||||
DEFAULT_NOUS_CLIENT_ID = "hermes-cli"
|
DEFAULT_NOUS_CLIENT_ID = "hermes-cli"
|
||||||
NOUS_INFERENCE_INVOKE_SCOPE = "inference:invoke"
|
NOUS_INFERENCE_INVOKE_SCOPE = "inference:invoke"
|
||||||
|
NOUS_BILLING_MANAGE_SCOPE = "billing:manage"
|
||||||
DEFAULT_NOUS_SCOPE = NOUS_INFERENCE_INVOKE_SCOPE
|
DEFAULT_NOUS_SCOPE = NOUS_INFERENCE_INVOKE_SCOPE
|
||||||
NOUS_DEVICE_CODE_SOURCE = "device_code"
|
NOUS_DEVICE_CODE_SOURCE = "device_code"
|
||||||
NOUS_AUTH_PATH_INVOKE_JWT = "invoke_jwt"
|
NOUS_AUTH_PATH_INVOKE_JWT = "invoke_jwt"
|
||||||
@ -7865,6 +7866,7 @@ def _nous_device_code_login(
|
|||||||
timeout_seconds: float = 15.0,
|
timeout_seconds: float = 15.0,
|
||||||
insecure: bool = False,
|
insecure: bool = False,
|
||||||
ca_bundle: Optional[str] = None,
|
ca_bundle: Optional[str] = None,
|
||||||
|
on_verification: Optional[Callable[[str, str], None]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Run the Nous device-code flow and return full OAuth state without persisting."""
|
"""Run the Nous device-code flow and return full OAuth state without persisting."""
|
||||||
pconfig = PROVIDER_REGISTRY["nous"]
|
pconfig = PROVIDER_REGISTRY["nous"]
|
||||||
@ -7919,6 +7921,16 @@ def _nous_device_code_login(
|
|||||||
else:
|
else:
|
||||||
print(" Could not open browser automatically — use the URL above.")
|
print(" Could not open browser automatically — use the URL above.")
|
||||||
|
|
||||||
|
# Surface the verification URL/code to an out-of-band consumer (e.g. the
|
||||||
|
# TUI gateway, whose stdout is a JSON-RPC pipe — a plain print() there is
|
||||||
|
# dropped). Fired AFTER the print/browser block and BEFORE polling blocks,
|
||||||
|
# so the consumer can render the link while we wait. Best-effort.
|
||||||
|
if on_verification is not None:
|
||||||
|
try:
|
||||||
|
on_verification(verification_url, user_code)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
effective_interval = max(1, min(interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS))
|
effective_interval = max(1, min(interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS))
|
||||||
print(f"Waiting for approval (polling every {effective_interval}s)...")
|
print(f"Waiting for approval (polling every {effective_interval}s)...")
|
||||||
|
|
||||||
@ -7984,6 +7996,91 @@ def _nous_device_code_login(
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def nous_token_has_billing_scope() -> bool:
|
||||||
|
"""Return True if the currently-held Nous token carries ``billing:manage``.
|
||||||
|
|
||||||
|
Reads the persisted ``scope`` string saved at login (``_save_provider_state``
|
||||||
|
stores ``token_data.get("scope") or scope``). A space-delimited match. Used by
|
||||||
|
the lazy step-up: if False, the first billing call will 403 ``insufficient_scope``
|
||||||
|
anyway, but checking up front lets a surface skip a doomed round-trip.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
state = get_provider_auth_state("nous") or {}
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
scope = state.get("scope")
|
||||||
|
if not isinstance(scope, str):
|
||||||
|
return False
|
||||||
|
return NOUS_BILLING_MANAGE_SCOPE in scope.split()
|
||||||
|
|
||||||
|
|
||||||
|
def step_up_nous_billing_scope(
|
||||||
|
*,
|
||||||
|
open_browser: bool = True,
|
||||||
|
timeout_seconds: float = 15.0,
|
||||||
|
on_verification: Optional[Callable[[str, str], None]] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Re-run the device flow requesting ``billing:manage`` and persist the result.
|
||||||
|
|
||||||
|
The lazy step-up (plan D-A): triggered when a billing endpoint returns
|
||||||
|
``403 insufficient_scope``. Runs a fresh device-connect with
|
||||||
|
``inference:invoke tool:invoke billing:manage`` on the scope. The user must be
|
||||||
|
an ADMIN/OWNER and tick "Allow terminal billing" in the portal for the minted
|
||||||
|
token to actually carry the scope; otherwise the server silently downscopes and this
|
||||||
|
returns False.
|
||||||
|
|
||||||
|
Reuses the held credential's portal/inference URLs + client_id so the step-up
|
||||||
|
targets the same deployment (incl. a preview via ``HERMES_PORTAL_BASE_URL`` set
|
||||||
|
at the original login). Persists to the auth store + shared store + pool, exactly
|
||||||
|
like ``_login_nous`` — but WITHOUT the model picker (this is a scope upgrade, not
|
||||||
|
a fresh login).
|
||||||
|
|
||||||
|
Returns True iff the new token carries ``billing:manage``.
|
||||||
|
"""
|
||||||
|
prior = get_provider_auth_state("nous") or {}
|
||||||
|
pconfig = PROVIDER_REGISTRY["nous"]
|
||||||
|
|
||||||
|
# Build the step-up scope: existing scopes (if any) + billing:manage, deduped,
|
||||||
|
# order-stable. Fall back to the standard inference+tool+billing set.
|
||||||
|
_raw_scope = prior.get("scope")
|
||||||
|
prior_scope = _raw_scope if isinstance(_raw_scope, str) else ""
|
||||||
|
requested: list[str] = []
|
||||||
|
for tok in (prior_scope.split() or [NOUS_INFERENCE_INVOKE_SCOPE, "tool:invoke"]):
|
||||||
|
if tok and tok not in requested:
|
||||||
|
requested.append(tok)
|
||||||
|
if NOUS_BILLING_MANAGE_SCOPE not in requested:
|
||||||
|
requested.append(NOUS_BILLING_MANAGE_SCOPE)
|
||||||
|
scope = " ".join(requested)
|
||||||
|
|
||||||
|
auth_state = _nous_device_code_login(
|
||||||
|
portal_base_url=prior.get("portal_base_url") or None,
|
||||||
|
inference_base_url=prior.get("inference_base_url") or None,
|
||||||
|
client_id=prior.get("client_id") or pconfig.client_id,
|
||||||
|
scope=scope,
|
||||||
|
open_browser=open_browser,
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
on_verification=on_verification,
|
||||||
|
)
|
||||||
|
|
||||||
|
with _auth_store_lock():
|
||||||
|
auth_store = _load_auth_store()
|
||||||
|
_save_provider_state(auth_store, "nous", auth_state)
|
||||||
|
_save_auth_store(auth_store)
|
||||||
|
|
||||||
|
# Mirror to shared store + reseed the pool (best-effort), same as _login_nous.
|
||||||
|
try:
|
||||||
|
_write_shared_nous_state(auth_state)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
_sync_nous_pool_from_auth_store()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
granted = auth_state.get("scope")
|
||||||
|
return isinstance(granted, str) and NOUS_BILLING_MANAGE_SCOPE in granted.split()
|
||||||
|
|
||||||
|
|
||||||
def _login_nous(args, pconfig: ProviderConfig) -> None:
|
def _login_nous(args, pconfig: ProviderConfig) -> None:
|
||||||
"""Nous Portal device authorization flow."""
|
"""Nous Portal device authorization flow."""
|
||||||
timeout_seconds = getattr(args, "timeout", None) or 15.0
|
timeout_seconds = getattr(args, "timeout", None) or 15.0
|
||||||
|
|||||||
@ -64,6 +64,39 @@ _EXCLUDED_NAMES = {
|
|||||||
"cron.pid",
|
"cron.pid",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# File names that ``hermes import`` must never overwrite, matched by basename so
|
||||||
|
# they're caught for the root profile (``gateway_state.json``) and for named
|
||||||
|
# profiles alike (``profiles/<name>/gateway_state.json``).
|
||||||
|
#
|
||||||
|
# These hold *volatile gateway/process runtime state that is namespaced to the
|
||||||
|
# machine or container the backup was taken on* — PIDs in a dead process
|
||||||
|
# namespace, a runtime lock, the process registry, and the gateway's last
|
||||||
|
# recorded run/desired state. Restoring them onto a different host (or a hosted
|
||||||
|
# container) is at best meaningless and at worst actively harmful:
|
||||||
|
#
|
||||||
|
# - ``gateway_state.json`` drives the container-boot reconciler
|
||||||
|
# (``container_boot._read_desired_state``), which only auto-starts a
|
||||||
|
# gateway whose recorded state is ``running``. A backup taken from a
|
||||||
|
# machine where the gateway was stopped (or carrying a stale/foreign
|
||||||
|
# value) overwrites the container's own state and leaves the gateway
|
||||||
|
# stuck "starting"/"cooking", disconnecting it from the Nous portal
|
||||||
|
# (NS-508 / the second half of NS-501).
|
||||||
|
# - ``gateway.pid`` / ``cron.pid`` / ``gateway.lock`` / ``processes.json``
|
||||||
|
# reference PIDs and locks in the *source* machine's process namespace; a
|
||||||
|
# numerically-equal PID in the new environment is a different process.
|
||||||
|
# These mirror exactly what ``container_boot._STALE_RUNTIME_FILES`` already
|
||||||
|
# sweeps on every container boot.
|
||||||
|
#
|
||||||
|
# Older backups predate the backup-side exclusions, so we filter on import too
|
||||||
|
# rather than trusting the archive's contents.
|
||||||
|
_IMPORT_SKIP_NAMES = {
|
||||||
|
"gateway_state.json",
|
||||||
|
"gateway.pid",
|
||||||
|
"cron.pid",
|
||||||
|
"gateway.lock",
|
||||||
|
"processes.json",
|
||||||
|
}
|
||||||
|
|
||||||
# zipfile.open() drops Unix mode bits on extract; restore tightens these to 0600.
|
# zipfile.open() drops Unix mode bits on extract; restore tightens these to 0600.
|
||||||
_SECRET_FILE_NAMES = {".env", "auth.json", "state.db"}
|
_SECRET_FILE_NAMES = {".env", "auth.json", "state.db"}
|
||||||
|
|
||||||
@ -385,6 +418,7 @@ def run_import(args) -> None:
|
|||||||
|
|
||||||
errors = []
|
errors = []
|
||||||
restored = 0
|
restored = 0
|
||||||
|
skipped_runtime: list[str] = []
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
|
|
||||||
for member in members:
|
for member in members:
|
||||||
@ -397,6 +431,16 @@ def run_import(args) -> None:
|
|||||||
if not rel:
|
if not rel:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Never overwrite volatile gateway/process runtime state. These are
|
||||||
|
# namespaced to the machine/container the backup was taken on;
|
||||||
|
# clobbering them (especially gateway_state.json) breaks the gateway
|
||||||
|
# reconciler on the target and disconnects hosted instances from the
|
||||||
|
# Nous portal. Matched by basename so both the root profile and
|
||||||
|
# named profiles (profiles/<name>/gateway_state.json) are covered.
|
||||||
|
if Path(rel).name in _IMPORT_SKIP_NAMES:
|
||||||
|
skipped_runtime.append(rel)
|
||||||
|
continue
|
||||||
|
|
||||||
target = hermes_root / rel
|
target = hermes_root / rel
|
||||||
|
|
||||||
# Security: reject absolute paths and traversals
|
# Security: reject absolute paths and traversals
|
||||||
@ -433,6 +477,16 @@ def run_import(args) -> None:
|
|||||||
if len(errors) > 10:
|
if len(errors) > 10:
|
||||||
print(f" ... and {len(errors) - 10} more")
|
print(f" ... and {len(errors) - 10} more")
|
||||||
|
|
||||||
|
if skipped_runtime:
|
||||||
|
print(
|
||||||
|
f"\n Preserved {len(skipped_runtime)} runtime state "
|
||||||
|
f"file(s) (kept this machine's, not the backup's):"
|
||||||
|
)
|
||||||
|
for rel in sorted(skipped_runtime)[:10]:
|
||||||
|
print(f" {rel}")
|
||||||
|
if len(skipped_runtime) > 10:
|
||||||
|
print(f" ... and {len(skipped_runtime) - 10} more")
|
||||||
|
|
||||||
# Post-import: restore profile wrapper scripts
|
# Post-import: restore profile wrapper scripts
|
||||||
profiles_dir = hermes_root / "profiles"
|
profiles_dir = hermes_root / "profiles"
|
||||||
restored_profiles = []
|
restored_profiles = []
|
||||||
|
|||||||
@ -215,6 +215,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||||||
gateway_only=True),
|
gateway_only=True),
|
||||||
CommandDef("usage", "Show token usage and rate limits for the current session", "Info"),
|
CommandDef("usage", "Show token usage and rate limits for the current session", "Info"),
|
||||||
CommandDef("credits", "Show Nous credit balance and top up", "Info"),
|
CommandDef("credits", "Show Nous credit balance and top up", "Info"),
|
||||||
|
CommandDef("billing", "Manage Nous terminal billing — buy credits, auto-reload, limits", "Info"),
|
||||||
CommandDef("insights", "Show usage insights and analytics", "Info",
|
CommandDef("insights", "Show usage insights and analytics", "Info",
|
||||||
args_hint="[days]"),
|
args_hint="[days]"),
|
||||||
CommandDef("platforms", "Show gateway/messaging platform status", "Info",
|
CommandDef("platforms", "Show gateway/messaging platform status", "Info",
|
||||||
@ -1053,8 +1054,9 @@ _SLACK_PRIORITY_ALIASES = ("btw", "bg")
|
|||||||
# the telegram-parity test reads it so an entry here is a deliberate
|
# the telegram-parity test reads it so an entry here is a deliberate
|
||||||
# "Slack-via-/hermes" decision, not a silent clamp.
|
# "Slack-via-/hermes" decision, not a silent clamp.
|
||||||
# - credits: the billing/top-up surface; reached via /hermes credits on Slack.
|
# - credits: the billing/top-up surface; reached via /hermes credits on Slack.
|
||||||
|
# - billing: the terminal-billing surface (buy/auto-reload/limit); /hermes billing.
|
||||||
# - debug: the log/report upload surface; reached via /hermes debug on Slack.
|
# - debug: the log/report upload surface; reached via /hermes debug on Slack.
|
||||||
_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "debug"})
|
_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "billing", "debug"})
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_slack_name(raw: str) -> str:
|
def _sanitize_slack_name(raw: str) -> str:
|
||||||
|
|||||||
@ -350,52 +350,124 @@ def get_managed_update_command() -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _install_method_project_root(project_root: Optional[Path] = None) -> Path:
|
||||||
|
"""Resolve the directory that holds the *running code* (the install tree).
|
||||||
|
|
||||||
|
This is the parent of ``hermes_cli/`` — i.e. the git checkout for source
|
||||||
|
installs, ``/opt/hermes`` inside the published image, the venv's
|
||||||
|
site-packages root for pip installs. It is a property of the running
|
||||||
|
interpreter, NOT of ``$HERMES_HOME``, which is why a code-scoped stamp
|
||||||
|
here is immune to two installs sharing one data directory.
|
||||||
|
"""
|
||||||
|
if project_root is not None:
|
||||||
|
return project_root
|
||||||
|
return Path(__file__).parent.parent.resolve()
|
||||||
|
|
||||||
|
|
||||||
def detect_install_method(project_root: Optional[Path] = None) -> str:
|
def detect_install_method(project_root: Optional[Path] = None) -> str:
|
||||||
"""Detect how Hermes was installed: 'docker', 'nixos', 'homebrew', 'git', or 'pip'.
|
"""Detect how Hermes was installed: 'docker', 'nixos', 'homebrew', 'git', or 'pip'.
|
||||||
|
|
||||||
Resolution order:
|
Resolution order:
|
||||||
1. Stamped ``~/.hermes/.install_method`` file (written by installers)
|
1. Code-scoped stamp ``<install tree>/.install_method`` (next to the
|
||||||
2. HERMES_MANAGED env / .managed marker (NixOS, Homebrew)
|
running code) — the authoritative marker.
|
||||||
3. .git directory presence -> 'git'
|
2. Legacy home-scoped stamp ``$HERMES_HOME/.install_method`` — read for
|
||||||
4. Fallback -> 'pip'
|
backward compatibility, but a ``docker`` value is IGNORED when we are
|
||||||
|
not actually running inside a container (see below).
|
||||||
|
3. HERMES_MANAGED env / .managed marker (NixOS, Homebrew)
|
||||||
|
4. .git directory presence -> 'git'
|
||||||
|
5. Fallback -> 'pip'
|
||||||
|
|
||||||
|
Why the stamp is code-scoped, not home-scoped (issue: shared ``~/.hermes``)
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
The install method describes *the binary that is running*, but
|
||||||
|
``$HERMES_HOME`` is a shared DATA directory — the Docker docs deliberately
|
||||||
|
bind-mount it (``~/.hermes:/opt/data``) so config/sessions/memory persist
|
||||||
|
and can be shared with a host-side Desktop/CLI install. When a
|
||||||
|
containerised gateway and a host install share one ``$HERMES_HOME``, a
|
||||||
|
home-scoped stamp is a single slot describing two different installs:
|
||||||
|
the container stamps ``docker`` on every boot, the host install then reads
|
||||||
|
``docker`` and ``hermes update`` refuses to run ("doesn't apply inside the
|
||||||
|
Docker container") even though the host binary is a perfectly updatable
|
||||||
|
git/pip install. Scoping the stamp to the install tree gives each install
|
||||||
|
its own truthful marker.
|
||||||
|
|
||||||
|
Self-healing for already-poisoned homes: a legacy ``docker`` value in the
|
||||||
|
home-scoped stamp is only honoured when we are genuinely in a container.
|
||||||
|
On a host install that read a contaminating ``docker`` stamp, we fall
|
||||||
|
through to managed/.git/pip detection instead — so existing shared-home
|
||||||
|
setups recover without the user touching anything.
|
||||||
|
|
||||||
Note: running inside a container is NOT treated as "docker" on its own.
|
Note: running inside a container is NOT treated as "docker" on its own.
|
||||||
The two supported install paths both self-identify via the
|
The supported installs self-identify via the code-scoped stamp:
|
||||||
``.install_method`` stamp (caught by step 1), so neither relies on
|
|
||||||
container detection here:
|
|
||||||
- the curl installer (scripts/install.sh, the README/website install
|
- the curl installer (scripts/install.sh, the README/website install
|
||||||
command) git-clones the repo and stamps ``git``;
|
command) git-clones the repo and stamps ``git`` next to the code;
|
||||||
- the published ``nousresearch/hermes-agent`` image stamps ``docker``
|
- the published ``nousresearch/hermes-agent`` image bakes a ``docker``
|
||||||
at boot via ``docker/stage2-hook.sh``.
|
stamp into ``/opt/hermes`` at build time.
|
||||||
An unsupported manual install dropped into a container (no stamp) was
|
An unsupported manual install dropped into a container (no stamp) falls
|
||||||
wrongly classified as the published image by bare container detection,
|
through to the ``.git``/pip checks and behaves like any off-path install.
|
||||||
so ``hermes update`` bailed with "doesn't apply inside the Docker
|
See issue #34397.
|
||||||
container". Without that fallback such installs fall through to the
|
|
||||||
``.git``/pip checks and behave like any off-path install. See issue #34397.
|
|
||||||
"""
|
"""
|
||||||
stamp = get_hermes_home() / ".install_method"
|
root = _install_method_project_root(project_root)
|
||||||
|
|
||||||
|
# 1. Code-scoped stamp — authoritative, immune to shared $HERMES_HOME.
|
||||||
try:
|
try:
|
||||||
method = stamp.read_text(encoding="utf-8").strip().lower()
|
method = (root / ".install_method").read_text(encoding="utf-8").strip().lower()
|
||||||
if method:
|
if method:
|
||||||
return method
|
return method
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# 2. Legacy home-scoped stamp — back-compat. Ignore a ``docker`` value
|
||||||
|
# when we are not actually containerised: that is the signature of a
|
||||||
|
# host install whose shared $HERMES_HOME was stamped by a co-located
|
||||||
|
# container, and honouring it wrongly blocks ``hermes update``.
|
||||||
|
try:
|
||||||
|
method = (
|
||||||
|
(get_hermes_home() / ".install_method")
|
||||||
|
.read_text(encoding="utf-8")
|
||||||
|
.strip()
|
||||||
|
.lower()
|
||||||
|
)
|
||||||
|
if method and not (method == "docker" and not _running_in_container()):
|
||||||
|
return method
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
managed = get_managed_system()
|
managed = get_managed_system()
|
||||||
if managed:
|
if managed:
|
||||||
return managed.lower().replace(" ", "-")
|
return managed.lower().replace(" ", "-")
|
||||||
if project_root is None:
|
if (root / ".git").is_dir():
|
||||||
project_root = Path(__file__).parent.parent.resolve()
|
|
||||||
if (project_root / ".git").is_dir():
|
|
||||||
return "git"
|
return "git"
|
||||||
return "pip"
|
return "pip"
|
||||||
|
|
||||||
|
|
||||||
def stamp_install_method(method: str) -> None:
|
def _running_in_container() -> bool:
|
||||||
"""Write the install method to ~/.hermes/.install_method."""
|
"""Thin wrapper around ``hermes_constants.is_container`` (import-safe)."""
|
||||||
stamp = get_hermes_home() / ".install_method"
|
|
||||||
try:
|
try:
|
||||||
stamp.parent.mkdir(parents=True, exist_ok=True)
|
from hermes_constants import is_container
|
||||||
stamp.write_text(method + "\n", encoding="utf-8")
|
|
||||||
|
return is_container()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def stamp_install_method(method: str, project_root: Optional[Path] = None) -> None:
|
||||||
|
"""Write the install method next to the running code (code-scoped stamp).
|
||||||
|
|
||||||
|
The stamp lives in the install tree (``<install tree>/.install_method``),
|
||||||
|
not in ``$HERMES_HOME``, so that two installs sharing one data directory
|
||||||
|
do not overwrite each other's marker. See ``detect_install_method`` for
|
||||||
|
the full rationale.
|
||||||
|
|
||||||
|
Best-effort: if the install tree is read-only (e.g. the immutable
|
||||||
|
``/opt/hermes`` in the published image, which instead bakes the stamp at
|
||||||
|
build time) the write silently no-ops and detection falls back to its
|
||||||
|
other signals.
|
||||||
|
"""
|
||||||
|
root = _install_method_project_root(project_root)
|
||||||
|
try:
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
(root / ".install_method").write_text(method + "\n", encoding="utf-8")
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -853,6 +925,15 @@ DEFAULT_CONFIG = {
|
|||||||
# plausible-looking output when a real path is blocked. Costs ~80
|
# plausible-looking output when a real path is blocked. Costs ~80
|
||||||
# tokens in the cached system prompt. Set False to disable globally.
|
# tokens in the cached system prompt. Set False to disable globally.
|
||||||
"task_completion_guidance": True,
|
"task_completion_guidance": True,
|
||||||
|
# Universal parallel-tool-call guidance — short prompt block applied to
|
||||||
|
# all models that tells the model to batch independent tool calls
|
||||||
|
# (reads, searches, web fetches, read-only commands) into one turn
|
||||||
|
# instead of one call per turn. The runtime already runs independent
|
||||||
|
# calls concurrently, so this just steers the model to produce the
|
||||||
|
# batch — cutting round-trips and the resent-context cost that
|
||||||
|
# compounds over a long conversation. Costs ~70 tokens in the cached
|
||||||
|
# system prompt. Set False to disable globally.
|
||||||
|
"parallel_tool_call_guidance": True,
|
||||||
# Local-environment toolchain probe — surfaces Python/pip/uv/PEP-668
|
# Local-environment toolchain probe — surfaces Python/pip/uv/PEP-668
|
||||||
# state in the system prompt when something non-default is detected
|
# state in the system prompt when something non-default is detected
|
||||||
# (e.g. python3 has no pip module, pip→python version mismatch, PEP
|
# (e.g. python3 has no pip module, pip→python version mismatch, PEP
|
||||||
@ -2424,11 +2505,14 @@ DEFAULT_CONFIG = {
|
|||||||
"updates": {
|
"updates": {
|
||||||
# Run a full ``hermes backup``-style zip of HERMES_HOME before every
|
# Run a full ``hermes backup``-style zip of HERMES_HOME before every
|
||||||
# ``hermes update``. Backups land in ``<HERMES_HOME>/backups/`` and
|
# ``hermes update``. Backups land in ``<HERMES_HOME>/backups/`` and
|
||||||
# can be restored with ``hermes import <path>``. Off by default —
|
# can be restored with ``hermes import <path>``. Defaults to true
|
||||||
# on large HERMES_HOME directories the zip can add minutes to every
|
# after the #48200 incident: a ``hermes update --yes`` run that
|
||||||
# update. Set to true to re-enable, or pass ``--backup`` to opt in
|
# computed a wrong path silently wiped the user's ``.env``,
|
||||||
# for a single update run.
|
# ``MEMORY.md``, ``kanban.db``, custom skills, and scripts in one
|
||||||
"pre_update_backup": False,
|
# go. The cost of a few minutes of zip time per update is
|
||||||
|
# negligible compared to the alternative. Set to false to opt
|
||||||
|
# out, or pass ``--no-backup`` for a single update run.
|
||||||
|
"pre_update_backup": True,
|
||||||
# How many pre-update backup zips to retain. Older ones are pruned
|
# How many pre-update backup zips to retain. Older ones are pruned
|
||||||
# automatically after each successful backup. Values below 1 are
|
# automatically after each successful backup. Values below 1 are
|
||||||
# floored to 1 — the backup just created is always preserved. To
|
# floored to 1 — the backup just created is always preserved. To
|
||||||
|
|||||||
@ -56,6 +56,30 @@ def _get_git_commit(project_root: Path) -> str:
|
|||||||
return "(unknown)"
|
return "(unknown)"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_git_commit_date(project_root: Path) -> str:
|
||||||
|
"""Return the date the HEAD commit was authored (YYYY-MM-DD), or ''.
|
||||||
|
|
||||||
|
Resolves live via ``git log`` on source installs. The published Docker
|
||||||
|
image excludes ``.git``, so this returns '' there — the dump line simply
|
||||||
|
drops the date suffix in that case (the baked SHA still identifies the
|
||||||
|
build).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "log", "-1", "--format=%cd", "--date=short", "HEAD"],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
cwd=str(project_root),
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
value = result.stdout.strip()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _redact(value: str) -> str:
|
def _redact(value: str) -> str:
|
||||||
"""Redact all but first 4 and last 4 chars.
|
"""Redact all but first 4 and last 4 chars.
|
||||||
|
|
||||||
@ -231,12 +255,12 @@ def run_dump(args):
|
|||||||
hermes_home = get_hermes_home()
|
hermes_home = get_hermes_home()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from hermes_cli import __version__, __release_date__
|
from hermes_cli import __version__
|
||||||
except ImportError:
|
except ImportError:
|
||||||
__version__ = "(unknown)"
|
__version__ = "(unknown)"
|
||||||
__release_date__ = ""
|
|
||||||
|
|
||||||
commit = _get_git_commit(project_root)
|
commit = _get_git_commit(project_root)
|
||||||
|
commit_date = _get_git_commit_date(project_root)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@ -283,10 +307,14 @@ def run_dump(args):
|
|||||||
|
|
||||||
lines = []
|
lines = []
|
||||||
lines.append("--- hermes dump ---")
|
lines.append("--- hermes dump ---")
|
||||||
|
# Identify the build by commit + the date that commit was made, resolved
|
||||||
|
# live via git. __release_date__ (the package release date) is
|
||||||
|
# intentionally NOT shown here — it reads like a wall-clock timestamp and
|
||||||
|
# confuses support triage. The commit date is the real "as-of" date.
|
||||||
ver_str = f"{__version__}"
|
ver_str = f"{__version__}"
|
||||||
if __release_date__:
|
|
||||||
ver_str += f" ({__release_date__})"
|
|
||||||
ver_str += f" [{commit}]"
|
ver_str += f" [{commit}]"
|
||||||
|
if commit_date:
|
||||||
|
ver_str += f" ({commit_date})"
|
||||||
lines.append(f"version: {ver_str}")
|
lines.append(f"version: {ver_str}")
|
||||||
lines.append(f"os: {os_info}")
|
lines.append(f"os: {os_info}")
|
||||||
lines.append(f"python: {sys.version.split()[0]}")
|
lines.append(f"python: {sys.version.split()[0]}")
|
||||||
|
|||||||
250
hermes_cli/gateway_enroll.py
Normal file
250
hermes_cli/gateway_enroll.py
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
"""``hermes gateway enroll`` — enroll a self-hosted gateway with a relay connector.
|
||||||
|
|
||||||
|
The connector⇄gateway channel is authenticated (the gateway may be
|
||||||
|
customer-managed and internet-exposed). This command is the gateway half of the
|
||||||
|
zero-touch enrollment in the connector repo's
|
||||||
|
``docs/connector-gateway-auth-design.md``:
|
||||||
|
|
||||||
|
1. Resolve a fresh Nous Portal access token from the existing login
|
||||||
|
(``~/.hermes/auth.json``) — the same path ``hermes dashboard register``
|
||||||
|
uses (``resolve_nous_access_token``). This proves *which Nous org (tenant)*
|
||||||
|
the caller owns; the connector derives the authoritative tenant from it via
|
||||||
|
``GET /api/oauth/account`` (never from anything the gateway asserts).
|
||||||
|
2. POST ``{enrollmentToken, gatewayId}`` to the connector's ``/relay/enroll``
|
||||||
|
with that token in the ``Authorization`` header, over TLS.
|
||||||
|
3. The connector verifies the enrollment token (signature + single-use +
|
||||||
|
tenant match), mints a per-gateway secret, get-or-creates the per-tenant
|
||||||
|
delivery key, and returns both ONCE.
|
||||||
|
4. Persist ``GATEWAY_RELAY_ID`` / ``GATEWAY_RELAY_SECRET`` /
|
||||||
|
``GATEWAY_RELAY_DELIVERY_KEY`` (+ ``GATEWAY_RELAY_URL`` if supplied) into
|
||||||
|
``~/.hermes/.env``. The per-gateway secret authenticates the WS upgrade;
|
||||||
|
the per-tenant delivery key verifies signed inbound deliveries.
|
||||||
|
|
||||||
|
Managed/hosted installs do NOT self-enroll: the orchestrator (NAS) mints the
|
||||||
|
secret directly and stamps it into the container env, so this command refuses to
|
||||||
|
run under ``is_managed()`` (mirrors ``dashboard register``).
|
||||||
|
|
||||||
|
EXPERIMENTAL: the relay auth scheme may change without a deprecation cycle until
|
||||||
|
≥2 Class-1 platforms validate the contract.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _default_gateway_id() -> str:
|
||||||
|
"""A stable-ish default gateway instance id: ``<hostname>-<pid-free slug>``.
|
||||||
|
|
||||||
|
The gatewayId identifies this enrolled instance for kill-switch granularity
|
||||||
|
(the connector indexes its secret verify list by it). Default to the host
|
||||||
|
name so a human can recognize it; overridable via ``--gateway-id``.
|
||||||
|
"""
|
||||||
|
host = ""
|
||||||
|
try:
|
||||||
|
host = socket.gethostname().strip()
|
||||||
|
except Exception:
|
||||||
|
host = ""
|
||||||
|
return f"gw-{host or 'hermes'}"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_connector_url(override: Optional[str]) -> Optional[str]:
|
||||||
|
"""Resolve the connector base URL (no trailing slash) for enrollment.
|
||||||
|
|
||||||
|
Precedence: explicit ``--connector-url`` flag > ``GATEWAY_RELAY_URL`` env >
|
||||||
|
``gateway.relay_url`` in config.yaml. The relay URL is a ``ws(s)://`` dial
|
||||||
|
target; enrollment is an ``http(s)://`` POST to the same host, so we map the
|
||||||
|
scheme. Returns None when nothing is configured (the user must supply one).
|
||||||
|
"""
|
||||||
|
raw = (override or os.environ.get("GATEWAY_RELAY_URL", "")).strip()
|
||||||
|
if not raw:
|
||||||
|
try:
|
||||||
|
from gateway.run import _load_gateway_config # late import to avoid cycle
|
||||||
|
|
||||||
|
cfg = (_load_gateway_config().get("gateway") or {})
|
||||||
|
raw = str(cfg.get("relay_url", "") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
raw = ""
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
raw = raw.rstrip("/")
|
||||||
|
# The relay dial URL is ws(s)://…/relay; enrollment posts to http(s)://…/relay/enroll.
|
||||||
|
if raw.startswith("ws://"):
|
||||||
|
raw = "http://" + raw[len("ws://"):]
|
||||||
|
elif raw.startswith("wss://"):
|
||||||
|
raw = "https://" + raw[len("wss://"):]
|
||||||
|
# Strip a trailing /relay path segment if the user pasted the dial URL.
|
||||||
|
if raw.endswith("/relay"):
|
||||||
|
raw = raw[: -len("/relay")]
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _post_enroll(
|
||||||
|
*,
|
||||||
|
connector_base_url: str,
|
||||||
|
access_token: str,
|
||||||
|
enrollment_token: str,
|
||||||
|
gateway_id: str,
|
||||||
|
timeout: float = 15.0,
|
||||||
|
) -> dict:
|
||||||
|
"""POST to the connector's ``/relay/enroll`` and return the JSON body.
|
||||||
|
|
||||||
|
Raises RuntimeError with a user-facing message on any non-2xx / transport
|
||||||
|
failure. The connector returns ``{secret, deliveryKey, tenant, gatewayId}``
|
||||||
|
on success, ``{error}`` at 400/401/403.
|
||||||
|
"""
|
||||||
|
url = f"{connector_base_url.rstrip('/')}/relay/enroll"
|
||||||
|
data = json.dumps({"enrollmentToken": enrollment_token, "gatewayId": gateway_id}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
payload = json.loads(resp.read().decode())
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = ""
|
||||||
|
try:
|
||||||
|
detail = (json.loads(exc.read().decode()) or {}).get("error", "")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if exc.code == 401:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Connector rejected the caller identity (401). Your Nous Portal "
|
||||||
|
"token could not be verified — try `hermes auth login nous` and retry."
|
||||||
|
) from exc
|
||||||
|
if exc.code == 403:
|
||||||
|
raise RuntimeError(
|
||||||
|
detail
|
||||||
|
or "Enrollment token invalid, expired, already used, or tenant mismatch (403)."
|
||||||
|
) from exc
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Connector returned HTTP {exc.code}" + (f": {detail}" if detail else "")
|
||||||
|
) from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Could not reach the connector at {connector_base_url}: {exc.reason}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not isinstance(payload, dict) or not payload.get("secret"):
|
||||||
|
raise RuntimeError("Connector returned an unexpected response (no secret).")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_gateway_enroll(args) -> None:
|
||||||
|
"""Enroll this gateway with a relay connector; persist the auth creds to .env."""
|
||||||
|
from hermes_cli.auth import AuthError, resolve_nous_access_token
|
||||||
|
from hermes_cli.config import is_managed, save_env_value
|
||||||
|
|
||||||
|
# Managed installs get GATEWAY_RELAY_* stamped in by the orchestrator (NAS
|
||||||
|
# mints the secret directly per the design's managed shape). Self-enrolling
|
||||||
|
# from inside such a container is a mistake — and save_env_value refuses to
|
||||||
|
# write anyway.
|
||||||
|
if is_managed():
|
||||||
|
print(
|
||||||
|
"✗ `hermes gateway enroll` is not available in a managed/hosted install.\n"
|
||||||
|
" The relay gateway secret is provisioned by the hosting platform."
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
enrollment_token = (getattr(args, "token", None) or os.environ.get("GATEWAY_RELAY_ENROLL_TOKEN", "")).strip()
|
||||||
|
if not enrollment_token:
|
||||||
|
print(
|
||||||
|
"✗ No enrollment token. Pass --token <token> (or set "
|
||||||
|
"GATEWAY_RELAY_ENROLL_TOKEN).\n"
|
||||||
|
" The connector mints this single-use token when your tenant's route "
|
||||||
|
"is provisioned; it is delivered with your gateway config."
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
connector_base_url = _resolve_connector_url(getattr(args, "connector_url", None))
|
||||||
|
if not connector_base_url:
|
||||||
|
print(
|
||||||
|
"✗ No connector URL. Pass --connector-url <url> (or set GATEWAY_RELAY_URL "
|
||||||
|
"/ gateway.relay_url in config.yaml)."
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
gateway_id = (getattr(args, "gateway_id", None) or _default_gateway_id()).strip()
|
||||||
|
|
||||||
|
# 1. Resolve a fresh Nous access token (the tenant-proving identity).
|
||||||
|
try:
|
||||||
|
access_token = resolve_nous_access_token()
|
||||||
|
except AuthError as exc:
|
||||||
|
if getattr(exc, "relogin_required", False):
|
||||||
|
print("✗ You're not logged into Nous Portal.")
|
||||||
|
print(" Run `hermes setup` (or `hermes auth login nous`) first, then retry.")
|
||||||
|
else:
|
||||||
|
print(f"✗ Could not resolve a Nous Portal access token: {exc}")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"✗ Could not resolve a Nous Portal access token: {exc}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 2-3. Redeem the enrollment token at the connector.
|
||||||
|
try:
|
||||||
|
result = _post_enroll(
|
||||||
|
connector_base_url=connector_base_url,
|
||||||
|
access_token=access_token,
|
||||||
|
enrollment_token=enrollment_token,
|
||||||
|
gateway_id=gateway_id,
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
print(f"✗ Enrollment failed: {exc}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
secret = str(result.get("secret") or "")
|
||||||
|
delivery_key = str(result.get("deliveryKey") or "")
|
||||||
|
tenant = str(result.get("tenant") or "")
|
||||||
|
resolved_gateway_id = str(result.get("gatewayId") or gateway_id)
|
||||||
|
|
||||||
|
# 4. Persist the creds idempotently. The secret + delivery key are sensitive;
|
||||||
|
# save_env_value writes them to ~/.hermes/.env (0600 dir) and never logs.
|
||||||
|
to_write = {
|
||||||
|
"GATEWAY_RELAY_ID": resolved_gateway_id,
|
||||||
|
"GATEWAY_RELAY_SECRET": secret,
|
||||||
|
"GATEWAY_RELAY_DELIVERY_KEY": delivery_key,
|
||||||
|
}
|
||||||
|
# Persist the connector URL too (as the ws(s):// dial target) when supplied
|
||||||
|
# explicitly, so the runtime can dial without re-specifying it.
|
||||||
|
explicit_url = (getattr(args, "connector_url", None) or "").strip()
|
||||||
|
if explicit_url:
|
||||||
|
to_write["GATEWAY_RELAY_URL"] = explicit_url.rstrip("/")
|
||||||
|
|
||||||
|
for key, value in to_write.items():
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
save_env_value(key, value)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"✗ Failed to write {key} to .env: {exc}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
from hermes_cli.config import get_env_path
|
||||||
|
|
||||||
|
print(f'✓ Enrolled gateway "{resolved_gateway_id}"' + (f" for tenant {tenant}" if tenant else ""))
|
||||||
|
print()
|
||||||
|
print(f" Wrote to {get_env_path()}:")
|
||||||
|
print(f" GATEWAY_RELAY_ID={resolved_gateway_id}")
|
||||||
|
print(" GATEWAY_RELAY_SECRET=<hidden>")
|
||||||
|
print(" GATEWAY_RELAY_DELIVERY_KEY=<hidden>")
|
||||||
|
if explicit_url:
|
||||||
|
print(f" GATEWAY_RELAY_URL={explicit_url.rstrip('/')}")
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
" The gateway now authenticates its relay WS upgrade with the per-gateway\n"
|
||||||
|
" secret and verifies signed inbound deliveries with the tenant delivery\n"
|
||||||
|
" key. Restart the gateway to pick up the new env."
|
||||||
|
)
|
||||||
@ -117,7 +117,7 @@ def build_models_payload(
|
|||||||
pricing: bool = False,
|
pricing: bool = False,
|
||||||
capabilities: bool = False,
|
capabilities: bool = False,
|
||||||
force_fresh_nous_tier: bool = False,
|
force_fresh_nous_tier: bool = False,
|
||||||
max_models: int = 50,
|
max_models: int | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Build the ``{providers, model, provider}`` shape every consumer
|
"""Build the ``{providers, model, provider}`` shape every consumer
|
||||||
needs from a single substrate call.
|
needs from a single substrate call.
|
||||||
|
|||||||
@ -5110,16 +5110,38 @@ def _purge_electron_build_cache(desktop_dir: Path) -> list[Path]:
|
|||||||
return removed
|
return removed
|
||||||
|
|
||||||
|
|
||||||
def _electron_dist_binary(project_root: Path) -> Path:
|
# Last-resort Electron mirror after GitHub download fails (#47266). Only used
|
||||||
"""Return the path to the Electron main binary inside ``node_modules``.
|
# when the user hasn't pinned ELECTRON_MIRROR.
|
||||||
|
_ELECTRON_FALLBACK_MIRROR = "https://npmmirror.com/mirrors/electron/"
|
||||||
|
|
||||||
electron-builder reads the binary from ``build.electronDist``
|
|
||||||
(``node_modules/electron/dist``) since #38673, so this is the exact file
|
def _electron_dir(project_root: Path) -> Path:
|
||||||
whose absence makes a pack fail with "The specified electronDist does not
|
"""Return the Electron package directory the desktop workspace installs.
|
||||||
exist". The basename differs per OS (the platform Electron is named for the
|
|
||||||
host the build runs on).
|
npm may keep workspace-only dev dependencies under
|
||||||
|
``apps/desktop/node_modules`` instead of hoisting them to the repo root.
|
||||||
|
Which layout you get depends on the npm version and what else is installed,
|
||||||
|
so a build path that assumes one or the other breaks intermittently across
|
||||||
|
machines. ``apps/desktop/package.json`` points electron-builder's
|
||||||
|
``electronDist`` at ``node_modules/electron/dist`` relative to the desktop
|
||||||
|
project, so prefer the workspace-local package and fall back to the root
|
||||||
|
hoist when that's where npm landed it.
|
||||||
"""
|
"""
|
||||||
dist = project_root / "node_modules" / "electron" / "dist"
|
desktop_local = project_root / "apps" / "desktop" / "node_modules" / "electron"
|
||||||
|
if desktop_local.exists():
|
||||||
|
return desktop_local
|
||||||
|
return project_root / "node_modules" / "electron"
|
||||||
|
|
||||||
|
|
||||||
|
def _electron_dist_binary(project_root: Path) -> Path:
|
||||||
|
"""Return the path to the Electron main binary inside the installed package.
|
||||||
|
|
||||||
|
electron-builder reads the binary from ``build.electronDist`` since #38673,
|
||||||
|
so this is the exact file whose absence makes a pack fail with "The
|
||||||
|
specified electronDist does not exist". The basename differs per OS (the
|
||||||
|
platform Electron is named for the host the build runs on).
|
||||||
|
"""
|
||||||
|
dist = _electron_dir(project_root) / "dist"
|
||||||
if sys.platform == "darwin":
|
if sys.platform == "darwin":
|
||||||
return dist / "Electron.app" / "Contents" / "MacOS" / "Electron"
|
return dist / "Electron.app" / "Contents" / "MacOS" / "Electron"
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
@ -5141,35 +5163,27 @@ def _electron_dist_ok(project_root: Path) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _electron_pkg_staged_missing_dist(project_root: Path) -> bool:
|
||||||
|
"""electron staged (package.json + install.js) but dist missing — blocked postinstall."""
|
||||||
|
electron_dir = _electron_dir(project_root)
|
||||||
|
return (
|
||||||
|
(electron_dir / "package.json").is_file()
|
||||||
|
and (electron_dir / "install.js").is_file()
|
||||||
|
and not _electron_dist_ok(project_root)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _redownload_electron_dist(
|
def _redownload_electron_dist(
|
||||||
project_root: Path,
|
project_root: Path,
|
||||||
env: dict,
|
env: dict,
|
||||||
*,
|
*,
|
||||||
mirror: Optional[str] = None,
|
mirror: Optional[str] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""(Re)populate ``node_modules/electron/dist`` via electron's own downloader.
|
"""Best-effort: run electron's install.js to populate dist/ (optional mirror)."""
|
||||||
|
|
||||||
Since #38673 the desktop build pins ``build.electronDist`` to
|
|
||||||
``node_modules/electron/dist``, so electron-builder reads the Electron binary
|
|
||||||
straight from there and never downloads it during ``npm run pack``. That dist
|
|
||||||
tree is produced by the ``electron`` package's postinstall (``install.js``)
|
|
||||||
during ``npm ci``. When that download is blocked or throttled (GitHub's
|
|
||||||
release host is unreachable in some regions — #47266), the dist is missing
|
|
||||||
and re-running ``pack`` only re-throws "The specified electronDist does not
|
|
||||||
exist". The mirror fallback therefore has to drive *this* downloader, not
|
|
||||||
another ``pack``.
|
|
||||||
|
|
||||||
No-op (returns True) when the dist binary is already present, so an unrelated
|
|
||||||
build failure doesn't trigger a needless ~200 MB re-download. Otherwise drops
|
|
||||||
any partial dist + version marker (electron's install.js short-circuits when
|
|
||||||
``path.txt`` already matches) and runs the downloader once, optionally via a
|
|
||||||
mirror. Best-effort: never raises. Returns True iff the dist binary exists
|
|
||||||
afterward.
|
|
||||||
"""
|
|
||||||
if _electron_dist_ok(project_root):
|
if _electron_dist_ok(project_root):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
electron_dir = project_root / "node_modules" / "electron"
|
electron_dir = _electron_dir(project_root)
|
||||||
installer = electron_dir / "install.js"
|
installer = electron_dir / "install.js"
|
||||||
if not installer.is_file():
|
if not installer.is_file():
|
||||||
return False
|
return False
|
||||||
@ -5194,6 +5208,15 @@ def _redownload_electron_dist(
|
|||||||
return _electron_dist_ok(project_root)
|
return _electron_dist_ok(project_root)
|
||||||
|
|
||||||
|
|
||||||
|
def _try_redownload_electron_dist(project_root: Path, env: dict) -> bool:
|
||||||
|
"""Canonical download, then fallback mirror unless the user pinned one."""
|
||||||
|
if _redownload_electron_dist(project_root, env):
|
||||||
|
return True
|
||||||
|
if env.get("ELECTRON_MIRROR"):
|
||||||
|
return False
|
||||||
|
return _redownload_electron_dist(project_root, env, mirror=_ELECTRON_FALLBACK_MIRROR)
|
||||||
|
|
||||||
|
|
||||||
def _stop_desktop_processes_locking_build(desktop_dir: Path) -> list[int]:
|
def _stop_desktop_processes_locking_build(desktop_dir: Path) -> list[int]:
|
||||||
"""Terminate any running desktop app executing from this build's ``release``
|
"""Terminate any running desktop app executing from this build's ``release``
|
||||||
dir so a rebuild can replace its (otherwise locked) executable.
|
dir so a rebuild can replace its (otherwise locked) executable.
|
||||||
@ -5387,8 +5410,8 @@ def cmd_gui(args: argparse.Namespace):
|
|||||||
print(" Pre-build first: cd apps/desktop && npm run build")
|
print(" Pre-build first: cd apps/desktop && npm run build")
|
||||||
print(" Or drop --skip-build to install dependencies and build automatically.")
|
print(" Or drop --skip-build to install dependencies and build automatically.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if not (PROJECT_ROOT / "node_modules" / "electron" / "package.json").exists():
|
if not (_electron_dir(PROJECT_ROOT) / "package.json").exists():
|
||||||
print("✗ --skip-build --source requires existing workspace dependencies.")
|
print("✗ --skip-build --source requires existing desktop workspace dependencies.")
|
||||||
print(f" Install first: cd {PROJECT_ROOT} && npm ci")
|
print(f" Install first: cd {PROJECT_ROOT} && npm ci")
|
||||||
print(" Or drop --skip-build to install dependencies and build automatically.")
|
print(" Or drop --skip-build to install dependencies and build automatically.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@ -5416,9 +5439,18 @@ def cmd_gui(args: argparse.Namespace):
|
|||||||
nixos_env = _nixos_build_env()
|
nixos_env = _nixos_build_env()
|
||||||
install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False, env=nixos_env)
|
install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False, env=nixos_env)
|
||||||
if install_result.returncode != 0:
|
if install_result.returncode != 0:
|
||||||
|
if not _electron_pkg_staged_missing_dist(PROJECT_ROOT):
|
||||||
print("✗ Desktop dependency install failed")
|
print("✗ Desktop dependency install failed")
|
||||||
print(f" Run manually: cd {PROJECT_ROOT} && npm ci")
|
print(f" Run manually: cd {PROJECT_ROOT} && npm ci")
|
||||||
sys.exit(install_result.returncode or 1)
|
sys.exit(install_result.returncode or 1)
|
||||||
|
repaired = _try_redownload_electron_dist(PROJECT_ROOT, env)
|
||||||
|
if repaired:
|
||||||
|
print(" ⚠ Dependency install failed with a missing Electron dist; "
|
||||||
|
"repopulated it and continuing.")
|
||||||
|
else:
|
||||||
|
print(" ⚠ Dependency install failed with a missing Electron dist; "
|
||||||
|
"continuing to the build so electron-builder can attempt "
|
||||||
|
"the Electron fetch itself.")
|
||||||
|
|
||||||
build_label = "source build" if source_mode else "packaged app"
|
build_label = "source build" if source_mode else "packaged app"
|
||||||
print(f"→ Building desktop {build_label}...")
|
print(f"→ Building desktop {build_label}...")
|
||||||
@ -5434,31 +5466,15 @@ def cmd_gui(args: argparse.Namespace):
|
|||||||
print(f" ⚠ Stopped running desktop app to free the build output (pid {', '.join(map(str, stopped))})")
|
print(f" ⚠ Stopped running desktop app to free the build output (pid {', '.join(map(str, stopped))})")
|
||||||
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
|
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
|
||||||
if build_result.returncode != 0 and not source_mode:
|
if build_result.returncode != 0 and not source_mode:
|
||||||
# A corrupt cached Electron zip makes `pack` fail with an ENOENT
|
# Corrupt cached Electron zip → partial unpack → ENOENT on rename.
|
||||||
# on the final `electron` -> `Hermes` rename: unpack-electron
|
# stdlib zipfile won't catch the common concat-junk case, so purge
|
||||||
# extracted a partial tree (missing the 193 MB binary) from the
|
# and retry once; @electron/get SHASUM is the real gate.
|
||||||
# bad zip. We do NOT try to prove the zip is corrupt ourselves —
|
purged: list[Path] = []
|
||||||
# stdlib zipfile silently tolerates the prepended/concatenated
|
|
||||||
# junk that is the most common corruption (a partial download
|
|
||||||
# resumed into the same file), so a `testzip()` gate would pass
|
|
||||||
# and never self-heal. Instead, on any packaged-build failure we
|
|
||||||
# purge the version's cached zip + the half-written unpacked dir
|
|
||||||
# and retry once: @electron/get re-downloads with its own SHASUM
|
|
||||||
# verification, which is the real source of truth. If the
|
|
||||||
# failure was something else, the clean re-download is harmless
|
|
||||||
# and the retry fails the same way.
|
|
||||||
purged = _purge_electron_build_cache(desktop_dir)
|
|
||||||
# electronDist is pinned to node_modules/electron/dist (#38673):
|
|
||||||
# electron-builder reads the Electron binary from there and `pack`
|
|
||||||
# never downloads it, so purging the cache + re-running pack can't
|
|
||||||
# by itself repopulate a missing/partial dist. When the dist is
|
|
||||||
# actually gone, re-run electron's own downloader so the retry has
|
|
||||||
# a binary to read. Gated on the dist check so an unrelated build
|
|
||||||
# failure (tsc/vite) doesn't trigger a pointless ~200 MB refetch.
|
|
||||||
restored = False
|
restored = False
|
||||||
if not _electron_dist_ok(PROJECT_ROOT):
|
if not _electron_dist_ok(PROJECT_ROOT):
|
||||||
|
purged = _purge_electron_build_cache(desktop_dir)
|
||||||
restored = _redownload_electron_dist(PROJECT_ROOT, env)
|
restored = _redownload_electron_dist(PROJECT_ROOT, env)
|
||||||
if purged or restored:
|
if restored:
|
||||||
print(" ⚠ Desktop build failed; refreshed the Electron download and retrying once...")
|
print(" ⚠ Desktop build failed; refreshed the Electron download and retrying once...")
|
||||||
for p in purged:
|
for p in purged:
|
||||||
print(f" - {p}")
|
print(f" - {p}")
|
||||||
@ -5467,35 +5483,16 @@ def cmd_gui(args: argparse.Namespace):
|
|||||||
_stop_desktop_processes_locking_build(desktop_dir)
|
_stop_desktop_processes_locking_build(desktop_dir)
|
||||||
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
|
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
|
||||||
if build_result.returncode != 0 and not source_mode and not env.get("ELECTRON_MIRROR"):
|
if build_result.returncode != 0 and not source_mode and not env.get("ELECTRON_MIRROR"):
|
||||||
# Still failing and the user hasn't pinned a mirror: GitHub's
|
|
||||||
# Electron release host is likely blocked/throttled (the repeating
|
|
||||||
# "retrying" download log). Retry once via npmmirror.com — the
|
|
||||||
# de-facto Electron community mirror (Alibaba). @electron/get
|
|
||||||
# SHASUM-checks the download, but the SHASUMS come from the same
|
|
||||||
# mirror, so that guards against a corrupt/partial download, NOT
|
|
||||||
# a compromised mirror: reaching for it is an explicit trust
|
|
||||||
# trade-off we only make AFTER the canonical GitHub download has
|
|
||||||
# failed, and we never override a user-pinned ELECTRON_MIRROR.
|
|
||||||
print(" ⚠ Desktop build still failing; the Electron download from "
|
print(" ⚠ Desktop build still failing; the Electron download from "
|
||||||
"GitHub looks blocked. Re-downloading via a public mirror "
|
"GitHub looks blocked. Re-downloading via a public mirror "
|
||||||
"(npmmirror.com)... (set ELECTRON_MIRROR to use another mirror)")
|
"(npmmirror.com)... (set ELECTRON_MIRROR to use another mirror)")
|
||||||
mirror = "https://npmmirror.com/mirrors/electron/"
|
mirror = _ELECTRON_FALLBACK_MIRROR
|
||||||
mirror_env = dict(env)
|
mirror_env = dict(env)
|
||||||
mirror_env["ELECTRON_MIRROR"] = mirror
|
mirror_env["ELECTRON_MIRROR"] = mirror
|
||||||
# electronDist is pinned (#38673), so `npm run pack` never
|
if not _electron_dist_ok(PROJECT_ROOT):
|
||||||
# downloads Electron — the mirror only helps if it drives
|
_redownload_electron_dist(PROJECT_ROOT, env, mirror=mirror)
|
||||||
# electron's own downloader. Re-fetch the binary through the
|
|
||||||
# mirror first; otherwise the retry just re-reads the same missing
|
|
||||||
# dist and re-throws "electronDist does not exist" (#47266).
|
|
||||||
have_dist = _electron_dist_ok(PROJECT_ROOT)
|
|
||||||
if not have_dist:
|
|
||||||
have_dist = _redownload_electron_dist(PROJECT_ROOT, env, mirror=mirror)
|
|
||||||
if have_dist:
|
|
||||||
_stop_desktop_processes_locking_build(desktop_dir)
|
_stop_desktop_processes_locking_build(desktop_dir)
|
||||||
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=mirror_env, check=False)
|
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=mirror_env, check=False)
|
||||||
else:
|
|
||||||
print(" ✗ Could not re-download Electron from the mirror "
|
|
||||||
"(node_modules/electron/dist still missing)")
|
|
||||||
if build_result.returncode != 0:
|
if build_result.returncode != 0:
|
||||||
print("✗ Desktop GUI build failed")
|
print("✗ Desktop GUI build failed")
|
||||||
print(f" Run manually: cd apps/desktop && npm run {build_script}")
|
print(f" Run manually: cd apps/desktop && npm run {build_script}")
|
||||||
@ -6076,6 +6073,10 @@ def _update_via_zip(args):
|
|||||||
)
|
)
|
||||||
if result.get("user_modified"):
|
if result.get("user_modified"):
|
||||||
print(f" ~ {len(result['user_modified'])} user-modified (kept)")
|
print(f" ~ {len(result['user_modified'])} user-modified (kept)")
|
||||||
|
print(
|
||||||
|
" → see them: hermes skills list-modified "
|
||||||
|
"(diff/reset to resume updates)"
|
||||||
|
)
|
||||||
if result.get("cleaned"):
|
if result.get("cleaned"):
|
||||||
print(f" − {len(result['cleaned'])} removed from manifest")
|
print(f" − {len(result['cleaned'])} removed from manifest")
|
||||||
if not result["copied"] and not result.get("updated"):
|
if not result["copied"] and not result.get("updated"):
|
||||||
@ -8117,7 +8118,13 @@ def _run_pre_update_backup(args) -> None:
|
|||||||
cfg = {}
|
cfg = {}
|
||||||
|
|
||||||
updates_cfg = cfg.get("updates", {}) if isinstance(cfg, dict) else {}
|
updates_cfg = cfg.get("updates", {}) if isinstance(cfg, dict) else {}
|
||||||
enabled = updates_cfg.get("pre_update_backup", False)
|
# The default config ships with ``pre_update_backup: true`` (see
|
||||||
|
# ``hermes_cli/config.py``). Fall back to true if the key is missing
|
||||||
|
# (e.g. a user has an older custom config without the field). The
|
||||||
|
# ``False`` default from before #48200 caused silent data loss when
|
||||||
|
# an update step computed a wrong path — the cost of a few minutes
|
||||||
|
# of zip time per update is negligible compared to the alternative.
|
||||||
|
enabled = updates_cfg.get("pre_update_backup", True)
|
||||||
keep = updates_cfg.get("backup_keep", 5)
|
keep = updates_cfg.get("backup_keep", 5)
|
||||||
|
|
||||||
if not enabled and not force_backup:
|
if not enabled and not force_backup:
|
||||||
@ -9064,6 +9071,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
|||||||
)
|
)
|
||||||
if result.get("user_modified"):
|
if result.get("user_modified"):
|
||||||
print(f" ~ {len(result['user_modified'])} user-modified (kept)")
|
print(f" ~ {len(result['user_modified'])} user-modified (kept)")
|
||||||
|
print(
|
||||||
|
" → see them: hermes skills list-modified "
|
||||||
|
"(diff/reset to resume updates)"
|
||||||
|
)
|
||||||
if result.get("cleaned"):
|
if result.get("cleaned"):
|
||||||
print(f" − {len(result['cleaned'])} removed from manifest")
|
print(f" − {len(result['cleaned'])} removed from manifest")
|
||||||
if not result["copied"] and not result.get("updated"):
|
if not result["copied"] and not result.get("updated"):
|
||||||
@ -11010,6 +11021,13 @@ def cmd_dashboard_register(args):
|
|||||||
_impl(args)
|
_impl(args)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_gateway_enroll(args):
|
||||||
|
"""Enroll a self-hosted gateway with a relay connector."""
|
||||||
|
from hermes_cli.gateway_enroll import cmd_gateway_enroll as _impl
|
||||||
|
|
||||||
|
_impl(args)
|
||||||
|
|
||||||
|
|
||||||
def cmd_completion(args, parser=None):
|
def cmd_completion(args, parser=None):
|
||||||
"""Print shell completion script."""
|
"""Print shell completion script."""
|
||||||
from hermes_cli.completion import generate_bash, generate_zsh, generate_fish
|
from hermes_cli.completion import generate_bash, generate_zsh, generate_fish
|
||||||
@ -11702,7 +11720,9 @@ def main():
|
|||||||
# =========================================================================
|
# =========================================================================
|
||||||
# gateway + proxy commands (parsers built in hermes_cli/subcommands/gateway.py)
|
# gateway + proxy commands (parsers built in hermes_cli/subcommands/gateway.py)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
build_gateway_parser(subparsers, cmd_gateway=cmd_gateway, cmd_proxy=cmd_proxy)
|
build_gateway_parser(
|
||||||
|
subparsers, cmd_gateway=cmd_gateway, cmd_proxy=cmd_proxy, cmd_gateway_enroll=cmd_gateway_enroll
|
||||||
|
)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# lsp command
|
# lsp command
|
||||||
|
|||||||
@ -15,24 +15,50 @@ from pathlib import Path
|
|||||||
from hermes_constants import get_hermes_home
|
from hermes_constants import get_hermes_home
|
||||||
from hermes_cli.secret_prompt import masked_secret_prompt
|
from hermes_cli.secret_prompt import masked_secret_prompt
|
||||||
|
|
||||||
|
_CANCELLED = -1
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Curses-based interactive picker (same pattern as hermes tools)
|
# Curses-based interactive picker (same pattern as hermes tools)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _curses_select(title: str, items: list[tuple[str, str]], default: int = 0) -> int:
|
def _curses_select(
|
||||||
|
title: str,
|
||||||
|
items: list[tuple[str, str]],
|
||||||
|
default: int = 0,
|
||||||
|
*,
|
||||||
|
cancel_returns: int | None = None,
|
||||||
|
) -> int:
|
||||||
"""Interactive single-select with arrow keys.
|
"""Interactive single-select with arrow keys.
|
||||||
|
|
||||||
items: list of (label, description) tuples.
|
items: list of (label, description) tuples.
|
||||||
Returns selected index, or default on escape/quit.
|
Returns selected index, or cancel_returns/default on escape/quit.
|
||||||
"""
|
"""
|
||||||
from hermes_cli.curses_ui import curses_radiolist
|
from hermes_cli.curses_ui import curses_radiolist
|
||||||
|
|
||||||
|
if cancel_returns is None:
|
||||||
|
cancel_returns = default
|
||||||
|
|
||||||
# Format (label, desc) tuples into display strings
|
# Format (label, desc) tuples into display strings
|
||||||
display_items = [
|
display_items = [
|
||||||
f"{label} {desc}" if desc else label
|
f"{label} - {desc}" if desc else label
|
||||||
for label, desc in items
|
for label, desc in items
|
||||||
]
|
]
|
||||||
return curses_radiolist(title, display_items, selected=default, cancel_returns=default)
|
result = curses_radiolist(title, display_items, selected=default, cancel_returns=cancel_returns)
|
||||||
|
_clear_interactive_transition()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _print_cancelled_setup() -> None:
|
||||||
|
print("\n Cancelled. No changes saved.\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_interactive_transition() -> None:
|
||||||
|
"""Clear stale curses content before entering a follow-up setup screen."""
|
||||||
|
if not sys.stdout.isatty():
|
||||||
|
return
|
||||||
|
sys.stdout.write("\033[2J\033[H")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
def _prompt(label: str, default: str | None = None, secret: bool = False) -> str:
|
def _prompt(label: str, default: str | None = None, secret: bool = False) -> str:
|
||||||
@ -205,6 +231,8 @@ def cmd_setup_provider(provider_name: str) -> None:
|
|||||||
|
|
||||||
name, _, provider = match
|
name, _, provider = match
|
||||||
|
|
||||||
|
_clear_interactive_transition()
|
||||||
|
|
||||||
_install_dependencies(name)
|
_install_dependencies(name)
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@ -241,14 +269,17 @@ def cmd_setup(args) -> None:
|
|||||||
items.append(("Built-in only", "— MEMORY.md / USER.md (default)"))
|
items.append(("Built-in only", "— MEMORY.md / USER.md (default)"))
|
||||||
|
|
||||||
builtin_idx = len(items) - 1
|
builtin_idx = len(items) - 1
|
||||||
selected = _curses_select("Memory provider setup", items, default=builtin_idx)
|
selected = _curses_select("Memory provider setup", items, default=builtin_idx, cancel_returns=_CANCELLED)
|
||||||
|
if selected == _CANCELLED:
|
||||||
|
_print_cancelled_setup()
|
||||||
|
return
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
if not isinstance(config.get("memory"), dict):
|
if not isinstance(config.get("memory"), dict):
|
||||||
config["memory"] = {}
|
config["memory"] = {}
|
||||||
|
|
||||||
# Built-in only
|
# Built-in only
|
||||||
if selected >= len(providers) or selected < 0:
|
if selected >= len(providers):
|
||||||
config["memory"]["provider"] = ""
|
config["memory"]["provider"] = ""
|
||||||
save_config(config)
|
save_config(config)
|
||||||
print("\n ✓ Memory provider: built-in only")
|
print("\n ✓ Memory provider: built-in only")
|
||||||
@ -257,6 +288,8 @@ def cmd_setup(args) -> None:
|
|||||||
|
|
||||||
name, _, provider = providers[selected]
|
name, _, provider = providers[selected]
|
||||||
|
|
||||||
|
_clear_interactive_transition()
|
||||||
|
|
||||||
# Install pip dependencies if declared in plugin.yaml
|
# Install pip dependencies if declared in plugin.yaml
|
||||||
_install_dependencies(name)
|
_install_dependencies(name)
|
||||||
|
|
||||||
@ -309,7 +342,10 @@ def cmd_setup(args) -> None:
|
|||||||
current_idx = 0
|
current_idx = 0
|
||||||
if current and current in choices:
|
if current and current in choices:
|
||||||
current_idx = choices.index(current)
|
current_idx = choices.index(current)
|
||||||
sel = _curses_select(f" {desc}", choice_items, default=current_idx)
|
sel = _curses_select(f" {desc}", choice_items, default=current_idx, cancel_returns=_CANCELLED)
|
||||||
|
if sel == _CANCELLED:
|
||||||
|
_print_cancelled_setup()
|
||||||
|
return
|
||||||
provider_config[key] = choices[sel]
|
provider_config[key] = choices[sel]
|
||||||
elif is_secret:
|
elif is_secret:
|
||||||
# Prompt for secret
|
# Prompt for secret
|
||||||
@ -407,24 +443,36 @@ def cmd_status(args) -> None:
|
|||||||
print(f" Built-in: always active")
|
print(f" Built-in: always active")
|
||||||
print(f" Provider: {provider_name or '(none — built-in only)'}")
|
print(f" Provider: {provider_name or '(none — built-in only)'}")
|
||||||
|
|
||||||
|
providers = _get_available_providers()
|
||||||
|
provider = None
|
||||||
|
for pname, _, candidate in providers:
|
||||||
|
if pname == provider_name:
|
||||||
|
provider = candidate
|
||||||
|
break
|
||||||
|
|
||||||
if provider_name:
|
if provider_name:
|
||||||
provider_config = mem_config.get(provider_name, {})
|
provider_config = mem_config.get(provider_name, {})
|
||||||
if provider_config:
|
display_config = provider_config
|
||||||
|
if provider and hasattr(provider, "get_status_config"):
|
||||||
|
try:
|
||||||
|
display_config = provider.get_status_config(provider_config)
|
||||||
|
except Exception as e:
|
||||||
|
display_config = dict(provider_config) if isinstance(provider_config, dict) else provider_config
|
||||||
|
if isinstance(display_config, dict):
|
||||||
|
display_config["status_config_error"] = str(e)
|
||||||
|
|
||||||
|
if display_config:
|
||||||
print(f"\n {provider_name} config:")
|
print(f"\n {provider_name} config:")
|
||||||
for key, val in provider_config.items():
|
for key, val in display_config.items():
|
||||||
print(f" {key}: {val}")
|
print(f" {key}: {val}")
|
||||||
|
|
||||||
providers = _get_available_providers()
|
if provider:
|
||||||
found = any(name == provider_name for name, _, _ in providers)
|
|
||||||
if found:
|
|
||||||
print(f"\n Plugin: installed ✓")
|
print(f"\n Plugin: installed ✓")
|
||||||
for pname, _, p in providers:
|
if provider.is_available():
|
||||||
if pname == provider_name:
|
|
||||||
if p.is_available():
|
|
||||||
print(f" Status: available ✓")
|
print(f" Status: available ✓")
|
||||||
else:
|
else:
|
||||||
print(f" Status: not available ✗")
|
print(f" Status: not available ✗")
|
||||||
schema = p.get_config_schema() if hasattr(p, "get_config_schema") else []
|
schema = provider.get_config_schema() if hasattr(provider, "get_config_schema") else []
|
||||||
# Check all fields that have env_var (both secret and non-secret)
|
# Check all fields that have env_var (both secret and non-secret)
|
||||||
required_fields = [f for f in schema if f.get("env_var")]
|
required_fields = [f for f in schema if f.get("env_var")]
|
||||||
if required_fields:
|
if required_fields:
|
||||||
@ -438,12 +486,10 @@ def cmd_status(args) -> None:
|
|||||||
if url and not is_set:
|
if url and not is_set:
|
||||||
line += f" → {url}"
|
line += f" → {url}"
|
||||||
print(line)
|
print(line)
|
||||||
break
|
|
||||||
else:
|
else:
|
||||||
print(f"\n Plugin: NOT installed ✗")
|
print(f"\n Plugin: NOT installed ✗")
|
||||||
print(f" Install the '{provider_name}' memory plugin to ~/.hermes/plugins/")
|
print(f" Install the '{provider_name}' memory plugin to ~/.hermes/plugins/")
|
||||||
|
|
||||||
providers = _get_available_providers()
|
|
||||||
if providers:
|
if providers:
|
||||||
print(f"\n Installed plugins:")
|
print(f"\n Installed plugins:")
|
||||||
for pname, desc, _ in providers:
|
for pname, desc, _ in providers:
|
||||||
|
|||||||
@ -1188,7 +1188,6 @@ def prewarm_picker_cache_async() -> Optional["_threading.Thread"]:
|
|||||||
current_model=ctx.current_model,
|
current_model=ctx.current_model,
|
||||||
user_providers=ctx.user_providers,
|
user_providers=ctx.user_providers,
|
||||||
custom_providers=ctx.custom_providers,
|
custom_providers=ctx.custom_providers,
|
||||||
max_models=50,
|
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Best-effort warmup — never surface errors into the session.
|
# Best-effort warmup — never surface errors into the session.
|
||||||
@ -1206,7 +1205,7 @@ def list_authenticated_providers(
|
|||||||
custom_providers: list | None = None,
|
custom_providers: list | None = None,
|
||||||
*,
|
*,
|
||||||
force_fresh_nous_tier: bool = False,
|
force_fresh_nous_tier: bool = False,
|
||||||
max_models: int = 8,
|
max_models: int | None = None,
|
||||||
current_model: str = "",
|
current_model: str = "",
|
||||||
) -> List[dict]:
|
) -> List[dict]:
|
||||||
"""Detect which providers have credentials and list their curated models.
|
"""Detect which providers have credentials and list their curated models.
|
||||||
@ -1426,7 +1425,7 @@ def list_authenticated_providers(
|
|||||||
if hermes_id in _MODELS_DEV_PREFERRED:
|
if hermes_id in _MODELS_DEV_PREFERRED:
|
||||||
model_ids = _merge_with_models_dev(hermes_id, model_ids)
|
model_ids = _merge_with_models_dev(hermes_id, model_ids)
|
||||||
total = len(model_ids)
|
total = len(model_ids)
|
||||||
top = model_ids[:max_models]
|
top = model_ids[:max_models] if max_models is not None else model_ids
|
||||||
|
|
||||||
slug = hermes_id
|
slug = hermes_id
|
||||||
pinfo = _mdev_pinfo(mdev_id)
|
pinfo = _mdev_pinfo(mdev_id)
|
||||||
@ -1589,7 +1588,7 @@ def list_authenticated_providers(
|
|||||||
if hermes_slug in _MODELS_DEV_PREFERRED:
|
if hermes_slug in _MODELS_DEV_PREFERRED:
|
||||||
model_ids = _merge_with_models_dev(hermes_slug, model_ids)
|
model_ids = _merge_with_models_dev(hermes_slug, model_ids)
|
||||||
total = len(model_ids)
|
total = len(model_ids)
|
||||||
top = model_ids[:max_models]
|
top = model_ids[:max_models] if max_models is not None else model_ids
|
||||||
|
|
||||||
results.append({
|
results.append({
|
||||||
"slug": hermes_slug,
|
"slug": hermes_slug,
|
||||||
@ -1664,7 +1663,7 @@ def list_authenticated_providers(
|
|||||||
if not _cp_model_ids:
|
if not _cp_model_ids:
|
||||||
_cp_model_ids = curated.get(_cp.slug, [])
|
_cp_model_ids = curated.get(_cp.slug, [])
|
||||||
_cp_total = len(_cp_model_ids)
|
_cp_total = len(_cp_model_ids)
|
||||||
_cp_top = _cp_model_ids[:max_models]
|
_cp_top = _cp_model_ids[:max_models] if max_models is not None else _cp_model_ids
|
||||||
|
|
||||||
results.append({
|
results.append({
|
||||||
"slug": _cp.slug,
|
"slug": _cp.slug,
|
||||||
@ -1813,7 +1812,7 @@ def list_authenticated_providers(
|
|||||||
"name": "Custom endpoint",
|
"name": "Custom endpoint",
|
||||||
"is_current": True,
|
"is_current": True,
|
||||||
"is_user_defined": True,
|
"is_user_defined": True,
|
||||||
"models": _models[:max_models] if max_models else _models,
|
"models": _models[:max_models] if max_models is not None else _models,
|
||||||
"total_models": len(_models),
|
"total_models": len(_models),
|
||||||
"source": "model-config",
|
"source": "model-config",
|
||||||
"api_url": str(current_base_url).strip().rstrip("/"),
|
"api_url": str(current_base_url).strip().rstrip("/"),
|
||||||
@ -2040,7 +2039,7 @@ def list_picker_providers(
|
|||||||
current_base_url: str = "",
|
current_base_url: str = "",
|
||||||
user_providers: dict = None,
|
user_providers: dict = None,
|
||||||
custom_providers: list | None = None,
|
custom_providers: list | None = None,
|
||||||
max_models: int = 8,
|
max_models: int | None = None,
|
||||||
current_model: str = "",
|
current_model: str = "",
|
||||||
) -> List[dict]:
|
) -> List[dict]:
|
||||||
"""Interactive-picker variant of :func:`list_authenticated_providers`.
|
"""Interactive-picker variant of :func:`list_authenticated_providers`.
|
||||||
@ -2083,7 +2082,7 @@ def list_picker_providers(
|
|||||||
except Exception:
|
except Exception:
|
||||||
live_ids = list(p.get("models", []))
|
live_ids = list(p.get("models", []))
|
||||||
p = dict(p)
|
p = dict(p)
|
||||||
p["models"] = live_ids[:max_models]
|
p["models"] = live_ids[:max_models] if max_models is not None else live_ids
|
||||||
p["total_models"] = len(live_ids)
|
p["total_models"] = len(live_ids)
|
||||||
|
|
||||||
has_models = bool(p.get("models"))
|
has_models = bool(p.get("models"))
|
||||||
|
|||||||
406
hermes_cli/nous_billing.py
Normal file
406
hermes_cli/nous_billing.py
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
"""Nous Portal terminal-billing HTTP client (Phase 2b).
|
||||||
|
|
||||||
|
Thin, fail-loud client for the four ``/api/billing/*`` endpoints the terminal
|
||||||
|
billing screens drive. Companion to ``hermes_cli/nous_account.py`` (which owns
|
||||||
|
read-only entitlement/balance) — this module owns the *write* side: buy credits,
|
||||||
|
poll a charge, configure auto-reload.
|
||||||
|
|
||||||
|
Design rules:
|
||||||
|
|
||||||
|
- **Money is decimal, never float.** The server emits decimal STRINGS
|
||||||
|
(``"142.5"`` — not fixed 2dp). We parse with :class:`decimal.Decimal` and never
|
||||||
|
round-trip through float.
|
||||||
|
- **This client raises typed exceptions; it does NOT fail open.** Fail-open is the
|
||||||
|
*caller's* job (the ``agent/billing_view.py`` builders) so each surface can
|
||||||
|
decide how to degrade. A raw network/HTTP error here surfaces as
|
||||||
|
:class:`BillingError` (or a subclass) carrying the parsed server ``error`` code,
|
||||||
|
HTTP status, ``portalUrl`` deep-link, and ``retry_after``.
|
||||||
|
- **Auth** = the OAuth bearer JWT Hermes already holds for inference
|
||||||
|
(``get_provider_auth_state("nous")["access_token"]``). No API-key auth on these.
|
||||||
|
- **Portal base URL** resolves with the same precedence as the device-flow login
|
||||||
|
(``auth.py``): ``HERMES_PORTAL_BASE_URL`` → ``NOUS_PORTAL_BASE_URL`` → the
|
||||||
|
stored auth-state ``portal_base_url`` → the registry default. This is how the
|
||||||
|
E2E run points the client at a preview deployment with zero code change.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
DEFAULT_PORTAL_BASE_URL = "https://portal.nousresearch.com"
|
||||||
|
|
||||||
|
# Default HTTP timeout (seconds). Charge/poll calls are quick; keep this tight so
|
||||||
|
# a hung portal doesn't freeze the TUI.
|
||||||
|
DEFAULT_TIMEOUT = 15.0
|
||||||
|
|
||||||
|
# Scope the privileged billing endpoints require. Mirrored from
|
||||||
|
# hermes_cli.auth.NOUS_BILLING_MANAGE_SCOPE (kept here too so this module has no
|
||||||
|
# import-time dependency on the much heavier auth module).
|
||||||
|
BILLING_MANAGE_SCOPE = "billing:manage"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Typed errors
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class BillingError(Exception):
|
||||||
|
"""A billing HTTP call failed.
|
||||||
|
|
||||||
|
Carries everything a surface needs to render the right message + affordance:
|
||||||
|
the server ``error`` code, HTTP ``status``, an optional human ``message``, the
|
||||||
|
``portalUrl`` deep-link (present on every gate denial), and ``retry_after``
|
||||||
|
seconds (429/503). ``payload`` is the full parsed JSON body when available.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
status: Optional[int] = None,
|
||||||
|
error: Optional[str] = None,
|
||||||
|
portal_url: Optional[str] = None,
|
||||||
|
retry_after: Optional[int] = None,
|
||||||
|
payload: Optional[dict[str, Any]] = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.status = status
|
||||||
|
self.error = error
|
||||||
|
self.portal_url = portal_url
|
||||||
|
self.retry_after = retry_after
|
||||||
|
self.payload = payload or {}
|
||||||
|
|
||||||
|
|
||||||
|
class BillingScopeRequired(BillingError):
|
||||||
|
"""``403 insufficient_scope`` — the held token lacks ``billing:manage``.
|
||||||
|
|
||||||
|
The lazy step-up trigger: catching this kicks off a fresh device-connect that
|
||||||
|
requests ``billing:manage`` (and tells the user an ADMIN must tick "Allow
|
||||||
|
terminal billing"). Also fires mid-session if the scope is stripped on refresh
|
||||||
|
after the user loses ADMIN.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class BillingRateLimited(BillingError):
|
||||||
|
"""``429 rate_limited`` or ``503 temporarily_unavailable``.
|
||||||
|
|
||||||
|
NOT a payment failure. Carries ``retry_after`` (seconds) — back off and tell
|
||||||
|
the user "try again in N min"; never auto-retry-spam (the limiter is
|
||||||
|
5/org/hr + 5/token/hr and easy to dig deeper into).
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class BillingAuthError(BillingError):
|
||||||
|
"""``401`` — missing/invalid bearer token (not logged in / expired)."""
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Base-URL + auth resolution
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_portal_base_url(state: Optional[dict[str, Any]] = None) -> str:
|
||||||
|
"""Resolve the portal base URL with login-time precedence.
|
||||||
|
|
||||||
|
``HERMES_PORTAL_BASE_URL`` → ``NOUS_PORTAL_BASE_URL`` → stored auth-state
|
||||||
|
``portal_base_url`` → registry default. Trailing slash stripped.
|
||||||
|
"""
|
||||||
|
env = os.getenv("HERMES_PORTAL_BASE_URL") or os.getenv("NOUS_PORTAL_BASE_URL")
|
||||||
|
if env and env.strip():
|
||||||
|
return env.strip().rstrip("/")
|
||||||
|
if state:
|
||||||
|
stored = state.get("portal_base_url")
|
||||||
|
if isinstance(stored, str) and stored.strip():
|
||||||
|
return stored.strip().rstrip("/")
|
||||||
|
return DEFAULT_PORTAL_BASE_URL
|
||||||
|
|
||||||
|
|
||||||
|
def _absolutize_portal_url(portal_url: Optional[str]) -> Optional[str]:
|
||||||
|
"""Resolve a (possibly relative) server portalUrl to an absolute URL.
|
||||||
|
|
||||||
|
The server emits ``portalUrl`` relative by design (e.g. ``/billing?topup=open``)
|
||||||
|
— it doesn't know which deployment the client points at. Resolve it against the
|
||||||
|
client's portal base (preview / staging / prod) so deep-links are clickable.
|
||||||
|
Idempotent: an already-absolute URL is returned unchanged (urljoin keeps it).
|
||||||
|
"""
|
||||||
|
if not (isinstance(portal_url, str) and portal_url.strip()):
|
||||||
|
return portal_url
|
||||||
|
base = resolve_portal_base_url()
|
||||||
|
# urljoin needs a trailing slash on the base to treat it as a directory and
|
||||||
|
# join an absolute path like "/billing?..." against the host. An already-
|
||||||
|
# absolute portal_url (with its own scheme/host) is returned as-is.
|
||||||
|
return urllib.parse.urljoin(base.rstrip("/") + "/", portal_url)
|
||||||
|
|
||||||
|
|
||||||
|
# Short-lived cache for the resolved (token, base). `resolve_nous_access_token`
|
||||||
|
# acquires two cross-process file locks + reads two files on every call (even on
|
||||||
|
# its fast path), which is wasteful when the 2s/5-min charge poll loop calls a
|
||||||
|
# billing endpoint ~150x per purchase. Cache the result briefly: the resolver
|
||||||
|
# only ever returns a token with >=120s of life (its refresh skew), so a 30s
|
||||||
|
# cache can never hand back an about-to-expire token. A 401 still surfaces
|
||||||
|
# normally (the cache holds a valid token, not the HTTP outcome).
|
||||||
|
_TOKEN_CACHE_TTL_SECONDS = 30.0
|
||||||
|
_token_cache: tuple[float, str, str] | None = None # (cached_at, token, base)
|
||||||
|
|
||||||
|
|
||||||
|
def _billing_not_logged_in(exc: Optional[BaseException] = None) -> "BillingAuthError":
|
||||||
|
"""Build the canonical 'not logged in' BillingAuthError (single source)."""
|
||||||
|
err = BillingAuthError(
|
||||||
|
"Not logged into Nous Portal — run `hermes portal` to log in.",
|
||||||
|
status=401,
|
||||||
|
error="invalid_token",
|
||||||
|
)
|
||||||
|
if exc is not None:
|
||||||
|
err.__cause__ = exc
|
||||||
|
return err
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_token_and_base(*, use_cache: bool = True) -> tuple[str, str]:
|
||||||
|
"""Return ``(access_token, portal_base_url)`` for billing calls.
|
||||||
|
|
||||||
|
Uses the same refresh-aware resolver the inference path uses
|
||||||
|
(``resolve_nous_access_token``), so a short-lived (~15 min) access token that
|
||||||
|
has expired is transparently refreshed via the stored ``refresh_token``
|
||||||
|
instead of failing as "not logged in". Raises :class:`BillingAuthError` only
|
||||||
|
when there is no usable Nous session at all.
|
||||||
|
|
||||||
|
The result is cached for ``_TOKEN_CACHE_TTL_SECONDS`` to keep the charge poll
|
||||||
|
loop from re-locking + re-reading the auth store on every 2s tick. Pass
|
||||||
|
``use_cache=False`` to force a fresh resolution (e.g. after a 401).
|
||||||
|
"""
|
||||||
|
global _token_cache
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
if use_cache and _token_cache is not None:
|
||||||
|
cached_at, token, base = _token_cache
|
||||||
|
if (_time.time() - cached_at) < _TOKEN_CACHE_TTL_SECONDS:
|
||||||
|
return token, base
|
||||||
|
|
||||||
|
try:
|
||||||
|
from hermes_cli.auth import get_provider_auth_state
|
||||||
|
|
||||||
|
state = get_provider_auth_state("nous") or {}
|
||||||
|
except Exception:
|
||||||
|
state = {}
|
||||||
|
|
||||||
|
base = resolve_portal_base_url(state)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from hermes_cli.auth import AuthError, resolve_nous_access_token
|
||||||
|
except ImportError:
|
||||||
|
# auth module unavailable — fall back to the raw stored token.
|
||||||
|
token = state.get("access_token")
|
||||||
|
if isinstance(token, str) and token.strip():
|
||||||
|
resolved = (token.strip(), base)
|
||||||
|
_token_cache = (_time.time(), *resolved)
|
||||||
|
return resolved
|
||||||
|
raise _billing_not_logged_in()
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = resolve_nous_access_token()
|
||||||
|
except AuthError as exc:
|
||||||
|
raise _billing_not_logged_in(exc) from exc
|
||||||
|
resolved = (token.strip(), base)
|
||||||
|
_token_cache = (_time.time(), *resolved)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# HTTP plumbing
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _retry_after_seconds(headers: Any) -> Optional[int]:
|
||||||
|
"""Parse a ``Retry-After`` header (integer seconds) — None if absent/bad."""
|
||||||
|
if headers is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
raw = headers.get("Retry-After")
|
||||||
|
except Exception:
|
||||||
|
raw = None
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(str(raw).strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_for_error(
|
||||||
|
status: int, payload: dict[str, Any], headers: Any = None
|
||||||
|
) -> None:
|
||||||
|
"""Map an HTTP error response to the right typed :class:`BillingError`."""
|
||||||
|
error = payload.get("error") if isinstance(payload, dict) else None
|
||||||
|
message = payload.get("message") if isinstance(payload, dict) else None
|
||||||
|
portal_url = _absolutize_portal_url(
|
||||||
|
payload.get("portalUrl") if isinstance(payload, dict) else None
|
||||||
|
)
|
||||||
|
retry_after = _retry_after_seconds(headers)
|
||||||
|
|
||||||
|
common = {
|
||||||
|
"status": status,
|
||||||
|
"error": error,
|
||||||
|
"portal_url": portal_url,
|
||||||
|
"retry_after": retry_after,
|
||||||
|
"payload": payload if isinstance(payload, dict) else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
if status == 401:
|
||||||
|
raise BillingAuthError(message or "Authentication required.", **common)
|
||||||
|
if status == 403 and error == "insufficient_scope":
|
||||||
|
raise BillingScopeRequired(
|
||||||
|
message or "This action needs the billing:manage scope.", **common
|
||||||
|
)
|
||||||
|
if status in (429, 503):
|
||||||
|
raise BillingRateLimited(
|
||||||
|
message or "Rate limited — try again shortly.", **common
|
||||||
|
)
|
||||||
|
raise BillingError(message or error or f"Billing request failed ({status}).", **common)
|
||||||
|
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
body: Optional[dict[str, Any]] = None,
|
||||||
|
extra_headers: Optional[dict[str, str]] = None,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
|
_retried_auth: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Make an authenticated billing request; return the parsed JSON dict.
|
||||||
|
|
||||||
|
Raises a typed :class:`BillingError` on any non-2xx response (or transport
|
||||||
|
failure). 2xx with an empty body returns ``{}``. A 401 triggers exactly one
|
||||||
|
retry with a freshly-resolved token (bypassing the short token cache) so a
|
||||||
|
cached-but-just-expired token self-heals instead of failing the call.
|
||||||
|
"""
|
||||||
|
token, base = _resolve_token_and_base(use_cache=not _retried_auth)
|
||||||
|
url = f"{base}{path}"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
if body is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
if extra_headers:
|
||||||
|
headers.update(extra_headers)
|
||||||
|
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
raw = resp.read().decode("utf-8")
|
||||||
|
return json.loads(raw) if raw.strip() else {}
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
# A 401 on a cached token → drop the cache and retry once with a fresh
|
||||||
|
# (refresh-aware) resolve before surfacing the auth error.
|
||||||
|
if exc.code == 401 and not _retried_auth:
|
||||||
|
global _token_cache
|
||||||
|
_token_cache = None
|
||||||
|
return _request(
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
body=body,
|
||||||
|
extra_headers=extra_headers,
|
||||||
|
timeout=timeout,
|
||||||
|
_retried_auth=True,
|
||||||
|
)
|
||||||
|
raw = ""
|
||||||
|
try:
|
||||||
|
raw = exc.read().decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
raw = ""
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw) if raw.strip() else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
payload = {}
|
||||||
|
_raise_for_error(exc.code, payload, getattr(exc, "headers", None))
|
||||||
|
raise # unreachable; _raise_for_error always raises
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise BillingError(
|
||||||
|
f"Could not reach Nous Portal: {exc.reason}", error="network_error"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# The four endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def get_billing_state(*, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]:
|
||||||
|
"""``GET /api/billing/state`` — role-tiered overview (no scope required)."""
|
||||||
|
return _request("GET", "/api/billing/state", timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def patch_auto_top_up(
|
||||||
|
*,
|
||||||
|
enabled: bool,
|
||||||
|
threshold: float | str,
|
||||||
|
top_up_amount: float | str,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""``PATCH /api/billing/auto-top-up`` — configure auto-reload (scope required).
|
||||||
|
|
||||||
|
Body is strict server-side: extra keys (``maxMonthlySpend``, a payment method)
|
||||||
|
are rejected with 400. Numbers are sent as JSON numbers per the contract.
|
||||||
|
"""
|
||||||
|
return _request(
|
||||||
|
"PATCH",
|
||||||
|
"/api/billing/auto-top-up",
|
||||||
|
body={
|
||||||
|
"enabled": bool(enabled),
|
||||||
|
"threshold": float(threshold),
|
||||||
|
"topUpAmount": float(top_up_amount),
|
||||||
|
},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def post_charge(
|
||||||
|
*,
|
||||||
|
amount_usd: float | str,
|
||||||
|
idempotency_key: str,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""``POST /api/billing/charge`` — buy credits (scope required).
|
||||||
|
|
||||||
|
``Idempotency-Key`` header is MANDATORY (a missing header is a server 400, not
|
||||||
|
a default): generate a UUID per user-confirmed purchase and reuse it on retry.
|
||||||
|
Returns ``202 {chargeId}`` — money is NOT confirmed yet; poll with
|
||||||
|
:func:`get_charge_status`.
|
||||||
|
"""
|
||||||
|
if not (isinstance(idempotency_key, str) and idempotency_key.strip()):
|
||||||
|
raise BillingError(
|
||||||
|
"Idempotency-Key is required for a charge.",
|
||||||
|
error="idempotency_key_required",
|
||||||
|
)
|
||||||
|
return _request(
|
||||||
|
"POST",
|
||||||
|
"/api/billing/charge",
|
||||||
|
body={"amountUsd": float(amount_usd)},
|
||||||
|
extra_headers={"Idempotency-Key": idempotency_key.strip()},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_charge_status(
|
||||||
|
charge_id: str, *, timeout: float = DEFAULT_TIMEOUT
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""``GET /api/billing/charge/{id}`` — poll a charge (scope required).
|
||||||
|
|
||||||
|
Returns ``{status: "pending"|"settled"|"failed", ...}``. An unknown or foreign
|
||||||
|
id returns ``{status:"pending"}`` (never 404, never another org's data) — so a
|
||||||
|
``pending`` that never resolves past the 5-min cap is a *timeout*, not an error.
|
||||||
|
"""
|
||||||
|
if not (isinstance(charge_id, str) and charge_id.strip()):
|
||||||
|
raise BillingError("A charge id is required.", error="invalid_charge_id")
|
||||||
|
# urllib does not need manual quoting for the opaque ids the server mints, but
|
||||||
|
# guard against a stray slash that would change the path shape.
|
||||||
|
safe_id = urllib.parse.quote(charge_id.strip(), safe="")
|
||||||
|
return _request("GET", f"/api/billing/charge/{safe_id}", timeout=timeout)
|
||||||
@ -713,6 +713,69 @@ def find_custom_provider_identity(base_url: str) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_custom_identity(
|
||||||
|
*,
|
||||||
|
base_url: Optional[str] = None,
|
||||||
|
config_provider: Optional[str] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Recover a routable ``custom:<name>`` identity for a bare custom provider.
|
||||||
|
|
||||||
|
The bare string ``"custom"`` is the *resolved billing class* shared by
|
||||||
|
every named ``providers:`` / ``custom_providers:`` entry — it is NOT a
|
||||||
|
routable provider identity (``resolve_runtime_provider("custom")`` falls
|
||||||
|
through to the OpenRouter default URL with no api_key, which surfaces to
|
||||||
|
the user as "No LLM provider configured").
|
||||||
|
|
||||||
|
Any code path that persists or restores a session's provider override
|
||||||
|
must run the resolved provider through this helper so a bare ``"custom"``
|
||||||
|
is upgraded back to its durable ``custom:<name>`` menu key. Two recovery
|
||||||
|
sources, in priority order:
|
||||||
|
|
||||||
|
1. ``base_url`` — reverse-lookup the entry that owns the endpoint URL
|
||||||
|
(the one fact that always survives the persistence round-trip when a
|
||||||
|
URL was recorded).
|
||||||
|
2. ``config_provider`` — the active ``config.model.provider`` (or its
|
||||||
|
``provider``/``HERMES_INFERENCE_PROVIDER`` equivalent). When the agent
|
||||||
|
was built without a base_url on the override (the recurring
|
||||||
|
Desktop/TUI regression vector), the configured provider is the only
|
||||||
|
durable identity left, so fall back to it when it names a real entry.
|
||||||
|
|
||||||
|
Returns ``custom:<name>`` when a routable identity is recovered, else
|
||||||
|
``None`` (caller keeps whatever it had — bare ``"custom"`` only as a last
|
||||||
|
resort, e.g. a genuine ad-hoc endpoint with no config entry).
|
||||||
|
"""
|
||||||
|
# 1. Reverse-lookup by endpoint URL.
|
||||||
|
if base_url:
|
||||||
|
identity = find_custom_provider_identity(base_url)
|
||||||
|
if identity:
|
||||||
|
return identity
|
||||||
|
|
||||||
|
# 2. Fall back to the configured provider when it names a real entry.
|
||||||
|
candidate = str(config_provider or "").strip()
|
||||||
|
if not candidate:
|
||||||
|
try:
|
||||||
|
candidate = str(_get_model_config().get("provider") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
candidate = ""
|
||||||
|
if not candidate:
|
||||||
|
candidate = os.environ.get("HERMES_INFERENCE_PROVIDER", "").strip()
|
||||||
|
|
||||||
|
candidate_norm = _normalize_custom_provider_name(candidate)
|
||||||
|
# A bare/non-routable candidate cannot heal a bare custom override.
|
||||||
|
if not candidate_norm or candidate_norm in {"custom", "auto", "openrouter"}:
|
||||||
|
return None
|
||||||
|
# Only return it when it actually resolves to a configured custom entry,
|
||||||
|
# so we never invent a `custom:<x>` that resolution can't honor.
|
||||||
|
try:
|
||||||
|
if _get_named_custom_provider(candidate) is not None:
|
||||||
|
if candidate_norm.startswith("custom:"):
|
||||||
|
return candidate_norm
|
||||||
|
return f"custom:{candidate_norm}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _normalize_base_url_for_match(value) -> str:
|
def _normalize_base_url_for_match(value) -> str:
|
||||||
return str(value or "").strip().rstrip("/").lower()
|
return str(value or "").strip().rstrip("/").lower()
|
||||||
|
|
||||||
|
|||||||
@ -27,16 +27,16 @@ def _collect_masked_input(
|
|||||||
while True:
|
while True:
|
||||||
ch = read_char()
|
ch = read_char()
|
||||||
if ch == "":
|
if ch == "":
|
||||||
write("\n")
|
write("\r\n")
|
||||||
raise EOFError
|
raise EOFError
|
||||||
if ch in _ENTER_CHARS:
|
if ch in _ENTER_CHARS:
|
||||||
write("\n")
|
write("\r\n")
|
||||||
return "".join(value)
|
return "".join(value)
|
||||||
if ch == "\x03":
|
if ch == "\x03":
|
||||||
write("\n")
|
write("\r\n")
|
||||||
raise KeyboardInterrupt
|
raise KeyboardInterrupt
|
||||||
if ch in _EOF_CHARS:
|
if ch in _EOF_CHARS:
|
||||||
write("\n")
|
write("\r\n")
|
||||||
raise EOFError
|
raise EOFError
|
||||||
if ch in _BACKSPACE_CHARS:
|
if ch in _BACKSPACE_CHARS:
|
||||||
if value:
|
if value:
|
||||||
|
|||||||
@ -684,10 +684,25 @@ class S6ServiceManager:
|
|||||||
# start`, etc. See `_gateway_command_inner` for the matching
|
# start`, etc. See `_gateway_command_inner` for the matching
|
||||||
# guard.
|
# guard.
|
||||||
lines.append("export HERMES_S6_SUPERVISED_CHILD=1")
|
lines.append("export HERMES_S6_SUPERVISED_CHILD=1")
|
||||||
|
# ``--replace`` makes the supervised gateway authoritative for its
|
||||||
|
# profile's HERMES_HOME. Without it, a gateway started OUTSIDE s6
|
||||||
|
# (a stray ``hermes gateway run`` from a shell, an agent action, or
|
||||||
|
# the Open WebUI helper) grabs the per-HERMES_HOME PID lock first;
|
||||||
|
# the supervised slot then execs a bare ``gateway run``, hits the
|
||||||
|
# "Another gateway instance is already running" guard, exits
|
||||||
|
# non-zero, and s6 restarts it — a restart loop that floods the
|
||||||
|
# log and never binds (NS-505). ``--replace``
|
||||||
|
# instead reaps the stale holder (hardened takeover path: marker +
|
||||||
|
# SIGTERM→SIGKILL-with-confirmation + scoped-lock cleanup, see
|
||||||
|
# gateway/run.py) so s6 always wins. The HERMES_S6_SUPERVISED_CHILD
|
||||||
|
# sentinel above prevents the run→start→run redirect recursion.
|
||||||
|
# Each profile is scoped to its own HERMES_HOME and s6 guarantees a
|
||||||
|
# single supervised instance per slot, so there is no legitimate
|
||||||
|
# supervised sibling for ``--replace`` to clobber.
|
||||||
if profile == "default":
|
if profile == "default":
|
||||||
gateway_cmd = "hermes gateway run"
|
gateway_cmd = "hermes gateway run --replace"
|
||||||
else:
|
else:
|
||||||
gateway_cmd = f"hermes -p {shlex.quote(profile)} gateway run"
|
gateway_cmd = f"hermes -p {shlex.quote(profile)} gateway run --replace"
|
||||||
# Skip the drop when already non-root (setgroups() lacks CAP_SETGID →
|
# Skip the drop when already non-root (setgroups() lacks CAP_SETGID →
|
||||||
# s6 boot-loop).
|
# s6 boot-loop).
|
||||||
lines.append(f'[ "$(id -u)" = 0 ] || exec {gateway_cmd}')
|
lines.append(f'[ "$(id -u)" = 0 ] || exec {gateway_cmd}')
|
||||||
|
|||||||
@ -1149,6 +1149,73 @@ def do_reset(name: str, restore: bool = False,
|
|||||||
c.print("[dim]Use /reset to start a new session now, or --now to apply immediately (invalidates prompt cache).[/]\n")
|
c.print("[dim]Use /reset to start a new session now, or --now to apply immediately (invalidates prompt cache).[/]\n")
|
||||||
|
|
||||||
|
|
||||||
|
def do_list_modified(console: Optional[Console] = None,
|
||||||
|
as_json: bool = False) -> None:
|
||||||
|
"""List bundled skills the user has edited (which `hermes update` keeps)."""
|
||||||
|
from tools.skills_sync import list_user_modified_bundled_skills
|
||||||
|
|
||||||
|
c = console or _console
|
||||||
|
modified = list_user_modified_bundled_skills()
|
||||||
|
|
||||||
|
if as_json:
|
||||||
|
import json
|
||||||
|
|
||||||
|
c.print(json.dumps([m["name"] for m in modified]))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not modified:
|
||||||
|
c.print("[dim]No user-modified bundled skills — everything tracks upstream.[/]\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
c.print(f"\n[bold]{len(modified)} user-modified bundled skill(s)[/] "
|
||||||
|
"[dim](kept as-is by `hermes update`):[/]")
|
||||||
|
for entry in modified:
|
||||||
|
c.print(f" [yellow]~[/] {entry['name']}")
|
||||||
|
c.print()
|
||||||
|
c.print("[dim]See changes: hermes skills diff <name>[/]")
|
||||||
|
c.print("[dim]Resume updates: hermes skills reset <name> (keep your copy, re-baseline)[/]")
|
||||||
|
c.print("[dim]Revert to stock: hermes skills reset <name> --restore[/]\n")
|
||||||
|
|
||||||
|
|
||||||
|
def do_diff(name: str, console: Optional[Console] = None) -> None:
|
||||||
|
"""Show how the user's copy of a bundled skill differs from the stock version."""
|
||||||
|
from tools.skills_sync import diff_bundled_skill
|
||||||
|
|
||||||
|
c = console or _console
|
||||||
|
result = diff_bundled_skill(name)
|
||||||
|
|
||||||
|
if not result["ok"]:
|
||||||
|
c.print(f"[bold red]Error:[/] {result['message']}\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not result["modified"]:
|
||||||
|
c.print(f"[green]{result['message']}[/]\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
c.print(f"\n[bold]{result['message']}[/]\n")
|
||||||
|
for entry in result["diffs"]:
|
||||||
|
status = entry["status"]
|
||||||
|
if status == "modified":
|
||||||
|
# Render the unified diff with light coloring.
|
||||||
|
for line in entry["diff"].splitlines():
|
||||||
|
if line.startswith("+") and not line.startswith("+++"):
|
||||||
|
c.print(f"[green]{line}[/]")
|
||||||
|
elif line.startswith("-") and not line.startswith("---"):
|
||||||
|
c.print(f"[red]{line}[/]")
|
||||||
|
elif line.startswith("@@"):
|
||||||
|
c.print(f"[cyan]{line}[/]")
|
||||||
|
else:
|
||||||
|
c.print(line, highlight=False)
|
||||||
|
elif status == "added":
|
||||||
|
c.print(f"[green]+ only in your copy:[/] {entry['path']}")
|
||||||
|
elif status == "removed":
|
||||||
|
c.print(f"[red]- only in stock:[/] {entry['path']}")
|
||||||
|
else: # binary
|
||||||
|
c.print(f"[yellow]~ {entry['path']}:[/] binary file differs")
|
||||||
|
c.print()
|
||||||
|
c.print(f"[dim]Revert with: hermes skills reset {name} --restore[/]\n")
|
||||||
|
|
||||||
|
|
||||||
def do_opt_out(remove: bool = False,
|
def do_opt_out(remove: bool = False,
|
||||||
console: Optional[Console] = None,
|
console: Optional[Console] = None,
|
||||||
skip_confirm: bool = False,
|
skip_confirm: bool = False,
|
||||||
@ -1624,6 +1691,10 @@ def skills_command(args) -> None:
|
|||||||
elif action == "reset":
|
elif action == "reset":
|
||||||
do_reset(args.name, restore=getattr(args, "restore", False),
|
do_reset(args.name, restore=getattr(args, "restore", False),
|
||||||
skip_confirm=getattr(args, "yes", False))
|
skip_confirm=getattr(args, "yes", False))
|
||||||
|
elif action == "list-modified":
|
||||||
|
do_list_modified(as_json=getattr(args, "json", False))
|
||||||
|
elif action == "diff":
|
||||||
|
do_diff(args.name)
|
||||||
elif action == "opt-out":
|
elif action == "opt-out":
|
||||||
do_opt_out(remove=getattr(args, "remove", False),
|
do_opt_out(remove=getattr(args, "remove", False),
|
||||||
skip_confirm=getattr(args, "yes", False))
|
skip_confirm=getattr(args, "yes", False))
|
||||||
@ -1654,7 +1725,7 @@ def skills_command(args) -> None:
|
|||||||
return
|
return
|
||||||
do_tap(tap_action, repo=repo)
|
do_tap(tap_action, repo=repo)
|
||||||
else:
|
else:
|
||||||
_console.print("Usage: hermes skills [browse|search|install|inspect|list|check|update|audit|uninstall|reset|opt-out|opt-in|publish|snapshot|tap]\n")
|
_console.print("Usage: hermes skills [browse|search|install|inspect|list|list-modified|diff|check|update|audit|uninstall|reset|opt-out|opt-in|publish|snapshot|tap]\n")
|
||||||
_console.print("Run 'hermes skills <command> --help' for details.\n")
|
_console.print("Run 'hermes skills <command> --help' for details.\n")
|
||||||
|
|
||||||
|
|
||||||
@ -1826,6 +1897,15 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
|
|||||||
do_reset(name, restore=restore, console=c, skip_confirm=True,
|
do_reset(name, restore=restore, console=c, skip_confirm=True,
|
||||||
invalidate_cache=invalidate_cache)
|
invalidate_cache=invalidate_cache)
|
||||||
|
|
||||||
|
elif action in {"list-modified", "modified"}:
|
||||||
|
do_list_modified(console=c, as_json="--json" in args)
|
||||||
|
|
||||||
|
elif action == "diff":
|
||||||
|
if not args:
|
||||||
|
c.print("[bold red]Usage:[/] /skills diff <name>\n")
|
||||||
|
return
|
||||||
|
do_diff(args[0], console=c)
|
||||||
|
|
||||||
elif action == "publish":
|
elif action == "publish":
|
||||||
if not args:
|
if not args:
|
||||||
c.print("[bold red]Usage:[/] /skills publish <skill-path> [--to github] [--repo owner/repo]\n")
|
c.print("[bold red]Usage:[/] /skills publish <skill-path> [--to github] [--repo owner/repo]\n")
|
||||||
@ -1883,6 +1963,8 @@ def _print_skills_help(console: Console) -> None:
|
|||||||
" [cyan]update[/] [name] Update hub skills with upstream changes\n"
|
" [cyan]update[/] [name] Update hub skills with upstream changes\n"
|
||||||
" [cyan]audit[/] [name] Re-scan hub skills for security\n"
|
" [cyan]audit[/] [name] Re-scan hub skills for security\n"
|
||||||
" [cyan]uninstall[/] <name> Remove a hub-installed skill\n"
|
" [cyan]uninstall[/] <name> Remove a hub-installed skill\n"
|
||||||
|
" [cyan]list-modified[/] List bundled skills you've edited (kept by update)\n"
|
||||||
|
" [cyan]diff[/] <name> Diff your copy of a bundled skill vs the stock version\n"
|
||||||
" [cyan]reset[/] <name> [--restore] Reset bundled-skill tracking (fix 'user-modified' flag)\n"
|
" [cyan]reset[/] <name> [--restore] Reset bundled-skill tracking (fix 'user-modified' flag)\n"
|
||||||
" [cyan]publish[/] <path> --repo <r> Publish a skill to GitHub via PR\n"
|
" [cyan]publish[/] <path> --repo <r> Publish a skill to GitHub via PR\n"
|
||||||
" [cyan]snapshot[/] export|import Export/import skill configurations\n"
|
" [cyan]snapshot[/] export|import Export/import skill configurations\n"
|
||||||
|
|||||||
@ -29,7 +29,9 @@ def _add_compat_platform_flag(parser: argparse.ArgumentParser) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callable) -> None:
|
def build_gateway_parser(
|
||||||
|
subparsers, *, cmd_gateway: Callable, cmd_proxy: Callable, cmd_gateway_enroll: Callable
|
||||||
|
) -> None:
|
||||||
"""Attach the ``gateway`` and ``proxy`` subcommands to ``subparsers``."""
|
"""Attach the ``gateway`` and ``proxy`` subcommands to ``subparsers``."""
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# gateway command
|
# gateway command
|
||||||
@ -236,6 +238,52 @@ def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callab
|
|||||||
help="Skip the confirmation prompt",
|
help="Skip the confirmation prompt",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# gateway enroll — enroll a self-hosted gateway with a relay connector
|
||||||
|
# (connector⇄gateway auth). Redeems a single-use enrollment token for the
|
||||||
|
# per-gateway secret + per-tenant delivery key and writes them to .env.
|
||||||
|
# See docs/relay-connector-contract.md (and the connector repo's
|
||||||
|
# docs/connector-gateway-auth-design.md). EXPERIMENTAL.
|
||||||
|
gateway_enroll = gateway_subparsers.add_parser(
|
||||||
|
"enroll",
|
||||||
|
help="Enroll this gateway with a relay connector (writes relay auth creds to .env)",
|
||||||
|
description=(
|
||||||
|
"Redeem a single-use enrollment token with a relay connector. "
|
||||||
|
"Authenticates as your Nous Portal account (the connector derives the "
|
||||||
|
"authoritative tenant from it), mints this gateway's per-gateway secret "
|
||||||
|
"and per-tenant delivery key, and writes GATEWAY_RELAY_ID / "
|
||||||
|
"GATEWAY_RELAY_SECRET / GATEWAY_RELAY_DELIVERY_KEY into ~/.hermes/.env. "
|
||||||
|
"Requires being logged in (hermes setup). Not available in managed installs."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
gateway_enroll.add_argument(
|
||||||
|
"--token",
|
||||||
|
default=None,
|
||||||
|
help=(
|
||||||
|
"The single-use enrollment token from the connector (delivered with "
|
||||||
|
"your gateway config). Also settable via GATEWAY_RELAY_ENROLL_TOKEN."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
gateway_enroll.add_argument(
|
||||||
|
"--connector-url",
|
||||||
|
dest="connector_url",
|
||||||
|
default=None,
|
||||||
|
help=(
|
||||||
|
"The connector base/relay URL, e.g. wss://connector.example.com/relay "
|
||||||
|
"or https://connector.example.com. Also settable via GATEWAY_RELAY_URL "
|
||||||
|
"/ gateway.relay_url in config.yaml."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
gateway_enroll.add_argument(
|
||||||
|
"--gateway-id",
|
||||||
|
dest="gateway_id",
|
||||||
|
default=None,
|
||||||
|
help=(
|
||||||
|
"A stable id for this gateway instance (kill-switch granularity). "
|
||||||
|
"Defaults to gw-<hostname>."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
gateway_enroll.set_defaults(func=cmd_gateway_enroll)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# proxy command — local OpenAI-compatible proxy that attaches the user's
|
# proxy command — local OpenAI-compatible proxy that attaches the user's
|
||||||
# OAuth-authenticated provider credentials to outbound requests. Lets
|
# OAuth-authenticated provider credentials to outbound requests. Lets
|
||||||
|
|||||||
@ -164,6 +164,35 @@ def build_skills_parser(subparsers, *, cmd_skills: Callable) -> None:
|
|||||||
help="Skip confirmation prompt when using --restore",
|
help="Skip confirmation prompt when using --restore",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
skills_list_modified = skills_subparsers.add_parser(
|
||||||
|
"list-modified",
|
||||||
|
help="List bundled skills you've edited (which `hermes update` keeps)",
|
||||||
|
description=(
|
||||||
|
"Show the bundled skills whose local copy differs from the version last "
|
||||||
|
"synced, i.e. the ones `hermes update` reports as user-modified and skips. "
|
||||||
|
"Use `hermes skills diff <name>` to see changes and `hermes skills reset "
|
||||||
|
"<name>` to resume updates."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
skills_list_modified.add_argument(
|
||||||
|
"--json",
|
||||||
|
action="store_true",
|
||||||
|
help="Output the list as JSON",
|
||||||
|
)
|
||||||
|
|
||||||
|
skills_diff = skills_subparsers.add_parser(
|
||||||
|
"diff",
|
||||||
|
help="Show how your copy of a bundled skill differs from the stock version",
|
||||||
|
description=(
|
||||||
|
"Print a unified diff between your local copy of a bundled skill and the "
|
||||||
|
"current bundled (stock) version, so you can confirm what changed before "
|
||||||
|
"running `hermes skills reset`."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
skills_diff.add_argument(
|
||||||
|
"name", help="Skill name to diff (e.g. google-workspace)"
|
||||||
|
)
|
||||||
|
|
||||||
skills_opt_out = skills_subparsers.add_parser(
|
skills_opt_out = skills_subparsers.add_parser(
|
||||||
"opt-out",
|
"opt-out",
|
||||||
help="Stop bundled skills from being seeded into this profile",
|
help="Stop bundled skills from being seeded into this profile",
|
||||||
|
|||||||
@ -70,7 +70,10 @@ from gateway.status import (
|
|||||||
from utils import env_var_enabled
|
from utils import env_var_enabled
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
from fastapi import (
|
||||||
|
FastAPI, File, Form, HTTPException, Request, UploadFile,
|
||||||
|
WebSocket, WebSocketDisconnect,
|
||||||
|
)
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
@ -82,7 +85,10 @@ except ImportError:
|
|||||||
try:
|
try:
|
||||||
from tools.lazy_deps import ensure as _lazy_ensure
|
from tools.lazy_deps import ensure as _lazy_ensure
|
||||||
_lazy_ensure("tool.dashboard", prompt=False)
|
_lazy_ensure("tool.dashboard", prompt=False)
|
||||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
from fastapi import (
|
||||||
|
FastAPI, File, Form, HTTPException, Request, UploadFile,
|
||||||
|
WebSocket, WebSocketDisconnect,
|
||||||
|
)
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
@ -1486,6 +1492,74 @@ async def upload_managed_file(payload: ManagedFileUpload, request: Request):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Stream uploads to disk in fixed-size chunks. The legacy JSON endpoint above
|
||||||
|
# buffers the whole file as a base64 data URL in a JSON body, which (a) inflates
|
||||||
|
# the payload ~33%, (b) holds the entire file (plus its decoded copy) in memory,
|
||||||
|
# and (c) reliably trips upstream proxy body-size/timeout limits with a 502 on
|
||||||
|
# large backup archives (NS-501). This multipart endpoint reads the request body
|
||||||
|
# in 1 MiB chunks straight to a temp file, enforces the size cap as it goes, and
|
||||||
|
# atomically renames into place — constant memory, no base64 inflation.
|
||||||
|
_UPLOAD_CHUNK_BYTES = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/files/upload-stream")
|
||||||
|
async def upload_managed_file_stream(
|
||||||
|
request: Request,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
path: str = Form(...),
|
||||||
|
overwrite: bool = Form(True),
|
||||||
|
):
|
||||||
|
policy, target, display_path = _resolve_managed_path(path, request, for_write=True)
|
||||||
|
if target.exists() and target.is_dir():
|
||||||
|
raise HTTPException(status_code=409, detail="A directory already exists at that path")
|
||||||
|
if target.exists() and not overwrite:
|
||||||
|
raise HTTPException(status_code=409, detail="File already exists")
|
||||||
|
|
||||||
|
try:
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
except PermissionError:
|
||||||
|
raise HTTPException(status_code=403, detail="File is not writable")
|
||||||
|
except OSError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Could not create parent directory: {exc}")
|
||||||
|
|
||||||
|
# Write to a sibling temp file first so a partial/aborted upload never
|
||||||
|
# clobbers an existing file, then atomically rename into place.
|
||||||
|
tmp_fd, tmp_name = tempfile.mkstemp(
|
||||||
|
prefix=f".{target.name}.", suffix=".upload", dir=str(target.parent)
|
||||||
|
)
|
||||||
|
tmp_path = Path(tmp_name)
|
||||||
|
total = 0
|
||||||
|
try:
|
||||||
|
with os.fdopen(tmp_fd, "wb") as out:
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(_UPLOAD_CHUNK_BYTES)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
total += len(chunk)
|
||||||
|
if total > _MANAGED_FILE_MAX_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="File is too large")
|
||||||
|
out.write(chunk)
|
||||||
|
os.replace(tmp_path, target)
|
||||||
|
except HTTPException:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
except PermissionError:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
raise HTTPException(status_code=403, detail="File is not writable")
|
||||||
|
except OSError as exc:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Could not write file: {exc}")
|
||||||
|
finally:
|
||||||
|
await file.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"entry": _managed_file_entry(policy, target),
|
||||||
|
"path": display_path,
|
||||||
|
**_managed_response_meta(policy),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/files/mkdir")
|
@app.post("/api/files/mkdir")
|
||||||
async def create_managed_directory(payload: ManagedDirectoryCreate, request: Request):
|
async def create_managed_directory(payload: ManagedDirectoryCreate, request: Request):
|
||||||
policy, target, display_path = _resolve_managed_path(payload.path, request, for_write=True)
|
policy, target, display_path = _resolve_managed_path(payload.path, request, for_write=True)
|
||||||
@ -3249,7 +3323,6 @@ def get_model_options(profile: Optional[str] = None):
|
|||||||
with _profile_scope(profile):
|
with _profile_scope(profile):
|
||||||
return build_models_payload(
|
return build_models_payload(
|
||||||
load_picker_context(),
|
load_picker_context(),
|
||||||
max_models=50,
|
|
||||||
include_unconfigured=True,
|
include_unconfigured=True,
|
||||||
picker_hints=True,
|
picker_hints=True,
|
||||||
canonical_order=True,
|
canonical_order=True,
|
||||||
@ -3324,7 +3397,7 @@ def get_recommended_default_model(provider: str = ""):
|
|||||||
try:
|
try:
|
||||||
from hermes_cli.inventory import build_models_payload, load_picker_context
|
from hermes_cli.inventory import build_models_payload, load_picker_context
|
||||||
|
|
||||||
payload = build_models_payload(load_picker_context(), max_models=50)
|
payload = build_models_payload(load_picker_context())
|
||||||
for row in payload.get("providers", []):
|
for row in payload.get("providers", []):
|
||||||
if str(row.get("slug", "")).lower() == slug:
|
if str(row.get("slug", "")).lower() == slug:
|
||||||
models = row.get("models") or []
|
models = row.get("models") or []
|
||||||
@ -7519,17 +7592,35 @@ async def list_mcp_catalog(profile: Optional[str] = None):
|
|||||||
}
|
}
|
||||||
for entry in catalog_entries:
|
for entry in catalog_entries:
|
||||||
auth = entry.auth
|
auth = entry.auth
|
||||||
|
transport = entry.transport
|
||||||
|
install = entry.install
|
||||||
entries.append({
|
entries.append({
|
||||||
"name": entry.name,
|
"name": entry.name,
|
||||||
"description": entry.description,
|
"description": entry.description,
|
||||||
"source": entry.source,
|
"source": entry.source,
|
||||||
"transport": entry.transport.type,
|
"transport": transport.type,
|
||||||
"auth_type": getattr(auth, "type", "none"),
|
"auth_type": getattr(auth, "type", "none"),
|
||||||
# Env vars the user must supply (names + prompts only, never values).
|
# Env vars the user must supply (names + prompts only, never values).
|
||||||
"required_env": [
|
"required_env": [
|
||||||
{"name": e.name, "prompt": e.prompt, "required": e.required}
|
{"name": e.name, "prompt": e.prompt, "required": e.required}
|
||||||
for e in getattr(auth, "env", []) or []
|
for e in getattr(auth, "env", []) or []
|
||||||
],
|
],
|
||||||
|
# Transport details so the UI can show exactly what connects/runs.
|
||||||
|
# The trust model (docs: user-guide/features/mcp) tells users to
|
||||||
|
# inspect command/args/url and the install bootstrap before
|
||||||
|
# installing — surface them rather than hiding them in the repo.
|
||||||
|
"command": transport.command,
|
||||||
|
"args": list(transport.args or []),
|
||||||
|
"url": transport.url,
|
||||||
|
# Git bootstrap (present only for entries that clone + build).
|
||||||
|
"install_url": install.url if install else None,
|
||||||
|
"install_ref": install.ref if install else None,
|
||||||
|
"bootstrap": list(install.bootstrap) if install else [],
|
||||||
|
# Default tool pre-selection hint and post-install guidance.
|
||||||
|
"default_enabled": list(entry.tools.default_enabled)
|
||||||
|
if entry.tools.default_enabled is not None
|
||||||
|
else None,
|
||||||
|
"post_install": entry.post_install or "",
|
||||||
"needs_install": entry.install is not None,
|
"needs_install": entry.install is not None,
|
||||||
"installed": installed_state.get(entry.name, (False, False))[0],
|
"installed": installed_state.get(entry.name, (False, False))[0],
|
||||||
"enabled": installed_state.get(entry.name, (False, False))[1],
|
"enabled": installed_state.get(entry.name, (False, False))[1],
|
||||||
|
|||||||
@ -32,10 +32,39 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from logging.handlers import RotatingFileHandler
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Sequence
|
from typing import Optional, Sequence
|
||||||
|
|
||||||
|
# On Windows, stdlib ``RotatingFileHandler`` calls ``os.rename()`` in
|
||||||
|
# ``doRollover()`` and fails with ``PermissionError [WinError 32]`` whenever
|
||||||
|
# another process holds an append-mode handle on ``agent.log`` — which is
|
||||||
|
# essentially always in Hermes (TUI, gateway, ``hy_memory`` server, MCP
|
||||||
|
# servers, and on-demand CLI commands all log from separate processes),
|
||||||
|
# pinning ``agent.log`` at the 5 MiB threshold and spamming stderr with
|
||||||
|
# a traceback on every emit. ``concurrent-log-handler`` wraps the rename in a
|
||||||
|
# cross-process file lock (via ``portalocker``: pywin32 on Windows) so only
|
||||||
|
# one process rotates at a time and the others wait their turn.
|
||||||
|
#
|
||||||
|
# This swap is Windows-ONLY and deliberately so:
|
||||||
|
# * The bug (WinError 32 on rename-while-open) is specific to Windows file
|
||||||
|
# locking semantics — POSIX renames an open file fine, so stdlib already
|
||||||
|
# works correctly on Linux/macOS.
|
||||||
|
# * On POSIX, managed-mode (NixOS) relies on the exact ``_open()`` /
|
||||||
|
# ``doRollover()`` lifecycle of stdlib ``RotatingFileHandler`` (the
|
||||||
|
# ``_ManagedRotatingFileHandler`` subclass chmods 0660 after each). CLH
|
||||||
|
# opens lazily and rotates differently, which breaks the group-writable
|
||||||
|
# guarantee and the eager file-creation those paths depend on.
|
||||||
|
# Aliasing keeps every existing ``RotatingFileHandler`` reference in this
|
||||||
|
# module (class declaration, ``isinstance`` checks, docstring) working
|
||||||
|
# unchanged. See #44873.
|
||||||
|
if sys.platform == "win32":
|
||||||
|
from concurrent_log_handler import ( # noqa: E402
|
||||||
|
ConcurrentRotatingFileHandler as RotatingFileHandler,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
from logging.handlers import RotatingFileHandler # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
from hermes_constants import get_config_path, get_hermes_home
|
from hermes_constants import get_config_path, get_hermes_home
|
||||||
|
|
||||||
# Sentinel to track whether setup_logging() has already run. The function
|
# Sentinel to track whether setup_logging() has already run. The function
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB |
@ -21,7 +21,7 @@ let
|
|||||||
|
|
||||||
# Single npm deps fetch from the workspace root lockfile.
|
# Single npm deps fetch from the workspace root lockfile.
|
||||||
# All workspace packages share this derivation.
|
# All workspace packages share this derivation.
|
||||||
npmDepsHash = "sha256-m9cjbjzi4SaFCjODfdrawS5e+1ag+MpRn528/upSNqo=";
|
npmDepsHash = "sha256-kbjJksq7limRIYqP3DwI+GNgCXkG96tXcsQqmuEedxo=";
|
||||||
|
|
||||||
npmDeps = pkgs.fetchNpmDeps {
|
npmDeps = pkgs.fetchNpmDeps {
|
||||||
inherit src;
|
inherit src;
|
||||||
|
|||||||
54
optional-mcps/unreal-engine/manifest.yaml
Normal file
54
optional-mcps/unreal-engine/manifest.yaml
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
# Nous-approved MCP catalog entry.
|
||||||
|
# Presence in this directory = approval. Merged via PR review.
|
||||||
|
manifest_version: 1
|
||||||
|
|
||||||
|
name: unreal-engine
|
||||||
|
description: Drive the Unreal Engine 5.8 editor over its local MCP server.
|
||||||
|
source: https://dev.epicgames.com/documentation/unreal-engine/unreal-mcp-in-unreal-editor
|
||||||
|
|
||||||
|
# Epic's official "Unreal MCP" plugin (internal id ModelContextProtocol)
|
||||||
|
# embeds an MCP server inside the running Unreal Editor process and serves it
|
||||||
|
# over local HTTP. There is nothing to install on the Hermes side — the user
|
||||||
|
# enables the plugin in-editor and the server binds to 127.0.0.1. Hermes's
|
||||||
|
# MCP client just connects to the URL.
|
||||||
|
#
|
||||||
|
# Default bind is http://127.0.0.1:8000/mcp (port + path are configurable in
|
||||||
|
# Editor Preferences > General > Model Context Protocol). If you change the
|
||||||
|
# port/path in-editor, edit the url in mcp_servers.unreal-engine afterward.
|
||||||
|
transport:
|
||||||
|
type: http
|
||||||
|
url: http://127.0.0.1:8000/mcp
|
||||||
|
|
||||||
|
# The editor-embedded server accepts connections only from the same machine
|
||||||
|
# and has no authentication of its own (Epic's experimental design — not for
|
||||||
|
# remote use). Nothing to prompt for.
|
||||||
|
auth:
|
||||||
|
type: none
|
||||||
|
|
||||||
|
# Tool selection at install time:
|
||||||
|
# The plugin advertises engine tools (spawn actors, configure lighting, create
|
||||||
|
# material instances, inspect Slate widgets, run automation tests) and is
|
||||||
|
# user-extensible, so the exact surface depends on the project's enabled
|
||||||
|
# toolsets. Leave default_enabled unset — the install-time probe lists whatever
|
||||||
|
# the live editor exposes and pre-checks all of it; users prune from there.
|
||||||
|
|
||||||
|
post_install: |
|
||||||
|
This entry connects to Epic's official Unreal MCP plugin, which runs INSIDE
|
||||||
|
the Unreal Editor. Before Hermes can connect:
|
||||||
|
|
||||||
|
1. Open your project in Unreal Editor 5.8+.
|
||||||
|
2. Edit > Plugins, search "Unreal MCP", enable it, restart the editor
|
||||||
|
(the Toolset Registry dependency enables automatically).
|
||||||
|
3. Edit > Editor Preferences > General > Model Context Protocol, turn on
|
||||||
|
"Auto Start Server" (or run `ModelContextProtocol.StartServer` in the
|
||||||
|
editor console). It binds to http://127.0.0.1:8000/mcp by default.
|
||||||
|
|
||||||
|
Start Hermes AFTER the editor's server is running so the tools are probed.
|
||||||
|
If you changed the port or URL path in Editor Preferences, update the url in
|
||||||
|
mcp_servers.unreal-engine to match.
|
||||||
|
|
||||||
|
Status: Epic ships this as EXPERIMENTAL. The server runs Tool calls serially
|
||||||
|
on the engine game thread — avoid issuing overlapping calls.
|
||||||
|
|
||||||
|
Re-run the tool checklist any time with:
|
||||||
|
hermes mcp configure unreal-engine
|
||||||
@ -702,7 +702,7 @@ class HindsightMemoryProvider(MemoryProvider):
|
|||||||
from hermes_cli.config import save_config
|
from hermes_cli.config import save_config
|
||||||
from hermes_cli.secret_prompt import masked_secret_prompt
|
from hermes_cli.secret_prompt import masked_secret_prompt
|
||||||
|
|
||||||
from hermes_cli.memory_setup import _curses_select
|
from hermes_cli.memory_setup import _CANCELLED, _curses_select, _print_cancelled_setup
|
||||||
|
|
||||||
print("\n Configuring Hindsight memory:\n")
|
print("\n Configuring Hindsight memory:\n")
|
||||||
|
|
||||||
@ -719,7 +719,10 @@ class HindsightMemoryProvider(MemoryProvider):
|
|||||||
]
|
]
|
||||||
existing_mode = existing_config.get("mode")
|
existing_mode = existing_config.get("mode")
|
||||||
mode_default_idx = mode_values.index(existing_mode) if existing_mode in mode_values else 0
|
mode_default_idx = mode_values.index(existing_mode) if existing_mode in mode_values else 0
|
||||||
mode_idx = _curses_select(" Select mode", mode_items, default=mode_default_idx)
|
mode_idx = _curses_select(" Select mode", mode_items, default=mode_default_idx, cancel_returns=_CANCELLED)
|
||||||
|
if mode_idx == _CANCELLED:
|
||||||
|
_print_cancelled_setup()
|
||||||
|
return
|
||||||
mode = mode_values[mode_idx]
|
mode = mode_values[mode_idx]
|
||||||
|
|
||||||
provider_config: dict = dict(existing_config)
|
provider_config: dict = dict(existing_config)
|
||||||
@ -737,6 +740,27 @@ class HindsightMemoryProvider(MemoryProvider):
|
|||||||
else:
|
else:
|
||||||
deps_to_install = [cloud_dep]
|
deps_to_install = [cloud_dep]
|
||||||
|
|
||||||
|
llm_provider = ""
|
||||||
|
if mode == "local_embedded":
|
||||||
|
providers_list = list(_PROVIDER_DEFAULT_MODELS.keys())
|
||||||
|
llm_items = [
|
||||||
|
(p, f"default model: {_PROVIDER_DEFAULT_MODELS[p]}")
|
||||||
|
for p in providers_list
|
||||||
|
]
|
||||||
|
existing_llm_provider = provider_config.get("llm_provider")
|
||||||
|
llm_default_idx = providers_list.index(existing_llm_provider) if existing_llm_provider in providers_list else 0
|
||||||
|
llm_idx = _curses_select(
|
||||||
|
" Select LLM provider",
|
||||||
|
llm_items,
|
||||||
|
default=llm_default_idx,
|
||||||
|
cancel_returns=_CANCELLED,
|
||||||
|
)
|
||||||
|
if llm_idx == _CANCELLED:
|
||||||
|
_print_cancelled_setup()
|
||||||
|
return
|
||||||
|
llm_provider = providers_list[llm_idx]
|
||||||
|
provider_config["llm_provider"] = llm_provider
|
||||||
|
|
||||||
print("\n Checking dependencies...")
|
print("\n Checking dependencies...")
|
||||||
uv_path = shutil.which("uv")
|
uv_path = shutil.which("uv")
|
||||||
if not uv_path:
|
if not uv_path:
|
||||||
@ -785,18 +809,6 @@ class HindsightMemoryProvider(MemoryProvider):
|
|||||||
env_writes["HINDSIGHT_API_KEY"] = api_key
|
env_writes["HINDSIGHT_API_KEY"] = api_key
|
||||||
|
|
||||||
else: # local_embedded
|
else: # local_embedded
|
||||||
providers_list = list(_PROVIDER_DEFAULT_MODELS.keys())
|
|
||||||
llm_items = [
|
|
||||||
(p, f"default model: {_PROVIDER_DEFAULT_MODELS[p]}")
|
|
||||||
for p in providers_list
|
|
||||||
]
|
|
||||||
existing_llm_provider = provider_config.get("llm_provider")
|
|
||||||
llm_default_idx = providers_list.index(existing_llm_provider) if existing_llm_provider in providers_list else 0
|
|
||||||
llm_idx = _curses_select(" Select LLM provider", llm_items, default=llm_default_idx)
|
|
||||||
llm_provider = providers_list[llm_idx]
|
|
||||||
|
|
||||||
provider_config["llm_provider"] = llm_provider
|
|
||||||
|
|
||||||
if llm_provider == "openai_compatible":
|
if llm_provider == "openai_compatible":
|
||||||
existing_base_url = provider_config.get("llm_base_url", "")
|
existing_base_url = provider_config.get("llm_base_url", "")
|
||||||
prompt = " LLM endpoint URL (e.g. http://192.168.1.10:8080/v1)"
|
prompt = " LLM endpoint URL (e.g. http://192.168.1.10:8080/v1)"
|
||||||
|
|||||||
@ -14,6 +14,10 @@ Context database by Volcengine (ByteDance) with filesystem-style knowledge hiera
|
|||||||
hermes memory setup # select "openviking"
|
hermes memory setup # select "openviking"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The setup can link to an existing `~/.openviking/ovcli.conf`, copy its current
|
||||||
|
connection values into Hermes, or create a minimal `ovcli.conf` when one does
|
||||||
|
not exist.
|
||||||
|
|
||||||
Or manually:
|
Or manually:
|
||||||
```bash
|
```bash
|
||||||
hermes config set memory.provider openviking
|
hermes config set memory.provider openviking
|
||||||
@ -27,7 +31,14 @@ All config via environment variables in `.env`:
|
|||||||
| Env Var | Default | Description |
|
| Env Var | Default | Description |
|
||||||
|---------|---------|-------------|
|
|---------|---------|-------------|
|
||||||
| `OPENVIKING_ENDPOINT` | `http://127.0.0.1:1933` | Server URL |
|
| `OPENVIKING_ENDPOINT` | `http://127.0.0.1:1933` | Server URL |
|
||||||
| `OPENVIKING_API_KEY` | (none) | API key (optional) |
|
| `OPENVIKING_API_KEY` | (none) | User/admin API key for authenticated servers |
|
||||||
|
| `OPENVIKING_ACCOUNT` | `default` | Tenant account for local/trusted mode |
|
||||||
|
| `OPENVIKING_USER` | `default` | Tenant user for local/trusted mode |
|
||||||
|
| `OPENVIKING_AGENT` | `hermes` | Hermes peer ID in OpenViking, used for peer-scoped memories |
|
||||||
|
|
||||||
|
When `OPENVIKING_API_KEY` is set, Hermes lets OpenViking derive account/user
|
||||||
|
identity from the key. In local or trusted deployments without an API key,
|
||||||
|
Hermes sends `OPENVIKING_ACCOUNT` and `OPENVIKING_USER` as identity headers.
|
||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -3,7 +3,6 @@ version: 2.0.0
|
|||||||
description: "OpenViking context database — session-managed memory with automatic extraction, tiered retrieval, and filesystem-style knowledge browsing."
|
description: "OpenViking context database — session-managed memory with automatic extraction, tiered retrieval, and filesystem-style knowledge browsing."
|
||||||
pip_dependencies:
|
pip_dependencies:
|
||||||
- httpx
|
- httpx
|
||||||
requires_env:
|
requires_env: []
|
||||||
- OPENVIKING_ENDPOINT
|
|
||||||
hooks:
|
hooks:
|
||||||
- on_session_end
|
- on_session_end
|
||||||
|
|||||||
@ -54,6 +54,15 @@ class TraceState:
|
|||||||
|
|
||||||
_STATE_LOCK = threading.Lock()
|
_STATE_LOCK = threading.Lock()
|
||||||
_TRACE_STATE: Dict[str, TraceState] = {}
|
_TRACE_STATE: Dict[str, TraceState] = {}
|
||||||
|
# Hard cap on live trace state. Each turn keys _TRACE_STATE by a unique
|
||||||
|
# turn_id, and an entry is normally reclaimed by _finish_trace when a turn
|
||||||
|
# ends cleanly (final response has content and no tool calls). A turn that
|
||||||
|
# never reaches that state — interrupted, a tool-only final step, or empty
|
||||||
|
# final content — would otherwise linger forever, so over the cap we evict
|
||||||
|
# the least-recently-updated entries (ending their root span first). The cap
|
||||||
|
# is far above any realistic concurrent-live-turn working set; it exists only
|
||||||
|
# to bound the leak from non-finalizing turns, not to limit concurrency.
|
||||||
|
_MAX_TRACE_STATE = 256
|
||||||
_LANGFUSE_CLIENT = None
|
_LANGFUSE_CLIENT = None
|
||||||
_READ_FILE_LINE_RE = re.compile(r"^\s*(\d+)\|(.*)$")
|
_READ_FILE_LINE_RE = re.compile(r"^\s*(\d+)\|(.*)$")
|
||||||
_READ_FILE_HEAD_LINES = 25
|
_READ_FILE_HEAD_LINES = 25
|
||||||
@ -219,14 +228,43 @@ def _get_langfuse() -> Optional[Langfuse]:
|
|||||||
return _LANGFUSE_CLIENT
|
return _LANGFUSE_CLIENT
|
||||||
|
|
||||||
|
|
||||||
def _trace_key(task_id: str, session_id: str) -> str:
|
def _scope_prefix(task_id: str, session_id: str) -> str:
|
||||||
|
"""The task/session/thread prefix shared by every trace-key shape."""
|
||||||
if task_id:
|
if task_id:
|
||||||
return task_id
|
return f"task:{task_id}"
|
||||||
if session_id:
|
if session_id:
|
||||||
return f"session:{session_id}"
|
return f"session:{session_id}"
|
||||||
return f"thread:{threading.get_ident()}"
|
return f"thread:{threading.get_ident()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _trace_key(
|
||||||
|
task_id: str,
|
||||||
|
session_id: str,
|
||||||
|
*,
|
||||||
|
turn_id: str = "",
|
||||||
|
api_request_id: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Build a stable in-process trace scope key for one agent turn.
|
||||||
|
|
||||||
|
Older Hermes paths only expose ``task_id``/``session_id``. Newer paths
|
||||||
|
pass ``turn_id`` and ``api_request_id`` in LLM/tool hooks; when present,
|
||||||
|
they must scope trace state so concurrent requests sharing one task/session
|
||||||
|
never collide. ``turn_id`` is preferred over ``api_request_id`` so the
|
||||||
|
turn-level ``post_llm_call`` hook (which carries ``turn_id`` but no
|
||||||
|
``api_request_id``) resolves to the same key as the request-level hooks.
|
||||||
|
"""
|
||||||
|
if turn_id:
|
||||||
|
return f"{_scope_prefix(task_id, session_id)}:turn:{turn_id}"
|
||||||
|
if api_request_id:
|
||||||
|
return f"{_scope_prefix(task_id, session_id)}:api:{api_request_id}"
|
||||||
|
# Legacy shape: a bare ``task_id`` (NOT the ``task:`` prefix) when present,
|
||||||
|
# otherwise the session/thread prefix. Kept distinct for backward
|
||||||
|
# compatibility with keys minted before turn/request scoping existed.
|
||||||
|
if task_id:
|
||||||
|
return task_id
|
||||||
|
return _scope_prefix(task_id, session_id)
|
||||||
|
|
||||||
|
|
||||||
def _is_base64_data_uri(value: str) -> bool:
|
def _is_base64_data_uri(value: str) -> bool:
|
||||||
prefix = value[:200].lower()
|
prefix = value[:200].lower()
|
||||||
return prefix.startswith("data:") and ";base64," in prefix
|
return prefix.startswith("data:") and ";base64," in prefix
|
||||||
@ -563,12 +601,15 @@ def _usage_and_cost(response: Any, *, provider: str, api_mode: str, model: str,
|
|||||||
|
|
||||||
|
|
||||||
def _start_root_trace(task_key: str, *, task_id: str, session_id: str, platform: str, provider: str, model: str,
|
def _start_root_trace(task_key: str, *, task_id: str, session_id: str, platform: str, provider: str, model: str,
|
||||||
api_mode: str, messages: Any, client: Langfuse) -> TraceState:
|
api_mode: str, messages: Any, client: Langfuse,
|
||||||
|
turn_id: str = "", api_request_id: str = "") -> TraceState:
|
||||||
trace_id = client.create_trace_id(seed=f"{session_id or 'sessionless'}::{task_id or task_key}")
|
trace_id = client.create_trace_id(seed=f"{session_id or 'sessionless'}::{task_id or task_key}")
|
||||||
trace_input = _extract_last_user_message(messages)
|
trace_input = _extract_last_user_message(messages)
|
||||||
metadata = {
|
metadata = {
|
||||||
"source": "hermes",
|
"source": "hermes",
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
|
"turn_id": turn_id,
|
||||||
|
"api_request_id": api_request_id,
|
||||||
"platform": platform,
|
"platform": platform,
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"model": model,
|
"model": model,
|
||||||
@ -669,6 +710,30 @@ def _merge_trace_output(output: Any, state: TraceState) -> Any:
|
|||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _evict_stale_locked() -> None:
|
||||||
|
"""Drop least-recently-updated trace state to make room for a new entry.
|
||||||
|
|
||||||
|
Caller MUST hold ``_STATE_LOCK`` and call this immediately before inserting
|
||||||
|
one new entry. Bounds the leak from turns that never reach ``_finish_trace``
|
||||||
|
(interrupted / tool-only final step / empty final content), whose unique
|
||||||
|
per-turn key would otherwise linger forever. We evict down to
|
||||||
|
``_MAX_TRACE_STATE - 1`` so that the about-to-be-added entry leaves the dict
|
||||||
|
at ``_MAX_TRACE_STATE`` — a true ceiling. The evicted entry's root span is
|
||||||
|
ended so it is not left dangling on the Langfuse side.
|
||||||
|
"""
|
||||||
|
over = len(_TRACE_STATE) - (_MAX_TRACE_STATE - 1)
|
||||||
|
if over <= 0:
|
||||||
|
return
|
||||||
|
# Oldest-first by last_updated_at; evict just enough to make room.
|
||||||
|
stale = sorted(_TRACE_STATE.items(), key=lambda kv: kv[1].last_updated_at)[:over]
|
||||||
|
for key, state in stale:
|
||||||
|
_TRACE_STATE.pop(key, None)
|
||||||
|
try:
|
||||||
|
state.root_span.end()
|
||||||
|
except Exception as exc: # pragma: no cover - fail-open
|
||||||
|
_debug(f"evict stale trace failed: {exc}")
|
||||||
|
|
||||||
|
|
||||||
def _finish_trace(task_key: str, *, output: Any = None) -> None:
|
def _finish_trace(task_key: str, *, output: Any = None) -> None:
|
||||||
client = _get_langfuse()
|
client = _get_langfuse()
|
||||||
if client is None:
|
if client is None:
|
||||||
@ -712,7 +777,8 @@ def _request_key(api_call_count: Any) -> str:
|
|||||||
def on_pre_llm_call(*, task_id: str = "", session_id: str = "", platform: str = "", model: str = "",
|
def on_pre_llm_call(*, task_id: str = "", session_id: str = "", platform: str = "", model: str = "",
|
||||||
provider: str = "", base_url: str = "", api_mode: str = "",
|
provider: str = "", base_url: str = "", api_mode: str = "",
|
||||||
api_call_count: int = 0, messages: Any = None, turn_type: str = "user",
|
api_call_count: int = 0, messages: Any = None, turn_type: str = "user",
|
||||||
conversation_history: Any = None, user_message: Any = None, **_: Any) -> None:
|
conversation_history: Any = None, user_message: Any = None,
|
||||||
|
turn_id: str = "", api_request_id: str = "", **_: Any) -> None:
|
||||||
# Older Hermes branches used pre_llm_call for request-scoped tracing and
|
# Older Hermes branches used pre_llm_call for request-scoped tracing and
|
||||||
# passed the actual API messages. Current Hermes also has a turn-scoped
|
# passed the actual API messages. Current Hermes also has a turn-scoped
|
||||||
# pre_llm_call used for context injection; tracing that hook creates an
|
# pre_llm_call used for context injection; tracing that hook creates an
|
||||||
@ -729,7 +795,12 @@ def on_pre_llm_call(*, task_id: str = "", session_id: str = "", platform: str =
|
|||||||
# pre_llm_call with API messages directly. Current Hermes fires
|
# pre_llm_call with API messages directly. Current Hermes fires
|
||||||
# pre_llm_call for context injection (conversation_history/user_message,
|
# pre_llm_call for context injection (conversation_history/user_message,
|
||||||
# no messages list) — tracing that would create orphan traces.
|
# no messages list) — tracing that would create orphan traces.
|
||||||
task_key = _trace_key(task_id, session_id)
|
task_key = _trace_key(
|
||||||
|
task_id,
|
||||||
|
session_id,
|
||||||
|
turn_id=turn_id,
|
||||||
|
api_request_id=api_request_id,
|
||||||
|
)
|
||||||
|
|
||||||
with _STATE_LOCK:
|
with _STATE_LOCK:
|
||||||
state = _TRACE_STATE.get(task_key)
|
state = _TRACE_STATE.get(task_key)
|
||||||
@ -744,7 +815,10 @@ def on_pre_llm_call(*, task_id: str = "", session_id: str = "", platform: str =
|
|||||||
api_mode=api_mode,
|
api_mode=api_mode,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
client=client,
|
client=client,
|
||||||
|
turn_id=turn_id,
|
||||||
|
api_request_id=api_request_id,
|
||||||
)
|
)
|
||||||
|
_evict_stale_locked()
|
||||||
_TRACE_STATE[task_key] = state
|
_TRACE_STATE[task_key] = state
|
||||||
state.last_updated_at = time.time()
|
state.last_updated_at = time.time()
|
||||||
|
|
||||||
@ -769,6 +843,8 @@ def on_pre_llm_request(
|
|||||||
max_tokens: Any = None,
|
max_tokens: Any = None,
|
||||||
conversation_history: Any = None,
|
conversation_history: Any = None,
|
||||||
user_message: Any = None,
|
user_message: Any = None,
|
||||||
|
turn_id: str = "",
|
||||||
|
api_request_id: str = "",
|
||||||
**_: Any,
|
**_: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
client = _get_langfuse()
|
client = _get_langfuse()
|
||||||
@ -782,7 +858,12 @@ def on_pre_llm_request(
|
|||||||
user_message=user_message,
|
user_message=user_message,
|
||||||
)
|
)
|
||||||
|
|
||||||
task_key = _trace_key(task_id, session_id)
|
task_key = _trace_key(
|
||||||
|
task_id,
|
||||||
|
session_id,
|
||||||
|
turn_id=turn_id,
|
||||||
|
api_request_id=api_request_id,
|
||||||
|
)
|
||||||
req_key = _request_key(api_call_count)
|
req_key = _request_key(api_call_count)
|
||||||
|
|
||||||
with _STATE_LOCK:
|
with _STATE_LOCK:
|
||||||
@ -798,7 +879,10 @@ def on_pre_llm_request(
|
|||||||
api_mode=api_mode,
|
api_mode=api_mode,
|
||||||
messages=input_messages,
|
messages=input_messages,
|
||||||
client=client,
|
client=client,
|
||||||
|
turn_id=turn_id,
|
||||||
|
api_request_id=api_request_id,
|
||||||
)
|
)
|
||||||
|
_evict_stale_locked()
|
||||||
_TRACE_STATE[task_key] = state
|
_TRACE_STATE[task_key] = state
|
||||||
state.last_updated_at = time.time()
|
state.last_updated_at = time.time()
|
||||||
previous = state.generations.pop(req_key, None)
|
previous = state.generations.pop(req_key, None)
|
||||||
@ -827,12 +911,18 @@ def on_post_llm_call(*, task_id: str = "", session_id: str = "", provider: str =
|
|||||||
api_duration: float = 0.0, finish_reason: str = "",
|
api_duration: float = 0.0, finish_reason: str = "",
|
||||||
usage: Any = None, assistant_content_chars: int = 0,
|
usage: Any = None, assistant_content_chars: int = 0,
|
||||||
assistant_tool_call_count: int = 0, assistant_response: Any = None,
|
assistant_tool_call_count: int = 0, assistant_response: Any = None,
|
||||||
|
turn_id: str = "", api_request_id: str = "",
|
||||||
**_: Any) -> None:
|
**_: Any) -> None:
|
||||||
client = _get_langfuse()
|
client = _get_langfuse()
|
||||||
if client is None:
|
if client is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
task_key = _trace_key(task_id, session_id)
|
task_key = _trace_key(
|
||||||
|
task_id,
|
||||||
|
session_id,
|
||||||
|
turn_id=turn_id,
|
||||||
|
api_request_id=api_request_id,
|
||||||
|
)
|
||||||
req_key = _request_key(api_call_count)
|
req_key = _request_key(api_call_count)
|
||||||
|
|
||||||
with _STATE_LOCK:
|
with _STATE_LOCK:
|
||||||
@ -950,12 +1040,18 @@ def on_post_llm_call(*, task_id: str = "", session_id: str = "", provider: str =
|
|||||||
|
|
||||||
|
|
||||||
def on_pre_tool_call(*, tool_name: str = "", args: Any = None, task_id: str = "",
|
def on_pre_tool_call(*, tool_name: str = "", args: Any = None, task_id: str = "",
|
||||||
session_id: str = "", tool_call_id: str = "", **_: Any) -> None:
|
session_id: str = "", tool_call_id: str = "",
|
||||||
|
turn_id: str = "", api_request_id: str = "", **_: Any) -> None:
|
||||||
client = _get_langfuse()
|
client = _get_langfuse()
|
||||||
if client is None:
|
if client is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
task_key = _trace_key(task_id, session_id)
|
task_key = _trace_key(
|
||||||
|
task_id,
|
||||||
|
session_id,
|
||||||
|
turn_id=turn_id,
|
||||||
|
api_request_id=api_request_id,
|
||||||
|
)
|
||||||
|
|
||||||
with _STATE_LOCK:
|
with _STATE_LOCK:
|
||||||
state = _TRACE_STATE.get(task_key)
|
state = _TRACE_STATE.get(task_key)
|
||||||
@ -976,8 +1072,14 @@ def on_pre_tool_call(*, tool_name: str = "", args: Any = None, task_id: str = ""
|
|||||||
|
|
||||||
|
|
||||||
def on_post_tool_call(*, tool_name: str = "", args: Any = None, result: Any = None,
|
def on_post_tool_call(*, tool_name: str = "", args: Any = None, result: Any = None,
|
||||||
task_id: str = "", session_id: str = "", tool_call_id: str = "", **_: Any) -> None:
|
task_id: str = "", session_id: str = "", tool_call_id: str = "",
|
||||||
task_key = _trace_key(task_id, session_id)
|
turn_id: str = "", api_request_id: str = "", **_: Any) -> None:
|
||||||
|
task_key = _trace_key(
|
||||||
|
task_id,
|
||||||
|
session_id,
|
||||||
|
turn_id=turn_id,
|
||||||
|
api_request_id=api_request_id,
|
||||||
|
)
|
||||||
observation = None
|
observation = None
|
||||||
|
|
||||||
with _STATE_LOCK:
|
with _STATE_LOCK:
|
||||||
|
|||||||
@ -131,10 +131,13 @@ All env vars are documented in `plugin.yaml`. The most important:
|
|||||||
the bytes (`content.read()`) and base64-inlines them on the NDJSON event; the
|
the bytes (`content.read()`) and base64-inlines them on the NDJSON event; the
|
||||||
adapter caches them to the shared media cache and populates `media_urls` /
|
adapter caches them to the shared media cache and populates `media_urls` /
|
||||||
`media_types`, so the agent sees the real image/file or can transcribe the
|
`media_types`, so the agent sees the real image/file or can transcribe the
|
||||||
voice note — parity with the BlueBubbles iMessage channel. Media larger than
|
voice note — parity with the BlueBubbles iMessage channel. Mixed iMessage
|
||||||
`PHOTON_MAX_INLINE_ATTACHMENT_BYTES` (default 20 MB), or any byte read that
|
bubbles that contain both text and attachments are normalized as a grouped
|
||||||
fails, falls back to a text marker (`[Photon attachment received: …]` or
|
payload so the user's typed text is preserved alongside the cached media.
|
||||||
`[Photon voice received: …]`) so the agent still knows something arrived.
|
Media larger than `PHOTON_MAX_INLINE_ATTACHMENT_BYTES` (default 20 MB), or
|
||||||
|
any byte read that fails, falls back to a text marker (`[Photon attachment
|
||||||
|
received: …]` or `[Photon voice received: …]`) so the agent still knows
|
||||||
|
something arrived.
|
||||||
- **Outbound attachments are supported.** Images, voice notes, video, and
|
- **Outbound attachments are supported.** Images, voice notes, video, and
|
||||||
documents are sent via `space.send(attachment(...))` /
|
documents are sent via `space.send(attachment(...))` /
|
||||||
`space.send(voice(...))` through the sidecar's `/send-attachment`
|
`space.send(voice(...))` through the sidecar's `/send-attachment`
|
||||||
|
|||||||
@ -508,6 +508,38 @@ class PhotonAdapter(BasePlatformAdapter):
|
|||||||
media_urls: List[str] = []
|
media_urls: List[str] = []
|
||||||
media_types: List[str] = []
|
media_types: List[str] = []
|
||||||
|
|
||||||
|
def _normalize_binary_payload(
|
||||||
|
payload: Dict[str, Any]
|
||||||
|
) -> tuple[str, MessageType, List[str], List[str]]:
|
||||||
|
is_voice = payload.get("type") == "voice"
|
||||||
|
name = payload.get("name") or ("voice" if is_voice else "(unnamed)")
|
||||||
|
mime = payload.get("mimeType") or ""
|
||||||
|
mtype = MessageType.VOICE if is_voice else _attachment_message_type(mime)
|
||||||
|
cached = _cache_inbound_attachment(
|
||||||
|
payload, name, mime, force_audio=is_voice
|
||||||
|
)
|
||||||
|
if cached:
|
||||||
|
return (
|
||||||
|
"(voice)" if is_voice else "(attachment)",
|
||||||
|
mtype,
|
||||||
|
[cached],
|
||||||
|
[mime or ("audio/mp4" if is_voice else "application/octet-stream")],
|
||||||
|
)
|
||||||
|
label = "voice" if is_voice else "attachment"
|
||||||
|
duration = payload.get("duration")
|
||||||
|
duration_text = (
|
||||||
|
f", duration: {duration}s"
|
||||||
|
if isinstance(duration, (int, float))
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"[Photon {label} received: {name} "
|
||||||
|
f"({mime or 'unknown MIME'}{duration_text})]",
|
||||||
|
mtype,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
ctype = content.get("type")
|
ctype = content.get("type")
|
||||||
if ctype == "reaction":
|
if ctype == "reaction":
|
||||||
# Route only tapbacks on messages WE sent — those are implicitly
|
# Route only tapbacks on messages WE sent — those are implicitly
|
||||||
@ -551,37 +583,40 @@ class PhotonAdapter(BasePlatformAdapter):
|
|||||||
text = content.get("text") or ""
|
text = content.get("text") or ""
|
||||||
mtype = MessageType.TEXT
|
mtype = MessageType.TEXT
|
||||||
elif ctype in {"attachment", "voice"}:
|
elif ctype in {"attachment", "voice"}:
|
||||||
is_voice = ctype == "voice"
|
text, mtype, media_urls, media_types = _normalize_binary_payload(content)
|
||||||
name = content.get("name") or ("voice" if is_voice else "(unnamed)")
|
elif ctype == "group":
|
||||||
mime = content.get("mimeType") or ""
|
text_parts: List[str] = []
|
||||||
mtype = MessageType.VOICE if is_voice else _attachment_message_type(mime)
|
mtype = MessageType.TEXT
|
||||||
cached = _cache_inbound_attachment(
|
for item in content.get("items") or []:
|
||||||
content, name, mime, force_audio=is_voice
|
if not isinstance(item, dict):
|
||||||
)
|
continue
|
||||||
if cached:
|
item_content = item.get("content") or {}
|
||||||
media_urls.append(cached)
|
if not isinstance(item_content, dict):
|
||||||
media_types.append(
|
continue
|
||||||
mime or ("audio/mp4" if is_voice else "application/octet-stream")
|
item_type = item_content.get("type")
|
||||||
)
|
if item_type == "text":
|
||||||
# The real bytes are attached, so the agent sees the media
|
item_text = item_content.get("text") or ""
|
||||||
# itself — a short marker is enough text, and it keeps group
|
if item_text:
|
||||||
# mention-gating consistent with plain messages.
|
text_parts.append(item_text)
|
||||||
text = "(voice)" if is_voice else "(attachment)"
|
continue
|
||||||
else:
|
if item_type in {"attachment", "voice"}:
|
||||||
# No bytes (over the sidecar cap, a failed read, or a caching
|
marker, item_mtype, item_urls, item_types = _normalize_binary_payload(
|
||||||
# failure) — fall back to a metadata marker so the agent still
|
item_content
|
||||||
# knows something arrived.
|
|
||||||
label = "voice" if is_voice else "attachment"
|
|
||||||
duration = content.get("duration")
|
|
||||||
duration_text = (
|
|
||||||
f", duration: {duration}s"
|
|
||||||
if isinstance(duration, (int, float))
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
text = (
|
|
||||||
f"[Photon {label} received: {name} "
|
|
||||||
f"({mime or 'unknown MIME'}{duration_text})]"
|
|
||||||
)
|
)
|
||||||
|
if mtype == MessageType.TEXT:
|
||||||
|
mtype = item_mtype
|
||||||
|
media_urls.extend(item_urls)
|
||||||
|
media_types.extend(item_types)
|
||||||
|
if not item_urls:
|
||||||
|
text_parts.append(marker)
|
||||||
|
continue
|
||||||
|
if item_type:
|
||||||
|
text_parts.append(f"[Photon content type not handled: {item_type}]")
|
||||||
|
if media_urls and mtype == MessageType.TEXT:
|
||||||
|
mtype = MessageType.DOCUMENT
|
||||||
|
text = "\n".join(part for part in text_parts if part).strip()
|
||||||
|
if not text:
|
||||||
|
text = "(attachment)" if media_urls else "[Photon empty group received]"
|
||||||
else:
|
else:
|
||||||
text = f"[Photon content type not handled: {ctype}]"
|
text = f"[Photon content type not handled: {ctype}]"
|
||||||
mtype = MessageType.TEXT
|
mtype = MessageType.TEXT
|
||||||
@ -729,6 +764,28 @@ class PhotonAdapter(BasePlatformAdapter):
|
|||||||
# never runs — can't leave it orphaned on the port.
|
# never runs — can't leave it orphaned on the port.
|
||||||
env["PHOTON_SIDECAR_WATCH_STDIN"] = "1"
|
env["PHOTON_SIDECAR_WATCH_STDIN"] = "1"
|
||||||
|
|
||||||
|
try:
|
||||||
|
patch = subprocess.run( # noqa: S603
|
||||||
|
[
|
||||||
|
self._node_bin,
|
||||||
|
str(_SIDECAR_DIR / "patch-spectrum-mixed-attachments.mjs"),
|
||||||
|
str(_SIDECAR_DIR),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if patch.returncode != 0:
|
||||||
|
raise RuntimeError((patch.stderr or patch.stdout or "").strip())
|
||||||
|
if patch.stderr.strip():
|
||||||
|
logger.debug("[photon] %s", patch.stderr.strip())
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"[photon] failed to apply Spectrum mixed attachment patch: %s",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
self._sidecar_proc = subprocess.Popen( # noqa: S603
|
self._sidecar_proc = subprocess.Popen( # noqa: S603
|
||||||
[self._node_bin, str(_SIDECAR_DIR / "index.mjs")],
|
[self._node_bin, str(_SIDECAR_DIR / "index.mjs")],
|
||||||
stdin=subprocess.PIPE,
|
stdin=subprocess.PIPE,
|
||||||
|
|||||||
@ -57,6 +57,7 @@
|
|||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { once } from "node:events";
|
import { once } from "node:events";
|
||||||
|
import { patchSpectrumTs } from "./patch-spectrum-mixed-attachments.mjs";
|
||||||
|
|
||||||
const projectId = process.env.PHOTON_PROJECT_ID;
|
const projectId = process.env.PHOTON_PROJECT_ID;
|
||||||
const projectSecret = process.env.PHOTON_PROJECT_SECRET;
|
const projectSecret = process.env.PHOTON_PROJECT_SECRET;
|
||||||
@ -89,7 +90,26 @@ if (!projectId || !projectSecret || !sharedToken) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Lazy-load spectrum-ts so a missing install fails with a clear message
|
// Lazy-load spectrum-ts so a missing install fails with a clear message
|
||||||
// instead of a cryptic module-resolution error during import.
|
// instead of a cryptic module-resolution error during import. Apply Hermes'
|
||||||
|
// pinned-sdk compatibility patch first so existing installs self-heal at
|
||||||
|
// runtime, not only during npm postinstall.
|
||||||
|
try {
|
||||||
|
const patchResult = patchSpectrumTs();
|
||||||
|
if (patchResult.patched) {
|
||||||
|
console.error(
|
||||||
|
`photon-sidecar: spectrum mixed attachment patch applied: ${patchResult.file}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
"photon-sidecar: spectrum mixed attachment patch failed. " +
|
||||||
|
"Run `npm install` inside plugins/platforms/photon/sidecar/ or " +
|
||||||
|
"upgrade the Photon sidecar patch for the pinned spectrum-ts version. " +
|
||||||
|
"Original error: " +
|
||||||
|
(e && e.stack ? e.stack : String(e))
|
||||||
|
);
|
||||||
|
process.exit(3);
|
||||||
|
}
|
||||||
let Spectrum,
|
let Spectrum,
|
||||||
imessage,
|
imessage,
|
||||||
attachment,
|
attachment,
|
||||||
@ -273,6 +293,16 @@ async function normalizeContent(content) {
|
|||||||
if (content.type === "attachment" || content.type === "voice") {
|
if (content.type === "attachment" || content.type === "voice") {
|
||||||
return await normalizeBinaryContent(content);
|
return await normalizeBinaryContent(content);
|
||||||
}
|
}
|
||||||
|
if (content.type === "group") {
|
||||||
|
const items = [];
|
||||||
|
for (const item of Array.isArray(content.items) ? content.items : []) {
|
||||||
|
items.push({
|
||||||
|
id: item && typeof item === "object" ? item.id ?? null : null,
|
||||||
|
content: await normalizeContent(item?.content),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { type: "group", items };
|
||||||
|
}
|
||||||
if (content.type === "reaction") {
|
if (content.type === "reaction") {
|
||||||
return {
|
return {
|
||||||
type: "reaction",
|
type: "reaction",
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "@hermes-agent/photon-sidecar",
|
"name": "@hermes-agent/photon-sidecar",
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"spectrum-ts": "3.1.0"
|
"spectrum-ts": "3.1.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -6,7 +6,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "index.mjs",
|
"main": "index.mjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node index.mjs"
|
"start": "node index.mjs",
|
||||||
|
"postinstall": "node patch-spectrum-mixed-attachments.mjs"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.17"
|
"node": ">=18.17"
|
||||||
|
|||||||
@ -0,0 +1,155 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Patch spectrum-ts' iMessage inbound mapper until upstream preserves mixed
|
||||||
|
// text + attachment Apple events. The current spectrum-ts mapper returns only
|
||||||
|
// buildAttachmentMessage(...) whenever attachments are present, which drops
|
||||||
|
// event.message.content.text before Hermes can see it.
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
const MARKER = "Hermes patch: Preserve mixed text + attachment iMessage payloads";
|
||||||
|
|
||||||
|
function scriptDir() {
|
||||||
|
return path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceOnce(source, from, to, label) {
|
||||||
|
const count = source.split(from).length - 1;
|
||||||
|
if (count !== 1) {
|
||||||
|
throw new Error(`expected exactly one ${label} match, found ${count}`);
|
||||||
|
}
|
||||||
|
return source.replace(from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceFirst(source, from, to, label) {
|
||||||
|
if (!source.includes(from)) {
|
||||||
|
throw new Error(`expected at least one ${label} match, found 0`);
|
||||||
|
}
|
||||||
|
return source.replace(from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTextChildSnippet(messageExpr) {
|
||||||
|
return `if (text2) {\n items.unshift({\n ...base,\n id: formatChildId(0, messageGuidStr),\n content: asText(text2),\n partIndex: 0,\n parentId: messageGuidStr\n });\n }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchRebuild(source) {
|
||||||
|
source = replaceOnce(
|
||||||
|
source,
|
||||||
|
` const attachments = messageAttachments(message);\n if (attachments.length === 1) {`,
|
||||||
|
` const attachments = messageAttachments(message);\n const text2 = message.content.text;\n if (attachments.length === 1) {`,
|
||||||
|
"rebuild text capture"
|
||||||
|
);
|
||||||
|
source = replaceOnce(
|
||||||
|
source,
|
||||||
|
` return buildAttachmentMessage(client, base, info, messageGuidStr, 0);`,
|
||||||
|
` const msg2 = await buildAttachmentMessage(\n client,\n base,\n info,\n text2 ? formatChildId(1, messageGuidStr) : messageGuidStr,\n text2 ? 1 : 0,\n text2 ? messageGuidStr : void 0\n );\n if (text2) {\n const textMsg = {\n ...base,\n id: formatChildId(0, messageGuidStr),\n content: asText(text2),\n partIndex: 0,\n parentId: messageGuidStr\n };\n return {\n ...base,\n id: messageGuidStr,\n content: asProviderGroup([textMsg, msg2])\n };\n }\n return msg2;`,
|
||||||
|
"rebuild single attachment"
|
||||||
|
);
|
||||||
|
source = replaceFirst(
|
||||||
|
source,
|
||||||
|
` formatChildId(i, messageGuidStr),\n i,\n messageGuidStr`,
|
||||||
|
` formatChildId(text2 ? i + 1 : i, messageGuidStr),\n text2 ? i + 1 : i,\n messageGuidStr`,
|
||||||
|
"rebuild multi attachment child index"
|
||||||
|
);
|
||||||
|
source = replaceFirst(
|
||||||
|
source,
|
||||||
|
` return {\n ...base,\n id: messageGuidStr,\n content: asProviderGroup(items)\n };\n }\n if (getBalloonBundleId(message) === URL_BALLOON_BUNDLE_ID) {`,
|
||||||
|
` ${addTextChildSnippet("message")}\n return {\n ...base,\n id: messageGuidStr,\n content: asProviderGroup(items)\n };\n }\n if (getBalloonBundleId(message) === URL_BALLOON_BUNDLE_ID) {`,
|
||||||
|
"rebuild multi attachment text child"
|
||||||
|
);
|
||||||
|
source = replaceFirst(
|
||||||
|
source,
|
||||||
|
` const text2 = message.content.text;\n return {\n ...base,`,
|
||||||
|
` return {\n ...base,`,
|
||||||
|
"rebuild duplicate text declaration"
|
||||||
|
);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchInbound(source) {
|
||||||
|
source = replaceOnce(
|
||||||
|
source,
|
||||||
|
` const attachments = messageAttachments(event.message);\n if (attachments.length === 1) {`,
|
||||||
|
` const attachments = messageAttachments(event.message);\n const text2 = event.message.content.text;\n if (attachments.length === 1) {`,
|
||||||
|
"inbound text capture"
|
||||||
|
);
|
||||||
|
source = replaceOnce(
|
||||||
|
source,
|
||||||
|
` messageGuidStr,\n 0\n );\n cacheMessage(cache, msg2);\n return [msg2];`,
|
||||||
|
` text2 ? formatChildId(1, messageGuidStr) : messageGuidStr,\n text2 ? 1 : 0,\n text2 ? messageGuidStr : void 0\n );\n if (text2) {\n const textMsg = {\n ...base,\n id: formatChildId(0, messageGuidStr),\n content: asText(text2),\n partIndex: 0,\n parentId: messageGuidStr\n };\n const parent = {\n ...base,\n id: messageGuidStr,\n content: asProviderGroup([textMsg, msg2])\n };\n cacheMessage(cache, parent);\n return [parent];\n }\n cacheMessage(cache, msg2);\n return [msg2];`,
|
||||||
|
"inbound single attachment"
|
||||||
|
);
|
||||||
|
source = replaceOnce(
|
||||||
|
source,
|
||||||
|
` formatChildId(i, messageGuidStr),\n i,\n messageGuidStr`,
|
||||||
|
` formatChildId(text2 ? i + 1 : i, messageGuidStr),\n text2 ? i + 1 : i,\n messageGuidStr`,
|
||||||
|
"inbound multi attachment child index"
|
||||||
|
);
|
||||||
|
source = replaceOnce(
|
||||||
|
source,
|
||||||
|
` const parent = {\n ...base,\n id: messageGuidStr,\n content: asProviderGroup(items)\n };`,
|
||||||
|
` ${addTextChildSnippet("event.message")}\n const parent = {\n ...base,\n id: messageGuidStr,\n content: asProviderGroup(items)\n };`,
|
||||||
|
"inbound multi attachment text child"
|
||||||
|
);
|
||||||
|
source = replaceOnce(
|
||||||
|
source,
|
||||||
|
` const text2 = event.message.content.text;\n const msg = {`,
|
||||||
|
` const msg = {`,
|
||||||
|
"inbound duplicate text declaration"
|
||||||
|
);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patchSpectrumTs(root = scriptDir()) {
|
||||||
|
const dist = path.join(root, "node_modules", "spectrum-ts", "dist");
|
||||||
|
if (!fs.existsSync(dist)) {
|
||||||
|
throw new Error(`spectrum-ts dist not found: ${dist}`);
|
||||||
|
}
|
||||||
|
const files = fs.readdirSync(dist)
|
||||||
|
.filter((name) => name.endsWith(".js"))
|
||||||
|
.map((name) => path.join(dist, name));
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const raw = fs.readFileSync(file, "utf8");
|
||||||
|
if (raw.includes(MARKER)) {
|
||||||
|
return { patched: false, file, reason: "already patched" };
|
||||||
|
}
|
||||||
|
// Normalize to LF for matching so the patch works regardless of the
|
||||||
|
// checkout's line-ending style (Windows git autocrlf produces CRLF,
|
||||||
|
// which would otherwise defeat the \n-based search strings). The
|
||||||
|
// original EOL style is restored on write.
|
||||||
|
const CR = String.fromCharCode(13);
|
||||||
|
const CRLF = CR + "\n";
|
||||||
|
const usedCRLF = raw.includes(CRLF);
|
||||||
|
const original = usedCRLF ? raw.split(CRLF).join("\n") : raw;
|
||||||
|
if (!original.includes("var toInboundMessages = async") ||
|
||||||
|
!original.includes("var rebuildFromAppleMessage = async")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let patched = original;
|
||||||
|
patched = patchRebuild(patched);
|
||||||
|
patched = patchInbound(patched);
|
||||||
|
patched = `// ${MARKER}\n${patched}`;
|
||||||
|
if (usedCRLF) {
|
||||||
|
patched = patched.split("\n").join(CRLF);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(file, patched, "utf8");
|
||||||
|
return { patched: true, file };
|
||||||
|
}
|
||||||
|
throw new Error("could not find spectrum-ts iMessage inbound chunk to patch");
|
||||||
|
}
|
||||||
|
|
||||||
|
const _invokedDirectly =
|
||||||
|
process.argv[1] &&
|
||||||
|
import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||||
|
if (_invokedDirectly) {
|
||||||
|
try {
|
||||||
|
const root = process.argv[2] ? path.resolve(process.argv[2]) : scriptDir();
|
||||||
|
const result = patchSpectrumTs(root);
|
||||||
|
const action = result.patched ? "patched" : "ok";
|
||||||
|
console.error(`photon-sidecar: spectrum mixed attachment patch ${action}: ${result.file}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`photon-sidecar: spectrum mixed attachment patch failed: ${err?.stack || err}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -106,6 +106,11 @@ dependencies = [
|
|||||||
"pathspec==1.1.1",
|
"pathspec==1.1.1",
|
||||||
"fastapi>=0.104.0,<1",
|
"fastapi>=0.104.0,<1",
|
||||||
"uvicorn[standard]>=0.24.0,<1",
|
"uvicorn[standard]>=0.24.0,<1",
|
||||||
|
# Streaming multipart uploads for the dashboard file manager (NS-501).
|
||||||
|
# FastAPI's UploadFile/Form depend on python-multipart; it is NOT pulled in
|
||||||
|
# by fastapi itself, so the dashboard's multipart upload endpoint would 500
|
||||||
|
# without an explicit dependency here (and in the `web` extra below).
|
||||||
|
"python-multipart>=0.0.9,<1",
|
||||||
"ptyprocess>=0.7.0,<1; sys_platform != 'win32'",
|
"ptyprocess>=0.7.0,<1; sys_platform != 'win32'",
|
||||||
"pywinpty>=2.0.0,<3; sys_platform == 'win32'",
|
"pywinpty>=2.0.0,<3; sys_platform == 'win32'",
|
||||||
# Image resize recovery for the vision tools. Pillow shrinks oversized images
|
# Image resize recovery for the vision tools. Pillow shrinks oversized images
|
||||||
@ -116,6 +121,20 @@ dependencies = [
|
|||||||
# install rather than gating it behind an extra + a mid-session lazy install
|
# install rather than gating it behind an extra + a mid-session lazy install
|
||||||
# (which deadlocked the CLI under prompt_toolkit — see #40490).
|
# (which deadlocked the CLI under prompt_toolkit — see #40490).
|
||||||
"Pillow==12.2.0",
|
"Pillow==12.2.0",
|
||||||
|
# Windows log rotation. Stdlib ``RotatingFileHandler.doRollover()`` uses
|
||||||
|
# ``os.rename()`` which fails with ``PermissionError [WinError 32]`` on
|
||||||
|
# Windows whenever any other process holds an append-mode handle on
|
||||||
|
# ``agent.log`` (always the case in Hermes — TUI, gateway, ``hy_memory``
|
||||||
|
# server, MCP servers, and on-demand CLI commands all log from separate
|
||||||
|
# processes), pinning ``agent.log`` at the 5 MiB threshold and spamming
|
||||||
|
# stderr on every emit (see #44873). ``concurrent-log-handler`` wraps the
|
||||||
|
# rename in a cross-process file lock (via ``portalocker``: pywin32 on
|
||||||
|
# Windows) so only one process rotates at a time. ``hermes_logging.py``
|
||||||
|
# aliases it ONLY on Windows — POSIX renames an open file fine, so stdlib
|
||||||
|
# already works there and managed-mode perms depend on its exact lifecycle.
|
||||||
|
# Hence the ``sys_platform == 'win32'`` marker: the dep (and its portalocker
|
||||||
|
# / pywin32 tree) ships only where it's actually used.
|
||||||
|
"concurrent-log-handler==0.9.29; sys_platform == 'win32'",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@ -239,7 +258,7 @@ youtube = [
|
|||||||
# `hermes dashboard` (localhost SPA + API). Not in core to keep the default install lean.
|
# `hermes dashboard` (localhost SPA + API). Not in core to keep the default install lean.
|
||||||
# starlette==1.0.1 pinned for CVE-2026-48710 (BadHost) — fastapi pulls Starlette
|
# starlette==1.0.1 pinned for CVE-2026-48710 (BadHost) — fastapi pulls Starlette
|
||||||
# transitively and pre-1.0.1 is the vulnerable range. See the mcp extra above.
|
# transitively and pre-1.0.1 is the vulnerable range. See the mcp extra above.
|
||||||
web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0", "starlette==1.0.1"]
|
web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0", "starlette==1.0.1", "python-multipart==0.0.20"]
|
||||||
all = [
|
all = [
|
||||||
# Policy (2026-05-12): `[all]` includes only extras that genuinely
|
# Policy (2026-05-12): `[all]` includes only extras that genuinely
|
||||||
# CAN'T be lazy-installed via `tools/lazy_deps.py` — i.e. things every
|
# CAN'T be lazy-installed via `tools/lazy_deps.py` — i.e. things every
|
||||||
|
|||||||
@ -185,6 +185,18 @@ function Write-Err {
|
|||||||
Write-Host "[X] $Message" -ForegroundColor Red
|
Write-Host "[X] $Message" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Invoke-NativeWithRelaxedErrorAction {
|
||||||
|
param([scriptblock]$Script)
|
||||||
|
|
||||||
|
$prevEAP = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
try {
|
||||||
|
& $Script
|
||||||
|
} finally {
|
||||||
|
$ErrorActionPreference = $prevEAP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Inspect npm output for a TLS-trust failure and, if found, print actionable
|
# Inspect npm output for a TLS-trust failure and, if found, print actionable
|
||||||
# remediation. npm/Node surface corporate MITM proxies and missing root CAs as
|
# remediation. npm/Node surface corporate MITM proxies and missing root CAs as
|
||||||
# "unable to get local issuer certificate" / "self-signed certificate in
|
# "unable to get local issuer certificate" / "self-signed certificate in
|
||||||
@ -318,6 +330,36 @@ function Install-AgentBrowser {
|
|||||||
# Dependency checks
|
# Dependency checks
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
# Resolve the PowerShell host executable used to spawn child PowerShell
|
||||||
|
# processes (the astral uv installer below). We must NOT hardcode the bare
|
||||||
|
# name `powershell`: it names *Windows PowerShell* and only resolves when its
|
||||||
|
# System32 directory is on PATH. When install.ps1 is run under PowerShell 7+
|
||||||
|
# (`pwsh`) -- or any session where `powershell` isn't on PATH -- a bare
|
||||||
|
# `powershell` spawn dies with "The term 'powershell' is not recognized",
|
||||||
|
# aborting uv installation (field report: Windows install stuck, uv install
|
||||||
|
# failed with exactly that message). Prefer the absolute path of the host we
|
||||||
|
# are already running in (PATH-independent), then fall back to whichever of
|
||||||
|
# powershell/pwsh is resolvable, and only then to the bare name.
|
||||||
|
function Get-PowerShellHostExe {
|
||||||
|
try {
|
||||||
|
$hostExe = (Get-Process -Id $PID).Path
|
||||||
|
if ($hostExe -and (Test-Path $hostExe)) {
|
||||||
|
$leaf = Split-Path $hostExe -Leaf
|
||||||
|
# Only trust the current host when it is a real PowerShell CLI
|
||||||
|
# (not e.g. powershell_ise.exe or an embedded host that can't take
|
||||||
|
# `-ExecutionPolicy`/`-Command`).
|
||||||
|
if ($leaf -match '^(?i:powershell|pwsh)\.exe$') { return $hostExe }
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
foreach ($candidate in @("powershell", "pwsh")) {
|
||||||
|
$cmd = Get-Command $candidate -CommandType Application -ErrorAction SilentlyContinue |
|
||||||
|
Select-Object -First 1
|
||||||
|
if ($cmd -and $cmd.Source) { return $cmd.Source }
|
||||||
|
}
|
||||||
|
# Last-ditch: hand back the bare name so the spawn surfaces its own error.
|
||||||
|
return "powershell"
|
||||||
|
}
|
||||||
|
|
||||||
function Install-Uv {
|
function Install-Uv {
|
||||||
# Hermes owns its own uv at $HermesHome\bin\uv.exe. Always install there —
|
# Hermes owns its own uv at $HermesHome\bin\uv.exe. Always install there —
|
||||||
# no PATH probing, no conda guards, no multi-location resolution chains.
|
# no PATH probing, no conda guards, no multi-location resolution chains.
|
||||||
@ -341,7 +383,11 @@ function Install-Uv {
|
|||||||
try {
|
try {
|
||||||
$ErrorActionPreference = "Continue"
|
$ErrorActionPreference = "Continue"
|
||||||
$env:UV_INSTALL_DIR = Join-Path $HermesHome "bin"
|
$env:UV_INSTALL_DIR = Join-Path $HermesHome "bin"
|
||||||
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null
|
# Spawn via the resolved host exe (see Get-PowerShellHostExe) rather
|
||||||
|
# than a bare `powershell`, which isn't guaranteed to be on PATH under
|
||||||
|
# PowerShell 7 / pwsh-only setups.
|
||||||
|
$psHostExe = Get-PowerShellHostExe
|
||||||
|
& $psHostExe -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null
|
||||||
$ErrorActionPreference = $prevEAP
|
$ErrorActionPreference = $prevEAP
|
||||||
|
|
||||||
if (Test-Path $managedUv) {
|
if (Test-Path $managedUv) {
|
||||||
@ -1306,7 +1352,7 @@ function Install-Repository {
|
|||||||
Write-Info "Trying SSH clone..."
|
Write-Info "Trying SSH clone..."
|
||||||
$env:GIT_SSH_COMMAND = "ssh -o BatchMode=yes -o ConnectTimeout=5"
|
$env:GIT_SSH_COMMAND = "ssh -o BatchMode=yes -o ConnectTimeout=5"
|
||||||
try {
|
try {
|
||||||
git -c windows.appendAtomically=false clone --depth 1 --branch $Branch $RepoUrlSsh $InstallDir
|
Invoke-NativeWithRelaxedErrorAction { git -c windows.appendAtomically=false clone --depth 1 --branch $Branch $RepoUrlSsh $InstallDir }
|
||||||
if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true }
|
if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true }
|
||||||
} catch { }
|
} catch { }
|
||||||
$env:GIT_SSH_COMMAND = $null
|
$env:GIT_SSH_COMMAND = $null
|
||||||
@ -1315,7 +1361,7 @@ function Install-Repository {
|
|||||||
if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue }
|
if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue }
|
||||||
Write-Info "SSH failed, trying HTTPS..."
|
Write-Info "SSH failed, trying HTTPS..."
|
||||||
try {
|
try {
|
||||||
git -c windows.appendAtomically=false clone --depth 1 --branch $Branch $RepoUrlHttps $InstallDir
|
Invoke-NativeWithRelaxedErrorAction { git -c windows.appendAtomically=false clone --depth 1 --branch $Branch $RepoUrlHttps $InstallDir }
|
||||||
if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true }
|
if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true }
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
@ -1443,8 +1489,20 @@ function Install-Venv {
|
|||||||
Remove-Item -Recurse -Force "venv"
|
Remove-Item -Recurse -Force "venv"
|
||||||
}
|
}
|
||||||
|
|
||||||
# uv creates the venv and pins the Python version in one step
|
# uv creates the venv and pins the Python version in one step. uv emits
|
||||||
& $UvCmd venv venv --python $PythonVersion
|
# normal progress such as "Using CPython ..." on stderr; under Windows
|
||||||
|
# PowerShell 5.1 with EAP=Stop that stderr is a NativeCommandError unless
|
||||||
|
# we temporarily relax EAP and trust $LASTEXITCODE for real failures.
|
||||||
|
Invoke-NativeWithRelaxedErrorAction { & $UvCmd venv venv --python $PythonVersion }
|
||||||
|
# Relaxing EAP above means a *genuine* uv-venv failure (exit != 0) no longer
|
||||||
|
# aborts on its own. Capture $LASTEXITCODE immediately and fail fast, so the
|
||||||
|
# `venv` stage can't falsely report success (and Invoke-Stage can't emit
|
||||||
|
# ok=true) when the venv was never created.
|
||||||
|
$venvExitCode = $LASTEXITCODE
|
||||||
|
if ($venvExitCode -ne 0) {
|
||||||
|
Pop-Location
|
||||||
|
throw "Failed to create virtual environment (uv venv exited with $venvExitCode)"
|
||||||
|
}
|
||||||
|
|
||||||
# Neutralize any inherited UV_PYTHON (e.g. $env:UV_PYTHON = "3.14" left in
|
# Neutralize any inherited UV_PYTHON (e.g. $env:UV_PYTHON = "3.14" left in
|
||||||
# the user's shell). uv honours UV_PYTHON over an existing venv for the
|
# the user's shell). uv honours UV_PYTHON over an existing venv for the
|
||||||
@ -1514,7 +1572,7 @@ function Install-Dependencies {
|
|||||||
# in the wrong directory and imports fail with ModuleNotFoundError.
|
# in the wrong directory and imports fail with ModuleNotFoundError.
|
||||||
# (Mirrors the same flag in scripts/install.sh::install_deps.)
|
# (Mirrors the same flag in scripts/install.sh::install_deps.)
|
||||||
$env:UV_PROJECT_ENVIRONMENT = "$InstallDir\venv"
|
$env:UV_PROJECT_ENVIRONMENT = "$InstallDir\venv"
|
||||||
& $UvCmd sync --extra all --locked
|
Invoke-NativeWithRelaxedErrorAction { & $UvCmd sync --extra all --locked }
|
||||||
if ($LASTEXITCODE -eq 0) {
|
if ($LASTEXITCODE -eq 0) {
|
||||||
Write-Success "Main package installed (hash-verified via uv.lock)"
|
Write-Success "Main package installed (hash-verified via uv.lock)"
|
||||||
$script:InstalledTier = "hash-verified (uv.lock)"
|
$script:InstalledTier = "hash-verified (uv.lock)"
|
||||||
@ -1589,7 +1647,7 @@ except Exception:
|
|||||||
if (-not $skipPipFallback) {
|
if (-not $skipPipFallback) {
|
||||||
foreach ($tier in $installTiers) {
|
foreach ($tier in $installTiers) {
|
||||||
Write-Info "Trying tier: $($tier.Name) ..."
|
Write-Info "Trying tier: $($tier.Name) ..."
|
||||||
& $UvCmd pip install -e $tier.Spec
|
Invoke-NativeWithRelaxedErrorAction { & $UvCmd pip install -e $tier.Spec }
|
||||||
if ($LASTEXITCODE -eq 0) {
|
if ($LASTEXITCODE -eq 0) {
|
||||||
Write-Success "Main package installed ($($tier.Name))"
|
Write-Success "Main package installed ($($tier.Name))"
|
||||||
$script:InstalledTier = $tier.Name
|
$script:InstalledTier = $tier.Name
|
||||||
@ -2161,39 +2219,31 @@ function Clear-ElectronBuildCache {
|
|||||||
return $removed
|
return $removed
|
||||||
}
|
}
|
||||||
|
|
||||||
# True when node_modules\electron\dist holds a usable Electron binary.
|
# Last-resort Electron mirror after GitHub download fails (#47266).
|
||||||
# electron-builder reads the binary from build.electronDist
|
$script:DesktopElectronFallbackMirror = "https://npmmirror.com/mirrors/electron/"
|
||||||
# (node_modules\electron\dist) since #38673, so this is the exact file whose
|
|
||||||
# absence makes a pack fail with "The specified electronDist does not exist". A
|
# Electron package dir — workspace-local nest first, then root hoist.
|
||||||
# dist dir that exists but is missing electron.exe (partial extraction / aborted
|
function Get-ElectronDir {
|
||||||
# postinstall) is NOT ok.
|
param([string]$InstallDir)
|
||||||
|
$desktopLocal = Join-Path $InstallDir 'apps\desktop\node_modules\electron'
|
||||||
|
if (Test-Path -LiteralPath $desktopLocal) { return $desktopLocal }
|
||||||
|
return (Join-Path $InstallDir 'node_modules\electron')
|
||||||
|
}
|
||||||
|
|
||||||
|
# True when dist/ holds a usable Electron binary (#38673 / run-electron-builder.cjs).
|
||||||
function Test-ElectronDist {
|
function Test-ElectronDist {
|
||||||
param([string]$InstallDir)
|
param([string]$InstallDir)
|
||||||
$distExe = Join-Path $InstallDir 'node_modules\electron\dist\electron.exe'
|
$electronDir = Get-ElectronDir -InstallDir $InstallDir
|
||||||
|
$distExe = Join-Path $electronDir 'dist\electron.exe'
|
||||||
return (Test-Path -LiteralPath $distExe)
|
return (Test-Path -LiteralPath $distExe)
|
||||||
}
|
}
|
||||||
|
|
||||||
# (Re)populate node_modules\electron\dist via electron's own downloader.
|
# Best-effort: run electron/install.js to populate dist/ (optional mirror).
|
||||||
#
|
|
||||||
# Since #38673 the desktop build pins build.electronDist to
|
|
||||||
# node_modules\electron\dist, so electron-builder reads the Electron binary
|
|
||||||
# straight from there and never downloads it during `npm run pack`. That dist
|
|
||||||
# tree is produced by the electron package's postinstall (install.js) during
|
|
||||||
# `npm ci`. When that download is blocked/throttled (GitHub's release host is
|
|
||||||
# unreachable in some regions - #47266), dist is missing and re-running pack only
|
|
||||||
# re-throws "The specified electronDist does not exist". The mirror fallback
|
|
||||||
# therefore has to drive THIS downloader, not another pack.
|
|
||||||
#
|
|
||||||
# No-op (returns $true) when the dist binary is already present. Otherwise drops a
|
|
||||||
# partial dist + version marker (electron's install.js short-circuits when
|
|
||||||
# path.txt already matches) and runs the downloader once, optionally via a
|
|
||||||
# mirror. Best-effort: never throws. Returns $true iff the dist binary exists
|
|
||||||
# afterward.
|
|
||||||
function Restore-ElectronDist {
|
function Restore-ElectronDist {
|
||||||
param([string]$InstallDir, [string]$Mirror)
|
param([string]$InstallDir, [string]$Mirror)
|
||||||
if (Test-ElectronDist -InstallDir $InstallDir) { return $true }
|
if (Test-ElectronDist -InstallDir $InstallDir) { return $true }
|
||||||
|
|
||||||
$electronDir = Join-Path $InstallDir 'node_modules\electron'
|
$electronDir = Get-ElectronDir -InstallDir $InstallDir
|
||||||
$distExe = Join-Path $electronDir 'dist\electron.exe'
|
$distExe = Join-Path $electronDir 'dist\electron.exe'
|
||||||
$installer = Join-Path $electronDir 'install.js'
|
$installer = Join-Path $electronDir 'install.js'
|
||||||
if (-not (Test-Path -LiteralPath $installer)) { return $false }
|
if (-not (Test-Path -LiteralPath $installer)) { return $false }
|
||||||
@ -2221,6 +2271,23 @@ function Restore-ElectronDist {
|
|||||||
return (Test-Path -LiteralPath $distExe)
|
return (Test-Path -LiteralPath $distExe)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Test-ElectronPkgStagedMissingDist {
|
||||||
|
param([string]$InstallDir)
|
||||||
|
$electronDir = Get-ElectronDir -InstallDir $InstallDir
|
||||||
|
return (
|
||||||
|
(Test-Path -LiteralPath (Join-Path $electronDir 'package.json')) -and
|
||||||
|
(Test-Path -LiteralPath (Join-Path $electronDir 'install.js')) -and
|
||||||
|
(-not (Test-ElectronDist -InstallDir $InstallDir))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Try-RestoreElectronDist {
|
||||||
|
param([string]$InstallDir)
|
||||||
|
if (Restore-ElectronDist -InstallDir $InstallDir) { return $true }
|
||||||
|
if ($env:ELECTRON_MIRROR) { return $false }
|
||||||
|
return Restore-ElectronDist -InstallDir $InstallDir -Mirror $script:DesktopElectronFallbackMirror
|
||||||
|
}
|
||||||
|
|
||||||
function Install-Desktop {
|
function Install-Desktop {
|
||||||
# Build apps/desktop into a launchable Hermes.exe. Only called from
|
# Build apps/desktop into a launchable Hermes.exe. Only called from
|
||||||
# Stage-Desktop, which is itself only included in the manifest when
|
# Stage-Desktop, which is itself only included in the manifest when
|
||||||
@ -2316,10 +2383,16 @@ function Install-Desktop {
|
|||||||
}
|
}
|
||||||
$ErrorActionPreference = $prevEAP
|
$ErrorActionPreference = $prevEAP
|
||||||
if ($code -ne 0) {
|
if ($code -ne 0) {
|
||||||
|
if (Test-ElectronPkgStagedMissingDist -InstallDir $InstallDir) {
|
||||||
|
Write-Warn "Desktop dependency install failed with a missing Electron dist; attempting self-heal..."
|
||||||
|
Try-RestoreElectronDist -InstallDir $InstallDir | Out-Null
|
||||||
|
} else {
|
||||||
Show-NpmCertHint ($npmOut -join "`n") | Out-Null
|
Show-NpmCertHint ($npmOut -join "`n") | Out-Null
|
||||||
throw "desktop workspace npm install failed (exit $code) -- see lines above for cause"
|
throw "desktop workspace npm install failed (exit $code) -- see lines above for cause"
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
Write-Success "Desktop workspace dependencies installed"
|
Write-Success "Desktop workspace dependencies installed"
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
|
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
|
||||||
Pop-Location
|
Pop-Location
|
||||||
@ -2362,57 +2435,34 @@ function Install-Desktop {
|
|||||||
& $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog
|
& $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog
|
||||||
$code = $LASTEXITCODE
|
$code = $LASTEXITCODE
|
||||||
if ($code -ne 0) {
|
if ($code -ne 0) {
|
||||||
# A corrupt cached Electron zip makes `pack` fail with an opaque
|
$purged = @()
|
||||||
# ENOENT on the final `electron` -> `Hermes` rename: app-builder's
|
|
||||||
# unpack-electron extracted a partial tree (missing the binary) from
|
|
||||||
# the bad zip, and re-running reuses the poisoned cache forever.
|
|
||||||
# Purge the cached download + any stale unpacked output and retry
|
|
||||||
# once; @electron/get re-downloads with its own SHASUM check. Without
|
|
||||||
# this a corrupt download hard-fails the whole installer.
|
|
||||||
$purged = @(Clear-ElectronBuildCache -DesktopDir $desktopDir)
|
|
||||||
# electronDist is pinned to node_modules\electron\dist (#38673):
|
|
||||||
# electron-builder reads the Electron binary from there and `pack`
|
|
||||||
# never downloads it, so purging the cache + re-running pack can't by
|
|
||||||
# itself repopulate a missing/partial dist. When the dist is actually
|
|
||||||
# gone, re-run electron's own downloader so the retry has a binary to
|
|
||||||
# read. Gated on the dist check so an unrelated build failure
|
|
||||||
# (tsc/vite) doesn't trigger a pointless ~200MB refetch.
|
|
||||||
$restored = $false
|
$restored = $false
|
||||||
if (-not (Test-ElectronDist -InstallDir $InstallDir)) {
|
if (-not (Test-ElectronDist -InstallDir $InstallDir)) {
|
||||||
|
$purged = @(Clear-ElectronBuildCache -DesktopDir $desktopDir)
|
||||||
$restored = Restore-ElectronDist -InstallDir $InstallDir
|
$restored = Restore-ElectronDist -InstallDir $InstallDir
|
||||||
}
|
}
|
||||||
if ($purged.Count -gt 0 -or $restored) {
|
if ($restored) {
|
||||||
Write-Warn "Desktop build failed - refreshed the Electron download, retrying once:"
|
Write-Warn "Desktop build failed - refreshed the Electron download, retrying once:"
|
||||||
foreach ($p in $purged) { Write-Info " - $p" }
|
foreach ($p in $purged) { Write-Info " - $p" }
|
||||||
& $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog
|
& $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog
|
||||||
$code = $LASTEXITCODE
|
$code = $LASTEXITCODE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
# Still failing and the user hasn't pinned their own mirror: GitHub's
|
|
||||||
# Electron release host is likely blocked/throttled (the repeating
|
|
||||||
# "retrying" log). Retry once via npmmirror.com — the de-facto Electron
|
|
||||||
# community mirror (Alibaba). @electron/get SHASUM-checks the download,
|
|
||||||
# but the SHASUMS come from the same mirror, so that guards against a
|
|
||||||
# corrupt/partial download, NOT a compromised mirror: an explicit trust
|
|
||||||
# trade-off we only make AFTER the canonical GitHub download has failed,
|
|
||||||
# and we never override a user-pinned ELECTRON_MIRROR.
|
|
||||||
if ($code -ne 0 -and -not $env:ELECTRON_MIRROR) {
|
if ($code -ne 0 -and -not $env:ELECTRON_MIRROR) {
|
||||||
$mirror = "https://npmmirror.com/mirrors/electron/"
|
$mirror = $script:DesktopElectronFallbackMirror
|
||||||
Write-Warn "Desktop build still failing - the Electron download from GitHub looks blocked."
|
Write-Warn "Desktop build still failing - the Electron download from GitHub looks blocked."
|
||||||
Write-Warn "Re-downloading Electron via a public mirror ($mirror), then rebuilding:"
|
Write-Warn "Re-downloading Electron via a public mirror ($mirror), then rebuilding:"
|
||||||
Write-Info " (set ELECTRON_MIRROR yourself to use a different/trusted mirror)"
|
Write-Info " (set ELECTRON_MIRROR yourself to use a different/trusted mirror)"
|
||||||
# electronDist is pinned (#38673), so `npm run pack` never downloads
|
if (-not (Test-ElectronDist -InstallDir $InstallDir)) {
|
||||||
# Electron - the mirror only helps if it drives electron's own
|
Restore-ElectronDist -InstallDir $InstallDir -Mirror $mirror | Out-Null
|
||||||
# downloader. Re-fetch the binary through the mirror first; otherwise
|
}
|
||||||
# the retry just re-reads the same missing dist and re-throws
|
$prevMirror = $env:ELECTRON_MIRROR
|
||||||
# "The specified electronDist does not exist" (#47266).
|
$env:ELECTRON_MIRROR = $mirror
|
||||||
$haveDist = Test-ElectronDist -InstallDir $InstallDir
|
try {
|
||||||
if (-not $haveDist) { $haveDist = Restore-ElectronDist -InstallDir $InstallDir -Mirror $mirror }
|
|
||||||
if ($haveDist) {
|
|
||||||
& $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog
|
& $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog
|
||||||
$code = $LASTEXITCODE
|
$code = $LASTEXITCODE
|
||||||
} else {
|
} finally {
|
||||||
Write-Warn "Could not re-download Electron from the mirror (node_modules\electron\dist still missing)"
|
$env:ELECTRON_MIRROR = $prevMirror
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$ErrorActionPreference = $prevEAP
|
$ErrorActionPreference = $prevEAP
|
||||||
|
|||||||
@ -2398,24 +2398,24 @@ _desktop_pack() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Public Electron mirror used as a last-resort fallback when GitHub's release
|
# Last-resort Electron mirror after GitHub download fails (#47266).
|
||||||
# host is blocked/throttled (the repeating "retrying" symptom). npmmirror.com is
|
|
||||||
# the de-facto Electron community mirror (Alibaba). @electron/get SHASUM-checks
|
|
||||||
# the download, but the SHASUMS come from the same mirror — that guards against a
|
|
||||||
# corrupt/partial download, NOT a compromised mirror. Reaching for it is an
|
|
||||||
# explicit trust trade-off we only make AFTER the canonical GitHub download has
|
|
||||||
# failed, and we never override a user-pinned ELECTRON_MIRROR.
|
|
||||||
DESKTOP_ELECTRON_FALLBACK_MIRROR="https://npmmirror.com/mirrors/electron/"
|
DESKTOP_ELECTRON_FALLBACK_MIRROR="https://npmmirror.com/mirrors/electron/"
|
||||||
|
|
||||||
# True (returns 0) when node_modules/electron/dist holds a usable Electron
|
# Electron package dir — workspace-local nest first, then root hoist.
|
||||||
# binary. electron-builder reads the binary from build.electronDist
|
_electron_dir() {
|
||||||
# (node_modules/electron/dist) since #38673, so this is the exact file whose
|
local install_dir="$1"
|
||||||
# absence makes a pack fail with "The specified electronDist does not exist". A
|
if [ -d "$install_dir/apps/desktop/node_modules/electron" ]; then
|
||||||
# dist dir that exists but is missing the binary (partial extraction / aborted
|
printf '%s\n' "$install_dir/apps/desktop/node_modules/electron"
|
||||||
# postinstall) is NOT ok. $1 = the workspace root holding node_modules.
|
else
|
||||||
|
printf '%s\n' "$install_dir/node_modules/electron"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# True when dist/ holds a usable Electron binary (#38673 / run-electron-builder.cjs).
|
||||||
_electron_dist_ok() {
|
_electron_dist_ok() {
|
||||||
local install_dir="$1"
|
local install_dir="$1"
|
||||||
local electron_dir="$install_dir/node_modules/electron"
|
local electron_dir
|
||||||
|
electron_dir="$(_electron_dir "$install_dir")"
|
||||||
if [ "$OS" = "macos" ]; then
|
if [ "$OS" = "macos" ]; then
|
||||||
[ -e "$electron_dir/dist/Electron.app/Contents/MacOS/Electron" ]
|
[ -e "$electron_dir/dist/Electron.app/Contents/MacOS/Electron" ]
|
||||||
else
|
else
|
||||||
@ -2423,26 +2423,12 @@ _electron_dist_ok() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# (Re)populate node_modules/electron/dist via electron's own downloader.
|
# Best-effort: run electron/install.js to populate dist/ (optional mirror).
|
||||||
#
|
|
||||||
# Since #38673 the desktop build pins build.electronDist to
|
|
||||||
# node_modules/electron/dist, so electron-builder reads the Electron binary
|
|
||||||
# straight from there and never downloads it during `npm run pack`. That dist
|
|
||||||
# tree is produced by the electron package's postinstall (install.js) during
|
|
||||||
# `npm ci`. When that download is blocked/throttled (GitHub's release host is
|
|
||||||
# unreachable in some regions - #47266), dist is missing and re-running pack only
|
|
||||||
# re-throws "The specified electronDist does not exist". The mirror fallback
|
|
||||||
# therefore has to drive THIS downloader, not another pack.
|
|
||||||
#
|
|
||||||
# No-op (returns 0) when the dist binary is already present. Otherwise drops a
|
|
||||||
# partial dist + version marker (electron's install.js short-circuits when
|
|
||||||
# path.txt already matches) and runs the downloader once. $1 = the workspace root
|
|
||||||
# holding node_modules; optional $2 = an ELECTRON_MIRROR base URL. Best-effort:
|
|
||||||
# returns 0 iff the dist binary exists afterward.
|
|
||||||
_restore_electron_dist() {
|
_restore_electron_dist() {
|
||||||
local install_dir="$1"
|
local install_dir="$1"
|
||||||
local mirror="${2:-}"
|
local mirror="${2:-}"
|
||||||
local electron_dir="$install_dir/node_modules/electron"
|
local electron_dir
|
||||||
|
electron_dir="$(_electron_dir "$install_dir")"
|
||||||
_electron_dist_ok "$install_dir" && return 0
|
_electron_dist_ok "$install_dir" && return 0
|
||||||
|
|
||||||
[ -f "$electron_dir/install.js" ] || return 1
|
[ -f "$electron_dir/install.js" ] || return 1
|
||||||
@ -2459,6 +2445,19 @@ _restore_electron_dist() {
|
|||||||
_electron_dist_ok "$install_dir"
|
_electron_dist_ok "$install_dir"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_electron_pkg_staged_missing_dist() {
|
||||||
|
local install_dir="$1"
|
||||||
|
local electron_dir
|
||||||
|
electron_dir="$(_electron_dir "$install_dir")"
|
||||||
|
[ -f "$electron_dir/package.json" ] && [ -f "$electron_dir/install.js" ] && ! _electron_dist_ok "$install_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
_restore_electron_dist_with_fallback() {
|
||||||
|
local install_dir="$1"
|
||||||
|
_restore_electron_dist "$install_dir" \
|
||||||
|
|| { [ -z "${ELECTRON_MIRROR:-}" ] && _restore_electron_dist "$install_dir" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; }
|
||||||
|
}
|
||||||
|
|
||||||
# Build apps/desktop into a launchable native app. Mirrors install.ps1's
|
# Build apps/desktop into a launchable native app. Mirrors install.ps1's
|
||||||
# Install-Desktop: a root-level npm install so the apps/* workspace resolves
|
# Install-Desktop: a root-level npm install so the apps/* workspace resolves
|
||||||
# the desktop's own deps (Electron ~150MB), then `npm run pack`
|
# the desktop's own deps (Electron ~150MB), then `npm run pack`
|
||||||
@ -2500,7 +2499,12 @@ install_desktop() {
|
|||||||
# `tsc -b` failing with no obvious cause. Fall back to `npm install`
|
# `tsc -b` failing with no obvious cause. Fall back to `npm install`
|
||||||
# only if `npm ci` is unavailable or the lockfile is out of sync.
|
# only if `npm ci` is unavailable or the lockfile is out of sync.
|
||||||
log_info "Installing desktop workspace dependencies (includes Electron ~150MB, 1-3min)..."
|
log_info "Installing desktop workspace dependencies (includes Electron ~150MB, 1-3min)..."
|
||||||
( cd "$INSTALL_DIR" && npm ci ) || ( cd "$INSTALL_DIR" && npm install ) || {
|
if ( cd "$INSTALL_DIR" && npm ci ) || ( cd "$INSTALL_DIR" && npm install ); then
|
||||||
|
log_success "Desktop workspace dependencies installed"
|
||||||
|
elif _electron_pkg_staged_missing_dist "$INSTALL_DIR"; then
|
||||||
|
log_warn "Desktop dependency install failed with a missing Electron dist; attempting self-heal..."
|
||||||
|
_restore_electron_dist_with_fallback "$INSTALL_DIR" || true
|
||||||
|
else
|
||||||
log_error "Desktop workspace npm install failed"
|
log_error "Desktop workspace npm install failed"
|
||||||
# Common cause: a previous 'sudo npm'/'sudo npx' left root-owned files in
|
# Common cause: a previous 'sudo npm'/'sudo npx' left root-owned files in
|
||||||
# ~/.npm, so this non-root install can't write the shared cache. npm hides
|
# ~/.npm, so this non-root install can't write the shared cache. npm hides
|
||||||
@ -2513,8 +2517,7 @@ install_desktop() {
|
|||||||
log_info "Then re-run this installer, or build manually:"
|
log_info "Then re-run this installer, or build manually:"
|
||||||
log_info " cd \"$INSTALL_DIR\" && npm ci && cd apps/desktop && npm run pack"
|
log_info " cd \"$INSTALL_DIR\" && npm ci && cd apps/desktop && npm run pack"
|
||||||
return 1
|
return 1
|
||||||
}
|
fi
|
||||||
log_success "Desktop workspace dependencies installed"
|
|
||||||
|
|
||||||
# 2. Build, with up to three escalating attempts so a transient/blocked
|
# 2. Build, with up to three escalating attempts so a transient/blocked
|
||||||
# Electron download self-heals instead of failing the whole install:
|
# Electron download self-heals instead of failing the whole install:
|
||||||
@ -2528,21 +2531,13 @@ install_desktop() {
|
|||||||
if _desktop_pack "$desktop_dir"; then
|
if _desktop_pack "$desktop_dir"; then
|
||||||
pack_ok=true
|
pack_ok=true
|
||||||
else
|
else
|
||||||
# (b) Corrupt cached Electron zip is the most common self-healable cause.
|
local purged=""
|
||||||
local purged
|
|
||||||
purged="$(clear_electron_build_cache "$desktop_dir")"
|
|
||||||
# electronDist is pinned to node_modules/electron/dist (#38673):
|
|
||||||
# electron-builder reads the binary from there and `pack` never downloads
|
|
||||||
# it, so purging the cache + re-running pack can't by itself repopulate a
|
|
||||||
# missing/partial dist. When the dist is actually gone, re-run electron's
|
|
||||||
# own downloader so the retry has a binary to read. Gated on the dist
|
|
||||||
# check so an unrelated build failure (tsc/vite) doesn't trigger a
|
|
||||||
# pointless ~200MB refetch.
|
|
||||||
local restored=false
|
local restored=false
|
||||||
if ! _electron_dist_ok "$INSTALL_DIR"; then
|
if ! _electron_dist_ok "$INSTALL_DIR"; then
|
||||||
|
purged="$(clear_electron_build_cache "$desktop_dir")"
|
||||||
if _restore_electron_dist "$INSTALL_DIR"; then restored=true; fi
|
if _restore_electron_dist "$INSTALL_DIR"; then restored=true; fi
|
||||||
fi
|
fi
|
||||||
if [ -n "$purged" ] || [ "$restored" = true ]; then
|
if [ "$restored" = true ]; then
|
||||||
log_warn "Desktop build failed; refreshed the Electron download and retrying once..."
|
log_warn "Desktop build failed; refreshed the Electron download and retrying once..."
|
||||||
if _desktop_pack "$desktop_dir"; then
|
if _desktop_pack "$desktop_dir"; then
|
||||||
pack_ok=true
|
pack_ok=true
|
||||||
@ -2550,28 +2545,15 @@ install_desktop() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# (c) Still failing and the user hasn't pinned their own mirror: the GitHub
|
# (c) GitHub blocked → mirror fallback (#47266).
|
||||||
# release host is likely blocked/throttled. Re-download the Electron
|
|
||||||
# binary via a public mirror, then retry. The mirror MUST drive
|
|
||||||
# electron's own downloader — `npm run pack` reads the pinned electronDist
|
|
||||||
# and never downloads, so a mirror passed only to pack is a no-op (#47266).
|
|
||||||
if [ "$pack_ok" = false ] && [ -z "${ELECTRON_MIRROR:-}" ]; then
|
if [ "$pack_ok" = false ] && [ -z "${ELECTRON_MIRROR:-}" ]; then
|
||||||
log_warn "Desktop build still failing — the Electron download from GitHub looks blocked."
|
log_warn "Desktop build still failing — the Electron download from GitHub looks blocked."
|
||||||
log_warn "Re-downloading Electron via a public mirror ($DESKTOP_ELECTRON_FALLBACK_MIRROR), then rebuilding..."
|
log_warn "Re-downloading Electron via a public mirror ($DESKTOP_ELECTRON_FALLBACK_MIRROR), then rebuilding..."
|
||||||
log_warn " (set ELECTRON_MIRROR yourself to use a different/trusted mirror)"
|
log_warn " (set ELECTRON_MIRROR yourself to use a different/trusted mirror)"
|
||||||
local have_dist=false
|
_electron_dist_ok "$INSTALL_DIR" || _restore_electron_dist "$INSTALL_DIR" "$DESKTOP_ELECTRON_FALLBACK_MIRROR" || true
|
||||||
if _electron_dist_ok "$INSTALL_DIR"; then
|
|
||||||
have_dist=true
|
|
||||||
elif _restore_electron_dist "$INSTALL_DIR" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; then
|
|
||||||
have_dist=true
|
|
||||||
fi
|
|
||||||
if [ "$have_dist" = true ]; then
|
|
||||||
if _desktop_pack "$desktop_dir" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; then
|
if _desktop_pack "$desktop_dir" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; then
|
||||||
pack_ok=true
|
pack_ok=true
|
||||||
fi
|
fi
|
||||||
else
|
|
||||||
log_warn "Could not re-download Electron from the mirror (node_modules/electron/dist still missing)"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$pack_ok" = false ]; then
|
if [ "$pack_ok" = false ]; then
|
||||||
@ -2750,7 +2732,12 @@ run_stage_body() {
|
|||||||
detect_os
|
detect_os
|
||||||
resolve_install_layout
|
resolve_install_layout
|
||||||
print_success
|
print_success
|
||||||
echo "git" > "$HERMES_HOME/.install_method"
|
# Code-scoped stamp: write next to the install tree, not into
|
||||||
|
# $HERMES_HOME. $HERMES_HOME is a shared data dir (it can be
|
||||||
|
# bind-mounted into a Docker gateway too), so a stamp there gets
|
||||||
|
# clobbered by the container's 'docker' stamp and wrongly blocks
|
||||||
|
# 'hermes update' on this host install. See detect_install_method().
|
||||||
|
echo "git" > "$INSTALL_DIR/.install_method"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
log_error "Unknown stage: $stage"
|
log_error "Unknown stage: $stage"
|
||||||
@ -2829,7 +2816,12 @@ main() {
|
|||||||
|
|
||||||
print_success
|
print_success
|
||||||
|
|
||||||
echo "git" > "$HERMES_HOME/.install_method"
|
# Code-scoped stamp: write next to the install tree, not into $HERMES_HOME.
|
||||||
|
# $HERMES_HOME is a shared data dir (it can be bind-mounted into a Docker
|
||||||
|
# gateway too), so a stamp there gets clobbered by the container's 'docker'
|
||||||
|
# stamp and wrongly blocks 'hermes update' on this host install.
|
||||||
|
# See detect_install_method().
|
||||||
|
echo "git" > "$INSTALL_DIR/.install_method"
|
||||||
}
|
}
|
||||||
|
|
||||||
if [ "$MANIFEST_MODE" = true ]; then
|
if [ "$MANIFEST_MODE" = true ]; then
|
||||||
|
|||||||
@ -45,6 +45,12 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
|
|||||||
|
|
||||||
# Auto-extracted from noreply emails + manual overrides
|
# Auto-extracted from noreply emails + manual overrides
|
||||||
AUTHOR_MAP = {
|
AUTHOR_MAP = {
|
||||||
|
"286497132+srojk34@users.noreply.github.com": "srojk34",
|
||||||
|
"59806492+sitkarev@users.noreply.github.com": "sitkarev",
|
||||||
|
"zheng@omegasys.eu": "omegazheng",
|
||||||
|
"220877172+james47kjv@users.noreply.github.com": "james47kjv",
|
||||||
|
"yuhanglin@YuhangdeMac-mini.local": "1960697431",
|
||||||
|
"admin@fent.quest": "XVVH",
|
||||||
"despitemeguru@gmail.com": "definitelynotguru",
|
"despitemeguru@gmail.com": "definitelynotguru",
|
||||||
"chaslui@outlook.com": "ChasLui",
|
"chaslui@outlook.com": "ChasLui",
|
||||||
"rio.jeong@thebytesize.ai": "rio-jeong",
|
"rio.jeong@thebytesize.ai": "rio-jeong",
|
||||||
@ -61,6 +67,7 @@ AUTHOR_MAP = {
|
|||||||
"joe.rinaldijohnson@shopify.com": "joerj123",
|
"joe.rinaldijohnson@shopify.com": "joerj123",
|
||||||
"adalsteinnhelgason@Aalsteinns-MacBook-Pro-3.local": "AIalliAI",
|
"adalsteinnhelgason@Aalsteinns-MacBook-Pro-3.local": "AIalliAI",
|
||||||
"adalsteinnhelgason@users.noreply.github.com": "AIalliAI",
|
"adalsteinnhelgason@users.noreply.github.com": "AIalliAI",
|
||||||
|
"iamlukethedev@users.noreply.github.com": "iamlukethedev",
|
||||||
"zhang.hz6666@gmail.com": "HaozheZhang6",
|
"zhang.hz6666@gmail.com": "HaozheZhang6",
|
||||||
"barronlroth@gmail.com": "barronlroth",
|
"barronlroth@gmail.com": "barronlroth",
|
||||||
"ondrej.drapalik@gmail.com": "OndrejDrapalik",
|
"ondrej.drapalik@gmail.com": "OndrejDrapalik",
|
||||||
@ -86,6 +93,7 @@ AUTHOR_MAP = {
|
|||||||
"al@randomsnowflake.me": "randomsnowflake",
|
"al@randomsnowflake.me": "randomsnowflake",
|
||||||
"zakame@zakame.net": "zakame",
|
"zakame@zakame.net": "zakame",
|
||||||
"152110621+jiangkoumo@users.noreply.github.com": "jiangkoumo",
|
"152110621+jiangkoumo@users.noreply.github.com": "jiangkoumo",
|
||||||
|
"qinhaojie.exe@bytedance.com": "qin-ctx",
|
||||||
"834740219@qq.com": "ViewWay",
|
"834740219@qq.com": "ViewWay",
|
||||||
"matt@vestigial.dev": "m4dni5",
|
"matt@vestigial.dev": "m4dni5",
|
||||||
"harjoth.khara@gmail.com": "harjothkhara",
|
"harjoth.khara@gmail.com": "harjothkhara",
|
||||||
@ -1564,6 +1572,7 @@ AUTHOR_MAP = {
|
|||||||
"bsmith@bramarstrategicservices.com": "bcsmith528", # PR #20589 salvage (register_slack_action_handler plugin API)
|
"bsmith@bramarstrategicservices.com": "bcsmith528", # PR #20589 salvage (register_slack_action_handler plugin API)
|
||||||
"sunsky.lau@gmail.com": "liuhao1024", # PR #45494 salvage (claim session slot before auto-resume task; #45456)
|
"sunsky.lau@gmail.com": "liuhao1024", # PR #45494 salvage (claim session slot before auto-resume task; #45456)
|
||||||
"andrewdmwalker@gmail.com": "capt-marbles", # PR #38440 salvage (resolve xAI OAuth credentials across profiles; #43589)
|
"andrewdmwalker@gmail.com": "capt-marbles", # PR #38440 salvage (resolve xAI OAuth credentials across profiles; #43589)
|
||||||
|
"infinitycrew39@gmail.com": "infinitycrew39", # PR #47945 salvage (scope langfuse trace state by turn/request ids; #48292)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
377
tests/agent/test_billing_view.py
Normal file
377
tests/agent/test_billing_view.py
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
"""Unit tests for the Phase 2b terminal-billing core + HTTP client.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Decimal money parsing/formatting (server emits decimal strings, not 2dp).
|
||||||
|
- BillingState payload parsing (role tiering, presets, bounds, sub-structs).
|
||||||
|
- Error-code → typed-exception mapping (the live-verified contract matrix).
|
||||||
|
- Fail-open builder behavior.
|
||||||
|
- Idempotency key generation.
|
||||||
|
- Custom-amount validation against bounds + multipleOf 0.01.
|
||||||
|
|
||||||
|
No network: HTTP-layer tests drive _raise_for_error directly and monkeypatch the
|
||||||
|
request function for the builder.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import agent.billing_view as bv
|
||||||
|
from agent.billing_view import (
|
||||||
|
AutoReload,
|
||||||
|
BillingState,
|
||||||
|
CardInfo,
|
||||||
|
MonthlyCap,
|
||||||
|
billing_state_from_payload,
|
||||||
|
build_billing_state,
|
||||||
|
format_money,
|
||||||
|
new_idempotency_key,
|
||||||
|
parse_money,
|
||||||
|
validate_charge_amount,
|
||||||
|
)
|
||||||
|
import hermes_cli.nous_billing as nb
|
||||||
|
from hermes_cli.nous_billing import (
|
||||||
|
BillingAuthError,
|
||||||
|
BillingError,
|
||||||
|
BillingRateLimited,
|
||||||
|
BillingScopeRequired,
|
||||||
|
_raise_for_error,
|
||||||
|
resolve_portal_base_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Decimal money
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw,expected",
|
||||||
|
[
|
||||||
|
("142.5", Decimal("142.5")), # decimal string, NOT 2dp — the headline case
|
||||||
|
("100", Decimal("100")),
|
||||||
|
("10000", Decimal("10000")),
|
||||||
|
("0.01", Decimal("0.01")),
|
||||||
|
(250, Decimal("250")),
|
||||||
|
(" 50 ", Decimal("50")),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parse_money_valid(raw, expected):
|
||||||
|
assert parse_money(raw) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", [None, "", "abc", "1.2.3", "$5", {}])
|
||||||
|
def test_parse_money_invalid_returns_none(raw):
|
||||||
|
assert parse_money(raw) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_money_never_uses_binary_float():
|
||||||
|
# If a float ever sneaks through, we still get an exact decimal, not 0.1+0.2 junk.
|
||||||
|
assert parse_money(0.1) == Decimal("0.1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"value,expected",
|
||||||
|
[
|
||||||
|
(Decimal("142.5"), "$142.50"),
|
||||||
|
(Decimal("100"), "$100"),
|
||||||
|
(Decimal("0.01"), "$0.01"),
|
||||||
|
(Decimal("1000"), "$1000"),
|
||||||
|
(None, "—"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_format_money(value, expected):
|
||||||
|
assert format_money(value) == expected
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# BillingState payload parsing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _member_payload() -> dict:
|
||||||
|
return {
|
||||||
|
"org": {"id": "o1", "slug": "acme", "name": "Acme", "role": "MEMBER"},
|
||||||
|
"balanceUsd": "142.5",
|
||||||
|
"cliBillingEnabled": True,
|
||||||
|
"chargePresets": ["100", "250", "500"],
|
||||||
|
"bounds": {"minUsd": "10", "maxUsd": "10000"},
|
||||||
|
"card": None,
|
||||||
|
"monthlyCap": None,
|
||||||
|
"autoReload": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_payload() -> dict:
|
||||||
|
p = _member_payload()
|
||||||
|
p["org"]["role"] = "OWNER"
|
||||||
|
p["card"] = {"brand": "visa", "last4": "4242"}
|
||||||
|
p["monthlyCap"] = {
|
||||||
|
"limitUsd": "1000",
|
||||||
|
"spentThisMonthUsd": "180",
|
||||||
|
"isDefaultCeiling": True,
|
||||||
|
}
|
||||||
|
p["autoReload"] = {"enabled": True, "thresholdUsd": "20", "reloadToUsd": "100"}
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_member_tier_parse():
|
||||||
|
s = billing_state_from_payload(_member_payload())
|
||||||
|
assert s.logged_in
|
||||||
|
assert s.role == "MEMBER"
|
||||||
|
assert s.balance_usd == Decimal("142.5")
|
||||||
|
assert s.cli_billing_enabled is True
|
||||||
|
assert s.charge_presets == (Decimal("100"), Decimal("250"), Decimal("500"))
|
||||||
|
assert s.min_usd == Decimal("10") and s.max_usd == Decimal("10000")
|
||||||
|
assert s.card is None and s.monthly_cap is None and s.auto_reload is None
|
||||||
|
assert s.is_admin is False
|
||||||
|
assert s.can_charge is False # not admin
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_owner_tier_parse():
|
||||||
|
s = billing_state_from_payload(_owner_payload())
|
||||||
|
assert s.is_admin is True
|
||||||
|
assert s.can_charge is True # admin + kill-switch on
|
||||||
|
assert s.card == CardInfo(brand="visa", last4="4242")
|
||||||
|
assert s.card is not None and s.card.masked == "visa ····4242"
|
||||||
|
assert s.monthly_cap == MonthlyCap(
|
||||||
|
limit_usd=Decimal("1000"),
|
||||||
|
spent_this_month_usd=Decimal("180"),
|
||||||
|
is_default_ceiling=True,
|
||||||
|
)
|
||||||
|
assert s.auto_reload == AutoReload(
|
||||||
|
enabled=True, threshold_usd=Decimal("20"), reload_to_usd=Decimal("100")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_can_charge_false_when_killswitch_off():
|
||||||
|
p = _owner_payload()
|
||||||
|
p["cliBillingEnabled"] = False
|
||||||
|
s = billing_state_from_payload(p)
|
||||||
|
assert s.is_admin is True
|
||||||
|
assert s.can_charge is False # kill-switch off gates the action
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_handles_garbage_substructs():
|
||||||
|
p = _member_payload()
|
||||||
|
p["card"] = "not-a-dict"
|
||||||
|
p["monthlyCap"] = 42
|
||||||
|
p["chargePresets"] = ["100", "bad", "250"] # bad preset dropped, not crash
|
||||||
|
s = billing_state_from_payload(p)
|
||||||
|
assert s.card is None and s.monthly_cap is None
|
||||||
|
assert s.charge_presets == (Decimal("100"), Decimal("250"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Error-code → typed-exception mapping (live-verified contract)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _Headers:
|
||||||
|
def __init__(self, d):
|
||||||
|
self._d = d
|
||||||
|
|
||||||
|
def get(self, k):
|
||||||
|
return self._d.get(k)
|
||||||
|
|
||||||
|
|
||||||
|
def test_401_maps_to_auth_error():
|
||||||
|
with pytest.raises(BillingAuthError) as ei:
|
||||||
|
_raise_for_error(401, {"error": "invalid_token"})
|
||||||
|
assert ei.value.status == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_403_insufficient_scope_maps_to_scope_required():
|
||||||
|
with pytest.raises(BillingScopeRequired) as ei:
|
||||||
|
_raise_for_error(403, {"error": "insufficient_scope", "portalUrl": "/billing"})
|
||||||
|
assert ei.value.error == "insufficient_scope"
|
||||||
|
# portalUrl is resolved to an absolute URL (relative-by-design from the server).
|
||||||
|
assert (ei.value.portal_url or "").startswith("http")
|
||||||
|
assert (ei.value.portal_url or "").endswith("/billing")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", [429, 503])
|
||||||
|
def test_rate_limited_maps_with_retry_after(status):
|
||||||
|
with pytest.raises(BillingRateLimited) as ei:
|
||||||
|
_raise_for_error(
|
||||||
|
status,
|
||||||
|
{"error": "rate_limited"},
|
||||||
|
_Headers({"Retry-After": "60"}),
|
||||||
|
)
|
||||||
|
assert ei.value.retry_after == 60
|
||||||
|
# Critically: a rate limit is NOT a generic BillingError-only — surfaces branch on type.
|
||||||
|
assert isinstance(ei.value, BillingRateLimited)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"error",
|
||||||
|
[
|
||||||
|
"no_payment_method",
|
||||||
|
"cli_billing_disabled",
|
||||||
|
"role_required",
|
||||||
|
"monthly_cap_exceeded",
|
||||||
|
"org_access_denied",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_other_403s_map_to_base_error_with_portal_url(error):
|
||||||
|
with pytest.raises(BillingError) as ei:
|
||||||
|
_raise_for_error(403, {"error": error, "portalUrl": "/billing?topup=open"})
|
||||||
|
# Not a scope/auth/rate subclass — the generic gate-denial path.
|
||||||
|
assert not isinstance(ei.value, (BillingScopeRequired, BillingAuthError, BillingRateLimited))
|
||||||
|
assert ei.value.error == error
|
||||||
|
# portalUrl resolved to an absolute deep-link (server sends it relative).
|
||||||
|
assert (ei.value.portal_url or "").startswith("http")
|
||||||
|
assert (ei.value.portal_url or "").endswith("/billing?topup=open")
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_cap_exceeded_carries_remaining_in_payload():
|
||||||
|
with pytest.raises(BillingError) as ei:
|
||||||
|
_raise_for_error(
|
||||||
|
403,
|
||||||
|
{
|
||||||
|
"error": "monthly_cap_exceeded",
|
||||||
|
"remainingUsd": "12.50",
|
||||||
|
"isDefaultCeiling": True,
|
||||||
|
"portalUrl": "/billing",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert ei.value.payload["remainingUsd"] == "12.50"
|
||||||
|
assert ei.value.payload["isDefaultCeiling"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_400_amount_out_of_bounds_is_base_error():
|
||||||
|
with pytest.raises(BillingError) as ei:
|
||||||
|
_raise_for_error(400, {"error": "amount_out_of_bounds", "message": "too big"})
|
||||||
|
assert ei.value.status == 400
|
||||||
|
assert "too big" in str(ei.value)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# post_charge requires idempotency key (client-side guard)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_charge_requires_idempotency_key():
|
||||||
|
with pytest.raises(BillingError) as ei:
|
||||||
|
nb.post_charge(amount_usd=50, idempotency_key="")
|
||||||
|
assert ei.value.error == "idempotency_key_required"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_charge_status_requires_id():
|
||||||
|
with pytest.raises(BillingError) as ei:
|
||||||
|
nb.get_charge_status("")
|
||||||
|
assert ei.value.error == "invalid_charge_id"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Base-URL resolution precedence
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_portal_base_url_env_override(monkeypatch):
|
||||||
|
monkeypatch.setenv("HERMES_PORTAL_BASE_URL", "https://preview.example.com/")
|
||||||
|
assert resolve_portal_base_url() == "https://preview.example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_portal_base_url_falls_back_to_state(monkeypatch):
|
||||||
|
monkeypatch.delenv("HERMES_PORTAL_BASE_URL", raising=False)
|
||||||
|
monkeypatch.delenv("NOUS_PORTAL_BASE_URL", raising=False)
|
||||||
|
assert (
|
||||||
|
resolve_portal_base_url({"portal_base_url": "https://stored.example.com/"})
|
||||||
|
== "https://stored.example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_portal_base_url_default(monkeypatch):
|
||||||
|
monkeypatch.delenv("HERMES_PORTAL_BASE_URL", raising=False)
|
||||||
|
monkeypatch.delenv("NOUS_PORTAL_BASE_URL", raising=False)
|
||||||
|
assert resolve_portal_base_url() == nb.DEFAULT_PORTAL_BASE_URL
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fail-open builder
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_billing_state_logged_out_on_auth_error(monkeypatch):
|
||||||
|
def _auth(*a, **kw):
|
||||||
|
raise BillingAuthError("nope", status=401)
|
||||||
|
|
||||||
|
monkeypatch.setattr(nb, "get_billing_state", _auth)
|
||||||
|
s = build_billing_state()
|
||||||
|
assert s.logged_in is False
|
||||||
|
assert s.error is None # cleanly logged out, not an error
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_billing_state_fail_open_on_http_error(monkeypatch):
|
||||||
|
def _boom(*a, **kw):
|
||||||
|
raise BillingError("portal exploded", status=500)
|
||||||
|
|
||||||
|
monkeypatch.setattr(nb, "get_billing_state", _boom)
|
||||||
|
s = build_billing_state()
|
||||||
|
assert s.logged_in is False
|
||||||
|
assert "portal exploded" in (s.error or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_billing_state_parses_and_prefers_server_portal_url(monkeypatch):
|
||||||
|
payload = _owner_payload()
|
||||||
|
payload["portalUrl"] = "https://portal.example.com/billing?topup=open"
|
||||||
|
monkeypatch.setattr(nb, "get_billing_state", lambda *a, **kw: payload)
|
||||||
|
s = build_billing_state()
|
||||||
|
assert s.logged_in is True
|
||||||
|
assert s.portal_url == "https://portal.example.com/billing?topup=open"
|
||||||
|
assert s.balance_usd == Decimal("142.5")
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_billing_state_builds_fallback_portal_url(monkeypatch):
|
||||||
|
payload = _member_payload() # no portalUrl key
|
||||||
|
monkeypatch.setattr(nb, "get_billing_state", lambda *a, **kw: payload)
|
||||||
|
monkeypatch.setattr(bv, "_fallback_portal_url", lambda base: "FALLBACK")
|
||||||
|
# resolve_portal_base_url is imported into bv via local import; patch nb's.
|
||||||
|
s = build_billing_state()
|
||||||
|
assert s.portal_url == "FALLBACK"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Idempotency
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_idempotency_key_unique_and_uuid_shaped():
|
||||||
|
a, b = new_idempotency_key(), new_idempotency_key()
|
||||||
|
assert a != b
|
||||||
|
assert len(a) == 36 and a.count("-") == 4
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Amount validation (Screen 3 custom input)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_amount_ok():
|
||||||
|
v = validate_charge_amount("100", min_usd=Decimal("10"), max_usd=Decimal("10000"))
|
||||||
|
assert v.ok and v.amount == Decimal("100")
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_amount_strips_dollar_sign():
|
||||||
|
v = validate_charge_amount("$250", min_usd=Decimal("10"), max_usd=Decimal("10000"))
|
||||||
|
assert v.ok and v.amount == Decimal("250")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw,err_substr",
|
||||||
|
[
|
||||||
|
("", "dollar amount"),
|
||||||
|
("0", "greater than"),
|
||||||
|
("-5", "greater than"),
|
||||||
|
("10.005", "cent"), # multipleOf 0.01 — sub-cent rejected
|
||||||
|
("5", "Minimum"), # below bounds.minUsd
|
||||||
|
("99999", "Maximum"), # above bounds.maxUsd
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_validate_amount_rejections(raw, err_substr):
|
||||||
|
v = validate_charge_amount(raw, min_usd=Decimal("10"), max_usd=Decimal("10000"))
|
||||||
|
assert not v.ok
|
||||||
|
assert err_substr.lower() in (v.error or "").lower()
|
||||||
@ -5,6 +5,7 @@ import pytest
|
|||||||
from agent.codex_responses_adapter import (
|
from agent.codex_responses_adapter import (
|
||||||
_format_responses_error,
|
_format_responses_error,
|
||||||
_normalize_codex_response,
|
_normalize_codex_response,
|
||||||
|
_preflight_codex_api_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -68,6 +69,115 @@ def test_normalize_codex_response_treats_summary_only_reasoning_as_incomplete():
|
|||||||
assert assistant_message.codex_reasoning_items is None
|
assert assistant_message.codex_reasoning_items is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Server-side built-in tool calls (xAI native web_search, code interpreter,
|
||||||
|
# etc.) come back as discrete ``*_call`` output items that xAI's
|
||||||
|
# /v1/responses surface routinely leaves at ``status="in_progress"`` even
|
||||||
|
# when the overall ``response.status == "completed"``. These must NOT mark
|
||||||
|
# the turn incomplete — otherwise grok-composer-2.5-fast research queries
|
||||||
|
# (which invoke server-side web_search) get misclassified as
|
||||||
|
# ``finish_reason="incomplete"`` and burn 3 fruitless continuation retries
|
||||||
|
# before failing with "Codex response remained incomplete after 3
|
||||||
|
# continuation attempts". Observed live against grok-composer-2.5-fast on
|
||||||
|
# SuperGrok OAuth (2026-06).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_codex_response_ignores_in_progress_server_side_tool_calls():
|
||||||
|
"""A completed response with a final message + lingering in_progress
|
||||||
|
server-side web_search_call items resolves to 'stop', not 'incomplete'."""
|
||||||
|
response = SimpleNamespace(
|
||||||
|
status="completed",
|
||||||
|
incomplete_details=None,
|
||||||
|
output=[
|
||||||
|
SimpleNamespace(
|
||||||
|
type="reasoning",
|
||||||
|
id="rs_1",
|
||||||
|
encrypted_content="opaque",
|
||||||
|
summary=[SimpleNamespace(text="researching blades")],
|
||||||
|
),
|
||||||
|
SimpleNamespace(
|
||||||
|
type="message",
|
||||||
|
role="assistant",
|
||||||
|
status="completed",
|
||||||
|
content=[SimpleNamespace(
|
||||||
|
type="output_text",
|
||||||
|
text="Milwaukee M18 blade 49-16-2734, ~$30 OEM.",
|
||||||
|
)],
|
||||||
|
),
|
||||||
|
SimpleNamespace(type="web_search_call", status="in_progress"),
|
||||||
|
SimpleNamespace(type="web_search_call", status="in_progress"),
|
||||||
|
SimpleNamespace(type="web_search_call", status="in_progress"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assistant_message, finish_reason = _normalize_codex_response(response)
|
||||||
|
|
||||||
|
assert finish_reason == "stop"
|
||||||
|
assert assistant_message.content == "Milwaukee M18 blade 49-16-2734, ~$30 OEM."
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_codex_response_in_progress_message_still_incomplete():
|
||||||
|
"""Guard scope: an in_progress *message* item (genuine model output that
|
||||||
|
is still streaming) must still mark the turn incomplete — only
|
||||||
|
server-side ``*_call`` items are exempted."""
|
||||||
|
response = SimpleNamespace(
|
||||||
|
status="completed",
|
||||||
|
incomplete_details=None,
|
||||||
|
output=[
|
||||||
|
SimpleNamespace(
|
||||||
|
type="message",
|
||||||
|
role="assistant",
|
||||||
|
status="in_progress",
|
||||||
|
content=[SimpleNamespace(type="output_text", text="partial...")],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
_assistant_message, finish_reason = _normalize_codex_response(response)
|
||||||
|
|
||||||
|
assert finish_reason == "incomplete"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _preflight_codex_api_kwargs — built-in (provider-executed) tools must pass
|
||||||
|
# through validation. Regression guard for the xAI native web_search
|
||||||
|
# injection: the preflight validator previously rejected any tool whose
|
||||||
|
# ``type != "function"`` with "unsupported type", which would 400 every xAI
|
||||||
|
# turn once the native web_search tool is declared.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_passes_native_web_search_tool_through():
|
||||||
|
kwargs = {
|
||||||
|
"model": "grok-composer-2.5-fast",
|
||||||
|
"instructions": "You are helpful.",
|
||||||
|
"input": [{"role": "user", "content": [{"type": "input_text", "text": "hi"}]}],
|
||||||
|
"store": False,
|
||||||
|
"tools": [
|
||||||
|
{"type": "function", "name": "read_file", "description": "Read.",
|
||||||
|
"parameters": {"type": "object", "properties": {}}},
|
||||||
|
{"type": "web_search"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
out = _preflight_codex_api_kwargs(kwargs, allow_stream=True)
|
||||||
|
tools = out["tools"]
|
||||||
|
assert {"type": "web_search"} in tools
|
||||||
|
assert any(t.get("type") == "function" and t.get("name") == "read_file" for t in tools)
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_still_rejects_unknown_tool_type():
|
||||||
|
kwargs = {
|
||||||
|
"model": "grok-composer-2.5-fast",
|
||||||
|
"instructions": "You are helpful.",
|
||||||
|
"input": [{"role": "user", "content": [{"type": "input_text", "text": "hi"}]}],
|
||||||
|
"store": False,
|
||||||
|
"tools": [{"type": "totally_made_up_tool"}],
|
||||||
|
}
|
||||||
|
with pytest.raises(ValueError, match="unsupported type"):
|
||||||
|
_preflight_codex_api_kwargs(kwargs, allow_stream=True)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _format_responses_error — adapted from anomalyco/opencode#28757.
|
# _format_responses_error — adapted from anomalyco/opencode#28757.
|
||||||
# Provider failures should surface BOTH the code (rate_limit_exceeded /
|
# Provider failures should surface BOTH the code (rate_limit_exceeded /
|
||||||
|
|||||||
183
tests/agent/test_empty_tool_name_loop_dampening.py
Normal file
183
tests/agent/test_empty_tool_name_loop_dampening.py
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
"""Regression for #47967 — empty-name phantom tool calls.
|
||||||
|
|
||||||
|
Weak open models (mimo, nemotron-class) that see tool-call XML/JSON sitting in
|
||||||
|
file contents or tool output get *primed* and emit their own structured tool
|
||||||
|
calls that mimic the payload — usually with an empty/whitespace ``name``. Those
|
||||||
|
calls can't be fuzzy-repaired toward a real tool, so the dispatch loop returns an
|
||||||
|
error and the model retries. Before this fix, every empty-name error dumped the
|
||||||
|
full tool catalog back to the model, which fed the priming loop more names to
|
||||||
|
mimic and inflated context 3-4x across the retry budget.
|
||||||
|
|
||||||
|
The fix: a blank/whitespace-only tool name gets a terse anti-priming error that
|
||||||
|
tells the model in-context tool-call syntax is DATA, with NO catalog dump. A
|
||||||
|
genuinely-wrong-but-nonempty name (an actual typo) still gets the full catalog
|
||||||
|
so the model can self-correct.
|
||||||
|
|
||||||
|
These assert the *behavior contract* of the dispatch branch (what content goes
|
||||||
|
back to the model for each name shape), exercised end-to-end through
|
||||||
|
``AIAgent.run_conversation`` against an in-process mock provider — not a snapshot
|
||||||
|
of the message string.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Repo root = three levels up from tests/agent/<file>.
|
||||||
|
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
if _REPO_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, _REPO_ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
class _MockHandler(BaseHTTPRequestHandler):
|
||||||
|
# Set by the fixture before each request cycle.
|
||||||
|
captured_requests: list = []
|
||||||
|
response_queue: list = []
|
||||||
|
|
||||||
|
def do_POST(self): # noqa: N802 (http.server API)
|
||||||
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
|
req = json.loads(self.rfile.read(length).decode())
|
||||||
|
type(self).captured_requests.append(req)
|
||||||
|
is_stream = req.get("stream") is True
|
||||||
|
if type(self).response_queue:
|
||||||
|
resp = type(self).response_queue.pop(0)
|
||||||
|
else:
|
||||||
|
resp = _text_resp("DONE")
|
||||||
|
msg = resp["choices"][0]["message"]
|
||||||
|
if is_stream:
|
||||||
|
content = msg.get("content") or ""
|
||||||
|
tcs = msg.get("tool_calls")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/event-stream")
|
||||||
|
self.end_headers()
|
||||||
|
chunks = [{"id": "m", "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}]}]
|
||||||
|
if content:
|
||||||
|
chunks.append({"id": "m", "choices": [{"index": 0, "delta": {"content": content}, "finish_reason": None}]})
|
||||||
|
if tcs:
|
||||||
|
for ti, tc in enumerate(tcs):
|
||||||
|
chunks.append({"id": "m", "choices": [{"index": 0, "delta": {"tool_calls": [{
|
||||||
|
"index": ti, "id": tc["id"], "type": "function",
|
||||||
|
"function": {"name": tc["function"]["name"], "arguments": tc["function"]["arguments"]}}]}, "finish_reason": None}]})
|
||||||
|
chunks.append({"id": "m", "choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls" if tcs else "stop"}]})
|
||||||
|
for c in chunks:
|
||||||
|
self.wfile.write(f"data: {json.dumps(c)}\n\n".encode())
|
||||||
|
self.wfile.write(b"data: [DONE]\n\n")
|
||||||
|
self.wfile.flush()
|
||||||
|
else:
|
||||||
|
body = json.dumps(resp).encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, *a, **kw): # silence the default stderr logging
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _tc_resp(name: str, args: str = "{}") -> dict:
|
||||||
|
return {
|
||||||
|
"id": "m",
|
||||||
|
"choices": [{"index": 0, "message": {
|
||||||
|
"role": "assistant", "content": "",
|
||||||
|
"tool_calls": [{"id": "call_1", "type": "function",
|
||||||
|
"function": {"name": name, "arguments": args}}]},
|
||||||
|
"finish_reason": "tool_calls"}],
|
||||||
|
"usage": {"prompt_tokens": 10, "completion_tokens": 0, "total_tokens": 10},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _text_resp(text: str) -> dict:
|
||||||
|
return {
|
||||||
|
"id": "m",
|
||||||
|
"choices": [{"index": 0, "message": {"role": "assistant", "content": text}, "finish_reason": "stop"}],
|
||||||
|
"usage": {"prompt_tokens": 10, "completion_tokens": 0, "total_tokens": 10},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def agent_env():
|
||||||
|
"""Spin up the mock provider + an isolated HERMES_HOME, yield (agent, helpers)."""
|
||||||
|
_MockHandler.captured_requests = []
|
||||||
|
_MockHandler.response_queue = []
|
||||||
|
srv = HTTPServer(("127.0.0.1", 0), _MockHandler)
|
||||||
|
port = srv.server_address[1]
|
||||||
|
t = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
test_home = tempfile.mkdtemp(prefix="hermes_e2e_47967_")
|
||||||
|
os.makedirs(os.path.join(test_home, ".hermes"))
|
||||||
|
prev_home = os.environ.get("HERMES_HOME")
|
||||||
|
os.environ["HERMES_HOME"] = os.path.join(test_home, ".hermes")
|
||||||
|
|
||||||
|
# Import fresh so the patched conversation_loop is exercised even when the
|
||||||
|
# module was imported earlier in the same worker.
|
||||||
|
for mod in list(sys.modules):
|
||||||
|
if mod == "run_agent" or mod.startswith("agent.") or mod.startswith("tools.") or mod.startswith("hermes_"):
|
||||||
|
del sys.modules[mod]
|
||||||
|
from run_agent import AIAgent
|
||||||
|
|
||||||
|
agent = AIAgent(
|
||||||
|
api_key="test-key", base_url=f"http://127.0.0.1:{port}/v1",
|
||||||
|
provider="openai-compat", model="test-model",
|
||||||
|
max_iterations=10, enabled_toolsets=[],
|
||||||
|
quiet_mode=True, skip_context_files=True, skip_memory=True,
|
||||||
|
save_trajectories=False, platform="cli",
|
||||||
|
)
|
||||||
|
agent.valid_tool_names = {"terminal", "read_file", "write_file", "execute_code", "session_search"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield agent, _MockHandler
|
||||||
|
finally:
|
||||||
|
srv.shutdown()
|
||||||
|
shutil.rmtree(test_home, ignore_errors=True)
|
||||||
|
if prev_home is None:
|
||||||
|
os.environ.pop("HERMES_HOME", None)
|
||||||
|
else:
|
||||||
|
os.environ["HERMES_HOME"] = prev_home
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_results(handler) -> list[str]:
|
||||||
|
out = []
|
||||||
|
for req in handler.captured_requests:
|
||||||
|
for m in req.get("messages", []):
|
||||||
|
if m.get("role") == "tool":
|
||||||
|
out.append(m.get("content", ""))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("blank", ["", " ", "\n", "\t "])
|
||||||
|
def test_empty_tool_name_gets_terse_error_no_catalog(agent_env, blank):
|
||||||
|
"""A blank/whitespace tool name must NOT trigger a full tool-catalog dump."""
|
||||||
|
agent, handler = agent_env
|
||||||
|
handler.response_queue.append(_tc_resp(blank, "{}"))
|
||||||
|
handler.response_queue.append(_text_resp("Recovered in plain text."))
|
||||||
|
|
||||||
|
agent.run_conversation("read ./payload and report", conversation_history=[], task_id="t")
|
||||||
|
|
||||||
|
joined = " ".join(_tool_results(handler))
|
||||||
|
assert "tool name was empty" in joined
|
||||||
|
# The whole point: do not feed the priming loop the catalog of names.
|
||||||
|
assert "Available tools:" not in joined
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_nonempty_name_keeps_catalog(agent_env):
|
||||||
|
"""A genuinely-wrong NONempty name still gets the catalog for self-correction."""
|
||||||
|
agent, handler = agent_env
|
||||||
|
handler.response_queue.append(_tc_resp("frobnicate_xyz", "{}"))
|
||||||
|
handler.response_queue.append(_text_resp("ok plain text"))
|
||||||
|
|
||||||
|
agent.run_conversation("do a thing", conversation_history=[], task_id="t")
|
||||||
|
|
||||||
|
joined = " ".join(_tool_results(handler))
|
||||||
|
assert "frobnicate_xyz" in joined
|
||||||
|
assert "Available tools:" in joined
|
||||||
|
assert "tool name was empty" not in joined
|
||||||
@ -142,6 +142,7 @@ class TestDefaultContextLengths:
|
|||||||
("grok-4", 256000),
|
("grok-4", 256000),
|
||||||
("grok-4-0709", 256000),
|
("grok-4-0709", 256000),
|
||||||
("grok-build-0.1", 256000),
|
("grok-build-0.1", 256000),
|
||||||
|
("grok-composer-2.5-fast", 200000),
|
||||||
("grok-code-fast-1", 256000),
|
("grok-code-fast-1", 256000),
|
||||||
("grok-3", 131072),
|
("grok-3", 131072),
|
||||||
("grok-3-mini", 131072),
|
("grok-3-mini", 131072),
|
||||||
|
|||||||
@ -27,6 +27,8 @@ from agent.prompt_builder import (
|
|||||||
TOOL_USE_ENFORCEMENT_GUIDANCE,
|
TOOL_USE_ENFORCEMENT_GUIDANCE,
|
||||||
TOOL_USE_ENFORCEMENT_MODELS,
|
TOOL_USE_ENFORCEMENT_MODELS,
|
||||||
OPENAI_MODEL_EXECUTION_GUIDANCE,
|
OPENAI_MODEL_EXECUTION_GUIDANCE,
|
||||||
|
PARALLEL_TOOL_CALL_GUIDANCE,
|
||||||
|
GOOGLE_MODEL_OPERATIONAL_GUIDANCE,
|
||||||
MEMORY_GUIDANCE,
|
MEMORY_GUIDANCE,
|
||||||
SESSION_SEARCH_GUIDANCE,
|
SESSION_SEARCH_GUIDANCE,
|
||||||
PLATFORM_HINTS,
|
PLATFORM_HINTS,
|
||||||
@ -1497,6 +1499,49 @@ class TestOpenAIModelExecutionGuidance:
|
|||||||
assert len(OPENAI_MODEL_EXECUTION_GUIDANCE) > 100
|
assert len(OPENAI_MODEL_EXECUTION_GUIDANCE) > 100
|
||||||
|
|
||||||
|
|
||||||
|
class TestParallelToolCallGuidance:
|
||||||
|
"""Behavior contracts for the universal parallel-tool-call guidance block.
|
||||||
|
|
||||||
|
Asserts the invariants the block must satisfy (steer batching, scope to
|
||||||
|
independent calls, stay short for the cached prompt) rather than freezing
|
||||||
|
its exact wording.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_is_nonempty_string(self):
|
||||||
|
assert isinstance(PARALLEL_TOOL_CALL_GUIDANCE, str)
|
||||||
|
assert PARALLEL_TOOL_CALL_GUIDANCE.strip()
|
||||||
|
|
||||||
|
def test_steers_batching_into_one_response(self):
|
||||||
|
text = PARALLEL_TOOL_CALL_GUIDANCE.lower()
|
||||||
|
# Must tell the model to group independent calls together — accept any
|
||||||
|
# phrasing that means "one turn" without freezing exact wording.
|
||||||
|
assert "single response" in text or ("same" in text and "turn" in text)
|
||||||
|
assert "independent" in text
|
||||||
|
|
||||||
|
def test_carves_out_dependent_calls(self):
|
||||||
|
# Must NOT tell the model to batch dependent calls — that would break
|
||||||
|
# ordering (read-before-patch). The block has to acknowledge the
|
||||||
|
# serialize-when-dependent case.
|
||||||
|
text = PARALLEL_TOOL_CALL_GUIDANCE.lower()
|
||||||
|
assert "depend" in text
|
||||||
|
|
||||||
|
def test_stays_short_for_cached_prompt(self):
|
||||||
|
# Shipped in every cached system prompt — keep it tight. The existing
|
||||||
|
# task-completion block is ~600 chars; allow generous headroom but
|
||||||
|
# guard against accidental essay growth.
|
||||||
|
assert len(PARALLEL_TOOL_CALL_GUIDANCE) < 900
|
||||||
|
|
||||||
|
def test_has_a_heading(self):
|
||||||
|
# Heading delimits it as its own section in the assembled prompt.
|
||||||
|
assert PARALLEL_TOOL_CALL_GUIDANCE.lstrip().startswith("#")
|
||||||
|
|
||||||
|
def test_not_duplicated_in_google_guidance(self):
|
||||||
|
# The universal block is now the single source of parallel-batching
|
||||||
|
# steer. The Google-only block must NOT carry its own copy, otherwise
|
||||||
|
# Gemini/Gemma would receive the instruction twice in one prompt.
|
||||||
|
assert "parallel tool call" not in GOOGLE_MODEL_OPERATIONAL_GUIDANCE.lower()
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Budget warning history stripping
|
# Budget warning history stripping
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@ -263,6 +263,102 @@ class TestCodexBuildKwargs:
|
|||||||
# full history.
|
# full history.
|
||||||
assert "reasoning.encrypted_content" in kw.get("include", [])
|
assert "reasoning.encrypted_content" in kw.get("include", [])
|
||||||
|
|
||||||
|
def test_xai_injects_native_web_search_when_client_web_search_present(self, transport):
|
||||||
|
"""xAI path swaps a client-side ``web_search`` function for xAI's
|
||||||
|
native server-side ``web_search`` built-in so grok server-side search
|
||||||
|
runs to completion (otherwise the turn stalls as
|
||||||
|
reasoning-with-no-answer -> false 'incomplete' -> 3 retries -> fail).
|
||||||
|
Non-conflicting client tools are preserved.
|
||||||
|
"""
|
||||||
|
messages = [{"role": "user", "content": "Find current prices."}]
|
||||||
|
kw = transport.build_kwargs(
|
||||||
|
model="grok-composer-2.5-fast", messages=messages,
|
||||||
|
tools=[
|
||||||
|
{"type": "function", "function": {
|
||||||
|
"name": "read_file", "description": "Read a file.",
|
||||||
|
"parameters": {"type": "object",
|
||||||
|
"properties": {"path": {"type": "string"}}}}},
|
||||||
|
{"type": "function", "function": {
|
||||||
|
"name": "web_search", "description": "Search the web.",
|
||||||
|
"parameters": {"type": "object",
|
||||||
|
"properties": {"query": {"type": "string"}}}}},
|
||||||
|
],
|
||||||
|
is_xai_responses=True,
|
||||||
|
)
|
||||||
|
tool_types = [t.get("type") for t in kw.get("tools", [])]
|
||||||
|
assert "web_search" in tool_types, kw.get("tools")
|
||||||
|
# Non-conflicting client-side tools are preserved.
|
||||||
|
names = [t.get("name") for t in kw.get("tools", []) if t.get("type") == "function"]
|
||||||
|
assert "read_file" in names
|
||||||
|
|
||||||
|
def test_xai_does_not_inject_native_web_search_without_client_web_search(self, transport):
|
||||||
|
"""The native ``web_search`` built-in is a 1:1 swap for an
|
||||||
|
already-requested client ``web_search`` — NOT an additive grant. A
|
||||||
|
turn whose toolset has no ``web_search`` (user never enabled the web
|
||||||
|
toolset) must not get Grok server-side search force-injected, which
|
||||||
|
would silently bypass Hermes's web-provider config and tool-trace
|
||||||
|
plumbing for every xai-oauth turn.
|
||||||
|
"""
|
||||||
|
messages = [{"role": "user", "content": "Read this file."}]
|
||||||
|
kw = transport.build_kwargs(
|
||||||
|
model="grok-composer-2.5-fast", messages=messages,
|
||||||
|
tools=[{"type": "function", "function": {
|
||||||
|
"name": "read_file", "description": "Read a file.",
|
||||||
|
"parameters": {"type": "object",
|
||||||
|
"properties": {"path": {"type": "string"}}}}}],
|
||||||
|
is_xai_responses=True,
|
||||||
|
)
|
||||||
|
tools = kw.get("tools", [])
|
||||||
|
assert not any(t.get("type") == "web_search" for t in tools), tools
|
||||||
|
names = [t.get("name") for t in tools if t.get("type") == "function"]
|
||||||
|
assert "read_file" in names
|
||||||
|
|
||||||
|
def test_xai_drops_clientside_web_search_to_avoid_duplicate(self, transport):
|
||||||
|
"""When the client registers its own 'web_search' function, the xAI
|
||||||
|
path must drop it and rely on the native built-in — otherwise xAI
|
||||||
|
returns HTTP 400 'Duplicate tool names: web_search'."""
|
||||||
|
messages = [{"role": "user", "content": "Search the web."}]
|
||||||
|
kw = transport.build_kwargs(
|
||||||
|
model="grok-composer-2.5-fast", messages=messages,
|
||||||
|
tools=[{"type": "function", "function": {
|
||||||
|
"name": "web_search", "description": "Search the web.",
|
||||||
|
"parameters": {"type": "object",
|
||||||
|
"properties": {"query": {"type": "string"}}}}}],
|
||||||
|
is_xai_responses=True,
|
||||||
|
)
|
||||||
|
tools = kw.get("tools", [])
|
||||||
|
# Exactly one tool named/typed web_search, and it is the native built-in.
|
||||||
|
web_search_entries = [
|
||||||
|
t for t in tools
|
||||||
|
if t.get("name") == "web_search" or t.get("type") == "web_search"
|
||||||
|
]
|
||||||
|
assert len(web_search_entries) == 1
|
||||||
|
assert web_search_entries[0] == {"type": "web_search"}
|
||||||
|
# No client-side function form of web_search survives.
|
||||||
|
assert not any(
|
||||||
|
t.get("type") == "function" and t.get("name") == "web_search"
|
||||||
|
for t in tools
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_non_xai_path_does_not_inject_native_web_search(self, transport):
|
||||||
|
"""Native web_search injection is scoped to xAI — Codex/GitHub paths
|
||||||
|
keep the client-side web_search function untouched."""
|
||||||
|
messages = [{"role": "user", "content": "Search."}]
|
||||||
|
kw = transport.build_kwargs(
|
||||||
|
model="gpt-5.4", messages=messages,
|
||||||
|
tools=[{"type": "function", "function": {
|
||||||
|
"name": "web_search", "description": "Search the web.",
|
||||||
|
"parameters": {"type": "object",
|
||||||
|
"properties": {"query": {"type": "string"}}}}}],
|
||||||
|
is_xai_responses=False,
|
||||||
|
)
|
||||||
|
tools = kw.get("tools", [])
|
||||||
|
assert not any(t.get("type") == "web_search" for t in tools)
|
||||||
|
assert any(
|
||||||
|
t.get("type") == "function" and t.get("name") == "web_search"
|
||||||
|
for t in tools
|
||||||
|
)
|
||||||
|
|
||||||
def test_xai_reasoning_disabled_no_reasoning_key(self, transport):
|
def test_xai_reasoning_disabled_no_reasoning_key(self, transport):
|
||||||
messages = [{"role": "user", "content": "Hi"}]
|
messages = [{"role": "user", "content": "Hi"}]
|
||||||
kw = transport.build_kwargs(
|
kw = transport.build_kwargs(
|
||||||
|
|||||||
@ -34,37 +34,35 @@ class TestPromptTextInputThreadSafety:
|
|||||||
# not the orphaned-coroutine result.
|
# not the orphaned-coroutine result.
|
||||||
assert mock_rit.called
|
assert mock_rit.called
|
||||||
|
|
||||||
def test_background_thread_falls_back_to_direct_input(self):
|
def test_background_thread_cancels_instead_of_hanging(self):
|
||||||
"""On a daemon thread, skip run_in_terminal and call input() directly.
|
"""On a daemon thread with an active app, cancel cleanly (return None).
|
||||||
|
|
||||||
This preserves the fallback for any prompt that still runs off the main
|
stdin is owned by the prompt_toolkit event loop / JSON-RPC pipe on the
|
||||||
UI thread: run_in_terminal's coroutine would otherwise be orphaned.
|
non-main (process_loop / slash-worker) thread, so a bare input() there
|
||||||
|
would block until the worker's timeout (#23185 / billing auto-reload
|
||||||
|
hang). The guard cancels to None instead of hanging — it must NOT call
|
||||||
|
run_in_terminal (orphaned coroutine) and must NOT call input().
|
||||||
"""
|
"""
|
||||||
cli = _make_cli()
|
cli = _make_cli()
|
||||||
captured = {}
|
|
||||||
|
|
||||||
def fake_input(prompt):
|
|
||||||
captured["prompt"] = prompt
|
|
||||||
return "1"
|
|
||||||
|
|
||||||
result_holder = {}
|
result_holder = {}
|
||||||
|
|
||||||
def run_on_daemon():
|
def run_on_daemon():
|
||||||
with patch("prompt_toolkit.application.run_in_terminal") as mock_rit, \
|
with patch("prompt_toolkit.application.run_in_terminal") as mock_rit, \
|
||||||
patch("builtins.input", side_effect=fake_input):
|
patch("builtins.input", side_effect=AssertionError("input() must not be called off-main-thread")) as mock_input:
|
||||||
result_holder["value"] = cli._prompt_text_input("Choice [1/2/3]: ")
|
result_holder["value"] = cli._prompt_text_input("Choice [1/2/3]: ")
|
||||||
result_holder["rit_called"] = mock_rit.called
|
result_holder["rit_called"] = mock_rit.called
|
||||||
|
result_holder["input_called"] = mock_input.called
|
||||||
|
|
||||||
t = threading.Thread(target=run_on_daemon, daemon=True)
|
t = threading.Thread(target=run_on_daemon, daemon=True)
|
||||||
t.start()
|
t.start()
|
||||||
t.join(timeout=2.0)
|
t.join(timeout=2.0)
|
||||||
assert not t.is_alive(), "daemon thread hung — input() was not driven"
|
assert not t.is_alive(), "daemon thread hung — guard did not cancel cleanly"
|
||||||
|
|
||||||
# run_in_terminal was bypassed entirely on the background thread.
|
# Cancelled cleanly: None returned, neither run_in_terminal nor input() called.
|
||||||
|
assert result_holder["value"] is None
|
||||||
assert result_holder["rit_called"] is False
|
assert result_holder["rit_called"] is False
|
||||||
# input() was invoked with the prompt and its return value was captured.
|
assert result_holder["input_called"] is False
|
||||||
assert captured.get("prompt") == "Choice [1/2/3]: "
|
|
||||||
assert result_holder["value"] == "1"
|
|
||||||
|
|
||||||
def test_no_app_uses_direct_input(self):
|
def test_no_app_uses_direct_input(self):
|
||||||
"""Without an active prompt_toolkit app, always call input() directly."""
|
"""Without an active prompt_toolkit app, always call input() directly."""
|
||||||
|
|||||||
67
tests/docker/test_immutable_install_permissions.py
Normal file
67
tests/docker/test_immutable_install_permissions.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
"""Docker smoke tests for immutable install permissions."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
|
||||||
|
def test_container_sets_hosted_write_policy_env(built_image: str) -> None:
|
||||||
|
script = (
|
||||||
|
'test "$HERMES_HOME" = "/opt/data" && '
|
||||||
|
'test "$HERMES_WRITE_SAFE_ROOT" = "/opt/data" && '
|
||||||
|
'test "$HERMES_DISABLE_LAZY_INSTALLS" = "1" && '
|
||||||
|
'test "$PYTHONDONTWRITEBYTECODE" = "1"'
|
||||||
|
)
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "run", "--rm", "--entrypoint", "sh", built_image, "-c", script],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr[-2000:]
|
||||||
|
|
||||||
|
|
||||||
|
def test_hermes_user_cannot_modify_install_but_can_write_data(built_image: str) -> None:
|
||||||
|
script = textwrap.dedent(
|
||||||
|
r"""
|
||||||
|
set -eu
|
||||||
|
/opt/hermes/.venv/bin/python - <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
install_file = Path("/opt/hermes/agent/message_sanitization.py")
|
||||||
|
try:
|
||||||
|
with install_file.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write("\n# unexpected hosted mutation\n")
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise SystemExit("install source write unexpectedly succeeded")
|
||||||
|
|
||||||
|
skill_dir = Path("/opt/data/skills/permission-smoke")
|
||||||
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
skill_file = skill_dir / "SKILL.md"
|
||||||
|
skill_file.write_text("# Permission smoke\n", encoding="utf-8")
|
||||||
|
if skill_file.read_text(encoding="utf-8") != "# Permission smoke\n":
|
||||||
|
raise SystemExit("data write verification failed")
|
||||||
|
PY
|
||||||
|
"""
|
||||||
|
).strip()
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"--entrypoint",
|
||||||
|
"su",
|
||||||
|
built_image,
|
||||||
|
"hermes",
|
||||||
|
"-s",
|
||||||
|
"/bin/sh",
|
||||||
|
"-c",
|
||||||
|
script,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr[-2000:]
|
||||||
0
tests/gateway/relay/__init__.py
Normal file
0
tests/gateway/relay/__init__.py
Normal file
75
tests/gateway/relay/stub_connector.py
Normal file
75
tests/gateway/relay/stub_connector.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
"""Test-only in-memory stub connector implementing RelayTransport.
|
||||||
|
|
||||||
|
MUST stay under tests/ — never under plugins/ or gateway/ (a CI guard in
|
||||||
|
test_no_stub_leak.py asserts this). It lets Phase 1 prove the gateway side of
|
||||||
|
the relay end-to-end with zero dependency on the real (Node) connector.
|
||||||
|
|
||||||
|
The stub:
|
||||||
|
- hands back a fixed CapabilityDescriptor at handshake,
|
||||||
|
- lets a test push synthetic inbound MessageEvents (push_inbound),
|
||||||
|
- records every outbound action (sent/interrupts) for assertions,
|
||||||
|
- answers get_chat_info from a small fixture map.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from gateway.platforms.base import MessageEvent
|
||||||
|
from gateway.relay.descriptor import CapabilityDescriptor
|
||||||
|
from gateway.relay.transport import InboundHandler
|
||||||
|
|
||||||
|
|
||||||
|
class StubConnector:
|
||||||
|
"""In-memory RelayTransport for tests."""
|
||||||
|
|
||||||
|
def __init__(self, descriptor: CapabilityDescriptor) -> None:
|
||||||
|
self._descriptor = descriptor
|
||||||
|
self._inbound: Optional[InboundHandler] = None
|
||||||
|
self.connected = False
|
||||||
|
self.sent: List[Dict[str, Any]] = []
|
||||||
|
self.interrupts: List[Dict[str, Any]] = []
|
||||||
|
self.follow_ups: List[Dict[str, Any]] = []
|
||||||
|
self.chat_info: Dict[str, Dict[str, Any]] = {}
|
||||||
|
# Canned result for the next send_outbound (override per-test).
|
||||||
|
self.next_send_result: Dict[str, Any] = {"success": True, "message_id": "m1"}
|
||||||
|
# Canned result for the next send_follow_up (override per-test). Default
|
||||||
|
# mimics a resolved capability egress; set success=False to simulate an
|
||||||
|
# absent/expired capability or a tenant mismatch on the connector side.
|
||||||
|
self.next_follow_up_result: Dict[str, Any] = {"success": True, "message_id": "f1"}
|
||||||
|
|
||||||
|
async def connect(self) -> bool:
|
||||||
|
self.connected = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
self.connected = False
|
||||||
|
|
||||||
|
async def handshake(self) -> CapabilityDescriptor:
|
||||||
|
return self._descriptor
|
||||||
|
|
||||||
|
def set_inbound_handler(self, handler: InboundHandler) -> None:
|
||||||
|
self._inbound = handler
|
||||||
|
|
||||||
|
async def send_outbound(self, action: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
self.sent.append(action)
|
||||||
|
if action.get("op") == "send":
|
||||||
|
return dict(self.next_send_result)
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||||
|
return self.chat_info.get(chat_id, {"name": chat_id, "type": "dm"})
|
||||||
|
|
||||||
|
async def send_interrupt(self, session_key: str, reason: Optional[str] = None) -> None:
|
||||||
|
self.interrupts.append({"session_key": session_key, "reason": reason})
|
||||||
|
|
||||||
|
async def send_follow_up(self, action: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
self.follow_ups.append(action)
|
||||||
|
return dict(self.next_follow_up_result)
|
||||||
|
|
||||||
|
# ── test driver ──────────────────────────────────────────────────────
|
||||||
|
async def push_inbound(self, event: MessageEvent) -> None:
|
||||||
|
"""Simulate the connector delivering a normalized inbound event."""
|
||||||
|
if self._inbound is None:
|
||||||
|
raise RuntimeError("no inbound handler registered (call adapter.connect first)")
|
||||||
|
await self._inbound(event)
|
||||||
167
tests/gateway/relay/test_auth.py
Normal file
167
tests/gateway/relay/test_auth.py
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
"""Unit tests for gateway/relay/auth.py — the gateway-side relay auth primitives.
|
||||||
|
|
||||||
|
Two layers:
|
||||||
|
|
||||||
|
1. **Self-consistency** — make_token/verify_token round-trip, delivery-signature
|
||||||
|
verify, rotation verify list, tamper + skew + expiry rejection.
|
||||||
|
2. **Cross-implementation conformance** — frozen vectors generated by the
|
||||||
|
connector's TypeScript (``src/core/relayAuthToken.ts`` ``makeToken``/``sign``)
|
||||||
|
are reproduced byte-for-byte by the Python port. If the connector ever
|
||||||
|
changes its wire scheme, these vectors must be regenerated in lockstep
|
||||||
|
(and that is the point — the test fails loudly on drift). Regenerate with:
|
||||||
|
|
||||||
|
node -e 'import("./dist/core/relayAuthToken.js").then(m=>{ \
|
||||||
|
const s="00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; \
|
||||||
|
console.log(m.makeToken("gw-instance-1", s, 0)); \
|
||||||
|
console.log(m.sign("1750000000."+JSON.stringify({a:1}), s)); })'
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from gateway.relay.auth import (
|
||||||
|
DELIVERY_SIG_HEADER,
|
||||||
|
DELIVERY_TS_HEADER,
|
||||||
|
make_token,
|
||||||
|
make_upgrade_token,
|
||||||
|
sign,
|
||||||
|
verify_delivery_signature,
|
||||||
|
verify_signature,
|
||||||
|
verify_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
# A fixed 256-bit hex secret used for the frozen connector vectors below.
|
||||||
|
_SECRET = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
|
||||||
|
|
||||||
|
# ── Frozen vectors produced by the connector's TypeScript (relayAuthToken.ts).
|
||||||
|
# Generated via dist/core/relayAuthToken.js makeToken/sign; see module docstring.
|
||||||
|
_CONN_TOKEN = "Z3ctaW5zdGFuY2UtMTowOjM3YWE3YjE0NWU4NzY0ZDQwM2JhOWM2MzlmMjMwZGQ2M2RlOGVkOTliODhmZWQzNmFhMDI2MjVhOGE3ZTM1NjQ"
|
||||||
|
# The EXACT bytes the connector signed: JS JSON.stringify emits compact JSON
|
||||||
|
# (no spaces). The gateway verifies over the literal received body, so the
|
||||||
|
# vector is the compact form — NOT Python's spaced json.dumps default. This is
|
||||||
|
# the raw-byte-preservation discipline (a single differing byte breaks the HMAC).
|
||||||
|
_CONN_BODY = '{"type":"message","event":{"text":"hi","source":{"chat_id":"c1"}}}'
|
||||||
|
_CONN_TS = 1750000000
|
||||||
|
_CONN_SIG = "ac9509c8dae52b5590f06378260877334ff1adc4b1c96bafa4b514165fae6dc6"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Self-consistency ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_round_trip_no_expiry():
|
||||||
|
tok = make_token("payload-123", _SECRET, 0)
|
||||||
|
assert verify_token(tok, [_SECRET]) == "payload-123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_payload_may_contain_colons():
|
||||||
|
# verify_token must split from the right so a colon-bearing payload survives.
|
||||||
|
payload = "agent:main:discord:group:chanA"
|
||||||
|
tok = make_token(payload, _SECRET, 0)
|
||||||
|
assert verify_token(tok, [_SECRET]) == payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_token_is_make_token_of_gateway_id():
|
||||||
|
assert make_upgrade_token("gw-1", _SECRET, 0) == make_token("gw-1", _SECRET, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_wrong_secret_rejected():
|
||||||
|
tok = make_token("p", _SECRET, 0)
|
||||||
|
assert verify_token(tok, ["deadbeef" * 8]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_expired_rejected():
|
||||||
|
# ttl in the past -> exp < now -> rejected.
|
||||||
|
tok = make_token("p", _SECRET, ttl_seconds=1)
|
||||||
|
# Force expiry by signing with a manual past exp via the low-level helper.
|
||||||
|
# Simpler: a 1s ttl token is still valid now; instead assert a clearly-old one.
|
||||||
|
# Build an already-expired token by hand using the same scheme.
|
||||||
|
import base64
|
||||||
|
|
||||||
|
signed = "p:1" # exp=1 (1970) -> long past
|
||||||
|
sig = sign(signed, _SECRET)
|
||||||
|
raw = f"{signed}:{sig}".encode()
|
||||||
|
expired = base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||||
|
assert verify_token(expired, [_SECRET]) is None
|
||||||
|
# And the fresh one is accepted.
|
||||||
|
assert verify_token(tok, [_SECRET]) == "p"
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_rotation_verify_list():
|
||||||
|
# A token signed with the (old) secondary still verifies during rotation.
|
||||||
|
old, new = _SECRET, "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100"
|
||||||
|
tok_old = make_token("p", old, 0)
|
||||||
|
assert verify_token(tok_old, [new, old]) == "p" # primary=new, secondary=old
|
||||||
|
assert verify_token(tok_old, [new]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_garbage_rejected():
|
||||||
|
assert verify_token("not-base64url!!!", [_SECRET]) is None
|
||||||
|
assert verify_token("", [_SECRET]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_signature_constant_time_multi_secret():
|
||||||
|
payload = "1700000000.body"
|
||||||
|
s = sign(payload, _SECRET)
|
||||||
|
assert verify_signature(payload, s, ["wrong", _SECRET]) is True
|
||||||
|
assert verify_signature(payload, s, ["wrong"]) is False
|
||||||
|
assert verify_signature(payload, "zz", [_SECRET]) is False # bad hex
|
||||||
|
|
||||||
|
|
||||||
|
# ── Delivery signature (connector -> gateway inbound) ──────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_delivery_signature_accepts_valid():
|
||||||
|
body = json.dumps({"type": "message", "event": {"text": "x"}})
|
||||||
|
ts = 1700000000
|
||||||
|
s = sign(f"{ts}.{body}", _SECRET)
|
||||||
|
assert verify_delivery_signature(body, str(ts), s, [_SECRET], now=ts) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_delivery_signature_tamper_rejected():
|
||||||
|
body = json.dumps({"type": "message", "event": {"text": "x"}})
|
||||||
|
ts = 1700000000
|
||||||
|
s = sign(f"{ts}.{body}", _SECRET)
|
||||||
|
# A single changed body byte breaks the HMAC.
|
||||||
|
assert verify_delivery_signature(body + " ", str(ts), s, [_SECRET], now=ts) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_delivery_signature_skew_rejected():
|
||||||
|
body = "{}"
|
||||||
|
ts = 1700000000
|
||||||
|
s = sign(f"{ts}.{body}", _SECRET)
|
||||||
|
# Beyond the 300s replay window in either direction.
|
||||||
|
assert verify_delivery_signature(body, str(ts), s, [_SECRET], now=ts + 301) is False
|
||||||
|
assert verify_delivery_signature(body, str(ts), s, [_SECRET], now=ts - 301) is False
|
||||||
|
assert verify_delivery_signature(body, str(ts), s, [_SECRET], now=ts + 299) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_delivery_signature_missing_headers_rejected():
|
||||||
|
assert verify_delivery_signature("{}", None, "abc", [_SECRET]) is False
|
||||||
|
assert verify_delivery_signature("{}", "1700000000", None, [_SECRET]) is False
|
||||||
|
assert verify_delivery_signature("{}", "not-an-int", "abc", [_SECRET]) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_delivery_headers_match_connector_names():
|
||||||
|
# The gateway reads exactly the header names the connector writes.
|
||||||
|
assert DELIVERY_TS_HEADER == "x-relay-timestamp"
|
||||||
|
assert DELIVERY_SIG_HEADER == "x-relay-signature"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cross-implementation conformance (frozen connector vectors) ────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_make_token_matches_connector_byte_for_byte():
|
||||||
|
assert make_token("gw-instance-1", _SECRET, 0) == _CONN_TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_verifies_connector_token():
|
||||||
|
assert verify_token(_CONN_TOKEN, [_SECRET]) == "gw-instance-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_sign_matches_connector_delivery_sig():
|
||||||
|
assert sign(f"{_CONN_TS}.{_CONN_BODY}", _SECRET) == _CONN_SIG
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_verifies_connector_delivery_signature():
|
||||||
|
assert verify_delivery_signature(_CONN_BODY, str(_CONN_TS), _CONN_SIG, [_SECRET], now=_CONN_TS) is True
|
||||||
184
tests/gateway/relay/test_contract_doc_conformance.py
Normal file
184
tests/gateway/relay/test_contract_doc_conformance.py
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
"""Cross-repo contract conformance: docs/relay-connector-contract.md ⟷ Python.
|
||||||
|
|
||||||
|
The contract doc is the formal interface the connector repo
|
||||||
|
(NousResearch/gateway-gateway) implements against. The connector's TypeScript
|
||||||
|
structs are hand-mirrored from the doc, so if the Python source of truth drifts
|
||||||
|
from the doc, the two repos silently diverge and the handshake / session-keying
|
||||||
|
breaks only at integration time.
|
||||||
|
|
||||||
|
These tests make the doc ⟷ code relationship an enforced invariant:
|
||||||
|
|
||||||
|
* Every ``CapabilityDescriptor`` field (§2 table) is documented with the
|
||||||
|
correct required/optional flag, and the doc lists no fields the dataclass
|
||||||
|
lacks.
|
||||||
|
* Every ``SessionSource`` wire key (what ``to_dict()`` actually serializes)
|
||||||
|
is named in the contract doc's §3 discriminator section, and every
|
||||||
|
discriminator the doc calls out as a column header exists on the dataclass.
|
||||||
|
|
||||||
|
They are invariants, NOT change-detector snapshots: they assert the *relation*
|
||||||
|
between two artifacts that must move together, not a frozen list of names. Add
|
||||||
|
a field to the descriptor and the doc, and the test stays green; add it to only
|
||||||
|
one, and CI fails — which is exactly the lockstep guarantee the plan's
|
||||||
|
Cross-Repo Coordination Checklist calls for.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gateway.relay.descriptor import CapabilityDescriptor
|
||||||
|
from gateway.session import SessionSource
|
||||||
|
|
||||||
|
# Repo root: tests/gateway/relay/ -> repo root is parents[3]
|
||||||
|
_CONTRACT_DOC = (
|
||||||
|
Path(__file__).resolve().parents[3] / "docs" / "relay-connector-contract.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _doc_text() -> str:
|
||||||
|
assert _CONTRACT_DOC.exists(), (
|
||||||
|
f"Contract doc missing at {_CONTRACT_DOC}. It is the formal cross-repo "
|
||||||
|
f"interface (Phase 1, Task 1.5) and must ship with the relay adapter."
|
||||||
|
)
|
||||||
|
return _CONTRACT_DOC.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_descriptor_table(text: str) -> dict[str, bool]:
|
||||||
|
"""Parse §2's markdown table → {field_name: required}.
|
||||||
|
|
||||||
|
Rows look like: ``| `field` | type | yes|no | meaning |``. Returns a map of
|
||||||
|
field name to whether the Required column says "yes".
|
||||||
|
"""
|
||||||
|
fields: dict[str, bool] = {}
|
||||||
|
# Restrict to the §2 section so §3/§4 tables don't bleed in.
|
||||||
|
section = text.split("## 2. CapabilityDescriptor", 1)[-1].split("## 3.", 1)[0]
|
||||||
|
row_re = re.compile(r"^\|\s*`([a-z_]+)`\s*\|[^|]*\|\s*(yes|no)\s*\|", re.M)
|
||||||
|
for name, required in row_re.findall(section):
|
||||||
|
fields[name] = required.strip() == "yes"
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def test_descriptor_fields_match_contract_doc():
|
||||||
|
"""§2 table ⟷ CapabilityDescriptor dataclass, names + required/optional."""
|
||||||
|
documented = _parse_descriptor_table(_doc_text())
|
||||||
|
assert documented, "Failed to parse any descriptor fields from the §2 table."
|
||||||
|
|
||||||
|
dc_fields = CapabilityDescriptor.__dataclass_fields__ # type: ignore[attr-defined]
|
||||||
|
# A dataclass field is "required" iff it has no default and no default_factory.
|
||||||
|
import dataclasses
|
||||||
|
|
||||||
|
code_required = {
|
||||||
|
name
|
||||||
|
for name, f in dc_fields.items()
|
||||||
|
if f.default is dataclasses.MISSING
|
||||||
|
and f.default_factory is dataclasses.MISSING # type: ignore[misc]
|
||||||
|
}
|
||||||
|
code_names = set(dc_fields.keys())
|
||||||
|
doc_names = set(documented.keys())
|
||||||
|
|
||||||
|
missing_from_doc = code_names - doc_names
|
||||||
|
assert not missing_from_doc, (
|
||||||
|
f"CapabilityDescriptor fields missing from the §2 contract-doc table: "
|
||||||
|
f"{sorted(missing_from_doc)}. Document them so the connector mirrors them."
|
||||||
|
)
|
||||||
|
extra_in_doc = doc_names - code_names
|
||||||
|
assert not extra_in_doc, (
|
||||||
|
f"Contract-doc §2 table documents fields the dataclass does not have: "
|
||||||
|
f"{sorted(extra_in_doc)}. Remove them or add them to descriptor.py."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Required/optional must agree, so the connector knows which fields it may omit.
|
||||||
|
for name, doc_required in documented.items():
|
||||||
|
assert doc_required == (name in code_required), (
|
||||||
|
f"Field '{name}': contract doc says required={doc_required}, but the "
|
||||||
|
f"dataclass says required={name in code_required}. Reconcile them."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _session_source_wire_keys() -> set[str]:
|
||||||
|
"""Keys ``SessionSource.to_dict()`` can emit (the actual wire surface).
|
||||||
|
|
||||||
|
Build a maximally-populated source so conditionally-included keys (the
|
||||||
|
``if self.x:`` branches in ``to_dict``) all appear.
|
||||||
|
"""
|
||||||
|
from gateway.config import Platform
|
||||||
|
|
||||||
|
src = SessionSource(
|
||||||
|
platform=Platform.DISCORD,
|
||||||
|
chat_id="c",
|
||||||
|
chat_name="n",
|
||||||
|
chat_type="channel",
|
||||||
|
user_id="u",
|
||||||
|
user_name="un",
|
||||||
|
thread_id="t",
|
||||||
|
chat_topic="topic",
|
||||||
|
user_id_alt="ua",
|
||||||
|
chat_id_alt="ca",
|
||||||
|
guild_id="g",
|
||||||
|
parent_chat_id="p",
|
||||||
|
message_id="m",
|
||||||
|
)
|
||||||
|
return set(src.to_dict().keys())
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_source_wire_keys_documented_in_contract():
|
||||||
|
"""Every wire key SessionSource.to_dict() emits is named in the contract doc.
|
||||||
|
|
||||||
|
The doc enumerates discriminators in prose + a per-platform table (§3) rather
|
||||||
|
than a strict field table, so this asserts presence-by-name: a wire key the
|
||||||
|
connector must populate but which appears nowhere in the doc is a silent gap.
|
||||||
|
"""
|
||||||
|
text = _doc_text()
|
||||||
|
# Limit to §3 (the MessageEvent / SessionSource section).
|
||||||
|
section = text.split("## 3. Inbound", 1)[-1].split("## 4.", 1)[0]
|
||||||
|
wire_keys = _session_source_wire_keys()
|
||||||
|
|
||||||
|
# Keys that are self-evidently covered by the §3 narrative/table.
|
||||||
|
# We assert each wire key appears as a backticked token or table cell.
|
||||||
|
undocumented = sorted(k for k in wire_keys if k not in section)
|
||||||
|
assert not undocumented, (
|
||||||
|
f"SessionSource wire keys absent from the §3 contract-doc section: "
|
||||||
|
f"{undocumented}. The connector normalizes events into these keys; if the "
|
||||||
|
f"doc doesn't name them the connector author can't know to populate them. "
|
||||||
|
f"Document them (prose or the discriminator table)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_internal_only_session_fields_stay_off_the_wire():
|
||||||
|
"""Guard the inverse: fields deliberately NOT serialized must not leak.
|
||||||
|
|
||||||
|
``is_bot`` is an internal author-classification flag that today is NOT in
|
||||||
|
``to_dict()`` (so the connector's TS contract correctly omits it). If someone
|
||||||
|
adds it to the wire without updating the contract doc + connector, this flips
|
||||||
|
and forces the conversation. This documents the intentional omission.
|
||||||
|
"""
|
||||||
|
wire_keys = _session_source_wire_keys()
|
||||||
|
assert "is_bot" not in wire_keys, (
|
||||||
|
"is_bot is now serialized by SessionSource.to_dict(). If this is "
|
||||||
|
"intentional, add it to docs/relay-connector-contract.md §3 and the "
|
||||||
|
"connector's SessionSource interface, then update this guard."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("discriminator", ["chat_id", "chat_type", "user_id", "thread_id", "guild_id"])
|
||||||
|
def test_discord_telegram_discriminator_columns_present(discriminator):
|
||||||
|
"""§3's per-platform table headers must exist as SessionSource fields.
|
||||||
|
|
||||||
|
These five columns drive build_session_key() and are the #1 High-severity
|
||||||
|
risk surface (Discord guild_id collision). If the doc advertises a
|
||||||
|
discriminator column the dataclass can't carry, the connector has nowhere to
|
||||||
|
put it.
|
||||||
|
"""
|
||||||
|
assert discriminator in SessionSource.__dataclass_fields__, ( # type: ignore[attr-defined]
|
||||||
|
f"Contract doc §3 lists '{discriminator}' as a session discriminator, "
|
||||||
|
f"but SessionSource has no such field."
|
||||||
|
)
|
||||||
|
# And it must be reachable on the wire (chat_type is always emitted; the rest
|
||||||
|
# are conditional but still possible keys).
|
||||||
|
assert discriminator in _session_source_wire_keys(), (
|
||||||
|
f"Discriminator '{discriminator}' never appears in SessionSource.to_dict() "
|
||||||
|
f"output — the connector cannot transmit it to the gateway."
|
||||||
|
)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user