aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--_docs/chat-contract.md42
-rw-r--r--help.html27
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_<thread>` 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
@@ -1689,6 +1689,33 @@ listmods</pre>
<h2>📋 Changelog</h2>
<div class="version-item">
+ <div class="v-header"><span class="v-num">v4.6.72–80 — October 2026</span>
+ <span class="v-date">hardening after review</span></div>
+ <ul>
+ <li><strong>The agent can finally see the answer.</strong> The terminal's 30 s poller and the
+ agent's <code>chat_hermes read</code> 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.</li>
+ <li><strong>A corrupt thread is an error, not an empty one.</strong> A damaged file used to
+ read as <code>[]</code>, and the next append rewrote it with a single message. It now
+ fails with <code>500</code> and leaves the file alone; <code>chat.py list</code> shows it
+ as <code>CORRUPT</code> instead of breaking the listing.</li>
+ <li><strong>Cursor survives truncation.</strong> 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.</li>
+ <li><strong>No more double replies.</strong> <code>hermes-poll</code> printed every reply
+ twice; it now prints one 🤖 line per message.</li>
+ <li><strong>Retry guidance that matches reality.</strong> <code>429</code> says how many
+ seconds to wait. A <code>503</code> means the front-end rate zone fired before the app —
+ retry, it is not your mistake.</li>
+ <li><strong>Hardening.</strong> <code>chat.py show</code> validates the thread id (a
+ <code>../</code> path used to reach any <code>.json</code> 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.</li>
+ </ul>
+ </div>
+
+ <div class="version-item">
<div class="v-header"><span class="v-num">v4.6.71 — October 2026</span>
<span class="v-date">chat bridge</span></div>
<ul>