From 9f6744dd5cb99b904f9a23fce6f2d98e8332bbf8 Mon Sep 17 00:00:00 2001 From: Hermes SysOp Date: Mon, 9 Nov 2026 23:58:35 +0100 Subject: fix(newos4): six crash classes from the feedback queue (v4.6.158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each item is symptom -> cause -> fix, re-verified against the served bytes. 1. debug-ai panel threw "Cannot read properties of undefined (reading 'substring')" (RPT-20261108-231622). _updateDebugPanel and two sibling previews called JSON.stringify(entry.args).substring(); JSON.stringify(undefined) returns undefined, and _toolCallLog entries carry args: undefined for any tool call with no arguments. All three previews now go through _formatToolArgs(), which renders "null" and never throws. 2. test_module threw "Cannot convert undefined or null to object" (RPT-20261108-232127). Object.keys(instance.commands) assumed every module initialises a command map. The throw landed after loadModuleFromCode had already succeeded, so the rolledBack:true branches were skipped and an unverified module stayed loaded. Both sites now require instance.commands. 3. list_files threw "originalPath.replace is not a function" (RPT-20261109-172742, 3 occurrences in errors.json). A model can send a non-string path despite the schema. The path family (list_files/read_file/write_file/make_directory/ delete_file) now coerces through _pathArg() and answers the tool's own honest error instead of a TypeError. 4. wpm search failed for EVERY keyword (RPT-20261109-123907): packages.json entry `kanban` used `description` while the other 31 use `desc`, and search() called pkg.desc.toLowerCase() mid-loop. Catalog reads now go through _pkg() / _packageMap() (desc || description, values coerced to strings), and the kanban entry carries name/version/desc/commands. Re-running the original loop for pdf/git/zzz is clean. 5. wx/webxai/webx turned a chat-lock timeout into an unhandled rejection (RPT-20261107-150726): forwardToAI() called chat(args) in a synchronous function without returning or catching the promise, so the 10 s busy throw never reached dispatchCommand's catch. It now returns the promise with a .catch that prints "AI error: ..." through terminal.print β€” console.error is hooked by the debug supervisor and would re-file the same report. 6. CryptoGzip._base64ToBytes threw a raw DOMException on non-base64 input (RPT-20261102-203616). It now validates first and throws Error("not valid base64 input ()"). The duplicate shadowing definitions at the bottom of the class were deleted β€” the later copy is the one that carried the unguarded atob(). Also regenerated modules/core/core-bundle.js (generated, never hand-edited) and bumped the build v4.6.157 -> v4.6.158 (main.html x3 + module-loader.js). Verified: 35/35 node tests, including 6 new regression tests each proven red against the pre-fix source; an independent orchestrator harness 36/36; and newos4-maintain.py --fix all PASS after regenerating the stale changelog page. Served bytes are byte-identical for the bundle, main.html and both ext modules, and a live headless-Chromium run against v4.6.158 shows WpmModule.search('pdf') returning 1 result, the debug panel rendering a no-arg entry as "null", _base64ToBytes('grüße') raising a plain Error that names base64, and 0 JS errors on load. m.pull (RPT-20261102-211438) is closed as a usage error: no module in the tree defines pull(); the real interfaces are the webdav-pull and model-pull commands. --- modules/core/core-bundle.js | 61 +++++++++++++++++++++++++++++++---------- modules/core/module-aiasm.js | 17 +++++++++--- modules/core/module-aiassist.js | 42 +++++++++++++++++++++------- modules/core/module-loader.js | 2 +- 4 files changed, 92 insertions(+), 30 deletions(-) (limited to 'modules/core') diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js index 2b95828..69b5e1a 100644 --- a/modules/core/core-bundle.js +++ b/modules/core/core-bundle.js @@ -10496,6 +10496,28 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re _escapeHtml(str) { return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } + // Tool-call args are whatever the model sent: often a JSON string, but + // undefined when the call carried no arguments and an object when the + // caller passed one directly. JSON.stringify(undefined) is undefined, + // so the old inline `JSON.stringify(x).substring(...)` threw a + // TypeError inside the debug panel. Never throw here. + _formatToolArgs(args, max) { + let s; + if (typeof args === 'string') { + s = args; + } else { + try { s = JSON.stringify(args); } catch (_e) { s = String(args); } + if (s === undefined || s === null) s = 'null'; + } + const cap = (typeof max === 'number' && max > 0) ? max : 80; + return s.substring(0, cap); + } + // Path arguments are schema-typed as strings, but a model can send a + // number/array/object anyway. Coerce instead of letting .replace() / + // .startsWith() throw "originalPath.replace is not a function". + _pathArg(v) { + return typeof v === 'string' ? v : (v == null ? '' : String(v)); + } _updateDebugPanel() { if (!this._debugPanelEnabled) return; const panel = document.getElementById('debug-ai-panel'); @@ -10505,7 +10527,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re let toolHtml = ''; for (const entry of recent) { const statusClass = entry.error ? 'debug-entry-error' : 'debug-entry-ok'; - const argsStr = typeof entry.args === 'string' ? entry.args.substring(0, 60) : JSON.stringify(entry.args).substring(0, 60); + const argsStr = this._formatToolArgs(entry.args, 60); toolHtml += '
' + '#' + entry.turn + ' ' + '' + this._escapeHtml(entry.name) + ' ' + @@ -10544,7 +10566,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re panel.innerHTML = '
' + 'πŸ”§ AI Debug ' + this._toolCallLog.length + ' calls' + '' + - '' + + '' + '' + '' + 'βœ•' + @@ -11518,7 +11540,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {}); switch (name) { case 'list_files': { - const originalPath = a.path || ''; + const originalPath = this._pathArg(a.path); const p = originalPath.replace(/^\//, '').replace(/\/+$/, ''); const fsapi = this.terminal.fs; const saved = [...fsapi.currentPath]; @@ -11535,7 +11557,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re return JSON.stringify(entries.map(f => ({ name: f.name, type: f.type, size: f.size }))); } case 'read_file': { - const rp = a.path || ''; + const rp = this._pathArg(a.path); const savedR = [...this.terminal.fs.currentPath]; if (rp.startsWith('/')) this.terminal.fs.currentPath = []; const path = rp.replace(/^\//, ''); @@ -11574,7 +11596,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re }); } case 'write_file': { - const wp = a.path || ''; + const wp = this._pathArg(a.path); const savedW = [...this.terminal.fs.currentPath]; if (wp.startsWith('/')) this.terminal.fs.currentPath = []; const path = wp.replace(/^\//, ''); @@ -11593,7 +11615,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re return `File written: ${wp} (${content.length} chars)`; } case 'make_directory': { - const mp = a.path || ''; + const mp = this._pathArg(a.path); const savedM = [...this.terminal.fs.currentPath]; if (mp.startsWith('/')) this.terminal.fs.currentPath = []; const path = mp.replace(/^\//, ''); @@ -11603,7 +11625,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re return JSON.stringify({ error: `Cannot create: ${path}` }); } case 'delete_file': { - const dp = a.path || ''; + const dp = this._pathArg(a.path); const savedD = [...this.terminal.fs.currentPath]; if (dp.startsWith('/')) this.terminal.fs.currentPath = []; const path = dp.replace(/^\//, ''); @@ -12523,7 +12545,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re if (!code) return JSON.stringify({ error: `No local edit for ${modName}` }); // Save current module state for rollback const oldInstance = this.terminal.moduleManager.modules[modName]; - const oldCommands = oldInstance ? Object.keys(oldInstance.commands) : []; + const oldCommands = (oldInstance && oldInstance.commands) ? Object.keys(oldInstance.commands) : []; // Load new version const ok = await this.terminal.moduleManager.loadModuleFromCode(code, `blob://_mods/${modName}.js`); if (!ok) { @@ -12538,7 +12560,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re } // Verify commands registered const newInstance = this.terminal.moduleManager.modules[modName]; - const newCommands = newInstance ? Object.keys(newInstance.commands) : []; + const newCommands = (newInstance && newInstance.commands) ? Object.keys(newInstance.commands) : []; const lostCommands = oldCommands.filter(c => !newCommands.includes(c)); if (lostCommands.length > 0) { if (oldInstance) this.terminal.moduleManager.modules[modName] = oldInstance; @@ -13247,7 +13269,7 @@ maxFetchChars: this.maxFetchChars, for (var i = 0; i < this._toolCallLog.length; i++) { var e = this._toolCallLog[i]; var err = e.error ? ' ⚠️' : ''; - var argsStr = typeof e.args === 'string' ? e.args.substring(0, 80) : JSON.stringify(e.args).substring(0, 80); + var argsStr = this._formatToolArgs(e.args, 80); out += ' #' + e.turn + ' ' + e.name + '(' + argsStr + ') ' + e.duration + 's' + err + '\n'; } out += '── End ──'; @@ -14113,11 +14135,20 @@ maxFetchChars: this.maxFetchChars, if (!args[0]) { this.terminal.print("Usage: webxai ", "warning"); return; } - if (this.terminal.moduleManager.modules['WebX11AIAssistantModule']) { - this.terminal.moduleManager.modules['WebX11AIAssistantModule'].chat(args); - } else { - this.terminal.ollamaManager.chat(args.join(' ')); - } + const ai = this.terminal.moduleManager.modules['WebX11AIAssistantModule']; + let p; + try { + p = ai ? ai.chat(args) : this.terminal.ollamaManager.chat(args.join(' ')); + } catch (e) { + // Synchronous throw β€” report it through the same path as a rejection. + p = Promise.reject(e); + } + // Chat-lock timeouts are normal UX (user sent twice). Print, don't let the rejection + // reach window.unhandledrejection β†’ debug-supervisor auto-report. chat() releases its + // own lock in finally, so nothing is cleared here. + return Promise.resolve(p).catch((e) => { + this.terminal.print('AI error: ' + (e && e.message ? e.message : e), 'error'); + }); } } diff --git a/modules/core/module-aiasm.js b/modules/core/module-aiasm.js index 60e1757..34c1582 100644 --- a/modules/core/module-aiasm.js +++ b/modules/core/module-aiasm.js @@ -17,10 +17,19 @@ if (!args[0]) { this.terminal.print("Usage: webxai ", "warning"); return; } - if (this.terminal.moduleManager.modules['WebX11AIAssistantModule']) { - this.terminal.moduleManager.modules['WebX11AIAssistantModule'].chat(args); - } else { - this.terminal.ollamaManager.chat(args.join(' ')); + const ai = this.terminal.moduleManager.modules['WebX11AIAssistantModule']; + let p; + try { + p = ai ? ai.chat(args) : this.terminal.ollamaManager.chat(args.join(' ')); + } catch (e) { + // Synchronous throw β€” report it through the same path as a rejection. + p = Promise.reject(e); } + // Chat-lock timeouts are normal UX (user sent twice). Print, don't let the rejection + // reach window.unhandledrejection β†’ debug-supervisor auto-report. chat() releases its + // own lock in finally, so nothing is cleared here. + return Promise.resolve(p).catch((e) => { + this.terminal.print('AI error: ' + (e && e.message ? e.message : e), 'error'); + }); } } diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js index a3ffb6f..fb4329f 100644 --- a/modules/core/module-aiassist.js +++ b/modules/core/module-aiassist.js @@ -1551,6 +1551,28 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re _escapeHtml(str) { return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } + // Tool-call args are whatever the model sent: often a JSON string, but + // undefined when the call carried no arguments and an object when the + // caller passed one directly. JSON.stringify(undefined) is undefined, + // so the old inline `JSON.stringify(x).substring(...)` threw a + // TypeError inside the debug panel. Never throw here. + _formatToolArgs(args, max) { + let s; + if (typeof args === 'string') { + s = args; + } else { + try { s = JSON.stringify(args); } catch (_e) { s = String(args); } + if (s === undefined || s === null) s = 'null'; + } + const cap = (typeof max === 'number' && max > 0) ? max : 80; + return s.substring(0, cap); + } + // Path arguments are schema-typed as strings, but a model can send a + // number/array/object anyway. Coerce instead of letting .replace() / + // .startsWith() throw "originalPath.replace is not a function". + _pathArg(v) { + return typeof v === 'string' ? v : (v == null ? '' : String(v)); + } _updateDebugPanel() { if (!this._debugPanelEnabled) return; const panel = document.getElementById('debug-ai-panel'); @@ -1560,7 +1582,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re let toolHtml = ''; for (const entry of recent) { const statusClass = entry.error ? 'debug-entry-error' : 'debug-entry-ok'; - const argsStr = typeof entry.args === 'string' ? entry.args.substring(0, 60) : JSON.stringify(entry.args).substring(0, 60); + const argsStr = this._formatToolArgs(entry.args, 60); toolHtml += '
' + '#' + entry.turn + ' ' + '' + this._escapeHtml(entry.name) + ' ' + @@ -1599,7 +1621,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re panel.innerHTML = '
' + 'πŸ”§ AI Debug ' + this._toolCallLog.length + ' calls' + '' + - '' + + '' + '' + '' + 'βœ•' + @@ -2573,7 +2595,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {}); switch (name) { case 'list_files': { - const originalPath = a.path || ''; + const originalPath = this._pathArg(a.path); const p = originalPath.replace(/^\//, '').replace(/\/+$/, ''); const fsapi = this.terminal.fs; const saved = [...fsapi.currentPath]; @@ -2590,7 +2612,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re return JSON.stringify(entries.map(f => ({ name: f.name, type: f.type, size: f.size }))); } case 'read_file': { - const rp = a.path || ''; + const rp = this._pathArg(a.path); const savedR = [...this.terminal.fs.currentPath]; if (rp.startsWith('/')) this.terminal.fs.currentPath = []; const path = rp.replace(/^\//, ''); @@ -2629,7 +2651,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re }); } case 'write_file': { - const wp = a.path || ''; + const wp = this._pathArg(a.path); const savedW = [...this.terminal.fs.currentPath]; if (wp.startsWith('/')) this.terminal.fs.currentPath = []; const path = wp.replace(/^\//, ''); @@ -2648,7 +2670,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re return `File written: ${wp} (${content.length} chars)`; } case 'make_directory': { - const mp = a.path || ''; + const mp = this._pathArg(a.path); const savedM = [...this.terminal.fs.currentPath]; if (mp.startsWith('/')) this.terminal.fs.currentPath = []; const path = mp.replace(/^\//, ''); @@ -2658,7 +2680,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re return JSON.stringify({ error: `Cannot create: ${path}` }); } case 'delete_file': { - const dp = a.path || ''; + const dp = this._pathArg(a.path); const savedD = [...this.terminal.fs.currentPath]; if (dp.startsWith('/')) this.terminal.fs.currentPath = []; const path = dp.replace(/^\//, ''); @@ -3578,7 +3600,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re if (!code) return JSON.stringify({ error: `No local edit for ${modName}` }); // Save current module state for rollback const oldInstance = this.terminal.moduleManager.modules[modName]; - const oldCommands = oldInstance ? Object.keys(oldInstance.commands) : []; + const oldCommands = (oldInstance && oldInstance.commands) ? Object.keys(oldInstance.commands) : []; // Load new version const ok = await this.terminal.moduleManager.loadModuleFromCode(code, `blob://_mods/${modName}.js`); if (!ok) { @@ -3593,7 +3615,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re } // Verify commands registered const newInstance = this.terminal.moduleManager.modules[modName]; - const newCommands = newInstance ? Object.keys(newInstance.commands) : []; + const newCommands = (newInstance && newInstance.commands) ? Object.keys(newInstance.commands) : []; const lostCommands = oldCommands.filter(c => !newCommands.includes(c)); if (lostCommands.length > 0) { if (oldInstance) this.terminal.moduleManager.modules[modName] = oldInstance; @@ -4302,7 +4324,7 @@ maxFetchChars: this.maxFetchChars, for (var i = 0; i < this._toolCallLog.length; i++) { var e = this._toolCallLog[i]; var err = e.error ? ' ⚠️' : ''; - var argsStr = typeof e.args === 'string' ? e.args.substring(0, 80) : JSON.stringify(e.args).substring(0, 80); + var argsStr = this._formatToolArgs(e.args, 80); out += ' #' + e.turn + ' ' + e.name + '(' + argsStr + ') ' + e.duration + 's' + err + '\n'; } out += '── End ──'; diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index 685b5fd..43c7e06 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.157'; + const LATEST_VER = 'v4.6.158'; const CACHE = '?v=' + LATEST_VER; // Exposed so ext modules can register their own URL with the same cache-busting version // instead of an unversioned path the browser may already have cached (shell.js did). -- cgit v1.2.3