From 96dbc9924a9f33c40e88a648370759f688d6283d Mon Sep 17 00:00:00 2001 From: Agando Date: Fri, 23 Oct 2026 20:54:28 +0200 Subject: docs: chat bridge in AGENTS.md, changelog v4.6.72-80, contract deltas Contract now documents truncated/cursor semantics, the full code table with retry rules, 503-from-nginx (HTML, retryable), the two-cursor rule, strictly-increasing epoch, and the security posture in plain words: client-minted tokens mean public write, mitigated but not closed. --- _docs/chat-contract.md | 42 ++++++++++++++++++++++++++++++++++++------ help.html | 27 +++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/_docs/chat-contract.md b/_docs/chat-contract.md index 6f94d79..aa99b0c 100644 --- a/_docs/chat-contract.md +++ b/_docs/chat-contract.md @@ -42,12 +42,15 @@ Response `200`: {"success": true, "thread": "a1b2c3d4e5f60718", "messages": [{"id": "MSG-…", "epoch": 1792778400123, "ts": "2026-10-23T18:40:00+00:00", "author": "operator", "text": "…"}], "awaiting": true, + "truncated": false, "cursor": 1792778400123} ``` - `awaiting` = the last message in the thread was **not** written by `hermes`. There is no unread counter: the server keeps no read state, so a counter could never clear. -- `cursor` = epoch of the newest message in the thread. Clients store it and pass it as `since`. +- `cursor` = epoch of the **last message actually returned**. With more than 50 new messages the + response is cut to 50 (`truncated: true`) and the cursor stops at the cut — returning the + thread's real tail would skip everything in between, permanently. **The cursor is epoch, never `ts`.** `ts` carries an offset (`+00:00`), and comparing strings with mixed offsets silently drops messages in the same second and reorders others. @@ -64,17 +67,44 @@ mixed offsets silently drops messages in the same second and reorders others. | Code | When | Body | |---|---|---| -| `401` | token missing or not 64 hex | `{"success": false, "error": "missing or malformed X-Chat-Token"}` | -| `413` | body > 32 KB, or text > 4000 chars | `{"success": false, "error": "text too long (max 4000 chars)"}` | -| `429` | 20 messages per thread / 40 per IP per 10 min | `{"success": false, "error": "rate limit…"}` | -| `507` | more than 500 threads on the host | `{"success": false, "error": "thread limit reached"}` | -| `405` | wrong method | `{"success": false, "error": "Method not allowed"}` | +| Status | `code` | Retry? | +|---|---|---| +| `401` | `bad_token` | no — `hermes-reset` | +| `413` | `too_long` (4000 codepoints) / `too_large` (32 KB body) | no — shorten | +| `429` | `rate_limited` + `retry_after_ms`, `scope`, `limit`, `window_s`, and a `Retry-After` header | yes, after the wait | +| `507` | `thread_limit` | no | +| `405` | `method` | no | +| `500` | `thread file is corrupt` | no — the file is left untouched; a corrupt thread is never silently reset | +| `503` | *(nginx, HTML body)* | **yes** — the front-end rate zone (`60 r/min`, burst 50) fired before the app. Back off; do not parse it as JSON. | + +Every body is `{"success": false, "code": "…", "error": "…", …}`. A client may retry on +`429` and `503` only — everything else is permanent for that payload. Rules that follow from this table: - **Auth is checked before anything touches disk.** A `401` means no file was opened. - **Text is rejected, never truncated.** A shortened question gets a wrongly scoped answer. - Rate limits are two windows, per thread **and** per IP: a token is mintable in a loop, an IP is not. +- **Two readers, two cursors.** The terminal's 30 s poller and the agent's `chat_hermes read` read + the same thread but store separate cursors client-side (`webx11_chat_state.cursor` for display, + `webx11_chat_agent_cursor_` for the tool). Sharing one made the poller consume the reply + before the model could see it. +- **`epoch` is strictly increasing.** Two posts in the same millisecond get `max(now, last+1)`, so + a `since` reader never loses the second one. + +## Security posture + +The token is minted by the client, so this channel is **public write by design** — there is no +login. Anyone who can reach the endpoint can post. Consequences, deliberately accepted: + +- The auto-reply agent treats message text as **untrusted data**: it may read files to check a + claim, but it never runs a command or writes anything because a message asked it to. +- Bounded by 20/thread, 40/IP per 10 min, 500 threads, 200 messages per thread, 30 days. +- `data/chat/` is not reachable over HTTP (`403`); thread ids are `sha256(token)[0:16]`, so there is + nothing to enumerate and no id in any URL. + +Closing this properly needs platform-session auth, which the browser app does not have. Do not +bolt on half-auth: it moves the hole without closing it. ## Retention diff --git a/help.html b/help.html index b120822..c70b641 100644 --- a/help.html +++ b/help.html @@ -1688,6 +1688,33 @@ listmods

📋 Changelog

+
+
v4.6.72–80 — October 2026 + hardening after review
+
    +
  • The agent can finally see the answer. The terminal's 30 s poller and the + agent's chat_hermes read shared one cursor, so the poller printed each reply + and advanced past it — the human saw it, the model got an empty list. They now keep + separate cursors, and the tool's first read returns the whole thread.
  • +
  • A corrupt thread is an error, not an empty one. A damaged file used to + read as [], and the next append rewrote it with a single message. It now + fails with 500 and leaves the file alone; chat.py list shows it + as CORRUPT instead of breaking the listing.
  • +
  • Cursor survives truncation. More than 50 new messages cut the response; + the cursor now stops at the cut instead of the thread tail, so nothing in between is + skipped for good. Same-millisecond posts get strictly increasing epochs.
  • +
  • No more double replies. hermes-poll printed every reply + twice; it now prints one 🤖 line per message.
  • +
  • Retry guidance that matches reality. 429 says how many + seconds to wait. A 503 means the front-end rate zone fired before the app — + retry, it is not your mistake.
  • +
  • Hardening. chat.py show validates the thread id (a + ../ path used to reach any .json on the host); lock files are + group-writable so both sides can open them; the auto-reply prompt treats message text as + untrusted data and never acts on instructions found in it.
  • +
+
+
v4.6.71 — October 2026 chat bridge
-- cgit v1.2.3