diff options
| -rw-r--r-- | _docs/chat-contract.md | 42 | ||||
| -rw-r--r-- | 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_<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 @@ -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> |