aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-29 12:47:46 +0100
committerAgando <roduccomm@gmail.com>2026-10-29 12:47:46 +0100
commit1a27ba0e2ec736aa9b30c69a3aa2ee2ab86d0709 (patch)
treecad837b716ced65d4f6024098e71f7660304d121
parentaf36a408670e6caa61527b5bc5e6f9915b69369c (diff)
fix(newos4): shell tokenises command lines quote-aware everywhere (v4.6.132)
v4.6.131 taught the pipe stages to forward their raw text, but the shell's own wrapper still split each stage with `cmdText.split(/\s+/)` — so `grep "alpha beta" /qt.txt | wc -l` handed grep the two args `"alpha` and `beta"` and matched nothing. Measured live: the wrapper (ShellModule.init() replaces terminal.executePipeChain) is the implementation that runs, so the core fix alone is decorative. All six command-line split sites in modules/ext/shell.js now go through one helper, `_tokenize()`, which delegates to `security._parseArgs` — the parser the bare-command path already uses — so the two cannot drift again: 111 single command in a `&&` / `;` chain -> `grep "a b" f ; echo done` 176 pipe stage (the wrapper that runs) -> `grep "a b" f | wc -l` 358 `<` redirect input -> `cat < /f` 401 pipe chain inside the script runner -> also forwards the stage's raw text now 424 single command in the script runner -> also forwards raw text 778 `[ -f "path" ]` / `test -f "path"` -> quoted paths in test expressions Empty stages are skipped instead of being dispatched as a command named '' (`help | | wc -l`). The guard test grew the assertion that would have caught this: test-pipe-guard.js now checks `shellTokensQuoted` (tokeniser used, no `cmdText.split(`, empty-stage guard present) alongside `shellForwardsRaw`; both are RED against HEAD's shell.js. Verification node modules/test/test-pipe-guard.js -> {"empty":[],"blank":[],"classifyGuarded":true, "pipeGuarded":true,"shellForwardsRaw":true,"shellTokensQuoted":true} node modules/test/test-parse-args.js -> 28/28 · daily job -> node tests 21/21 pass newos4-maintain.py -> maintenance result: OK (index 349, help 363/363, changelog v4.6.132) live v4.6.132, read from the terminal DOM (no print monkey-patch): cat /qt.txt -> alpha beta grep "alpha beta" /qt.txt -> 1 match, 1 file grep "alpha beta" /qt.txt | wc -l -> 2 (match line + summary; 1 when the quotes are lost) grep "alpha beta" /qt.txt ; echo done-> match count + done ShellModule._tokenize(c) === security._parseArgs(c) for 5 sample lines -> true _tokenize('') / _tokenize(' ') -> [] / [] Also closes the last open feedback item: RPT-20261027-160751 ("Connection failed on all providers") -> resolve, recorded in decisions.jsonl with evidence (headless probe URL `?probe=ctx`, v4.6.117, single occurrence, no stack; Ollama answers 200 with 37 models now). Feedback queue: awaiting 0.
-rw-r--r--CHANGELOG.md53
-rw-r--r--main.html6
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/ext/critical-ext-bundle.js73
-rw-r--r--modules/ext/shell.js73
-rw-r--r--modules/test/test-pipe-guard.js10
6 files changed, 166 insertions, 51 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 71d8e3f..c978a56 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -74,16 +74,67 @@ Generated from git history by `scripts/build-changelog.py` — do not edit by ha
Generated from git history by `scripts/build-changelog.py` — do not edit by hand between the markers.
+# newOS4 changelog
+
+Generated from git history by `scripts/build-changelog.py` — do not edit by hand between the markers.
+
+# newOS4 changelog
+
+Generated from git history by `scripts/build-changelog.py` — do not edit by hand between the markers.
+
+# newOS4 changelog
+
+Generated from git history by `scripts/build-changelog.py` — do not edit by hand between the markers.
+
+# newOS4 changelog
+
+Generated from git history by `scripts/build-changelog.py` — do not edit by hand between the markers.
+
+# newOS4 changelog
+
+Generated from git history by `scripts/build-changelog.py` — do not edit by hand between the markers.
+
+# newOS4 changelog
+
+Generated from git history by `scripts/build-changelog.py` — do not edit by hand between the markers.
+
+# newOS4 changelog
+
+Generated from git history by `scripts/build-changelog.py` — do not edit by hand between the markers.
+
+# newOS4 changelog
+
+Generated from git history by `scripts/build-changelog.py` — do not edit by hand between the markers.
+
<!-- generated:start -->
+## v4.6.131 — 2026-10-29
+
+_Touches modules (8), scripts (3), help.html (1), main.html (1) · fix 1_
+
+- `Pipe stage quoting, maintain ordering, help cost row, plan status (v4.6.131)` `2c6c218`
+
+## v4.6.130 — 2026-10-29
+
+_Touches modules (5), main.html (1) · fix 1_
+
+- `Js keeps its string literals — dispatcher forwards the raw command line (v4.6.130)` `161372f`
+
+## v4.6.128 — 2026-10-29
+
+_Touches modules (2), help.html (1), main.html (1) · fix 1_
+
+- `Js console handles promise rejections + Node-builtin hint (v4.6.128)` `75c5113`
+
## v4.6.119 — 2026-10-27
-_Touches modules (4), scripts (4), CHANGELOG.md (2), main.html (1) · release 3, fix 1_
+_Touches scripts (6), modules (4), CHANGELOG.md (3), guide.html (2) · release 3, fix 2_
- `if test …` was always false and `for` items never globbed` `e575ee8`
- `Generate the changelog from git and keep it current in maintenance` `22bb505`
- `Link the changelog in and deep-link each board fix to its build` `b26bfb8`
- `Tag builds, feed the changelog to machines, fail maintenance when it ages` `782b3db`
+- `Apply the GLM-5.3 review to the changelog pipeline` `f3b2664`
## v4.6.118 — 2026-10-27
diff --git a/main.html b/main.html
index b2fedef..66c1d76 100644
--- a/main.html
+++ b/main.html
@@ -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.131"></script>
+<script src="modules/core/core-bundle.js?v=v4.6.132"></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.131"></script>
+<script src="modules/core/module-loader.js?v=v4.6.132"></script>
<!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work -->
-<script src="modules/ext/critical-ext-bundle.js?v=v4.6.131"></script>
+<script src="modules/ext/critical-ext-bundle.js?v=v4.6.132"></script>
<script>
// ═══ ONE TRUE VERSION SOURCE ═══
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index 3e50c3e..0d39123 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.131';
+ const LATEST_VER = 'v4.6.132';
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/critical-ext-bundle.js b/modules/ext/critical-ext-bundle.js
index 00d7345..0f2e1b9 100644
--- a/modules/ext/critical-ext-bundle.js
+++ b/modules/ext/critical-ext-bundle.js
@@ -244,12 +244,14 @@ class ShellModule {
await this.executePipeChain(pipeParts);
} else {
// Single command — check for shell built-ins first
- const parts = chainCmd.split(/\s+/);
- const cmd = parts[0].toLowerCase();
+ // v4.6.132: quote-aware (split(/\s+/) broke `grep "a b" f; ls`)
+ const argv = self._tokenize(chainCmd);
+ if (!argv.length) continue;
+ const cmd = argv[0].toLowerCase();
// Globs expand at the prompt too — the script runner did it and the
// interactive path did not, so `ls *.txt` printed (empty) but the same
// line inside a script worked.
- const args = self._expandGlobs(parts.slice(1), (this.fs && this.fs.currentPath) || []);
+ const args = self._expandGlobs(argv.slice(1), (this.fs && this.fs.currentPath) || []);
const expandedCmd = [cmd].concat(args).join(' ');
if (self.commands[cmd]) {
try {
@@ -284,9 +286,14 @@ class ShellModule {
let pipeInput = null;
for (let i = 0; i < pipeParts.length; i++) {
const cmdText = pipeParts[i];
- const parts = cmdText.split(/\s+/);
- const cmd = parts[0].toLowerCase();
- let args = parts.slice(1);
+ // v4.6.132: quote-aware tokenisation. split(/\s+/) turned `grep "a b" f | wc -l`
+ // into args ['"a','b"','f'], and `cmd | | cmd2` into a stage with cmd '' that was
+ // still dispatched. The security tokeniser keeps a quoted argument whole and
+ // returns [] for an empty stage, so the guard below can skip it.
+ const argv = this.security._parseArgs(String(cmdText || '').trim());
+ if (argv.length === 0) continue;
+ const cmd = argv[0].toLowerCase();
+ let args = argv.slice(1);
// Security gate — replicate the original executePipeChain checks
const cls = this.security._classify(cmdText);
@@ -339,6 +346,20 @@ class ShellModule {
// ── Tokenizer helpers ──
+ /**
+ * Split a command line into argv, honouring quotes — the same parser the dispatcher uses
+ * (`security._parseArgs`), so the shell paths and the bare-command path cannot drift.
+ * v4.6.132: every `.split(/\s+/)` call site in this file turned a quoted argument into several,
+ * and dispatched an empty stage as a command named ''.
+ */
+ _tokenize(cmdText) {
+ const src = String(cmdText || '').trim();
+ if (!src) return [];
+ const sec = (this.terminal && this.terminal.security) || this.security;
+ if (sec && typeof sec._parseArgs === 'function') return sec._parseArgs(src);
+ return src.split(/\s+/); // defensive: the terminal always exposes security
+ }
+
_splitSequence(input) {
// Split on ; (not inside quotes)
const parts = [];
@@ -486,9 +507,11 @@ class ShellModule {
}
this.terminal._pipeInput = inputContent;
try {
- const parts = cmdText.split(/\s+/);
- const cmd = parts[0].toLowerCase();
- const args = parts.slice(1);
+ // v4.6.132: quote-aware — `cat < "/my file.txt"` and `grep "a b" f < x` lost the
+ // quoted argument to split(/\s+/).
+ const argv = this._tokenize(cmdText);
+ const cmd = (argv[0] || '').toLowerCase();
+ const args = argv.slice(1);
// If command reads files, inject pipe content as temp file
if (this._isPipeAware(cmd)) {
fs.writeFile('__pipebuf__', inputContent);
@@ -529,9 +552,12 @@ class ShellModule {
let pipeInput = null;
for (let i = 0; i < pipeParts.length; i++) {
const pText = pipeParts[i];
- const pParts = pText.split(/\s+/);
- const pCmd = pParts[0].toLowerCase();
- const pArgs = pParts.slice(1);
+ // v4.6.132: tokenise like the interactive path (and keep the raw text, so a
+ // `js` stage in a script keeps its string literals too).
+ const pArgv = this._tokenize(pText);
+ if (!pArgv.length) continue;
+ const pCmd = pArgv[0].toLowerCase();
+ const pArgs = pArgv.slice(1);
if (pipeInput !== null) {
fs.writeFile('__pipebuf__', pipeInput);
}
@@ -539,23 +565,24 @@ class ShellModule {
let capt = '';
this.terminal._pushPrintCapture(buf => { capt += buf; });
try {
- await this.terminal.dispatchCommand(pCmd, pArgs);
+ await this.terminal.dispatchCommand(pCmd, pArgs, pText);
} finally {
this.terminal._popPrintCapture();
}
pipeInput = capt.trim();
} else {
- await this.terminal.dispatchCommand(pCmd, pArgs);
+ await this.terminal.dispatchCommand(pCmd, pArgs, pText);
}
if (fs.readFile('__pipebuf__') !== null) {
delete fs.root.content['__pipebuf__'];
}
}
} else {
- const parts = cmdText.split(/\s+/);
- const cmd = parts[0].toLowerCase();
- const args = parts.slice(1);
- await this.terminal.dispatchCommand(cmd, args);
+ // v4.6.132: quote-aware, and pass the raw text so `js`/`node -e` keep literals here too
+ const argv = this._tokenize(cmdText);
+ const cmd = (argv[0] || '').toLowerCase();
+ const args = argv.slice(1);
+ await this.terminal.dispatchCommand(cmd, args, cmdText);
}
} catch(e) {
cmdOk = false;
@@ -884,7 +911,8 @@ class ShellModule {
.trim();
if (!t) return false;
const fs = this.terminal.fs;
- const parts = t.split(/\s+/);
+ // v4.6.132: quote-aware — `[ -f "/my file.txt" ]` was false because the path was split.
+ const parts = this._tokenize(t);
const p = parts[1];
switch (parts[0]) {
case '-f': return typeof fs.readFile(p) === 'string';
@@ -982,10 +1010,11 @@ class ShellModule {
}
async _dispatchOne(cmdText, env, ctx) {
- const parts = String(cmdText).trim().split(/\s+/);
- const cmd = (parts[0] || '').toLowerCase();
+ // v4.6.132: quote-aware tokenisation (split(/\s+/) split `grep "a b" f` into three args)
+ const argv = this._tokenize(cmdText);
+ const cmd = (argv[0] || '').toLowerCase();
const cwd = (this.terminal.fs && this.terminal.fs.currentPath) || [];
- const args = this._expandGlobs(parts.slice(1), cwd);
+ const args = this._expandGlobs(argv.slice(1), cwd);
if (this.commands[cmd]) {
try { this.terminal.markDirty(); await this.commands[cmd](args); return true; }
catch (e) { this.terminal.print('Error: ' + e.message, 'error'); return false; }
diff --git a/modules/ext/shell.js b/modules/ext/shell.js
index 8640630..cb26484 100644
--- a/modules/ext/shell.js
+++ b/modules/ext/shell.js
@@ -108,12 +108,14 @@ class ShellModule {
await this.executePipeChain(pipeParts);
} else {
// Single command — check for shell built-ins first
- const parts = chainCmd.split(/\s+/);
- const cmd = parts[0].toLowerCase();
+ // v4.6.132: quote-aware (split(/\s+/) broke `grep "a b" f; ls`)
+ const argv = self._tokenize(chainCmd);
+ if (!argv.length) continue;
+ const cmd = argv[0].toLowerCase();
// Globs expand at the prompt too — the script runner did it and the
// interactive path did not, so `ls *.txt` printed (empty) but the same
// line inside a script worked.
- const args = self._expandGlobs(parts.slice(1), (this.fs && this.fs.currentPath) || []);
+ const args = self._expandGlobs(argv.slice(1), (this.fs && this.fs.currentPath) || []);
const expandedCmd = [cmd].concat(args).join(' ');
if (self.commands[cmd]) {
try {
@@ -148,9 +150,14 @@ class ShellModule {
let pipeInput = null;
for (let i = 0; i < pipeParts.length; i++) {
const cmdText = pipeParts[i];
- const parts = cmdText.split(/\s+/);
- const cmd = parts[0].toLowerCase();
- let args = parts.slice(1);
+ // v4.6.132: quote-aware tokenisation. split(/\s+/) turned `grep "a b" f | wc -l`
+ // into args ['"a','b"','f'], and `cmd | | cmd2` into a stage with cmd '' that was
+ // still dispatched. The security tokeniser keeps a quoted argument whole and
+ // returns [] for an empty stage, so the guard below can skip it.
+ const argv = this.security._parseArgs(String(cmdText || '').trim());
+ if (argv.length === 0) continue;
+ const cmd = argv[0].toLowerCase();
+ let args = argv.slice(1);
// Security gate — replicate the original executePipeChain checks
const cls = this.security._classify(cmdText);
@@ -203,6 +210,20 @@ class ShellModule {
// ── Tokenizer helpers ──
+ /**
+ * Split a command line into argv, honouring quotes — the same parser the dispatcher uses
+ * (`security._parseArgs`), so the shell paths and the bare-command path cannot drift.
+ * v4.6.132: every `.split(/\s+/)` call site in this file turned a quoted argument into several,
+ * and dispatched an empty stage as a command named ''.
+ */
+ _tokenize(cmdText) {
+ const src = String(cmdText || '').trim();
+ if (!src) return [];
+ const sec = (this.terminal && this.terminal.security) || this.security;
+ if (sec && typeof sec._parseArgs === 'function') return sec._parseArgs(src);
+ return src.split(/\s+/); // defensive: the terminal always exposes security
+ }
+
_splitSequence(input) {
// Split on ; (not inside quotes)
const parts = [];
@@ -350,9 +371,11 @@ class ShellModule {
}
this.terminal._pipeInput = inputContent;
try {
- const parts = cmdText.split(/\s+/);
- const cmd = parts[0].toLowerCase();
- const args = parts.slice(1);
+ // v4.6.132: quote-aware — `cat < "/my file.txt"` and `grep "a b" f < x` lost the
+ // quoted argument to split(/\s+/).
+ const argv = this._tokenize(cmdText);
+ const cmd = (argv[0] || '').toLowerCase();
+ const args = argv.slice(1);
// If command reads files, inject pipe content as temp file
if (this._isPipeAware(cmd)) {
fs.writeFile('__pipebuf__', inputContent);
@@ -393,9 +416,12 @@ class ShellModule {
let pipeInput = null;
for (let i = 0; i < pipeParts.length; i++) {
const pText = pipeParts[i];
- const pParts = pText.split(/\s+/);
- const pCmd = pParts[0].toLowerCase();
- const pArgs = pParts.slice(1);
+ // v4.6.132: tokenise like the interactive path (and keep the raw text, so a
+ // `js` stage in a script keeps its string literals too).
+ const pArgv = this._tokenize(pText);
+ if (!pArgv.length) continue;
+ const pCmd = pArgv[0].toLowerCase();
+ const pArgs = pArgv.slice(1);
if (pipeInput !== null) {
fs.writeFile('__pipebuf__', pipeInput);
}
@@ -403,23 +429,24 @@ class ShellModule {
let capt = '';
this.terminal._pushPrintCapture(buf => { capt += buf; });
try {
- await this.terminal.dispatchCommand(pCmd, pArgs);
+ await this.terminal.dispatchCommand(pCmd, pArgs, pText);
} finally {
this.terminal._popPrintCapture();
}
pipeInput = capt.trim();
} else {
- await this.terminal.dispatchCommand(pCmd, pArgs);
+ await this.terminal.dispatchCommand(pCmd, pArgs, pText);
}
if (fs.readFile('__pipebuf__') !== null) {
delete fs.root.content['__pipebuf__'];
}
}
} else {
- const parts = cmdText.split(/\s+/);
- const cmd = parts[0].toLowerCase();
- const args = parts.slice(1);
- await this.terminal.dispatchCommand(cmd, args);
+ // v4.6.132: quote-aware, and pass the raw text so `js`/`node -e` keep literals here too
+ const argv = this._tokenize(cmdText);
+ const cmd = (argv[0] || '').toLowerCase();
+ const args = argv.slice(1);
+ await this.terminal.dispatchCommand(cmd, args, cmdText);
}
} catch(e) {
cmdOk = false;
@@ -748,7 +775,8 @@ class ShellModule {
.trim();
if (!t) return false;
const fs = this.terminal.fs;
- const parts = t.split(/\s+/);
+ // v4.6.132: quote-aware — `[ -f "/my file.txt" ]` was false because the path was split.
+ const parts = this._tokenize(t);
const p = parts[1];
switch (parts[0]) {
case '-f': return typeof fs.readFile(p) === 'string';
@@ -846,10 +874,11 @@ class ShellModule {
}
async _dispatchOne(cmdText, env, ctx) {
- const parts = String(cmdText).trim().split(/\s+/);
- const cmd = (parts[0] || '').toLowerCase();
+ // v4.6.132: quote-aware tokenisation (split(/\s+/) split `grep "a b" f` into three args)
+ const argv = this._tokenize(cmdText);
+ const cmd = (argv[0] || '').toLowerCase();
const cwd = (this.terminal.fs && this.terminal.fs.currentPath) || [];
- const args = this._expandGlobs(parts.slice(1), cwd);
+ const args = this._expandGlobs(argv.slice(1), cwd);
if (this.commands[cmd]) {
try { this.terminal.markDirty(); await this.commands[cmd](args); return true; }
catch (e) { this.terminal.print('Error: ' + e.message, 'error'); return false; }
diff --git a/modules/test/test-pipe-guard.js b/modules/test/test-pipe-guard.js
index 7851613..e689e60 100644
--- a/modules/test/test-pipe-guard.js
+++ b/modules/test/test-pipe-guard.js
@@ -43,10 +43,16 @@ const shellBody = si >= 0 ? shellSrc.slice(si, shellSrc.indexOf('_splitSequence(
const shellForwardsRaw = si >= 0
&& shellBody.indexOf('this.dispatchCommand(cmd, args, cmdText)') > 0
&& shellBody.indexOf('this.capturePipeOutput(cmd, args, cmdText)') > 0;
+// 5. v4.6.132: that override must tokenise with the quote-aware parser, not split(/\s+/), and must
+// skip empty stages instead of dispatching cmd ''.
+const shellTokensQuoted = si >= 0
+ && shellBody.indexOf('this.security._parseArgs(String(cmdText') > 0
+ && shellBody.indexOf('if (argv.length === 0) continue') > 0
+ && shellBody.indexOf('cmdText.split(') < 0;
-const report = { empty, blank, classifyGuarded, pipeGuarded, shellForwardsRaw };
+const report = { empty, blank, classifyGuarded, pipeGuarded, shellForwardsRaw, shellTokensQuoted };
console.log(JSON.stringify(report));
const ok = Array.isArray(empty) && empty.length === 0
&& Array.isArray(blank) && blank.length === 0
- && classifyGuarded && pipeGuarded && shellForwardsRaw;
+ && classifyGuarded && pipeGuarded && shellForwardsRaw && shellTokensQuoted;
process.exit(ok ? 0 : 1);