summaryrefslogtreecommitdiff
path: root/modules/ext
diff options
context:
space:
mode:
authorHermes SysOp <sysop@webx11.duckdns.org>2026-11-09 23:58:35 +0100
committerHermes SysOp <sysop@webx11.duckdns.org>2026-11-09 23:58:35 +0100
commit9f6744dd5cb99b904f9a23fce6f2d98e8332bbf8 (patch)
treed3f3a525e8594db010bbf0e2aa6e4c2590445339 /modules/ext
parent8e3a6155f6a5a1266ff1fa801c8864484bcdbda9 (diff)
fix(newos4): six crash classes from the feedback queue (v4.6.158)
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 (<reason>)"). 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.
Diffstat (limited to 'modules/ext')
-rw-r--r--modules/ext/cryptogzip.js42
-rw-r--r--modules/ext/wpm.js56
2 files changed, 73 insertions, 25 deletions
diff --git a/modules/ext/cryptogzip.js b/modules/ext/cryptogzip.js
index cc275df..25b6ca4 100644
--- a/modules/ext/cryptogzip.js
+++ b/modules/ext/cryptogzip.js
@@ -190,8 +190,31 @@ class CryptoGzipModule {
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s);
}
+ // Validate before atob(): atob() throws a raw DOMException (InvalidCharacterError / "characters
+ // outside of the Latin1 range") for arbitrary input, which escaped as an unhandledrejection when
+ // called from the node/JS console (RPT-20261102-203616-e2641bff). Everything atob() would reject
+ // is rejected here first, as a plain Error the command wrappers can print.
+ _base64InvalidReason(s) {
+ if (typeof s !== 'string') return 'expected a string, got ' + (s === null ? 'null' : typeof s);
+ const t = s.replace(/[\t\n\f\r ]/g, ''); // atob() ignores exactly these ASCII whitespace chars
+ if (t.length === 0) return null; // atob('') === '' — keep the empty case working
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(t)) {
+ const bad = t.match(/[^A-Za-z0-9+/=]/);
+ if (bad) return 'illegal character ' + JSON.stringify(bad[0]) + ' — expected A-Z a-z 0-9 + / =';
+ return 'misplaced or excessive "=" padding';
+ }
+ const rem = t.length % 4;
+ if (rem === 1) return 'length ' + t.length + ' is not a valid base64 length';
+ // Padding may be omitted (atob accepts that too), but when present the length must be a
+ // multiple of 4 — otherwise atob() throws "not correctly encoded".
+ if (t.indexOf('=') !== -1 && rem !== 0) return 'length ' + t.length + ' has incomplete "=" padding';
+ return null;
+ }
+ _isBase64(s) { return this._base64InvalidReason(s) === null; }
_base64ToBytes(b64) {
- const s = atob(b64);
+ const reason = this._base64InvalidReason(b64);
+ if (reason) throw new Error('not valid base64 input (' + reason + ')');
+ const s = atob(b64.replace(/[\t\n\f\r ]/g, ''));
const u8 = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i);
return u8;
@@ -582,19 +605,10 @@ class CryptoGzipModule {
}
// ============================================================
- // Base64 Helpers
- // ============================================================
-
- _bytesToBase64(bytes) {
- const bin = Array.from(bytes, (b) => String.fromCharCode(b)).join('');
- return btoa(bin);
- }
-
- _base64ToBytes(b64) {
- const bin = atob(b64);
- return Uint8Array.from(bin, (c) => c.charCodeAt(0));
- }
-
+ // Base64 Helpers — defined once, above, next to the gzip code.
+ // (A second, later copy of _bytesToBase64/_base64ToBytes used to
+ // live here and silently shadow the first; it carried the unguarded
+ // atob() call. Do not re-add a duplicate.)
// ============================================================
registerCommands() {
diff --git a/modules/ext/wpm.js b/modules/ext/wpm.js
index 68ac9cb..e9b9b50 100644
--- a/modules/ext/wpm.js
+++ b/modules/ext/wpm.js
@@ -21,6 +21,35 @@ class WpmModule {
saveInstalled() {
localStorage.setItem('webx11_wpm_installed', JSON.stringify(this.installed));
}
+ // ── Catalog normalisation ───────────────────────────────────────────────
+ // packages.json is hand-edited JSON and its field names have drifted: `kanban`
+ // shipped with `description` while the other 31 entries use `desc`
+ // (RPT-20261109-123907-5a6273ab), and an entry can be missing `category` /
+ // `commands` entirely. Reading pkg.desc.toLowerCase() straight out of the index
+ // threw a TypeError out of `wpm search` for EVERY keyword (the bad entry is only
+ // reached mid-loop), so all catalog reads go through _pkg()/_packageMap().
+ // `desc` wins when both keys exist; values are coerced so .toLowerCase() is safe.
+ _pkg(name, raw) {
+ const src = (raw && typeof raw === 'object') ? raw : {};
+ const str = (v) => (v === undefined || v === null) ? '' : String(v);
+ let commands = src.commands;
+ if (typeof commands === 'string') commands = [commands];
+ if (!Array.isArray(commands)) commands = [];
+ return Object.assign({}, src, {
+ name: str(src.name) || str(name),
+ version: str(src.version) || '?',
+ desc: str(src.desc) || str(src.description),
+ category: str(src.category),
+ url: str(src.url),
+ commands: commands,
+ });
+ }
+ _packageMap(idx) {
+ const pkgs = (idx && idx.packages && typeof idx.packages === 'object') ? idx.packages : {};
+ const out = {};
+ Object.keys(pkgs).forEach(k => { out[k] = this._pkg(k, pkgs[k]); });
+ return out;
+ }
registerCommands() {
this.commands['wpm'] = (args) => this.handle(args);
}
@@ -52,8 +81,9 @@ class WpmModule {
if (!name) return this.terminal.print('Usage: wpm install <package>', 'warning');
const idx = await this.loadIndex();
if (!idx) return;
- const pkg = idx.packages[name];
- if (!pkg) return this.terminal.print(`Package "${name}" not found. Try: wpm search <keyword>`, 'error');
+ const raw = idx.packages && idx.packages[name];
+ if (!raw) return this.terminal.print(`Package "${name}" not found. Try: wpm search <keyword>`, 'error');
+ const pkg = this._pkg(name, raw);
if (this.installed[name]) return this.terminal.print(`Package "${name}" already installed. Use wpm update to reinstall.`, 'warning');
this.terminal.print(`Installing ${pkg.name} v${pkg.version}...`, 'info');
this.terminal.print(` ${pkg.desc}`, 'info');
@@ -106,8 +136,9 @@ class WpmModule {
});
}
this.terminal.print('\n=== Available Packages ===', 'info');
- Object.keys(idx.packages).sort().forEach(name => {
- const pkg = idx.packages[name];
+ const pkgs = this._packageMap(idx);
+ Object.keys(pkgs).sort().forEach(name => {
+ const pkg = pkgs[name];
const installed = this.installed[name] ? ' ✅' : '';
this.terminal.print(` ${name} - ${pkg.desc}${installed}`);
});
@@ -116,10 +147,10 @@ class WpmModule {
if (!keyword) return this.terminal.print('Usage: wpm search <keyword>', 'warning');
const idx = await this.loadIndex();
if (!idx) return;
- const kw = keyword.toLowerCase();
+ const kw = String(keyword).toLowerCase();
const results = [];
- Object.entries(idx.packages).forEach(([name, pkg]) => {
- if (name.includes(kw) || pkg.desc.toLowerCase().includes(kw) || pkg.category.toLowerCase().includes(kw)) {
+ Object.entries(this._packageMap(idx)).forEach(([name, pkg]) => {
+ if (name.toLowerCase().includes(kw) || pkg.desc.toLowerCase().includes(kw) || pkg.category.toLowerCase().includes(kw)) {
results.push({ name, ...pkg });
}
});
@@ -137,8 +168,9 @@ class WpmModule {
if (!this.index) return;
this.terminal.print('Index refreshed.', 'success');
const outdated = [];
+ const pkgs = this._packageMap(this.index);
Object.keys(this.installed).forEach(name => {
- const pkg = this.index.packages[name];
+ const pkg = pkgs[name];
if (pkg && pkg.version !== this.installed[name].version) {
outdated.push(name);
}
@@ -151,7 +183,8 @@ class WpmModule {
if (!name) return this.terminal.print('Usage: wpm remove <package>', 'warning');
if (!this.installed[name]) return this.terminal.print(`Package "${name}" is not installed via wpm`, 'warning');
const idx = await this.loadIndex();
- const className = idx && idx.packages[name] ? idx.packages[name].name.replace(/\s+/g, '') + 'Module' : null;
+ const raw = idx && idx.packages ? idx.packages[name] : null;
+ const className = raw ? this._pkg(name, raw).name.replace(/\s+/g, '') + 'Module' : null;
if (className && this.terminal.moduleManager.unregisterModule(className)) {
this.terminal.print(`Module ${className} unregistered`, 'info');
}
@@ -163,8 +196,9 @@ class WpmModule {
if (!name) return this.terminal.print('Usage: wpm info <package>', 'warning');
const idx = await this.loadIndex();
if (!idx) return;
- const pkg = idx.packages[name];
- if (!pkg) return this.terminal.print(`Package "${name}" not found`, 'error');
+ const raw = idx.packages && idx.packages[name];
+ if (!raw) return this.terminal.print(`Package "${name}" not found`, 'error');
+ const pkg = this._pkg(name, raw);
this.terminal.print(`=== ${pkg.name} v${pkg.version} ===`, 'info');
this.terminal.print(`Description: ${pkg.desc}`);
this.terminal.print(`Category: ${pkg.category}`);