aboutsummaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
Diffstat (limited to 'modules')
-rw-r--r--modules/core/core-bundle.js61
-rw-r--r--modules/core/module-aiasm.js17
-rw-r--r--modules/core/module-aiassist.js42
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/ext/cryptogzip.js42
-rw-r--r--modules/ext/wpm.js56
-rw-r--r--modules/test/test-ai-path-args.js143
-rw-r--r--modules/test/test-ai-test-module-commands.js105
-rw-r--r--modules/test/test-aiasm-rejection.js95
-rw-r--r--modules/test/test-cryptogzip-guard.js136
-rw-r--r--modules/test/test-debug-args-preview.js112
-rw-r--r--modules/test/test-wpm-index.js164
12 files changed, 920 insertions, 55 deletions
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
+ // 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 += '<div class="debug-entry ' + statusClass + '">' +
'<span class="debug-turn">#' + entry.turn + '</span> ' +
'<span class="debug-name">' + this._escapeHtml(entry.name) + '</span> ' +
@@ -10544,7 +10566,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re
panel.innerHTML = '<div class="debug-header" style="display:flex;justify-content:space-between;align-items:center;padding:6px 10px;border-bottom:1px solid #333;font-weight:bold;color:var(--success-color);font-size:0.9em;flex-wrap:wrap;gap:4px;">' +
'<span>🔧 AI Debug <span class="debug-count" style="color:var(--text-muted);font-weight:normal;font-size:0.85em;">' + this._toolCallLog.length + ' calls</span></span>' +
'<span style="display:flex;gap:4px;">' +
- '<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var t=m._toolCallLog.map(function(e){return\'#\'+e.turn+\' \'+e.name+\' (\'+e.duration+\'s)\'+(e.error?\' ⚠️\':\'\')+\'\\n\'+JSON.stringify(e.args).substring(0,80)}).join(\'\\n\\n\');navigator.clipboard.writeText(t).then(function(){m.terminal&&m.terminal.print(\'📋 Debug log copied to clipboard\',\'success\')}).catch(function(){prompt(\'Copy debug log:\',t)})}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">📋</button>' +
+ '<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var t=m._toolCallLog.map(function(e){return\'#\'+e.turn+\' \'+e.name+\' (\'+e.duration+\'s)\'+(e.error?\' ⚠️\':\'\')+\'\\n\'+m._formatToolArgs(e.args,80)}).join(\'\\n\\n\');navigator.clipboard.writeText(t).then(function(){m.terminal&&m.terminal.print(\'📋 Debug log copied to clipboard\',\'success\')}).catch(function(){prompt(\'Copy debug log:\',t)})}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">📋</button>' +
'<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var json=JSON.stringify(m._toolCallLog,null,2);var b=new Blob([json],{type:\'application/json\'});var u=URL.createObjectURL(b);var a=document.createElement(\'a\');a.href=u;a.download=\'debug-log-\'+new Date().toISOString().slice(0,19)+\'.json\';document.body.appendChild(a);a.click();setTimeout(function(){document.body.removeChild(a);URL.revokeObjectURL(u)},2000);m.terminal&&m.terminal.print(\'📥 Debug log exported\',\'success\')}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">📥</button>' +
'<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var existing=[];var raw=m.terminal.fs.readFile(\'/debug.log\');if(raw){try{existing=JSON.parse(raw);}catch(e){}}var log=m._toolCallLog;var seen={};existing.forEach(function(e){seen[e.turn]=true;});log.forEach(function(e){if(!seen[e.turn]){existing.push(e);seen[e.turn]=true;}});var json=JSON.stringify(existing,null,2);m.terminal.fs.writeFile(\'/debug.log\',json);m.terminal.print(\'💾 Debug log appended — \'+existing.length+\' entries total\',\'success\')}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">💾</button>' +
'<span style="cursor:pointer;color:var(--text-muted);font-size:1.1em;touch-action:manipulation;" onclick="var p=document.getElementById(\'debug-ai-panel\');if(p)p.style.display=\'none\';var a=window.webx11&&webx11.moduleManager&&webx11.moduleManager.modules&&webx11.moduleManager.modules.WebX11AIAssistantModule;if(a)a._debugPanelEnabled=false;">✕</span>' +
@@ -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 <your-question>", "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 <your-question>", "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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
+ // 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 += '<div class="debug-entry ' + statusClass + '">' +
'<span class="debug-turn">#' + entry.turn + '</span> ' +
'<span class="debug-name">' + this._escapeHtml(entry.name) + '</span> ' +
@@ -1599,7 +1621,7 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re
panel.innerHTML = '<div class="debug-header" style="display:flex;justify-content:space-between;align-items:center;padding:6px 10px;border-bottom:1px solid #333;font-weight:bold;color:var(--success-color);font-size:0.9em;flex-wrap:wrap;gap:4px;">' +
'<span>🔧 AI Debug <span class="debug-count" style="color:var(--text-muted);font-weight:normal;font-size:0.85em;">' + this._toolCallLog.length + ' calls</span></span>' +
'<span style="display:flex;gap:4px;">' +
- '<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var t=m._toolCallLog.map(function(e){return\'#\'+e.turn+\' \'+e.name+\' (\'+e.duration+\'s)\'+(e.error?\' ⚠️\':\'\')+\'\\n\'+JSON.stringify(e.args).substring(0,80)}).join(\'\\n\\n\');navigator.clipboard.writeText(t).then(function(){m.terminal&&m.terminal.print(\'📋 Debug log copied to clipboard\',\'success\')}).catch(function(){prompt(\'Copy debug log:\',t)})}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">📋</button>' +
+ '<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var t=m._toolCallLog.map(function(e){return\'#\'+e.turn+\' \'+e.name+\' (\'+e.duration+\'s)\'+(e.error?\' ⚠️\':\'\')+\'\\n\'+m._formatToolArgs(e.args,80)}).join(\'\\n\\n\');navigator.clipboard.writeText(t).then(function(){m.terminal&&m.terminal.print(\'📋 Debug log copied to clipboard\',\'success\')}).catch(function(){prompt(\'Copy debug log:\',t)})}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">📋</button>' +
'<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var json=JSON.stringify(m._toolCallLog,null,2);var b=new Blob([json],{type:\'application/json\'});var u=URL.createObjectURL(b);var a=document.createElement(\'a\');a.href=u;a.download=\'debug-log-\'+new Date().toISOString().slice(0,19)+\'.json\';document.body.appendChild(a);a.click();setTimeout(function(){document.body.removeChild(a);URL.revokeObjectURL(u)},2000);m.terminal&&m.terminal.print(\'📥 Debug log exported\',\'success\')}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">📥</button>' +
'<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var existing=[];var raw=m.terminal.fs.readFile(\'/debug.log\');if(raw){try{existing=JSON.parse(raw);}catch(e){}}var log=m._toolCallLog;var seen={};existing.forEach(function(e){seen[e.turn]=true;});log.forEach(function(e){if(!seen[e.turn]){existing.push(e);seen[e.turn]=true;}});var json=JSON.stringify(existing,null,2);m.terminal.fs.writeFile(\'/debug.log\',json);m.terminal.print(\'💾 Debug log appended — \'+existing.length+\' entries total\',\'success\')}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">💾</button>' +
'<span style="cursor:pointer;color:var(--text-muted);font-size:1.1em;touch-action:manipulation;" onclick="var p=document.getElementById(\'debug-ai-panel\');if(p)p.style.display=\'none\';var a=window.webx11&&webx11.moduleManager&&webx11.moduleManager.modules&&webx11.moduleManager.modules.WebX11AIAssistantModule;if(a)a._debugPanelEnabled=false;">✕</span>' +
@@ -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).
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}`);
diff --git a/modules/test/test-ai-path-args.js b/modules/test/test-ai-path-args.js
new file mode 100644
index 0000000..878f6b0
--- /dev/null
+++ b/modules/test/test-ai-path-args.js
@@ -0,0 +1,143 @@
+// node modules/test/test-ai-path-args.js
+// Regression for the filed report "originalPath.replace is not a function"
+// (data/debug/errors.json, 3 occurrences, last 2026-11-09T19:46Z).
+// The tool schema says path is a string, but a model can send a number/array/
+// object. The old `const p = a.path || ''` handed that straight to .replace()/
+// .startsWith(), which threw inside runTool and turned every such call into a
+// TypeError instead of a usable answer. _pathArg() coerces first.
+const fsMod = require('fs'), path = require('path');
+const ROOT = path.join(__dirname, '..', 'core');
+const SRC = process.env.AIASSIST_SRC || path.join(ROOT, 'module-aiassist.js');
+
+const store = {};
+global.localStorage = {
+ getItem: k => (k in store ? store[k] : null),
+ setItem: (k, v) => { store[k] = String(v); },
+ removeItem: k => { delete store[k]; },
+ clear: () => { for (const k of Object.keys(store)) delete store[k]; }
+};
+global.window = global;
+global.indexedDB = { open() { throw new Error('no idb'); }, deleteDatabase() { throw new Error('no idb'); } };
+global.document = { getElementById: () => null, createElement: () => ({ style: {} }), body: { appendChild() {}, removeChild() {} } };
+global.navigator = global.navigator || {};
+
+const load = (file, cls) => new Function(fsMod.readFileSync(path.join(ROOT, file), 'utf8') + '\nreturn ' + cls + ';')();
+const VFS = load('virtual-fs.js', 'VirtualFileSystem');
+const AI = new Function(fsMod.readFileSync(SRC, 'utf8') + '\nreturn WebX11AIAssistantModule;')();
+
+function mk() {
+ const out = [];
+ const vfs = new VFS();
+ vfs.saveToStorage = () => true;
+ const ai = Object.create(AI.prototype); // skip the DOM-touching constructor
+ ai.terminal = {
+ print: (m, t) => out.push({ text: String(m), type: t || '' }),
+ log: () => {}, printHTML: () => {},
+ fs: vfs, moduleManager: { modules: {} }
+ };
+ ai.checkGuardrail = () => null;
+ ai._errorLog = [];
+ ai._toolCallLog = [];
+ ai._toolCache = new Map();
+ ai._toolEpoch = 0;
+ ai._currentTag = 'main';
+ ai._toolLimit = () => 8000;
+ return { ai, vfs, out };
+}
+
+let fails = 0, passes = 0;
+function ok(name, cond, detail) {
+ if (cond) { passes++; return; }
+ fails++;
+ console.log('FAIL ' + name + (detail ? ' — ' + detail : ''));
+}
+
+// The old defect surfaced through runTool's catch-all as
+// {"error":"originalPath.replace is not a function"} — so "did not throw" alone
+// is not enough; the result must also be free of that TypeError.
+function parse(label, raw) {
+ let out = null, threw = null;
+ try { out = JSON.parse(raw); } catch (e) { threw = e; }
+ ok(label + ': returned a JSON string', threw === null && out !== null, threw ? threw.message : String(raw).slice(0, 200));
+ ok(label + ': no string-method TypeError leaked',
+ typeof raw === 'string' && !/is not a function/.test(raw), String(raw).slice(0, 200));
+ return out;
+}
+function isEntriesOrError(v) {
+ return Array.isArray(v) || (v && typeof v === 'object' && typeof v.error === 'string');
+}
+const call = async (ai, name, args) => {
+ let res, threw = null;
+ try { res = await ai.runTool(name, args); } catch (e) { threw = e; }
+ ok(name + ' ' + JSON.stringify(args) + ': runTool did not throw', threw === null, threw && threw.message);
+ return res;
+};
+
+(async () => {
+ // A. list_files with a numeric path
+ {
+ const { ai } = mk();
+ const res = await call(ai, 'list_files', JSON.stringify({ path: 123 }));
+ const v = parse('list_files path:123', res);
+ ok('list_files path:123 is an entry list or {error}', isEntriesOrError(v), String(res).slice(0, 200));
+ }
+
+ // B. list_files with an object path
+ {
+ const { ai } = mk();
+ const res = await call(ai, 'list_files', JSON.stringify({ path: {} }));
+ const v = parse('list_files path:{}', res);
+ ok('list_files path:{} is an entry list or {error}', isEntriesOrError(v), String(res).slice(0, 200));
+ }
+
+ // C. list_files with an explicitly undefined path (JSON.stringify drops the
+ // key, so this must behave like "list the current directory")
+ {
+ const { ai } = mk();
+ const res = await call(ai, 'list_files', JSON.stringify({ path: undefined }));
+ const v = parse('list_files path:undefined', res);
+ ok('list_files path:undefined still lists the current dir', Array.isArray(v), String(res).slice(0, 200));
+ }
+
+ // D. an object passed directly (runTool accepts an object as argsStr)
+ {
+ const { ai } = mk();
+ const res = await call(ai, 'list_files', { path: [1, 2] });
+ const v = parse('list_files path:[1,2]', res);
+ ok('list_files path:[1,2] is an entry list or {error}', isEntriesOrError(v), String(res).slice(0, 200));
+ }
+
+ // E. read_file with a numeric path must answer "not found", not TypeError
+ {
+ const { ai } = mk();
+ const res = await call(ai, 'read_file', JSON.stringify({ path: 123 }));
+ const v = parse('read_file path:123', res);
+ ok('read_file path:123 reports File not found', !!v && /File not found/.test(String(v.error)), String(res).slice(0, 200));
+ }
+
+ // F. the other path-family tools must survive the same input
+ for (const tool of ['delete_file', 'make_directory', 'write_file']) {
+ const { ai } = mk();
+ const args = tool === 'write_file' ? { path: 123, content: 'x' } : { path: 123 };
+ const res = await call(ai, tool, JSON.stringify(args));
+ ok(tool + ' path:123 has no string-method TypeError',
+ typeof res === 'string' && !/is not a function/.test(res), String(res).slice(0, 200));
+ }
+
+ // G. control: a real string path still works exactly as before
+ {
+ const { ai, vfs } = mk();
+ vfs.mkdirRecursive('/demo');
+ vfs.writeFile('/demo/keep.txt', 'hello');
+ const res = await call(ai, 'list_files', JSON.stringify({ path: '/demo' }));
+ const v = parse('list_files path:/demo', res);
+ ok('string path still lists entries', Array.isArray(v) && v.some(e => e.name === 'keep.txt'),
+ String(res).slice(0, 200));
+ const read = await call(ai, 'read_file', JSON.stringify({ path: '/demo/keep.txt' }));
+ const rv = parse('read_file path:/demo/keep.txt', read);
+ ok('string path still reads content', !!rv && rv.content === 'hello', String(read).slice(0, 200));
+ }
+
+ console.log('test-ai-path-args: ' + passes + ' passed, ' + fails + ' failed');
+ process.exit(fails ? 1 : 0);
+})();
diff --git a/modules/test/test-ai-test-module-commands.js b/modules/test/test-ai-test-module-commands.js
new file mode 100644
index 0000000..9683011
--- /dev/null
+++ b/modules/test/test-ai-test-module-commands.js
@@ -0,0 +1,105 @@
+// node modules/test/test-ai-test-module-commands.js
+// test_module used `oldInstance ? Object.keys(oldInstance.commands) : []`. A module
+// that has no command map (or whose reloaded instance does not re-register one)
+// made Object.keys throw "Cannot convert undefined or null to object". The throw
+// happened AFTER loadModuleFromCode() succeeded, so runTool's catch returned an
+// error and the rollback branches were skipped: the unverified module stayed
+// loaded. The guards keep the rollback path reachable.
+const fsMod = require('fs'), path = require('path');
+const ROOT = path.join(__dirname, '..', 'core');
+const SRC = process.env.AIASSIST_SRC || path.join(ROOT, 'module-aiassist.js');
+
+const store = {};
+global.localStorage = {
+ getItem: k => (k in store ? store[k] : null),
+ setItem: (k, v) => { store[k] = String(v); },
+ removeItem: k => { delete store[k]; },
+ clear: () => { for (const k of Object.keys(store)) delete store[k]; }
+};
+global.window = global;
+global.document = { getElementById: () => null, createElement: () => ({ style: {} }), body: { appendChild() {}, removeChild() {} } };
+global.navigator = global.navigator || {};
+
+const AI = new Function(fsMod.readFileSync(SRC, 'utf8') + '\nreturn WebX11AIAssistantModule;')();
+
+// `next` is what loadModuleFromCode installs as the reloaded instance.
+function mk(oldInstance, next) {
+ const out = [];
+ const modules = { Demo: oldInstance };
+ const ai = Object.create(AI.prototype);
+ ai.terminal = {
+ print: (m, t) => out.push({ text: String(m), type: t || '' }),
+ log: () => {}, printHTML: () => {},
+ fs: { readFile: () => '// demo module source', currentPath: [] },
+ moduleManager: {
+ modules,
+ loadModuleFromCode: async (code, url) => { modules.Demo = next; return true; }
+ }
+ };
+ ai.checkGuardrail = () => null;
+ ai._errorLog = [];
+ ai._toolCallLog = [];
+ ai._captureConsoleErrors = async () => [];
+ ai._captureCommandOutput = async () => 'ok';
+ ai._captureShellCommand = async () => 'ok';
+ return { ai, modules, out };
+}
+
+let fails = 0, passes = 0;
+function ok(name, cond, detail) {
+ if (cond) { passes++; return; }
+ fails++;
+ console.log('FAIL ' + name + (detail ? ' — ' + detail : ''));
+}
+
+(async () => {
+ // A. reloaded instance lost its command map: must report + roll back,
+ // not throw and leave the bad instance installed.
+ {
+ const oldInstance = { commands: { 'demo-a': {}, 'demo-b': {} } };
+ const next = { /* no commands property at all */ };
+ const { ai, modules } = mk(oldInstance, next);
+ const raw = await ai.runTool('test_module', JSON.stringify({ name: 'Demo' }));
+ let res = null;
+ try { res = JSON.parse(raw); } catch (e) {}
+ ok('A1 result is JSON', res !== null, String(raw).slice(0, 200));
+ ok('A2 reports Commands lost after edit', !!res && res.error === 'Commands lost after edit',
+ String(raw).slice(0, 200));
+ ok('A3 lists both lost commands', !!res && Array.isArray(res.lost) && res.lost.length === 2,
+ JSON.stringify(res && res.lost));
+ ok('A4 marks rolledBack', !!res && res.rolledBack === true, String(raw).slice(0, 200));
+ ok('A5 old instance restored', modules.Demo === oldInstance, 'modules.Demo is ' + (modules.Demo === next ? 'the new instance' : 'neither'));
+ ok('A6 no Object.keys TypeError', !/Cannot convert undefined or null to object/.test(String(raw)),
+ String(raw).slice(0, 200));
+ }
+
+ // B. the OLD instance itself has no command map: Object.keys(old.commands)
+ // used to throw before the new module was even loaded.
+ {
+ const oldInstance = { /* no commands */ };
+ const next = { commands: { 'demo-a': {} } };
+ const { ai } = mk(oldInstance, next);
+ const raw = await ai.runTool('test_module', JSON.stringify({ name: 'Demo' }));
+ let res = null;
+ try { res = JSON.parse(raw); } catch (e) {}
+ ok('B1 does not throw on a command-less old instance', res !== null, String(raw).slice(0, 200));
+ ok('B2 reports success (nothing was lost)', !!res && res.success === true, String(raw).slice(0, 200));
+ ok('B3 no Object.keys TypeError', !/Cannot convert undefined or null to object/.test(String(raw)),
+ String(raw).slice(0, 200));
+ }
+
+ // C. control: unchanged command map still passes the verification
+ {
+ const oldInstance = { commands: { 'demo-a': {} } };
+ const next = { commands: { 'demo-a': {} } };
+ const { ai, modules } = mk(oldInstance, next);
+ const raw = await ai.runTool('test_module', JSON.stringify({ name: 'Demo' }));
+ let res = null;
+ try { res = JSON.parse(raw); } catch (e) {}
+ ok('C1 success', !!res && res.success === true, String(raw).slice(0, 200));
+ ok('C2 new instance stays loaded', modules.Demo === next);
+ }
+
+ console.log('test-ai-test-module-commands: ' + passes + ' passed, ' + fails + ' failed');
+ process.exit(fails ? 1 : 0);
+})();
diff --git a/modules/test/test-aiasm-rejection.js b/modules/test/test-aiasm-rejection.js
new file mode 100644
index 0000000..d6e18f6
--- /dev/null
+++ b/modules/test/test-aiasm-rejection.js
@@ -0,0 +1,95 @@
+// node modules/test/test-aiasm-rejection.js
+// test-aiasm-rejection — the wx / webxai / webx aliases must not turn the AI chat-lock timeout
+// into an unhandled promise rejection (RPT-20261107-150726-bf727247).
+//
+// Why this file exists: forwardToAI() used to call the assistant's chat(args) with no await and no
+// .catch() inside a non-async function. When the assistant was already mid-turn, chat() waited
+// _chatLockTimeoutMs (10s) and then rejected with "AI is still busy with another turn…". Nothing
+// was attached to that promise, so it surfaced on window.unhandledrejection and the debug
+// supervisor auto-filed a bug report for what is normal "the user typed twice" UX.
+const fs = require('fs');
+const path = require('path');
+
+const SRC = path.join(__dirname, '..', 'core', 'module-aiasm.js');
+const Alias = new Function(fs.readFileSync(SRC, 'utf8') + '\nreturn WebXAIAliasModule;')();
+
+const BUSY = 'AI is still busy with another turn (waited 10s). Wait for it to finish, or run '
+ + "'ai-stop' and retry — the running turn's lock is left alone.";
+
+const unhandled = [];
+process.on('unhandledRejection', (e) => unhandled.push(e));
+
+let passes = 0, fails = 0;
+function ok(name, cond, detail) {
+ if (cond) { passes++; return; }
+ fails++;
+ console.log('FAIL ' + name + (detail ? ' — ' + detail : ''));
+}
+
+function mk(chatImpl) {
+ const printed = [];
+ const term = {
+ print: (msg, kind) => printed.push({ msg: String(msg), kind: kind || '' }),
+ moduleManager: { modules: { WebX11AIAssistantModule: chatImpl ? { chat: chatImpl } : null } },
+ ollamaManager: { chat: () => Promise.resolve('ok') }
+ };
+ const m = new Alias();
+ m.terminal = term;
+ m.registerCommands();
+ return { m, printed, term };
+}
+
+const tick = (ms) => new Promise(r => setTimeout(r, ms || 25));
+
+(async () => {
+ // A1-A5: the busy rejection is printed, the returned promise settles, nothing escapes
+ const a = mk(() => Promise.reject(new Error(BUSY)));
+ const r = a.m.forwardToAI(['hi']);
+ ok('A1 forwardToAI returns a promise (callers can await it)', !!r && typeof r.then === 'function', typeof r);
+ let awaited;
+ try { awaited = await r; } catch (e) { awaited = 'REJECTED:' + e.message; }
+ ok('A2 the returned promise settles instead of rejecting', String(awaited).indexOf('REJECTED') !== 0, String(awaited));
+ const errLine = a.printed.find(p => /^AI error: /.test(p.msg));
+ ok('A3 the busy timeout is printed to the terminal', !!errLine && /still busy/.test(errLine.msg),
+ JSON.stringify(a.printed));
+ ok('A4 it is printed as an error line', !!errLine && errLine.kind === 'error', errLine && errLine.kind);
+ await tick();
+ ok('A5 no unhandledRejection escaped', unhandled.length === 0,
+ unhandled.map(e => (e && e.message) || String(e)).join('; '));
+
+ // A6-A7: the same through the command map the terminal dispatcher actually awaits
+ const b = mk(() => Promise.reject(new Error(BUSY)));
+ let cmdErr = null;
+ try { await b.m.commands['wx'](['second question']); } catch (e) { cmdErr = e; }
+ ok('A6 `wx` command does not reject', !cmdErr, cmdErr && cmdErr.message);
+ await tick();
+ ok('A7 `wx` prints the busy error', b.printed.some(p => /AI error: .*still busy/.test(p.msg)),
+ JSON.stringify(b.printed));
+
+ // A8-A10: happy path — a resolving chat must print nothing at all
+ const c = mk(() => Promise.resolve({ role: 'assistant', content: 'pong' }));
+ let res;
+ try { res = await c.m.forwardToAI(['ping']); } catch (e) { res = 'REJECTED:' + e.message; }
+ await tick();
+ ok('A8 a resolving chat prints nothing', c.printed.length === 0, JSON.stringify(c.printed));
+ ok('A9 happy path does not throw', String(res).indexOf('REJECTED') !== 0, String(res));
+ ok('A10 happy path leaks no unhandled rejection', unhandled.length === 0, String(unhandled.length));
+
+ // A11-A12: ollama fallback (no assistant module) is handled the same way
+ const d = mk(null);
+ d.term.ollamaManager.chat = () => Promise.reject(new Error('ollama offline'));
+ await d.m.forwardToAI(['hi']);
+ await tick();
+ ok('A11 ollama fallback failure is printed', d.printed.some(p => /AI error: ollama offline/.test(p.msg)),
+ JSON.stringify(d.printed));
+ ok('A12 ollama fallback leaks no unhandled rejection', unhandled.length === 0, String(unhandled.length));
+
+ // A13: usage is still printed when the question is missing
+ const e = mk(null);
+ e.m.forwardToAI([]);
+ ok('A13 usage printed when args are empty', e.printed.some(p => /Usage: webxai/.test(p.msg)),
+ JSON.stringify(e.printed));
+
+ console.log('test-aiasm-rejection: ' + passes + ' passed, ' + fails + ' failed');
+ process.exit(fails ? 1 : 0);
+})();
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);
+})();
diff --git a/modules/test/test-debug-args-preview.js b/modules/test/test-debug-args-preview.js
new file mode 100644
index 0000000..12292eb
--- /dev/null
+++ b/modules/test/test-debug-args-preview.js
@@ -0,0 +1,112 @@
+// node modules/test/test-debug-args-preview.js
+// Regression for the filed report:
+// "Error in WebX11AIAssistantModule.debug-ai: Cannot read properties of undefined (reading 'substring')"
+// Cause: _toolCallLog entries carry `args: undefined` (a tool call with no
+// arguments) or no `args` key at all (_recordToolCall never sets one), and both
+// the debug panel and the tool-call log did `JSON.stringify(args).substring(...)`.
+// JSON.stringify(undefined) === undefined, so .substring threw inside the render
+// path and the panel died before printing anything.
+const fsMod = require('fs'), path = require('path');
+const ROOT = path.join(__dirname, '..', 'core');
+const SRC = process.env.AIASSIST_SRC || path.join(ROOT, 'module-aiassist.js');
+
+// --- browser stubs (the class is a browser module) -------------------------
+const store = {};
+global.localStorage = {
+ getItem: k => (k in store ? store[k] : null),
+ setItem: (k, v) => { store[k] = String(v); },
+ removeItem: k => { delete store[k]; },
+ clear: () => { for (const k of Object.keys(store)) delete store[k]; }
+};
+global.window = global;
+const panelEl = {
+ attrs: {}, innerHTML: '',
+ getAttribute(k) { return (k in this.attrs) ? this.attrs[k] : null; },
+ setAttribute(k, v) { this.attrs[k] = String(v); }
+};
+global.document = {
+ getElementById: id => (id === 'debug-ai-panel' ? panelEl : null),
+ createElement: () => ({ style: {}, click() {}, setAttribute() {} }),
+ body: { appendChild() {}, removeChild() {} }
+};
+
+const src = fsMod.readFileSync(SRC, 'utf8');
+const AI = new Function(src + '\nreturn WebX11AIAssistantModule;')();
+
+// Bypass the constructor (it builds prompts / touches DOM) but keep the real
+// prototype methods under test.
+function mk() {
+ const out = [];
+ const ai = Object.create(AI.prototype);
+ ai._toolCallLog = [];
+ ai._debugPanelEnabled = true;
+ ai._lastRawResponse = null;
+ ai._lastRawMessages = null;
+ ai._errorLog = [];
+ ai.terminal = {
+ print: (m, t) => out.push({ text: String(m), type: t || '' }),
+ log: () => {}, printHTML: () => {}
+ };
+ return { ai, out };
+}
+
+let fails = 0, passes = 0;
+function ok(name, cond, detail) {
+ if (cond) { passes++; return; }
+ fails++;
+ console.log('FAIL ' + name + (detail ? ' — ' + detail : ''));
+}
+
+// 1-4: helper contract (guarded so a missing helper reports FAIL instead of
+// aborting the run — this is what proves the test catches the old code).
+{
+ const { ai } = mk();
+ const fmt = (v, m) => (typeof ai._formatToolArgs === 'function' ? ai._formatToolArgs(v, m) : '<no _formatToolArgs>');
+ ok('1 _formatToolArgs(undefined) === "null"', fmt(undefined, 60) === 'null', JSON.stringify(fmt(undefined, 60)));
+ ok('2 _formatToolArgs(null) === "null"', fmt(null, 60) === 'null', JSON.stringify(fmt(null, 60)));
+ ok('3 _formatToolArgs({a:1}) === \'{"a":1}\'', fmt({ a: 1 }, 60) === '{"a":1}', fmt({ a: 1 }, 60));
+ const long = 'x'.repeat(200);
+ const cut = fmt(long, 10);
+ ok('4 truncates to max', cut === 'xxxxxxxxxx', JSON.stringify(cut));
+}
+
+// 5: the debug panel renders an entry whose args is undefined (the live shape
+// pushed at the tool-call site) and one with no args key at all (_recordToolCall).
+{
+ const { ai } = mk();
+ ai._toolCallLog.push({ turn: 1, name: 'list_files', args: undefined, duration: 0.2, error: false });
+ ai._toolCallLog.push({ turn: 2, name: 'get_status', duration: 0.1, ok: true });
+ panelEl.attrs['data-tab'] = 'tools';
+ panelEl.innerHTML = '';
+ let threw = null;
+ try { ai._updateDebugPanel(); } catch (e) { threw = e; }
+ ok('5 _updateDebugPanel does not throw on args:undefined', threw === null, threw && threw.message);
+ ok('5b panel rendered the "null" preview', !threw && /debug-args">null</.test(panelEl.innerHTML),
+ String(panelEl.innerHTML).slice(0, 400));
+}
+
+// 6: the terminal tool-call log (var e = this._toolCallLog[i]) does not throw.
+{
+ const { ai, out } = mk();
+ ai._toolCallLog.push({ turn: 1, name: 'list_files', args: undefined, duration: 0.2, error: false });
+ ai._toolCallLog.push({ turn: 2, name: 'read_file', args: '{"path":"/a.txt"}', duration: 0.1 });
+ let threw = null;
+ try { ai._showToolCallLog(); } catch (e) { threw = e; }
+ const printed = out.map(o => o.text).join('\n');
+ ok('6 _showToolCallLog does not throw on args:undefined', threw === null, threw && threw.message);
+ ok('6b log shows null for the argument-less call', /list_files\(null\)/.test(printed), printed.slice(0, 300));
+ ok('6c real args still shown', /read_file\(\{"path":"\/a\.txt"\}\)/.test(printed), printed.slice(0, 300));
+}
+
+// 7: a circular argument object must not throw either (JSON.stringify throws there).
+{
+ const { ai } = mk();
+ const circ = {}; circ.self = circ;
+ let threw = null, got = '';
+ try { got = ai._formatToolArgs(circ, 40); } catch (e) { threw = e; }
+ ok('7 circular args degrade instead of throwing', threw === null && typeof got === 'string' && got.length > 0,
+ threw ? threw.message : got);
+}
+
+console.log('test-debug-args-preview: ' + passes + ' passed, ' + fails + ' failed');
+process.exit(fails ? 1 : 0);
diff --git a/modules/test/test-wpm-index.js b/modules/test/test-wpm-index.js
new file mode 100644
index 0000000..ae9cc35
--- /dev/null
+++ b/modules/test/test-wpm-index.js
@@ -0,0 +1,164 @@
+// node modules/test/test-wpm-index.js
+'use strict';
+// Regression test — RPT-20261109-123907-5a6273ab (source tool: wpm_search)
+//
+// packages.json `kanban` was authored with `description` while the other 31 entries
+// use `desc`, and modules/ext/wpm.js read `pkg.desc.toLowerCase()` inside search().
+// Result: EVERY `wpm search <kw>` threw
+// TypeError: Cannot read properties of undefined (reading 'toLowerCase')
+// for any keyword (pdf, git, zzz) because the bad entry sits mid-loop.
+//
+// This test loads the real served catalog + the real wpm.js (no network, no browser)
+// and asserts that search/list/info/remove/install cannot throw on it, and that an
+// entry with no desc/category at all is also survivable.
+//
+// Run: node modules/test/test-wpm-index.js
+
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.resolve(__dirname, '..', '..');
+const WPM_SRC = path.join(ROOT, 'modules', 'ext', 'wpm.js');
+const CATALOG = path.join(ROOT, 'packages.json');
+
+let checks = 0;
+let failures = 0;
+function ok(cond, label) {
+ checks++;
+ if (cond) { console.log(' ok ' + label); return true; }
+ failures++;
+ console.error(' FAIL ' + label);
+ return false;
+}
+
+// ── Load wpm.js exactly as the browser would, minus the browser ──────────────
+// window is an empty object (a real browser has window.webx11 undefined until
+// module-loader.js runs), so the self-registration block at the bottom is skipped.
+const code = fs.readFileSync(WPM_SRC, 'utf8');
+const store = {};
+const fakeLocalStorage = {
+ getItem: (k) => (Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null),
+ setItem: (k, v) => { store[k] = String(v); },
+ removeItem: (k) => { delete store[k]; },
+};
+const noNetwork = () => Promise.reject(new Error('network disabled in test'));
+const WpmModule = new Function('window', 'localStorage', 'fetch', code + '\n;return WpmModule;')
+ ({}, fakeLocalStorage, noNetwork);
+
+function makeWpm(catalog) {
+ const logs = [];
+ const m = new WpmModule();
+ m.terminal = {
+ print: (...args) => { logs.push(args.map((a) => String(a)).join(' ')); },
+ moduleManager: {
+ loadModuleFromURL: async () => true,
+ unregisterModule: () => true,
+ },
+ };
+ m.installed = {};
+ m.saveInstalled = () => {};
+ // Pre-seed the index: loadIndex() returns this.index and never fetches.
+ m.index = catalog || JSON.parse(fs.readFileSync(CATALOG, 'utf8'));
+ return { m, logs };
+}
+
+async function main() {
+ const raw = JSON.parse(fs.readFileSync(CATALOG, 'utf8'));
+ const catalog = raw.packages;
+ const names = Object.keys(catalog);
+
+ console.log('\n[1] catalog shape + normalisation (' + names.length + ' packages)');
+ ok(names.length === 32, 'catalog still has 32 packages');
+ ok(!!catalog.kanban, 'kanban entry exists (the entry that broke search)');
+
+ const { m: probe } = makeWpm();
+ const map = probe._packageMap(probe.index);
+
+ const missingDesc = names.filter((n) => !map[n] || typeof map[n].desc !== 'string' || map[n].desc.length === 0);
+ const missingCat = names.filter((n) => !map[n] || typeof map[n].category !== 'string' || map[n].category.length === 0);
+ ok(missingDesc.length === 0, 'every entry normalises to a non-empty string desc' + (missingDesc.length ? ' — bad: ' + missingDesc.join(',') : ''));
+ ok(missingCat.length === 0, 'every entry normalises to a non-empty string category' + (missingCat.length ? ' — bad: ' + missingCat.join(',') : ''));
+ names.forEach((n) => {
+ ok(typeof catalog[n].url === 'string' && catalog[n].url.length > 0, n + ': raw url present');
+ });
+ ok(typeof catalog.kanban.desc === 'string' && catalog.kanban.desc.length > 0,
+ 'kanban now carries the canonical `desc` field');
+ ok(catalog.kanban.description === undefined || catalog.kanban.description === catalog.kanban.desc,
+ 'kanban legacy `description` (kept for other readers) agrees with desc');
+
+ console.log('\n[2] search over the real catalog for the keywords that used to throw');
+ for (const kw of ['pdf', 'git', 'zzz']) {
+ const { m, logs } = makeWpm();
+ let err = null;
+ try { await m.search(kw); } catch (e) { err = e; }
+ ok(!err, 'search("' + kw + '") did not throw' + (err ? ' — ' + err.name + ': ' + err.message : ''));
+ ok(logs.length > 0, 'search("' + kw + '") printed something (' + logs.length + ' lines)');
+ ok(!logs.some((l) => l.includes('undefined')), 'search("' + kw + '") printed no "undefined"');
+ if (kw === 'pdf') {
+ // search() prints the entry's display `name` ("File Manager"), not the map key.
+ ok(logs.some((l) => /pdf/i.test(l) && l.includes('File Manager')), 'search("pdf") finds the PDF-previewing file manager');
+ } else {
+ ok(logs.some((l) => l.includes('No packages matching')), 'search("' + kw + '") reports no match cleanly');
+ }
+ }
+
+ console.log('\n[3] the other catalog readers (list / info / remove / install) survive kanban');
+ {
+ const { m, logs } = makeWpm();
+ let err = null;
+ try {
+ await m.list();
+ await m.info('kanban');
+ } catch (e) { err = e; }
+ ok(!err, 'list()+info("kanban") did not throw' + (err ? ' — ' + err.message : ''));
+ ok(!logs.some((l) => l.includes('undefined')), 'list()+info("kanban") printed no "undefined"');
+ ok(logs.some((l) => l.includes('kanban - ')), 'list() renders the kanban line');
+
+ m.installed.kanban = { version: '1.0.0', installed: '2026-11-09T00:00:00Z' };
+ let rmErr = null;
+ try { await m.remove('kanban'); } catch (e) { rmErr = e; }
+ ok(!rmErr, 'remove("kanban") did not throw (name falls back to the map key)' + (rmErr ? ' — ' + rmErr.message : ''));
+
+ const inst = makeWpm();
+ let inErr = null;
+ try { await inst.m.install('kanban'); } catch (e) { inErr = e; }
+ ok(!inErr, 'install("kanban") did not throw (catalog fetch is stubbed to fail)');
+ ok(!inst.logs.some((l) => l.includes('undefined')), 'install("kanban") printed no "undefined"');
+ ok(inst.logs.some((l) => l.includes('Installing Kanban Board v1.0.0')), 'install("kanban") prints the normalised name/version');
+ }
+
+ console.log('\n[4] an entry with no desc/category/name/version at all cannot throw');
+ {
+ const { m, logs } = makeWpm();
+ m.index.packages.brokenpkg = { url: 'modules/ext/brokenpkg.js' };
+ let err = null;
+ try {
+ await m.search('zzz');
+ await m.search('broken');
+ await m.list();
+ await m.info('brokenpkg');
+ } catch (e) { err = e; }
+ ok(!err, 'field-less entry: search/list/info did not throw' + (err ? ' — ' + err.name + ': ' + err.message : ''));
+ ok(logs.length > 0, 'field-less entry: something was printed');
+ ok(!logs.some((l) => l.includes('undefined')), 'field-less entry: no "undefined" leaked to the terminal');
+ ok(logs.some((l) => l.includes('brokenpkg [')), 'field-less entry: search hit renders empty category, not a crash');
+ }
+
+ // also: an index whose `packages` key is missing entirely
+ console.log('\n[5] malformed index (no `packages` object) cannot throw');
+ {
+ const { m, logs } = makeWpm({ name: 'broken-index' });
+ let err = null;
+ try { await m.search('pdf'); await m.list(); } catch (e) { err = e; }
+ ok(!err, 'index without packages: search/list did not throw' + (err ? ' — ' + err.message : ''));
+ ok(logs.some((l) => l.includes('No packages matching')), 'index without packages: clean "no match" output');
+ }
+
+ console.log('\n' + (failures ? 'FAIL' : 'PASS') + ' — ' + (checks - failures) + '/' + checks + ' checks');
+ process.exit(failures ? 1 : 0);
+}
+
+main().catch((e) => {
+ console.error('FAIL — unexpected error: ' + (e && e.stack || e));
+ process.exit(1);
+});