diff options
| author | Hermes SysOp <sysop@webx11.duckdns.org> | 2026-11-12 16:24:15 +0100 |
|---|---|---|
| committer | Hermes SysOp <sysop@webx11.duckdns.org> | 2026-11-12 16:24:15 +0100 |
| commit | c08a795ae822e905639caf563e36cf7953d530f1 (patch) | |
| tree | bf4db2041002f4de721f95526c363ff186f30fe0 /modules/test | |
| parent | 8d0fe407b80bb822e8ef2852977afa3afbdc554a (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.
Diffstat (limited to 'modules/test')
| -rw-r--r-- | modules/test/test-cryptogzip-guard.js | 10 | ||||
| -rw-r--r-- | modules/test/test-cryptogzip-gunzip.js | 130 | ||||
| -rw-r--r-- | modules/test/test-tool-arg-coercion.js | 280 |
3 files changed, 417 insertions, 3 deletions
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); +})(); |