Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,14 @@ def run_conversation(

# Add user message
user_msg = {"role": "user", "content": user_message}
# F-003 sender attribution: a remote client (different device attached to
# this gateway) declares who typed this prompt. The key rides the message
# dict into session persistence (the flush passes it through; the API
# builder strips it) and overrides the local-device auto-stamp.
_pending_sender = getattr(agent, "_pending_user_sender_device", None)
if _pending_sender:
user_msg["sender_device"] = _pending_sender
agent._pending_user_sender_device = None
messages.append(user_msg)
current_turn_user_idx = len(messages) - 1
agent._persist_user_message_idx = current_turn_user_idx
Expand Down Expand Up @@ -1044,6 +1052,9 @@ def run_conversation(
api_msg.pop("finish_reason")
# Strip internal thinking-prefill marker
api_msg.pop("_thinking_prefill", None)
# Strip sender attribution — persistence-only metadata, rejected
# by strict APIs that validate unknown message fields.
api_msg.pop("sender_device", None)
# Strip Codex Responses API fields (call_id, response_item_id) for
# strict providers like Mistral, Fireworks, etc. that reject unknown fields.
# Uses new dicts so the internal messages list retains the fields
Expand Down
43 changes: 43 additions & 0 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -8239,6 +8239,39 @@ class PtyUnavailableError(RuntimeError): # type: ignore[no-redef]
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"})


def _primary_lan_ip() -> str:
"""Best-effort primary outbound IPv4 of this machine, or ''.

The UDP-connect trick never sends a packet; it just asks the kernel
which source address it would route from. Works without DNS and
without any mesh/tailnet tooling installed.
"""
import socket as _socket

try:
with _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM) as s:
s.connect(("10.255.255.255", 1))
return s.getsockname()[0] or ""
except Exception:
return ""


def _presence_advertise_endpoint(host: str, port: int) -> str:
"""Dialable ws endpoint to advertise in session-presence records.

Loopback binds advertise nothing (the address is meaningless from
another device). Wildcard binds advertise the primary LAN IP; an
explicit non-loopback bind (LAN/Tailscale address) advertises itself.
"""
bound = (host or "").strip().lower()
if not bound or bound in _LOOPBACK_HOSTS:
return ""
if bound in {"0.0.0.0", "::"}:
lan_ip = _primary_lan_ip()
return f"ws://{lan_ip}:{port}/api/ws" if lan_ip else ""
return f"ws://{host.strip()}:{port}/api/ws"


def _ws_client_reason(ws: "WebSocket") -> Optional[str]:
"""Return a rejection reason for the client IP, or None when allowed.

Expand Down Expand Up @@ -9988,6 +10021,16 @@ def start_server(
app.state.bound_host = host
app.state.bound_port = port

# Advertise a dialable ws endpoint in session-presence records when the
# dashboard is reachable beyond loopback, so other devices that discover
# a live session here (synced presence folder, future registry) know
# where to attach. setdefault keeps an operator-configured
# HERMES_SESSION_PRESENCE_ENDPOINT authoritative; loopback binds
# advertise nothing — the address would be meaningless off-machine.
_adv_endpoint = _presence_advertise_endpoint(host, port)
if _adv_endpoint:
os.environ.setdefault("HERMES_SESSION_PRESENCE_ENDPOINT", _adv_endpoint)

if open_browser:
import webbrowser

Expand Down
3 changes: 3 additions & 0 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1610,6 +1610,9 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo
reasoning_details=msg.get("reasoning_details") if role == "assistant" else None,
codex_reasoning_items=msg.get("codex_reasoning_items") if role == "assistant" else None,
codex_message_items=msg.get("codex_message_items") if role == "assistant" else None,
# Explicit sender from a remote client wins; None lets
# append_message auto-stamp the local device for user rows.
sender_device=msg.get("sender_device") if role == "user" else None,
)
self._last_flushed_db_idx = len(messages)
except Exception as e:
Expand Down
42 changes: 42 additions & 0 deletions tests/cli/test_presence_advertise_endpoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Tests for the dashboard's presence-endpoint advertisement (channels Phase 2).

When the dashboard binds beyond loopback, session-presence records should
carry a dialable ws endpoint so other devices that discover a live session
know where to attach. Loopback binds advertise nothing.
"""

from unittest.mock import patch

from hermes_cli.web_server import _presence_advertise_endpoint


class TestPresenceAdvertiseEndpoint:
def test_loopback_binds_advertise_nothing(self):
assert _presence_advertise_endpoint("127.0.0.1", 8664) == ""
assert _presence_advertise_endpoint("localhost", 8664) == ""
assert _presence_advertise_endpoint("::1", 8664) == ""
assert _presence_advertise_endpoint("", 8664) == ""

def test_explicit_lan_bind_advertises_itself(self):
assert (
_presence_advertise_endpoint("192.168.1.20", 8664)
== "ws://192.168.1.20:8664/api/ws"
)

def test_explicit_tailscale_style_host_advertises_itself(self):
assert (
_presence_advertise_endpoint("ko-mac.tailnet.ts.net", 8664)
== "ws://ko-mac.tailnet.ts.net:8664/api/ws"
)

def test_wildcard_bind_advertises_primary_lan_ip(self):
with patch("hermes_cli.web_server._primary_lan_ip", return_value="10.10.20.5"):
assert (
_presence_advertise_endpoint("0.0.0.0", 8664)
== "ws://10.10.20.5:8664/api/ws"
)

def test_wildcard_bind_without_detectable_ip_advertises_nothing(self):
with patch("hermes_cli.web_server._primary_lan_ip", return_value=""):
assert _presence_advertise_endpoint("0.0.0.0", 8664) == ""
assert _presence_advertise_endpoint("::", 8664) == ""
15 changes: 15 additions & 0 deletions tests/tui_gateway/test_concurrent_attach.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,18 @@ def test_duplicate_attach_is_idempotent(server):

# No double-delivery from a repeated attach of the same client.
assert t2.event_types() == ["message.delta"]


# -------------------------------------------------------------------------
# prompt.submit sender_device sanitation (channels Phase 2)
# -------------------------------------------------------------------------

def test_sanitize_sender_device(server):
f = server._sanitize_sender_device
assert f("omar-iphone") == "omar-iphone"
assert f(" Omar's MacBook Pro ") == "Omar's MacBook Pro"
assert f("x" * 500) == "x" * 80
assert f(None) == ""
assert f(123) == ""
assert f(["nope"]) == ""
assert f("line\nbreaks\tcollapse") == "line breaks collapse"
22 changes: 22 additions & 0 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4447,10 +4447,26 @@ def _(rid, params: dict) -> dict:
# ── Methods: prompt ──────────────────────────────────────────────────


def _sanitize_sender_device(value) -> str:
"""Clamp a client-declared sender device name to a sane one-line label.

Remote clients self-report this (F-003 attribution); it lands in
``messages.sender_device`` and renders as a chat label, so collapse
whitespace and cap length rather than trusting it verbatim.
"""
if not isinstance(value, str):
return ""
return " ".join(value.split())[:80]


@method("prompt.submit")
def _(rid, params: dict) -> dict:
sid, text = params.get("session_id", ""), params.get("text", "")
truncate_user_ordinal = params.get("truncate_before_user_ordinal")
# Optional: which device the human typed this on. Local clients omit it
# (append_message auto-stamps the gateway's device); clients attached
# from ANOTHER device pass theirs so group sessions attribute correctly.
sender_device = _sanitize_sender_device(params.get("sender_device"))
session, err = _sess_nowait(params, rid)
if err:
return err
Expand All @@ -4462,6 +4478,8 @@ def _(rid, params: dict) -> dict:
with session["history_lock"]:
if session.get("running"):
return _err(rid, 4009, "session busy")
if sender_device:
session["pending_sender_device"] = sender_device
if truncate_user_ordinal is not None:
try:
ordinal = int(truncate_user_ordinal)
Expand Down Expand Up @@ -4706,9 +4724,13 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
history_version = int(session.get("history_version", 0))
images = list(session.get("attached_images", []))
session["attached_images"] = []
_pending_sender = session.pop("pending_sender_device", None)
if not isinstance(session.get("inflight_turn"), dict):
_start_inflight_turn(session, text)
agent = session["agent"]
# Hand the prompt's declared sender to the conversation loop; consumed
# (and cleared) when the user message dict is built for this turn.
agent._pending_user_sender_device = _pending_sender or None
_emit("message.start", sid)

def run():
Expand Down
Loading