diff options
| author | Agando <roduccomm@gmail.com> | 2026-10-29 12:29:14 +0100 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-10-29 12:29:14 +0100 |
| commit | db65ce7628b0073c41ff1c13334723a043b24479 (patch) | |
| tree | 1fd5bc4711d7f385937a22b7ae5e58513e06c3f0 | |
| parent | 43d067396b5a620441b9a299c45e7b0b129ccd15 (diff) | |
fix(newos4): js keeps its string literals — dispatcher forwards the raw command line (v4.6.130)
_parseArgs() treats quote characters as delimiters and drops them, so every string literal in a
js expression died before eval: `js "abc".toUpperCase()` reached the module as
`abc.toUpperCase()` → ReferenceError: abc is not defined. Same for `js import('fs')` →
`import(fs)`. Found while verifying the v4.6.128 console fix
(plan 2026-10-29_112000-newos4-parseargs-strips-js-string-literals.md).
- terminal.js: checkAndRun() records the untokenised line (`_currentRaw`), _dispatch() forwards it,
dispatchCommand(cmd, args, rawText) passes it to the module. Security classification still runs
on the tokenised form, and pipes/AI/script-runner calls pass nothing, so they behave as before.
- console.js: `js` evaluates _expr(args, rawText) — the raw remainder. `node -e` deliberately does
NOT: the tokenizer already keeps `'…'` inside `"…"`, and the raw form would hand eval the string
literal `"import('fs')"` instead of the import (caught by the live probe, not by review).
js-run keeps tokenised paths.
- modules/test/test-parse-args.js: tokenizer table (records the deletion as the current contract),
dispatcher plumbing asserts, _expr cases, and the user-visible paths. 27 assertions.
- module-loader.js + main.html + bundle: v4.6.130 (129 was minted, then console.js changed again —
re-minting so no cached client keeps the intermediate).
Verification
node modules/test/test-parse-args.js -> 27/27 pass
same file against HEAD sources (git show HEAD:...) -> TypeError: _expr is not a function (RED)
scripts/lint-no-undef.py terminal.js console.js -> clean, 122 cross-file globals
core-bundle.js rebuilt from HEAD sources + this patch in /tmp/iso (the working tree carries
another session's uncommitted ollama-manager.js `_httpError`); diff vs HEAD bundle = 14 lines,
all mine. critical-ext-bundle.js byte-identical.
Live page v4.6.130 (real executeCommand, output captured):
js "abc".toUpperCase() -> ABC
js 'a b'.split(' ').length -> 2
js JSON.stringify({a:1}) -> {"a":1}
js 6*7 -> 42
js import('fs') -> red "Failed to resolve module specifier 'fs'" + Node-builtin hint
node -e "import('fs')" -> same error + hint (was printing the expression as a string)
echo "two words" -> two words
js-run /nope.js -> js-run: /nope.js: No such file
unhandledrejection count -> 0
The sibling's parked terminal.js hunk (BillingModule registry entry) was stashed for this commit and
is restored in the working tree afterwards; it is not part of this change.
| -rw-r--r-- | main.html | 6 | ||||
| -rw-r--r-- | modules/core/core-bundle.js | 14 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 2 | ||||
| -rw-r--r-- | modules/core/terminal.js | 14 | ||||
| -rw-r--r-- | modules/ext/console.js | 26 | ||||
| -rw-r--r-- | modules/test/test-parse-args.js | 116 |
6 files changed, 163 insertions, 15 deletions
@@ -1349,13 +1349,13 @@ </script> <!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix --> -<script src="modules/core/core-bundle.js?v=v4.6.128"></script> +<script src="modules/core/core-bundle.js?v=v4.6.130"></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.128"></script> +<script src="modules/core/module-loader.js?v=v4.6.130"></script> <!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work --> -<script src="modules/ext/critical-ext-bundle.js?v=v4.6.128"></script> +<script src="modules/ext/critical-ext-bundle.js?v=v4.6.130"></script> <script> // ═══ ONE TRUE VERSION SOURCE ═══ diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js index fcf1505..9682596 100644 --- a/modules/core/core-bundle.js +++ b/modules/core/core-bundle.js @@ -2788,6 +2788,11 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm return null; } async checkAndRun(rawInput) { + // v4.6.130: keep the untokenised line for the command itself. _parseArgs turns + // quote characters into delimiters and drops them, so `js "abc".toUpperCase()` + // reaches the module as `abc.toUpperCase()` and every string literal dies. + // Security classification still runs on the tokenised form below. + this._currentRaw = rawInput; const badOp = this.detectComposition(rawInput); if (badOp) { this.terminal.print('⚠️ No shell here — `' + badOp + '` is not interpreted. ' + @@ -2870,7 +2875,7 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm return this._dispatch(cmd, args); } async _dispatch(cmd, args) { - return this.terminal.dispatchCommand(cmd, args); + return this.terminal.dispatchCommand(cmd, args, this._currentRaw || null); } async _tryAIRoute(rawInput) { // AI routing cooldown guard @@ -4215,7 +4220,10 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm } return trimmed; } - async dispatchCommand(cmd, args) { + // rawText (v4.6.130): the untokenised command line, when the call came from the + // terminal. Commands that need their delimiters (`js`, `node -e`) read it; every + // other command keeps using `args` and ignores the extra parameter. + async dispatchCommand(cmd, args, rawText) { if (cmd === 'clear') { this.clearOutput(); return; } if (cmd === 'expand') { this.toggleExpand(); return; } if (cmd === 'sysmsg') { this.toggleSystemMessages(args[0]); return; } @@ -4522,7 +4530,7 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm if (typeof _m.onCommandStart === 'function') { try { _m.onCommandStart(cmd, args); } catch (_e) {} } } } - try { await mod.commands[cmd](args); + try { await mod.commands[cmd](args, rawText); if (cmd !== 'tutorial' && cmd !== 'course') { for (const _m of Object.values(this.moduleManager.modules)) { if (typeof _m.onCommandExecuted === 'function') { try { _m.onCommandExecuted(cmd, args); } catch (_e) {} } diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index 1d79b3a..ad6d0b7 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.128'; + const LATEST_VER = 'v4.6.130'; 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 76406b5..16e2c7e 100644 --- a/modules/core/terminal.js +++ b/modules/core/terminal.js @@ -196,6 +196,11 @@ return null; } async checkAndRun(rawInput) { + // v4.6.130: keep the untokenised line for the command itself. _parseArgs turns + // quote characters into delimiters and drops them, so `js "abc".toUpperCase()` + // reaches the module as `abc.toUpperCase()` and every string literal dies. + // Security classification still runs on the tokenised form below. + this._currentRaw = rawInput; const badOp = this.detectComposition(rawInput); if (badOp) { this.terminal.print('⚠️ No shell here — `' + badOp + '` is not interpreted. ' + @@ -278,7 +283,7 @@ return this._dispatch(cmd, args); } async _dispatch(cmd, args) { - return this.terminal.dispatchCommand(cmd, args); + return this.terminal.dispatchCommand(cmd, args, this._currentRaw || null); } async _tryAIRoute(rawInput) { // AI routing cooldown guard @@ -1623,7 +1628,10 @@ } return trimmed; } - async dispatchCommand(cmd, args) { + // rawText (v4.6.130): the untokenised command line, when the call came from the + // terminal. Commands that need their delimiters (`js`, `node -e`) read it; every + // other command keeps using `args` and ignores the extra parameter. + async dispatchCommand(cmd, args, rawText) { if (cmd === 'clear') { this.clearOutput(); return; } if (cmd === 'expand') { this.toggleExpand(); return; } if (cmd === 'sysmsg') { this.toggleSystemMessages(args[0]); return; } @@ -1930,7 +1938,7 @@ if (typeof _m.onCommandStart === 'function') { try { _m.onCommandStart(cmd, args); } catch (_e) {} } } } - try { await mod.commands[cmd](args); + try { await mod.commands[cmd](args, rawText); if (cmd !== 'tutorial' && cmd !== 'course') { for (const _m of Object.values(this.moduleManager.modules)) { if (typeof _m.onCommandExecuted === 'function') { try { _m.onCommandExecuted(cmd, args); } catch (_e) {} } diff --git a/modules/ext/console.js b/modules/ext/console.js index 60f75a7..f2731f1 100644 --- a/modules/ext/console.js +++ b/modules/ext/console.js @@ -14,12 +14,17 @@ class JSConsoleModule { } registerCommands() { - this.commands['js'] = (args) => this.evalJS(args); + // rawText is the untokenised line from the dispatcher (v4.6.130): `js "abc".toUpperCase()` + // must reach eval with its quotes, and _parseArgs deletes them. + this.commands['js'] = (args, rawText) => this.evalJS(args, rawText); this.commands['js-run'] = (args) => this.evalJSFile(args); this.commands['node'] = (args) => { // Bridge: AI hallucinates `node script.js` — map to js-run if (!args[0]) return this.terminal.print('Usage: node <script.js> (alias for js-run)', 'warning'); - // Handle `node -e "expr"` — inline eval like Node.js + // Handle `node -e "expr"` — inline eval like Node.js. No rawText here on purpose: + // _parseArgs keeps `'…'` inside `"…"` verbatim, so `args` already carries the + // expression, whereas the raw form would hand eval `"import('fs')"` (a string + // literal) instead of `import('fs')` (an import). Measured v4.6.130. if (args[0] === '-e') { return this.evalJS(args.slice(1)); } @@ -27,9 +32,19 @@ class JSConsoleModule { }; } - evalJS(args) { + // The expression to evaluate: the raw remainder when the terminal provided one, + // otherwise the tokenised arguments (AI tool calls, script runner, direct calls). + _expr(args, rawText) { + if (typeof rawText === 'string') { + const rest = rawText.trim().replace(/^\S+\s*/, ''); // drop the command word + if (rest) return rest; + } + return (args || []).join(' '); + } + + evalJS(args, rawText) { if (!args[0]) return this.terminal.print('Usage: js <expression>', 'warning'); - const expr = args.join(' '); + const expr = this._expr(args, rawText); // v4.6.128: Sandboxed eval — restrict access to dangerous globals, still allows math/JSON/Array etc. try { const result = (function() { @@ -68,7 +83,8 @@ class JSConsoleModule { _printError(e) { const msg = (e && e.message) ? e.message : String(e); this.terminal.print(`Error: ${msg}`, 'error'); - const m = /(?:Failed to resolve module specifier|Cannot find module) '([^']+)'/.exec(msg); + const m = /(?:Failed to resolve module specifier|Cannot find module) '([^']+)'/.exec(msg) + || /No such built-in module: ?([\w@/.:-]+)/.exec(msg); if (m) { this.terminal.print(`'${m[1]}' is a Node built-in; the browser has no Node — ` + `use the VFS (ls/cat/run, terminal.fs) instead`, 'warning'); diff --git a/modules/test/test-parse-args.js b/modules/test/test-parse-args.js new file mode 100644 index 0000000..75fd7ab --- /dev/null +++ b/modules/test/test-parse-args.js @@ -0,0 +1,116 @@ +// node modules/test/test-parse-args.js +// +// Freezes the tokenizer contract AND proves the raw-passthrough that lets `js` see string +// literals again (v4.6.130). Two layers: +// 1. _parseArgs — table of input -> tokens. It deletes quote characters by design; the +// table records that as the CURRENT contract, not as desired behaviour. +// 2. evalJS/_expr — the console command gets the untokenised remainder from the dispatcher +// (dispatchCommand(cmd, args, rawText)), so `js "abc".toUpperCase()` works +// while `echo "hi"` still prints `hi`. +const fs = require('fs'); +const path = require('path'); + +const coreDir = path.join(__dirname, '../core'); +const extDir = path.join(__dirname, '../ext'); +const termSrc = fs.readFileSync(path.join(coreDir, 'terminal.js'), 'utf8'); + +let fails = 0, passes = 0; +const eq = (name, got, want) => { + const g = JSON.stringify(got), w = JSON.stringify(want); + if (g === w) { passes++; return; } + fails++; + console.log(`FAIL ${name}\n got ${g}\n want ${w}`); +}; +const ok = (name, cond, detail) => { + if (cond) { passes++; return; } + fails++; + console.log(`FAIL ${name}${detail ? '\n ' + detail : ''}`); +}; + +// ---- 1. tokenizer table --------------------------------------------------------------- +const m = termSrc.match(/_parseArgs\((\w+)\)\s*\{[\s\S]*?\n\s{12}\}/); +if (!m) { console.log('FAIL _parseArgs not found — signature or indent changed'); process.exit(1); } +const parseArgs = new Function('return ({' + m[0] + '})._parseArgs;')(); + +const table = [ + ['', []], + [' ', []], + ['js', ['js']], + ['js 1+1', ['js', '1+1']], + ['js "a b"', ['js', 'a b']], // spaces survive, quotes do not + ['js "abc".toUpperCase()', ['js', 'abc.toUpperCase()']], // the defect, recorded + ["js import('fs')", ['js', 'import(fs)']], // ditto + ['node -e "import(\'fs\')"', ['node', '-e', "import('fs')"]], // inner quotes survive inDouble + ["'unbalanced", ['unbalanced']], + ['echo "x" > f', ['echo', 'x', '>', 'f']], +]; +for (const [input, want] of table) eq(`_parseArgs(${JSON.stringify(input)})`, parseArgs(input), want); + +// ---- 2. dispatcher plumbing ------------------------------------------------------------ +ok('dispatchCommand takes rawText', /async dispatchCommand\(cmd, args, rawText\)/.test(termSrc)); +ok('module receives rawText', /mod\.commands\[cmd\]\(args, rawText\)/.test(termSrc)); +ok('checkAndRun records raw input', /this\._currentRaw\s*=\s*rawInput/.test(termSrc)); +ok('_dispatch forwards raw input', /dispatchCommand\(cmd, args, this\._currentRaw/.test(termSrc)); + +// ---- 3. console command uses the raw expression --------------------------------------- +const consoleSrc = fs.readFileSync(path.join(extDir, 'console.js'), 'utf8'); +const JSConsoleModule = new Function(consoleSrc + '\n;return JSConsoleModule;')(); + +function mk() { + const out = []; + const inst = new JSConsoleModule(); + inst.terminal = { print: (msg, lvl) => out.push({ msg: String(msg), lvl: lvl || 'plain' }) }; + inst.registerCommands(); + return { inst, out }; +} + +const { inst: probe } = mk(); +eq('_expr from raw (js)', probe._expr(['abc.toUpperCase()'], 'js "abc".toUpperCase()'), '"abc".toUpperCase()'); +eq('_expr falls back to args', probe._expr(['1+1'], undefined), '1+1'); +eq('_expr empty stays empty', probe._expr([], 'js'), ''); + +// node -e deliberately keeps the tokenised args: the raw form would turn `import('fs')` +// into the string literal `"import('fs')"`. The tokenizer already keeps `'…'` inside `"…"`. + +// the behaviour the user reported: a string literal must survive the dispatcher +(async () => { + const tick = () => new Promise(r => setTimeout(r, 60)); + + const n1 = mk(); + n1.inst.commands['node'](['-e', "import('fs')"]); + await tick(); + ok('node -e passes the import, not a string literal', + n1.out.length >= 1 && !/^import\('fs'\)$/.test(n1.out[0].msg), JSON.stringify(n1.out)); + + const t1 = mk(); + t1.inst.evalJS(['abc.toUpperCase()'], 'js "abc".toUpperCase()'); + eq('js "abc".toUpperCase() -> ABC', t1.out, [{ msg: 'ABC', lvl: 'success' }]); + + // A failed dynamic import: Node rejects, the browser rejects with exactly this shape. + // Node cannot resolve a bare `fs` from CJS, so use a module specifier that really fails. + const t2 = mk(); + t2.inst.evalJS(['x'], "js import('node:nonexistent-v4-6-129')"); + await tick(); + eq('js import(<missing>) prints two lines', t2.out.length, 2); + eq(' first line is an error', t2.out[0] && t2.out[0].lvl, 'error'); + ok(' second line is the Node-builtin hint', t2.out[1] && /is a Node built-in/.test(t2.out[1].msg), + JSON.stringify(t2.out)); + + const t3 = mk(); + t3.inst.evalJS(['1+1'], 'js 1+1'); + eq('js 1+1 -> 2 (unchanged)', t3.out, [{ msg: '2', lvl: 'success' }]); + + const t4 = mk(); // programmatic call (AI tool, script runner) has no rawText + t4.inst.evalJS(['6*7']); + eq('evalJS without rawText -> 42', t4.out, [{ msg: '42', lvl: 'success' }]); + + // Only js/node consult rawText — js-run keeps tokenised paths, every other command + // (echo, grep, cat) never reads it, so their quoted arguments behave exactly as before. + ok('js-run does not take rawText', /commands\['js-run'\] = \(args\) => this\.evalJSFile\(args\)/.test(consoleSrc)); + ok('_expr is used only by evalJS', (consoleSrc.match(/_expr\(/g) || []).length === 2, + String((consoleSrc.match(/_expr\(/g) || []).length)); + eq('_parseArgs keeps a quoted space as one token', parseArgs('echo "two words"'), ['echo', 'two words']); + + console.log(`${passes}/${passes + fails} pass`); + process.exit(fails ? 1 : 0); +})(); |