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/test/test-cryptogzip-guard.js | 136 ++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 modules/test/test-cryptogzip-guard.js (limited to 'modules/test/test-cryptogzip-guard.js') diff --git a/modules/test/test-cryptogzip-guard.js b/modules/test/test-cryptogzip-guard.js new file mode 100644 index 0000000..14f50d3 --- /dev/null +++ b/modules/test/test-cryptogzip-guard.js @@ -0,0 +1,136 @@ +// node modules/test/test-cryptogzip-guard.js +// test-cryptogzip-guard — CryptoGzipModule._base64ToBytes must reject non-base64 input with a +// plain, actionable Error, not a raw DOMException out of atob() (RPT-20261102-203616-e2641bff: +// "Failed to execute 'atob' on 'Window': The string to be decoded contains characters outside of +// the Latin1 range"), which escaped as an unhandledrejection when called from the node console. +// +// It also pins the happy path byte-for-byte (including unpadded base64 and newline-wrapped +// base64, both of which atob already accepted) and the two gunzip callers. +const fs = require('fs'); +const path = require('path'); +const zlib = require('zlib'); + +global.window = {}; // the file's auto-registration tail is window-guarded + +const SRC = path.join(__dirname, '..', 'ext', 'cryptogzip.js'); +const CryptoGzipModule = new Function(fs.readFileSync(SRC, 'utf8') + '\nreturn CryptoGzipModule;')(); + +let passes = 0, fails = 0; +function ok(name, cond, detail) { + if (cond) { passes++; return; } + fails++; + console.log('FAIL ' + name + (detail ? ' — ' + detail : '')); +} + +const m = new CryptoGzipModule(); +const isDomException = (e) => typeof DOMException === 'function' && e instanceof DOMException; + +function mkTerm(files) { + const store = Object.assign({}, files || {}); + const printed = []; + const term = { + print: (msg, kind) => printed.push({ msg: String(msg), kind: kind || '' }), + fs: { + readFile: (p) => (p in store ? store[p] : null), + writeFile: (p, c) => { store[p] = c; return true; }, + deleteFile: (p) => { delete store[p]; }, + readDir: () => [] + }, + inputCallback: null, + _onCommand: null + }; + const mod = new CryptoGzipModule(); + mod.terminal = term; + mod.registerCommands(); + return { mod, term, printed, store }; +} + +(async () => { + // C1-C2: happy path + const hello = m._base64ToBytes('aGVsbG8='); + ok('C1 decodes base64 to the right bytes', Buffer.from(hello).toString() === 'hello', + Buffer.from(hello).toString()); + ok('C2 returns a 5-byte Uint8Array', hello instanceof Uint8Array && hello.length === 5, + hello.constructor && hello.constructor.name + ' len ' + hello.length); + + // C3: every input that used to reach atob() must now be a plain Error mentioning base64 + const bad = [ + ['hello world!', 'non-base64 text'], + ['☃', 'non-Latin1 character'], + [null, 'null'], + [undefined, 'undefined'], + [123, 'number'], + [{}, 'object'], + ['abcde=', 'bad padding'], + ['ab=', 'incomplete padding'] + ]; + for (const [value, label] of bad) { + let err = null; + try { m._base64ToBytes(value); } catch (e) { err = e; } + const plainError = err instanceof Error && !isDomException(err) && err.name !== 'InvalidCharacterError'; + ok('C3 ' + label + ' throws a plain Error that mentions base64', + plainError && /base64/.test(err.message), + err ? err.name + ': ' + err.message : 'did not throw'); + } + + // C4: long valid base64 with CRLF/LF wrapping still decodes (atob ignores ASCII whitespace) + const long = 'The quick brown fox jumps over the lazy dog. '.repeat(40); + const b64 = Buffer.from(long, 'utf8').toString('base64'); + const wrapped = b64.replace(/(.{60})/g, '$1\r\n') + '\n'; + const out = Buffer.from(m._base64ToBytes(wrapped)).toString('utf8'); + ok('C4 newline-wrapped base64 decodes', out === long, out.length + ' vs ' + long.length + ' chars'); + + // C5: byte-identical to atob() for every form atob accepted before (incl. unpadded) + const corpus = ['aGVsbG8=', 'aGVsbG8', 'ab==', 'abc=', 'abcd', '', ' aGVs bG8=\n', 'aGVsbG8g\td29ybGQ=']; + let same = true, why = ''; + for (const c of corpus) { + let want, got; + try { want = Buffer.from(atob(c), 'binary').toString('hex'); } catch (e) { want = 'THROW'; } + try { got = Buffer.from(m._base64ToBytes(c)).toString('hex'); } catch (e) { got = 'THROW'; } + if (want !== got) { same = false; why = JSON.stringify(c) + ' atob=' + want + ' guard=' + got; break; } + } + ok('C5 happy path is byte-identical to atob()', same, why); + + // C6: _gunzipString round-trip (base64 gzip payload) + const gz = zlib.gzipSync(Buffer.from('hi hi hi', 'utf8')); + const text = await m._gunzipString(gz.toString('base64')); + ok('C6 _gunzipString round-trips a real gzip payload', text === 'hi hi hi', JSON.stringify(text)); + + // C7: _gunzipString on garbage rejects with the guarded Error, not a DOMException + let gzErr = null; + try { await m._gunzipString('hello world!'); } catch (e) { gzErr = e; } + ok('C7 _gunzipString on non-base64 throws a guarded Error', + !!gzErr && gzErr instanceof Error && !isDomException(gzErr) && /base64/.test(gzErr.message), + gzErr ? gzErr.name + ': ' + gzErr.message : 'did not throw'); + + // C8: `gunzip` command path prints its existing "gunzip error:" line instead of crashing + const t8 = mkTerm({ 'notbase64.gz': 'hello world!' }); + let cmdThrew = null; + try { await t8.mod.commands['gunzip'](['notbase64.gz']); } catch (e) { cmdThrew = e; } + const line = t8.printed.find(p => /^gunzip error: /.test(p.msg)); + ok('C8 gunzip prints "gunzip error: … not valid base64 …" and does not throw', + !cmdThrew && !!line && /base64/.test(line.msg) && t8.store['notbase64.gz'] === 'hello world!', + cmdThrew ? 'threw ' + cmdThrew.message : JSON.stringify(t8.printed)); + + // C9: legacy path — a base64 .gz written by `gzip` still decompresses + const t9 = mkTerm({ 'note.txt.gz': zlib.gzipSync(Buffer.from('gunzip me', 'utf8')).toString('base64') }); + await t9.mod.commands['gunzip'](['note.txt.gz']); + ok('C9 gunzip still decompresses a base64 .gz', t9.store['note.txt'] === 'gunzip me' + && !('note.txt.gz' in t9.store), JSON.stringify(Object.keys(t9.store))); + + // C10-C11: the VFS helpers that call _base64ToBytes keep their behaviour + const gzRaw = zlib.gzipSync(Buffer.from('latin1 payload', 'utf8')); + const latin1 = Buffer.from(gzRaw).toString('latin1'); // what _vfsWriteBytes stores + const t10 = mkTerm({ 'blob.gz': latin1 }); + const raw = t10.mod._vfsReadBytes('blob.gz'); // guard throws -> Latin-1 fallback + ok('C10 _vfsReadBytes falls back to raw Latin-1 bytes (guard rejection is swallowed)', + t10.mod._isGzip(raw) && Buffer.from(raw).equals(gzRaw), raw.length + ' bytes'); + + const t11 = mkTerm({ 'b64.gz': gzRaw.toString('base64') }); + const dec = t11.mod._vfsReadBytes('b64.gz'); // heuristic -> base64 decode + ok('C11 _vfsReadBytes still base64-decodes a stored payload', + t11.mod._isGzip(dec) && Buffer.from(dec).equals(gzRaw), dec.length + ' bytes'); + + console.log('test-cryptogzip-guard: ' + passes + ' passed, ' + fails + ' failed'); + process.exit(fails ? 1 : 0); +})(); -- cgit v1.2.3