aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHermes SysOp <sysop@webx11.duckdns.org>2026-11-12 16:24:15 +0100
committerHermes SysOp <sysop@webx11.duckdns.org>2026-11-12 16:24:15 +0100
commitc08a795ae822e905639caf563e36cf7953d530f1 (patch)
treebf4db2041002f4de721f95526c363ff186f30fe0
parent8d0fe407b80bb822e8ef2852977afa3afbdc554a (diff)
fix(newos4): one arg-coercion boundary + binary-safe gunzip + clean boot log (v4.6.159)
1. Tool arguments are coerced once, at the dispatch boundary (module-aiassist.js) - _coerceToolArgs(name, a) at :1584 reads the tool's own JSON schema and, for every declared string prop, converts number/boolean with String() and object/array with JSON.stringify (never "[object Object]"); declared integer/number props accept unambiguous numeric strings. null/undefined stay absent so the handlers' defaults and _pathArg still own "no value given". - Called at both dispatch boundaries: runTool (:2650) and runModuleTool (:3565 — reachable from view_module and from ship_module -> test_module/health_check, which have their own parse+switch). - A JSON body of "null" or "[1,2]" parses to a non-object; both sites normalise to {} before any handler reads a.field. - The 26 handlers keep their `a.x || ''` reads: `|| ''` catches only FALSY, which is how a truthy number or object reached .trim()/.split()/.replace() and filed one auto-report per tool (user_get "key.split is not a function", create_skill, patch_skill, delegate_task "task.substring is not a function", create_mindmap, web_fetch, list_skills, ship_module). 2. gunzip is binary-safe (cryptogzip.js — plan 2026-11-03 T3, the last open task) - _cmdGunzip used _vfsRead + _gunzipString (base64-only), so a real .gz fetched with wget reported "not valid base64 input (illegal character \u001f)" and could not be decompressed at all. It now reads bytes (_vfsReadBytes handles both the module's base64 output and raw Latin-1 bytes), checks the 1f 8b magic, and reports "not a gzip file" for non-gzip input without writing or deleting anything. The module's own gzip output still round-trips. 3. Boot log is clean (terminal.js) - Seven _safeRegister() calls for Editor/Shell/Alias/Finder/FileManager/Diff/BusyBox were guaranteed no-ops: those classes are declared in critical-ext-bundle.js, a <script> after module-loader.js, so they do not exist yet when new WebX11Terminal() runs; adding them to the registry map would still resolve to undefined. Removed with a comment — the ext bundle registers each one on load. - SimpleGUIModule IS a core class but was missing from _safeRegister's registry map, so the fallback could never restore it. Added. 4. New guard: admin/scripts/verify-plan-age.py + profile wrapper plan-age-guard.sh (cron newos4-plan-age-guard, no_agent, daily 07:10). Silent when healthy; reports plans marked <!-- autonomy: review --> with no execution stamp older than 72 h — the process gap that let four fix plans sit for six days before v4.6.158. Read-only by construction (148 real plans byte+mtime identical before/after a run), rc=2 when the directory cannot be scanned, --strict opt-in so a report is never recorded as a crashed guard. Verified: 37/37 node tests, 3 new test files and 631 new assertions (613 arg-coercion, 18 gunzip); test-cryptogzip-guard's C8 updated because it pinned the old misleading "not valid base64" message. Independent orchestrator checks: all 66 declared string props across 58 tool defs coerced under 4 hostile shapes; positive cases untouched (string content not double-encoded, numbers still numbers, null still absent, unknown tool identity preserved). Served bytes byte-identical for core-bundle.js, main.html and cryptogzip.js. Live Chromium on v4.6.159: write_file with object content wrote 27 chars of readable JSON (not "[object Object]"), read_file with offset:"0"/limit:"200" coerced to numbers, user_set with key 12345 -> "12345" and object value -> JSON, and the boot log now carries 0 SafeRegister warnings (52 messages, 0 JS errors) with SimpleGUIModule registered.
-rw-r--r--main.html6
-rw-r--r--modules/core/core-bundle.js78
-rw-r--r--modules/core/module-aiassist.js60
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/core/terminal.js18
-rw-r--r--modules/ext/cryptogzip.js11
-rw-r--r--modules/test/test-cryptogzip-guard.js10
-rw-r--r--modules/test/test-cryptogzip-gunzip.js130
-rw-r--r--modules/test/test-tool-arg-coercion.js280
9 files changed, 569 insertions, 26 deletions
diff --git a/main.html b/main.html
index 588dc7c..273f954 100644
--- a/main.html
+++ b/main.html
@@ -1350,13 +1350,13 @@
</script>
<!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix -->
-<script src="modules/core/core-bundle.js?v=v4.6.158"></script>
+<script src="modules/core/core-bundle.js?v=v4.6.159"></script>
<!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules -->
-<script src="modules/core/module-loader.js?v=v4.6.158"></script>
+<script src="modules/core/module-loader.js?v=v4.6.159"></script>
<!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work -->
-<script src="modules/ext/critical-ext-bundle.js?v=v4.6.158"></script>
+<script src="modules/ext/critical-ext-bundle.js?v=v4.6.159"></script>
<script>
// ═══ ONE TRUE VERSION SOURCE ═══
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index 69b5e1a..b365fa0 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -4080,14 +4080,15 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
// bump LATEST_VER + main.html ?v=, re-run admin/scripts/probe-ui-modes.py.
// Tracked in .hermes/plans/2026-11-09_*-newos4-ui-rollback.md
// this._safeRegister('UIShellModule', 'UI shell');
- // v4.5.47: Critical ext modules promoted to boot path
- this._safeRegister('EditorModule', 'editor');
- this._safeRegister('ShellModule', 'shell');
- this._safeRegister('AliasModule', 'aliases');
- this._safeRegister('FinderModule', 'finder');
- this._safeRegister('FileManagerModule', 'file manager');
- this._safeRegister('DiffModule', 'diff');
- this._safeRegister('BusyBoxModule', 'busybox');
+ // v4.5.47 used to _safeRegister() seven critical ext classes here (EditorModule,
+ // ShellModule, AliasModule, FinderModule, FileManagerModule, DiffModule,
+ // BusyBoxModule). They are declared in modules/ext/critical-ext-bundle.js, which is
+ // a <script> AFTER module-loader.js — i.e. it does not exist yet when
+ // `new WebX11Terminal()` runs, so every one of those calls was a guaranteed no-op
+ // that only emitted "[SafeRegister] Skipped … is not in the registry map" on each
+ // page load (adding them to the registry map would still resolve to undefined).
+ // critical-ext-bundle.js owns them and registers each one itself on load.
+ // Do NOT re-add them here — that just restores the warnings.
}
_initRegisteredModules() {
for (const [name, mod] of Object.entries(this.moduleManager.modules)) {
@@ -4127,6 +4128,7 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
DiffModule: typeof DiffModule !== 'undefined' ? DiffModule : undefined,
BusyBoxModule: typeof BusyBoxModule !== 'undefined' ? BusyBoxModule : undefined,
SmartContextModule: typeof SmartContextModule !== 'undefined' ? SmartContextModule : undefined,
+ SimpleGUIModule: typeof SimpleGUIModule !== 'undefined' ? SimpleGUIModule : undefined,
DebugSupervisorModule: typeof DebugSupervisorModule !== 'undefined' ? DebugSupervisorModule : undefined,
UIShellModule: typeof UIShellModule !== 'undefined' ? UIShellModule : undefined,
BillingModule: typeof BillingModule !== 'undefined' ? BillingModule : undefined,
@@ -10518,6 +10520,54 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re
_pathArg(v) {
return typeof v === 'string' ? v : (v == null ? '' : String(v));
}
+ // Single boundary coercion for tool arguments. The schemas already say
+ // which args are strings, but models send numbers/arrays/objects anyway
+ // and a handler's `a.field || ''` only catches FALSY values — so
+ // `.trim()`/`.split()`/`.replace()` on a truthy number or object threw a
+ // TypeError and the call was lost (one filed auto-report per tool).
+ // Coerce once here, per declared schema type, instead of patching ~26
+ // handlers. Never throws: an unknown tool, bad defs or a non-object
+ // argument all fall back to the original value.
+ _coerceToolArgs(name, a) {
+ try {
+ if (!a || typeof a !== 'object' || Array.isArray(a)) return a;
+ var defs = this._toolDefs;
+ if (!defs) { defs = this._buildToolDefs(); this._toolDefs = defs; }
+ if (!Array.isArray(defs)) return a;
+ var props = null;
+ for (var i = 0; i < defs.length; i++) {
+ var fn = defs[i] && defs[i].function;
+ if (fn && fn.name === name) {
+ props = (fn.parameters && fn.parameters.properties) || null;
+ break;
+ }
+ }
+ if (!props) return a;
+ for (var p in props) {
+ if (!Object.prototype.hasOwnProperty.call(a, p)) continue;
+ var v = a[p];
+ // Absent/null stays absent: _pathArg and the handlers' own
+ // defaults still own "no value given".
+ if (v === null || v === undefined) continue;
+ var type = props[p] && props[p].type;
+ if (type === 'string') {
+ if (typeof v === 'string') continue;
+ if (typeof v === 'number' || typeof v === 'boolean') {
+ a[p] = String(v);
+ } else if (typeof v === 'object') {
+ // Structured content must reach the file as readable
+ // JSON, never "[object Object]".
+ try { a[p] = JSON.stringify(v); } catch (_e) { a[p] = String(v); }
+ }
+ } else if (type === 'integer' || type === 'number') {
+ // Only convert unambiguous numeric strings; anything else
+ // is left for the handler to validate/report.
+ if (typeof v === 'string' && /^-?\d+(\.\d+)?$/.test(v)) a[p] = Number(v);
+ }
+ }
+ return a;
+ } catch (_e) { return a; }
+ }
_updateDebugPanel() {
if (!this._debugPanelEnabled) return;
const panel = document.getElementById('debug-ai-panel');
@@ -11538,6 +11588,13 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re
this.terminal.print(`🟡 GUARDRAIL WARNING: ${guardrail.description} (rule: ${guardrail.id})`, 'warning');
}
a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {});
+ // A body of "null" / "[1,2]" parses to a non-object, and every
+ // handler below reads a.field — normalise that once here.
+ if (!a || typeof a !== 'object' || Array.isArray(a)) a = {};
+ // ONE schema-driven coercion for the whole switch: a model that
+ // sends a number/array/object for a schema-string field must not
+ // reach `.trim()`/`.split()` still holding that type.
+ a = this._coerceToolArgs(name, a);
switch (name) {
case 'list_files': {
const originalPath = this._pathArg(a.path);
@@ -12448,6 +12505,11 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re
let a = {};
try {
a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {});
+ if (!a || typeof a !== 'object' || Array.isArray(a)) a = {};
+ // Same single boundary as runTool: module tools take a string
+ // `name`/`code`/`file` too and are reachable directly and via
+ // runTool's `case 'view_module':` delegation.
+ a = this._coerceToolArgs(name, a);
switch (name) {
case 'view_module': {
const modName = a.name || '';
diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js
index fb4329f..a9b8fcc 100644
--- a/modules/core/module-aiassist.js
+++ b/modules/core/module-aiassist.js
@@ -1573,6 +1573,54 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re
_pathArg(v) {
return typeof v === 'string' ? v : (v == null ? '' : String(v));
}
+ // Single boundary coercion for tool arguments. The schemas already say
+ // which args are strings, but models send numbers/arrays/objects anyway
+ // and a handler's `a.field || ''` only catches FALSY values — so
+ // `.trim()`/`.split()`/`.replace()` on a truthy number or object threw a
+ // TypeError and the call was lost (one filed auto-report per tool).
+ // Coerce once here, per declared schema type, instead of patching ~26
+ // handlers. Never throws: an unknown tool, bad defs or a non-object
+ // argument all fall back to the original value.
+ _coerceToolArgs(name, a) {
+ try {
+ if (!a || typeof a !== 'object' || Array.isArray(a)) return a;
+ var defs = this._toolDefs;
+ if (!defs) { defs = this._buildToolDefs(); this._toolDefs = defs; }
+ if (!Array.isArray(defs)) return a;
+ var props = null;
+ for (var i = 0; i < defs.length; i++) {
+ var fn = defs[i] && defs[i].function;
+ if (fn && fn.name === name) {
+ props = (fn.parameters && fn.parameters.properties) || null;
+ break;
+ }
+ }
+ if (!props) return a;
+ for (var p in props) {
+ if (!Object.prototype.hasOwnProperty.call(a, p)) continue;
+ var v = a[p];
+ // Absent/null stays absent: _pathArg and the handlers' own
+ // defaults still own "no value given".
+ if (v === null || v === undefined) continue;
+ var type = props[p] && props[p].type;
+ if (type === 'string') {
+ if (typeof v === 'string') continue;
+ if (typeof v === 'number' || typeof v === 'boolean') {
+ a[p] = String(v);
+ } else if (typeof v === 'object') {
+ // Structured content must reach the file as readable
+ // JSON, never "[object Object]".
+ try { a[p] = JSON.stringify(v); } catch (_e) { a[p] = String(v); }
+ }
+ } else if (type === 'integer' || type === 'number') {
+ // Only convert unambiguous numeric strings; anything else
+ // is left for the handler to validate/report.
+ if (typeof v === 'string' && /^-?\d+(\.\d+)?$/.test(v)) a[p] = Number(v);
+ }
+ }
+ return a;
+ } catch (_e) { return a; }
+ }
_updateDebugPanel() {
if (!this._debugPanelEnabled) return;
const panel = document.getElementById('debug-ai-panel');
@@ -2593,6 +2641,13 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re
this.terminal.print(`🟡 GUARDRAIL WARNING: ${guardrail.description} (rule: ${guardrail.id})`, 'warning');
}
a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {});
+ // A body of "null" / "[1,2]" parses to a non-object, and every
+ // handler below reads a.field — normalise that once here.
+ if (!a || typeof a !== 'object' || Array.isArray(a)) a = {};
+ // ONE schema-driven coercion for the whole switch: a model that
+ // sends a number/array/object for a schema-string field must not
+ // reach `.trim()`/`.split()` still holding that type.
+ a = this._coerceToolArgs(name, a);
switch (name) {
case 'list_files': {
const originalPath = this._pathArg(a.path);
@@ -3503,6 +3558,11 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re
let a = {};
try {
a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {});
+ if (!a || typeof a !== 'object' || Array.isArray(a)) a = {};
+ // Same single boundary as runTool: module tools take a string
+ // `name`/`code`/`file` too and are reachable directly and via
+ // runTool's `case 'view_module':` delegation.
+ a = this._coerceToolArgs(name, a);
switch (name) {
case 'view_module': {
const modName = a.name || '';
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index 43c7e06..b926c4a 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.158';
+ const LATEST_VER = 'v4.6.159';
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/core/terminal.js b/modules/core/terminal.js
index 91bea4c..d0f6cff 100644
--- a/modules/core/terminal.js
+++ b/modules/core/terminal.js
@@ -1391,14 +1391,15 @@
// bump LATEST_VER + main.html ?v=, re-run admin/scripts/probe-ui-modes.py.
// Tracked in .hermes/plans/2026-11-09_*-newos4-ui-rollback.md
// this._safeRegister('UIShellModule', 'UI shell');
- // v4.5.47: Critical ext modules promoted to boot path
- this._safeRegister('EditorModule', 'editor');
- this._safeRegister('ShellModule', 'shell');
- this._safeRegister('AliasModule', 'aliases');
- this._safeRegister('FinderModule', 'finder');
- this._safeRegister('FileManagerModule', 'file manager');
- this._safeRegister('DiffModule', 'diff');
- this._safeRegister('BusyBoxModule', 'busybox');
+ // v4.5.47 used to _safeRegister() seven critical ext classes here (EditorModule,
+ // ShellModule, AliasModule, FinderModule, FileManagerModule, DiffModule,
+ // BusyBoxModule). They are declared in modules/ext/critical-ext-bundle.js, which is
+ // a <script> AFTER module-loader.js — i.e. it does not exist yet when
+ // `new WebX11Terminal()` runs, so every one of those calls was a guaranteed no-op
+ // that only emitted "[SafeRegister] Skipped … is not in the registry map" on each
+ // page load (adding them to the registry map would still resolve to undefined).
+ // critical-ext-bundle.js owns them and registers each one itself on load.
+ // Do NOT re-add them here — that just restores the warnings.
}
_initRegisteredModules() {
for (const [name, mod] of Object.entries(this.moduleManager.modules)) {
@@ -1438,6 +1439,7 @@
DiffModule: typeof DiffModule !== 'undefined' ? DiffModule : undefined,
BusyBoxModule: typeof BusyBoxModule !== 'undefined' ? BusyBoxModule : undefined,
SmartContextModule: typeof SmartContextModule !== 'undefined' ? SmartContextModule : undefined,
+ SimpleGUIModule: typeof SimpleGUIModule !== 'undefined' ? SimpleGUIModule : undefined,
DebugSupervisorModule: typeof DebugSupervisorModule !== 'undefined' ? DebugSupervisorModule : undefined,
UIShellModule: typeof UIShellModule !== 'undefined' ? UIShellModule : undefined,
BillingModule: typeof BillingModule !== 'undefined' ? BillingModule : undefined,
diff --git a/modules/ext/cryptogzip.js b/modules/ext/cryptogzip.js
index 25b6ca4..0bd4e71 100644
--- a/modules/ext/cryptogzip.js
+++ b/modules/ext/cryptogzip.js
@@ -137,11 +137,16 @@ class CryptoGzipModule {
}
try {
- const compressed = this._vfsRead(path);
- const content = await this._gunzipString(compressed);
+ // Binary-safe: a .gz written by `wget` lives in the VFS as raw Latin-1 bytes, and a .gz
+ // written by this module's own `gzip` lives there as base64 — _vfsReadBytes handles both.
+ // Going through _gunzipString (base64-only) made a real gzip file report
+ // "not valid base64 input (…)" instead of "not a gzip file".
+ const raw = this._vfsReadBytes(path);
+ if (!this._isGzip(raw)) throw new Error('not a gzip file');
+ const content = new TextDecoder().decode(await this._gunzipBytes(raw));
this._vfsWrite(outPath, content);
this._vfsDelete(path);
- this.terminal.print(`gunzipped: ${path} → ${outPath} (${compressed.length} → ${content.length} chars)`, 'success');
+ this.terminal.print(`gunzipped: ${path} → ${outPath} (${raw.length} → ${content.length} chars)`, 'success');
} catch (e) {
this.terminal.print('gunzip error: ' + e.message, 'error');
}
diff --git a/modules/test/test-cryptogzip-guard.js b/modules/test/test-cryptogzip-guard.js
index 14f50d3..75d4ab8 100644
--- a/modules/test/test-cryptogzip-guard.js
+++ b/modules/test/test-cryptogzip-guard.js
@@ -103,13 +103,17 @@ function mkTerm(files) {
!!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
+ // C8: `gunzip` command path prints its existing "gunzip error:" line instead of crashing.
+ // Since the T3 follow-up (plan 2026-11-03_061900-newos4-cryptogzip-base64-guard.md) gunzip
+ // reads bytes and validates the gzip magic, so non-gzip input is reported as
+ // "not a gzip file" — it used to leak "not valid base64 input (…)" from the string path.
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!',
+ ok('C8 gunzip prints "gunzip error: not a gzip file" and does not throw',
+ !cmdThrew && !!line && line.msg === 'gunzip error: not a gzip file'
+ && 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
diff --git a/modules/test/test-cryptogzip-gunzip.js b/modules/test/test-cryptogzip-gunzip.js
new file mode 100644
index 0000000..3a9411f
--- /dev/null
+++ b/modules/test/test-cryptogzip-gunzip.js
@@ -0,0 +1,130 @@
+// node modules/test/test-cryptogzip-gunzip.js
+// test-cryptogzip-gunzip — plan 2026-11-03_061900-newos4-cryptogzip-base64-guard.md, T3.
+// `_cmdGunzip` must go through the byte path so that:
+// (1) a base64 .gz written by the module's own `gzip` still decompresses,
+// (2) a real gzip file (raw Latin-1 bytes in the VFS, e.g. from `wget`) also decompresses
+// — before T3 this reported "not valid base64 input (…)" and could not be unpacked at all,
+// (3) a plain text file named *.gz reports exactly "not a gzip file" and changes nothing,
+// (4) the "output already exists" confirm guard still refuses and changes nothing.
+const fs = require('fs');
+const path = require('path');
+
+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 : ''));
+}
+
+// VFS stub mirroring the real semantics: the exact string handed to writeFile is what readFile
+// returns (base64 text, or Latin-1 one-char-per-byte for _vfsWriteBytes).
+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, answer: (line) => term.inputCallback(line) };
+}
+
+const SUCCESS = /^gunzipped: (\S+) → (\S+) \((\d+) → (\d+) chars\)$/;
+
+(async () => {
+ // ── T1: module-produced (base64) .gz still decompresses (legacy path) ────────────────
+ const t1 = mkTerm({});
+ t1.store['note.txt.gz'] = await t1.mod._gzipString('gunzip me');
+ await t1.mod.commands['gunzip'](['note.txt.gz']);
+ const s1 = t1.printed.find(p => p.kind === 'success');
+ ok('T1 base64 .gz decompresses', t1.store['note.txt'] === 'gunzip me',
+ JSON.stringify(Object.keys(t1.store)));
+ ok('T1 .gz removed after success', !('note.txt.gz' in t1.store),
+ JSON.stringify(Object.keys(t1.store)));
+ ok('T1 success line keeps its format', !!s1 && SUCCESS.test(s1.msg), s1 ? s1.msg : 'no success line');
+
+ // ── T2: real gzip bytes in the VFS (what `wget` stores) decompress ───────────────────
+ const t2 = mkTerm({});
+ const payload = 'binary payload — ümlauts ✓\nline two\n';
+ const gzBytes = await t2.mod._gzipBytes(new TextEncoder().encode(payload));
+ t2.mod._vfsWriteBytes('dl.txt.gz', gzBytes); // raw Latin-1, like wget
+ const asRead = t2.mod._vfsReadBytes('dl.txt.gz');
+ ok('T2 fixture is real gzip magic and read back byte-identical',
+ t2.mod._isGzip(asRead) && Buffer.from(asRead).equals(Buffer.from(gzBytes)),
+ asRead.length + ' bytes, magic ' + asRead[0].toString(16) + ' ' + asRead[1].toString(16));
+ await t2.mod.commands['gunzip'](['dl.txt.gz']);
+ ok('T2 raw gzip bytes decompress', t2.store['dl.txt'] === payload,
+ JSON.stringify(t2.store['dl.txt']));
+ ok('T2 no "not valid base64" on a real gzip file',
+ !t2.printed.some(p => /base64/.test(p.msg)), JSON.stringify(t2.printed));
+
+ // ── T3: plain text named .gz -> exactly "not a gzip file", nothing touched ───────────
+ const t3 = mkTerm({ 'fake.gz': 'this is not compressed at all' });
+ await t3.mod.commands['gunzip'](['fake.gz']);
+ const err3 = t3.printed.find(p => /^gunzip error: /.test(p.msg));
+ ok('T3 reports "not a gzip file"', !!err3 && err3.msg === 'gunzip error: not a gzip file',
+ err3 ? err3.msg : 'no gunzip error line');
+ ok('T3 error is not a base64 message', !!err3 && !/base64/.test(err3.msg),
+ err3 ? err3.msg : 'no gunzip error line');
+ ok('T3 input file untouched', t3.store['fake.gz'] === 'this is not compressed at all',
+ JSON.stringify(t3.store));
+ ok('T3 no output file created', !('fake' in t3.store), JSON.stringify(Object.keys(t3.store)));
+ ok('T3 nothing deleted (exactly one key left)', Object.keys(t3.store).length === 1,
+ JSON.stringify(Object.keys(t3.store)));
+ ok('T3 no success line', !t3.printed.some(p => p.kind === 'success'), JSON.stringify(t3.printed));
+
+ // ── T4: output-exists guard still refuses (answer "no") and changes nothing ──────────
+ const t4 = mkTerm({});
+ t4.store['dup.txt.gz'] = await t4.mod._gzipString('compressed contents');
+ t4.store['dup.txt'] = 'ORIGINAL — must survive';
+ const p4 = t4.mod.commands['gunzip'](['dup.txt.gz']);
+ t4.answer('no');
+ await p4;
+ ok('T4 refuses to overwrite without confirmation', t4.store['dup.txt'] === 'ORIGINAL — must survive',
+ JSON.stringify(t4.store['dup.txt']));
+ ok('T4 leaves the .gz in place', t4.store['dup.txt.gz'] !== undefined,
+ JSON.stringify(Object.keys(t4.store)));
+ ok('T4 prints Cancelled.', t4.printed.some(p => /^Cancelled\.$/.test(p.msg)),
+ JSON.stringify(t4.printed));
+
+ // ── T5: same guard, answer "yes" -> overwrite proceeds ───────────────────────────────
+ const t5 = mkTerm({});
+ t5.store['dup2.txt.gz'] = await t5.mod._gzipString('new contents');
+ t5.store['dup2.txt'] = 'stale';
+ const p5 = t5.mod.commands['gunzip'](['dup2.txt.gz']);
+ t5.answer('yes');
+ await p5;
+ ok('T5 overwrite proceeds on "yes"', t5.store['dup2.txt'] === 'new contents'
+ && !('dup2.txt.gz' in t5.store), JSON.stringify(t5.store));
+
+ // ── T6: non-.gz path is still refused with the Usage line ────────────────────────────
+ const t6 = mkTerm({ 'plain.txt.gz': 'x', 'other.txt': 'y' });
+ await t6.mod.commands['gunzip'](['other.txt']);
+ ok('T6 refuses a non-.gz path', t6.printed.some(p => /^Usage: gunzip /.test(p.msg))
+ && t6.store['other.txt'] === 'y', JSON.stringify(t6.printed));
+
+ // ── T7: missing file is still a "File not found" error (no throw) ────────────────────
+ const t7 = mkTerm({});
+ let threw7 = null;
+ try { await t7.mod.commands['gunzip'](['nope.gz']); } catch (e) { threw7 = e; }
+ ok('T7 missing file reports File not found', !threw7
+ && t7.printed.some(p => /^File not found: nope\.gz$/.test(p.msg)),
+ threw7 ? 'threw ' + threw7.message : JSON.stringify(t7.printed));
+
+ console.log('test-cryptogzip-gunzip: ' + passes + ' passed, ' + fails + ' failed');
+ process.exit(fails ? 1 : 0);
+})().catch(e => { console.error('FATAL ' + (e && e.stack || e)); process.exit(1); });
diff --git a/modules/test/test-tool-arg-coercion.js b/modules/test/test-tool-arg-coercion.js
new file mode 100644
index 0000000..42b4d80
--- /dev/null
+++ b/modules/test/test-tool-arg-coercion.js
@@ -0,0 +1,280 @@
+// node modules/test/test-tool-arg-coercion.js
+// Regression for the whole "tool argument is not a string" crash class in
+// module-aiassist.js. The tool schemas declare string/integer types, but a model
+// can send a number, array or object anyway; handlers do `const x = a.field || ''`
+// (which only catches FALSY values) and then call .trim()/.split()/.replace()/
+// .match()/.startsWith() — every such call became a TypeError and a filed
+// auto-report (user_set.key, create_skill.name/content, delegate_task.task,
+// create_mindmap.children, web_fetch.method, patch_skill.content, ...).
+//
+// The fix is ONE schema-driven coercion at the dispatch boundary:
+// a = this._coerceToolArgs(name, a);
+// These tests check the coercer itself against every tool definition, then
+// drive the formerly-unsafe tools through the real runTool with hostile args.
+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 || {};
+// Offline fetch: every request fails cleanly so tools that would hit the network
+// answer with their own error JSON instead of a TypeError.
+let fetchCalls = [];
+global.fetch = async (u) => {
+ fetchCalls.push(String(u));
+ return { ok: false, status: 503, json: async () => ({}), text: async () => '' };
+};
+
+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;')();
+
+let fails = 0, passes = 0;
+function ok(label, cond, detail) {
+ if (cond) { passes++; return; }
+ fails++;
+ console.log('FAIL ' + label + (detail ? ' — ' + detail : ''));
+}
+const show = v => { try { return JSON.stringify(v); } catch (e) { return String(v); } };
+
+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: {}, moduleRegistry: [],
+ loadModuleFromCode: () => true, unloadModule: () => {}, unregisterModule: () => {}
+ },
+ security: { _aiToolDepth: 0, checkAndRun: async () => {} },
+ _pushPrintCapture: () => {}, _popPrintCapture: () => {}
+ };
+ ai.checkGuardrail = () => null;
+ ai._errorLog = [];
+ ai._toolCallLog = [];
+ ai._toolCache = new Map();
+ ai._toolEpoch = 0;
+ ai._currentTag = 'main';
+ ai._userProfile = {};
+ ai._loopProfiles = {};
+ ai._toolLimit = () => 8000;
+ fetchCalls = [];
+ return { ai, vfs, out };
+}
+
+// ── Layer A: unit — the coercer against EVERY declared property ───────────────
+{
+ const { ai } = mk();
+ const defs = ai.getToolDefinitions();
+ ok('defs: getToolDefinitions() populated the cache', Array.isArray(ai._toolDefs) && ai._toolDefs.length === defs.length,
+ 'cache=' + (ai._toolDefs ? ai._toolDefs.length : 'none') + ' defs=' + defs.length);
+ ok('defs: 50+ tools defined', defs.length >= 50, 'got ' + defs.length);
+
+ const hostile = [42, { a: 1 }, ['x']];
+ const coveredTools = new Set();
+ let stringProps = 0, numericProps = 0;
+
+ for (const def of defs) {
+ const fn = def.function, props = (fn.parameters && fn.parameters.properties) || {};
+ for (const prop of Object.keys(props)) {
+ const type = props[prop].type;
+ const label = fn.name + '.' + prop;
+ if (type === 'string') {
+ stringProps++;
+ coveredTools.add(fn.name);
+ for (const v of hostile) {
+ const input = {}; input[prop] = v;
+ let out;
+ try { out = ai._coerceToolArgs(fn.name, input); }
+ catch (e) { ok(label + ' ' + show(v) + ': did not throw', false, e.message); continue; }
+ ok(label + ' ' + show(v) + ': coerced to a string', typeof out[prop] === 'string',
+ 'got ' + typeof out[prop] + ' ' + show(out[prop]));
+ }
+ // structured values must survive as readable JSON, not "[object Object]"
+ const objIn = {}; objIn[prop] = { a: 1 };
+ const objOut = ai._coerceToolArgs(fn.name, objIn);
+ ok(label + ' object: kept as JSON text',
+ objOut[prop] === '{"a":1}' && objOut[prop].indexOf('[object Object]') === -1, show(objOut[prop]));
+ // a real string is untouched
+ const sIn = {}; sIn[prop] = ' keep me ';
+ ok(label + ' string: untouched', ai._coerceToolArgs(fn.name, sIn)[prop] === ' keep me ');
+ // falsy-but-string stays a string; optional args stay absent/null
+ const zeroIn = {}; zeroIn[prop] = 0;
+ ok(label + ' 0: becomes "0"', ai._coerceToolArgs(fn.name, zeroIn)[prop] === '0');
+ const nullIn = {}; nullIn[prop] = null;
+ const nullOut = ai._coerceToolArgs(fn.name, nullIn);
+ ok(label + ' null: left for _pathArg/handler defaults', nullOut[prop] === null, show(nullOut[prop]));
+ const undefIn = {}; undefIn[prop] = undefined;
+ const undefOut = ai._coerceToolArgs(fn.name, undefIn);
+ ok(label + ' undefined: left absent', prop in undefOut && undefOut[prop] === undefined, show(undefOut));
+ } else if (type === 'integer' || type === 'number') {
+ numericProps++;
+ const numIn = {}; numIn[prop] = '42';
+ const numOut = ai._coerceToolArgs(fn.name, numIn)[prop];
+ ok(label + " '42': becomes the number 42", numOut === 42 && typeof numOut === 'number', typeof numOut + ' ' + show(numOut));
+ const badIn = {}; badIn[prop] = 'abc';
+ ok(label + " 'abc': left for the handler", ai._coerceToolArgs(fn.name, badIn)[prop] === 'abc');
+ const objIn2 = {}; objIn2[prop] = { a: 1 };
+ const objOut2 = ai._coerceToolArgs(fn.name, objIn2)[prop];
+ ok(label + ' object: unchanged', typeof objOut2 === 'object' && objOut2.a === 1, show(objOut2));
+ const realIn = {}; realIn[prop] = 7;
+ ok(label + ' 7: unchanged', ai._coerceToolArgs(fn.name, realIn)[prop] === 7);
+ }
+ }
+ }
+ ok('unit: every string prop of every tool was exercised', stringProps >= 30 && coveredTools.size >= 25,
+ 'stringProps=' + stringProps + ' tools=' + coveredTools.size);
+ ok('unit: numeric props exercised', numericProps >= 4, 'got ' + numericProps);
+
+ // unknown tool / non-object args / broken defs: pass through, never throw
+ const passthrough = { x: 1, y: { a: 1 }, z: 'ok' };
+ ok('unknown tool: returns the same object', ai._coerceToolArgs('no_such_tool_xyz', passthrough) === passthrough);
+ ok('unknown tool: values untouched', passthrough.y.a === 1);
+ for (const weird of [null, undefined, 'str', 42, []]) {
+ let out, threw = null;
+ try { out = ai._coerceToolArgs('user_get', weird); } catch (e) { threw = e; }
+ ok('non-object args ' + show(weird) + ': no throw', threw === null, threw && threw.message);
+ }
+ const broken = Object.create(AI.prototype);
+ broken._buildToolDefs = () => { throw new Error('boom'); };
+ const target = { key: { a: 1 } };
+ let brokenOut, brokenThrew = null;
+ try { brokenOut = broken._coerceToolArgs('user_get', target); } catch (e) { brokenThrew = e; }
+ ok('broken defs: no throw and original returned', brokenThrew === null && brokenOut === target, brokenThrew && brokenThrew.message);
+
+ console.log('unit: ' + defs.length + ' tool defs, ' + stringProps + ' string props in ' +
+ coveredTools.size + ' tools, ' + numericProps + ' numeric props');
+ console.log('unit: string-prop tools — ' + Array.from(coveredTools).sort().join(', '));
+}
+
+// ── Layer B: end-to-end through the real runTool ──────────────────────────────
+async function call(ai, name, args, label) {
+ let res = null, threw = null;
+ try { res = await ai.runTool(name, args); } catch (e) { threw = e; }
+ ok((label || name) + ': runTool did not throw', threw === null, threw && threw.message);
+ ok((label || name) + ': returned a string', typeof res === 'string', typeof res);
+ ok((label || name) + ': no string-method TypeError leaked',
+ typeof res === 'string' && !/is not a function/.test(res), String(res).slice(0, 240));
+ let parsed = null;
+ try { parsed = JSON.parse(res); } catch (e) {}
+ return { res, parsed };
+}
+
+(async () => {
+ // 1. key/value tools — user_get/user_set (:2911-2934 area). a.key was .split()
+ {
+ const { ai } = mk();
+ const g = await call(ai, 'user_get', { key: { a: 1 } });
+ ok('user_get: object key reached the handler as JSON', !!g.parsed && g.parsed.key === '{"a":1}', g.res);
+ const s = await call(ai, 'user_set', { key: 42, value: { theme: 'dark' } });
+ ok('user_set: numeric key coerced', !!s.parsed && s.parsed.success === true && s.parsed.key === '42', s.res);
+ ok('user_set: object value stored as JSON', ai._userProfile['42'] === '{"theme":"dark"}', show(ai._userProfile));
+ }
+
+ // 2. skill creator — create_skill (:3164-3168): name.trim()/toLowerCase(),
+ // content.startsWith(), tags.split()
+ {
+ const { ai, vfs } = mk();
+ const r = await call(ai, 'create_skill', { name: { a: 1 }, description: 7, content: ['line1', 'line2'], tags: 42 });
+ ok('create_skill: success with hostile args', !!r.parsed && r.parsed.success === true, r.res);
+ ok('create_skill: name is the JSON text, slugified', !!r.parsed && r.parsed.name === '{"a":1}', r.res);
+ const written = vfs.readFile('/skills/{"a":1}.md') || '';
+ ok('create_skill: array content written as readable JSON',
+ written.indexOf('line1') !== -1 && written.indexOf('[object Object]') === -1, written.slice(0, 200));
+ ok('create_skill: numeric tags coerced to a string list', /tags: \[42\]/.test(written), written.slice(0, 200));
+ }
+
+ // 3. patch_skill (:3185-3192): pcontent.match() / .split()
+ {
+ const { ai, vfs } = mk();
+ vfs.mkdirRecursive('/skills');
+ vfs.fsWriteJSON('/skills/index.json', { demo: { name: 'demo', description: '', tags: [], category: 'general' } });
+ const r = await call(ai, 'patch_skill', { name: 'demo', content: { steps: ['a', 'b'] } });
+ ok('patch_skill: success with object content', !!r.parsed && r.parsed.success === true, r.res);
+ ok('patch_skill: object content written as JSON',
+ (vfs.readFile('/skills/demo.md') || '') === '{"steps":["a","b"]}', vfs.readFile('/skills/demo.md'));
+ }
+
+ // 4. delegation — delegate_task (:3064): task.substring()
+ {
+ const { ai } = mk();
+ ai.terminal.moduleManager.modules['OrchestratorModule'] = {
+ delegateTask: async t => 'worker saw: ' + t
+ };
+ const r = await call(ai, 'delegate_task', { task: { goal: 'ship it' } });
+ ok('delegate_task: object task reached the worker as JSON',
+ !!r.parsed && r.parsed.success === true && /worker saw: \{"goal":"ship it"\}/.test(r.parsed.result || ''), r.res);
+ }
+
+ // 5. mindmap — create_mindmap (:3305-3312): topic.split(), children.split()
+ {
+ const { ai } = mk();
+ const added = [];
+ ai.terminal.moduleManager.modules['MindmapModule'] = {
+ _new: () => {}, _ensureMindmap: () => ({ root: { id: 'root-1' } }),
+ _add: a => added.push(a), _svg: () => {}, _countNodes: () => 2
+ };
+ const r = await call(ai, 'create_mindmap', { topic: 42, children: { a: 1 } });
+ ok('create_mindmap: success with hostile args', !!r.parsed && r.parsed.success === true, r.res);
+ ok('create_mindmap: topic coerced to "42"', !!r.parsed && r.parsed.topic === '42', r.res);
+ ok('create_mindmap: one child parsed from the JSON text', added.length === 1 && added[0][1] === '{"a":1}', show(added));
+ }
+
+ // 6. web_fetch (:2785-2788): (a.method || 'GET').toUpperCase() + body
+ {
+ const { ai } = mk();
+ const r = await call(ai, 'web_fetch', { url: 'https://example.invalid/x', method: { verb: 'POST' }, body: ['x'], max_chars: '500' });
+ ok('web_fetch: reached its own error path (offline stub), not a TypeError',
+ !!r.parsed && typeof r.parsed.error === 'string', r.res);
+ ok('web_fetch: coercion let the request be made (method was a string by then)',
+ fetchCalls.length === 1, show(fetchCalls));
+ }
+
+ // 7. filter tool — list_skills (:3208): (a.category || '').toLowerCase()
+ {
+ const { ai, vfs } = mk();
+ vfs.mkdirRecursive('/skills');
+ vfs.fsWriteJSON('/skills/index.json', { demo: { name: 'demo', description: '', tags: [], category: 'general' } });
+ const r = await call(ai, 'list_skills', { category: { cat: 'general' } });
+ ok('list_skills: no matching category, still a clean list', !!r.parsed && r.parsed.count === 0, r.res);
+ }
+
+ // 8. module tools live behind a SECOND parse+switch (runModuleTool) that
+ // runTool delegates to — and ship_module calls newCode.split() at step 5.
+ // Stub only the pipeline's inner stages so the object `code` reaches it.
+ {
+ const { ai, vfs } = mk();
+ vfs.mkdirRecursive('_mods');
+ vfs.writeFile('_mods/Demo.js', '// old\nconst a = 1;\n');
+ const orig = ai.runModuleTool.bind(ai);
+ ai.runModuleTool = async (n, argsStr) => {
+ if (n === 'ship_module') return orig(n, argsStr);
+ if (n === 'test_module') return JSON.stringify({ success: true, module: 'Demo', commands: 1 });
+ if (n === 'health_check') return JSON.stringify({ healthy: true });
+ return JSON.stringify({ success: true });
+ };
+ ai._runRegressionSuite = async () => ({ allPassed: true });
+ ai._calculateConfidence = () => 80;
+ const r = await call(ai, 'ship_module', { name: 'Demo', code: { src: 'x' }, tests: [] }, 'ship_module');
+ ok('ship_module: object code survived to step 5 (.split)',
+ !!r.parsed && r.parsed.success === true && /dry-run/.test(r.parsed.pipeline || ''), r.res);
+ ok('ship_module: object code written as JSON, not [object Object]',
+ (vfs.readFile('_mods/Demo.js') || '') === '{"src":"x"}', vfs.readFile('_mods/Demo.js'));
+ }
+
+ console.log('test-tool-arg-coercion: ' + passes + ' passed, ' + fails + ' failed');
+ process.exit(fails ? 1 : 0);
+})();