From 4d717018ea8a748b8bb16ad71c3deab6d8c20545 Mon Sep 17 00:00:00 2001 From: Agando Date: Fri, 23 Oct 2026 20:37:54 +0200 Subject: fix(chat): GLM review batch — corruption, traversal, cursor truncation, double print (v4.6.78-80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12 findings accepted, 3 refuted with evidence (see _docs/chat-review-glm-2026-10-23.md). Data loss: a corrupt thread read as [] and the next append rewrote it with one message — now 500 and the file is left alone. Cursor after a >50-message truncation pointed at the thread tail, making the oldest permanently unfetchable — now the last message actually sent, plus truncated:true. Two posts in the same millisecond shared a cursor — epoch is now strictly increasing. Security: chat.py show() had no id validation and joined argv onto CHAT_DIR ('../../x' read any .json as darkbot) — THREAD_RE added. The auto-reply prompt now treats message text as untrusted data and forbids acting on instructions found in it. Ops: relay housekeeping swept neither .lock-* nor orphaned .tmp-*; a crashed listing looked identical to an empty queue; grep AWAITING also matched message bodies; hermes-poll printed every reply twice. Verified: corrupt file -> 500 with the file untouched; traversal -> 'invalid thread id'; hermes-poll -> exactly one line per reply. --- modules/core/core-bundle.js | 21 ++++++++++++++------- modules/core/module-debug-supervisor.js | 21 ++++++++++++++------- modules/core/module-loader.js | 2 +- 3 files changed, 29 insertions(+), 15 deletions(-) (limited to 'modules/core') diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js index 90e2593..73f5ab3 100644 --- a/modules/core/core-bundle.js +++ b/modules/core/core-bundle.js @@ -20751,6 +20751,7 @@ class DebugSupervisorModule { const err = new Error((j && j.error) || ('HTTP ' + resp.status)); err.status = resp.status; err.code = (j && j.code) || ''; + err.retryAfterMs = (j && j.retry_after_ms) || null; throw err; } const st = { token: token, cursor: j.epoch || 0, thread: j.thread || '' }; @@ -20758,7 +20759,7 @@ class DebugSupervisorModule { return j; } - _chatPoll(force) { + _chatPoll(force, silent) { const st = this._chatThread(); const token = st.token; if (!token) return Promise.resolve(null); @@ -20779,7 +20780,7 @@ class DebugSupervisorModule { if ((m.epoch || 0) > (this._chatThread().cursor || 0)) { this._chatSaveState({ token: token, cursor: m.epoch || 0, thread: j.thread || '' }); } - if (m.author === 'hermes') this.terminal.print('🤖 ' + m.text, ''); + if (m.author === 'hermes' && !silent) this.terminal.print('🤖 ' + m.text, ''); }); return j; }) @@ -21578,7 +21579,7 @@ class DebugSupervisorModule { _cmdFeedback(args) { const msg = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim(); if (!msg) { - this.terminal.print('usage: feedback (alias: chat )', 'error'); + this.terminal.print('usage: hermes (alias: feedback )', 'error'); return; } this.terminal.print('📨 Sending…', ''); @@ -21588,9 +21589,10 @@ class DebugSupervisorModule { ' — reply arrives here; `hermes-poll` to check now', 'success')) .catch(e => { // 503 is nginx's limit_req, not ours: it arrives as HTML, so status is all we have. - const hint = e.status === 429 ? ' (rate limited — retry later)' + const wait = e.retryAfterMs ? ' in ' + Math.ceil(e.retryAfterMs / 1000) + 's' : ' later'; + const hint = e.status === 429 ? ' (rate limited — retry' + wait + ')' : e.status === 503 ? ' (host busy — retry)' - : e.status === 401 ? ' (bad token — try chat-reset)' + : e.status === 401 ? ' (bad token — try hermes-reset)' : ''; this.terminal.print('❌ ' + e.message + hint, 'error'); }); @@ -21598,10 +21600,15 @@ class DebugSupervisorModule { _cmdFeedbackInbox() { this.terminal.print('📬 Checking…', ''); + // _chatPoll prints the 🤖 lines (once); this loop skips hermes so it is not shown + // twice. Making the poll silent instead would print the reply nowhere. this._chatPoll(true).then(j => { if (!j) { this.terminal.print('❌ no answer from the chat endpoint', 'error'); return; } - const msgs = j.messages || []; - if (!msgs.length) this.terminal.print('(no new messages)', 'info'); + const incoming = j.messages || []; + // hermes lines were already printed by the poll above; only count them as + // 'new' so the box does not say "no new messages" right under a new one + const msgs = incoming.filter(m => m.author !== 'hermes'); + if (!incoming.length) this.terminal.print('(no new messages)', 'info'); msgs.forEach(m => this.terminal.print( (m.author === 'hermes' ? '🤖 ' : m.author === 'agent' ? '🧠 ' : '👤 ') + m.text, '')); if (j.awaiting) this.terminal.print('⏳ waiting for Hermes', 'info'); diff --git a/modules/core/module-debug-supervisor.js b/modules/core/module-debug-supervisor.js index 79c9acc..25e0566 100644 --- a/modules/core/module-debug-supervisor.js +++ b/modules/core/module-debug-supervisor.js @@ -134,6 +134,7 @@ class DebugSupervisorModule { const err = new Error((j && j.error) || ('HTTP ' + resp.status)); err.status = resp.status; err.code = (j && j.code) || ''; + err.retryAfterMs = (j && j.retry_after_ms) || null; throw err; } const st = { token: token, cursor: j.epoch || 0, thread: j.thread || '' }; @@ -141,7 +142,7 @@ class DebugSupervisorModule { return j; } - _chatPoll(force) { + _chatPoll(force, silent) { const st = this._chatThread(); const token = st.token; if (!token) return Promise.resolve(null); @@ -162,7 +163,7 @@ class DebugSupervisorModule { if ((m.epoch || 0) > (this._chatThread().cursor || 0)) { this._chatSaveState({ token: token, cursor: m.epoch || 0, thread: j.thread || '' }); } - if (m.author === 'hermes') this.terminal.print('🤖 ' + m.text, ''); + if (m.author === 'hermes' && !silent) this.terminal.print('🤖 ' + m.text, ''); }); return j; }) @@ -961,7 +962,7 @@ class DebugSupervisorModule { _cmdFeedback(args) { const msg = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim(); if (!msg) { - this.terminal.print('usage: feedback (alias: chat )', 'error'); + this.terminal.print('usage: hermes (alias: feedback )', 'error'); return; } this.terminal.print('📨 Sending…', ''); @@ -971,9 +972,10 @@ class DebugSupervisorModule { ' — reply arrives here; `hermes-poll` to check now', 'success')) .catch(e => { // 503 is nginx's limit_req, not ours: it arrives as HTML, so status is all we have. - const hint = e.status === 429 ? ' (rate limited — retry later)' + const wait = e.retryAfterMs ? ' in ' + Math.ceil(e.retryAfterMs / 1000) + 's' : ' later'; + const hint = e.status === 429 ? ' (rate limited — retry' + wait + ')' : e.status === 503 ? ' (host busy — retry)' - : e.status === 401 ? ' (bad token — try chat-reset)' + : e.status === 401 ? ' (bad token — try hermes-reset)' : ''; this.terminal.print('❌ ' + e.message + hint, 'error'); }); @@ -981,10 +983,15 @@ class DebugSupervisorModule { _cmdFeedbackInbox() { this.terminal.print('📬 Checking…', ''); + // _chatPoll prints the 🤖 lines (once); this loop skips hermes so it is not shown + // twice. Making the poll silent instead would print the reply nowhere. this._chatPoll(true).then(j => { if (!j) { this.terminal.print('❌ no answer from the chat endpoint', 'error'); return; } - const msgs = j.messages || []; - if (!msgs.length) this.terminal.print('(no new messages)', 'info'); + const incoming = j.messages || []; + // hermes lines were already printed by the poll above; only count them as + // 'new' so the box does not say "no new messages" right under a new one + const msgs = incoming.filter(m => m.author !== 'hermes'); + if (!incoming.length) this.terminal.print('(no new messages)', 'info'); msgs.forEach(m => this.terminal.print( (m.author === 'hermes' ? '🤖 ' : m.author === 'agent' ? '🧠 ' : '👤 ') + m.text, '')); if (j.awaiting) this.terminal.print('⏳ waiting for Hermes', 'info'); diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index b177626..acff509 100644 --- a/modules/core/module-loader.js +++ b/modules/core/module-loader.js @@ -4,7 +4,7 @@ // Core module bundle loaded synchronously in main.html. // This loader only handles version cache-busting, splash, terminal init, and optional ext modules. - const LATEST_VER = 'v4.6.77'; + const LATEST_VER = 'v4.6.80'; const CACHE = '?v=' + LATEST_VER; const stored = localStorage.getItem('webx11_cache_ver'); if (stored !== LATEST_VER) { -- cgit v1.2.3