aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-23 15:55:51 +0200
committerAgando <roduccomm@gmail.com>2026-10-23 15:55:51 +0200
commit8d00c6949d19338e949618ddae4f1aef592cf58b (patch)
tree635ec4466ac23fdf15623cb2d066a1a3838ee5e5
parent921e7bada5703eb3a5058e8d0f89dffa44b82efc (diff)
fix(newos4): empty-pipeline crash, stacks in reports, frame in digest + dashboard (v4.6.55)
Root cause: splitPipe() splits on every '|', so `help |`, `| wc -l`, `a || b` produced a stage with no argv and executePipeChain() called parts[0].toLowerCase() on undefined — 989 occurrences, first filed 2026-10-20, unfixable from the report because bug reports dropped the stack that named executePipeChain:3955. Same unguarded read existed in _classify(). - terminal.js: _classify() returns an inert classification for empty argv; executePipeChain() parses each stage once, filters stages with no argv (so the last real stage stays final), warns only when the whole pipeline is empty. Last-stage test now compares against the filtered length. - module-system.js: `bug` diagnostics include the stack (10 frames, 1200 chars); auto-filed reports carry it too — they are the majority of the inbox and were previously message-only. - report.php: per-stack 2 KB cap; fingerprint pinned to message/source/count so adding `stack` cannot break dedupe against reports filed before today. - ingest.php: first_frame() parsed once on ingest, stored as `frame` on each signature. - newos4-triage.py: FRAME| line with the first application frame. - dashboard.php: frame under each Top Errors row. - modules/test/test-pipe-guard.js: ordering tripwire (guard must precede the parts[0] read). Verified: test RED exit=1 -> GREEN exit=0; browser matrix on v4.6.55 (help |, | wc -l, echo hi || echo bye, ls | | wc -l, |) -> 0 new error signatures, "|" warns; auto-filed report now carries stack (RPT-...f3e31124, archived after the probe); frame = modules/core/core-bundle.js:3955:42; maintain OK; skill audit 7/7.
-rw-r--r--api/debug/dashboard.php7
-rw-r--r--api/debug/ingest.php16
-rw-r--r--api/debug/report.php24
-rw-r--r--main.html6
-rw-r--r--modules/core/core-bundle.js45
-rw-r--r--modules/core/module-debug-supervisor.js5
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/core/module-system.js4
-rw-r--r--modules/core/terminal.js36
-rw-r--r--modules/test/test-pipe-guard.js34
-rwxr-xr-xscripts/newos4-triage.py3
11 files changed, 159 insertions, 23 deletions
diff --git a/api/debug/dashboard.php b/api/debug/dashboard.php
index 08e6194..9b717c8 100644
--- a/api/debug/dashboard.php
+++ b/api/debug/dashboard.php
@@ -31,7 +31,8 @@ foreach ($errors as $e) {
$hash = $e['hash'] ?? '';
if ($hash) {
if (!isset($topErrors[$hash])) {
- $topErrors[$hash] = ['hash' => $hash, 'message' => $e['message'] ?? '', 'count' => 0, 'type' => $t];
+ $topErrors[$hash] = ['hash' => $hash, 'message' => $e['message'] ?? '', 'count' => 0, 'type' => $t,
+ 'frame' => $e['frame'] ?? null];
}
$topErrors[$hash]['count'] += $e['count'] ?? 1;
}
@@ -297,7 +298,9 @@ $thread = array_slice($thread, -6);
<?php $i = 1; foreach ($topErrors as $e): ?>
<tr>
<td><?= $i++ ?></td>
- <td class="max-w-300"><a href="error.php?hash=<?= urlencode($e['hash'] ?? '') ?>" style="color:var(--text);text-decoration:none;" title="View details"><?= htmlspecialchars(substr($e['message'] ?? '', 0, 80)) ?></a></td>
+ <td class="max-w-300"><a href="error.php?hash=<?= urlencode($e['hash'] ?? '') ?>" style="color:var(--text);text-decoration:none;" title="View details"><?= htmlspecialchars(substr($e['message'] ?? '', 0, 80)) ?></a>
+ <?php if (!empty($e['frame'])): ?><div class="hide-sm" style="font-size:.72em;color:var(--muted)"><?= htmlspecialchars($e['frame']) ?></div><?php endif; ?>
+ </td>
<td class="hide-sm"><span class="tag"><?= htmlspecialchars($e['type'] ?? '?') ?></span></td>
<td><?= (int)($e['count'] ?? 0) ?></td>
</tr>
diff --git a/api/debug/ingest.php b/api/debug/ingest.php
index 7388bce..98354fe 100644
--- a/api/debug/ingest.php
+++ b/api/debug/ingest.php
@@ -80,6 +80,20 @@ function norm_ts($s, $fallback) {
return gmdate('c', $t); // always +00:00, never Z
}
+// Where did it happen? Parsed ONCE here so the digest (Python) and the dashboard (PHP) read the
+// same value instead of re-implementing this regex twice. Returns 'modules/core/x.js:12:3'.
+function first_frame($stack) {
+ if (!is_string($stack) || $stack === '') return null;
+ foreach (preg_split('/\R/', $stack) as $i => $line) {
+ if ($i === 0 || !preg_match('/^\s+at /', $line)) continue; // line 0 is "TypeError: ..."
+ if (preg_match('#([\w./-]+\.js)(?:\?v=[\w.]+)?:(\d+):(\d+)#', $line, $m)) {
+ $p = preg_replace('#^.*?/spaces/newos4/#', '', $m[1]);
+ return $p . ':' . $m[2] . ':' . $m[3];
+ }
+ }
+ return null;
+}
+
$processed = 0; // entries handled (NOT occurrences) — the client's truncation warning uses this
foreach ($errors as $e) {
$processed++;
@@ -103,6 +117,7 @@ foreach ($errors as $e) {
$allErrors[$idx]['ingested_at'] = $now;
if (!empty($e['stack'])) {
$allErrors[$idx]['stack'] = substr($e['stack'], 0, 2000);
+ $allErrors[$idx]['frame'] = first_frame($e['stack']) ?? ($allErrors[$idx]['frame'] ?? null);
}
$ingested += $delta;
}
@@ -118,6 +133,7 @@ foreach ($errors as $e) {
'source' => $e['source'] ?? '',
'module' => $e['module'] ?? null,
'stack' => isset($e['stack']) ? substr($e['stack'], 0, 2000) : null,
+ 'frame' => first_frame($e['stack'] ?? null),
'session_id' => $sessionId,
'count' => $delta,
'first_seen' => norm_ts($e['firstSeen'] ?? null, $now),
diff --git a/api/debug/report.php b/api/debug/report.php
index 5d4fc95..5625ffe 100644
--- a/api/debug/report.php
+++ b/api/debug/report.php
@@ -29,6 +29,18 @@ $body = (string)($payload['body'] ?? '');
if (mb_strlen($body) > 4000) $body = mb_substr($body, 0, 4000);
$diag = (isset($payload['diagnostics']) && is_array($payload['diagnostics'])) ? $payload['diagnostics'] : [];
+// Bound every stack: 20 errors x 10 frames of long URLs can otherwise approach the 512 KB ceiling
+// and reject a legitimate report.
+if (!empty($diag['errors']) && is_array($diag['errors'])) {
+ foreach ($diag['errors'] as $k => $de) {
+ if (!is_array($de)) { unset($diag['errors'][$k]); continue; }
+ if (isset($de['stack'])) {
+ $diag['errors'][$k]['stack'] = mb_substr((string)$de['stack'], 0, 2000);
+ }
+ }
+ $diag['errors'] = array_values($diag['errors']);
+}
+
// Maintenance thread: lets the in-app agent and the Hermes maintenance job hold a conversation.
// `thread` is a client-generated id (thr-xxxxxxxx); `kind` distinguishes agent feedback from
// operator bug reports so triage can weight them differently.
@@ -62,7 +74,17 @@ foreach ($rate as $k => $v) {
save_json(RATE_FILE, $rate);
// Dedupe: same title + error set + version within 24h collapses onto the existing report.
-$fingerprint = sha1($title . '|' . json_encode($diag['errors'] ?? []) . '|' . (string)($diag['version'] ?? ''));
+// Hash only the fields the pre-2026-10-23 payload had. Adding `stack` to json_encode($diag['errors'])
+// would change every fingerprint, so an identical error filed today would not dedupe against the
+// same error filed yesterday.
+$errKey = [];
+foreach (is_array($diag['errors'] ?? null) ? $diag['errors'] : [] as $de) {
+ if (!is_array($de)) continue;
+ $errKey[] = ['message' => (string)($de['message'] ?? ''),
+ 'source' => (string)($de['source'] ?? ''),
+ 'count' => (int)($de['count'] ?? 1)];
+}
+$fingerprint = sha1($title . '|' . json_encode($errKey) . '|' . (string)($diag['version'] ?? ''));
$existing = glob(REPORTS_DIR . '/RPT-*.json') ?: [];
foreach (array_slice(array_reverse($existing), 0, 100) as $f) {
$d = json_decode(@file_get_contents($f), true);
diff --git a/main.html b/main.html
index 73f4b0b..e89534c 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.54"></script>
+<script src="modules/core/core-bundle.js?v=v4.6.55"></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.54"></script>
+<script src="modules/core/module-loader.js?v=v4.6.55"></script>
<!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work -->
-<script src="modules/ext/critical-ext-bundle.js?v=v4.6.54"></script>
+<script src="modules/ext/critical-ext-bundle.js?v=v4.6.55"></script>
<script>
// ═══ ONE TRUE VERSION SOURCE ═══
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index 212573d..67747bf 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -2609,7 +2609,14 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
return args;
}
_classify(raw) {
- const parts = this._parseArgs(raw.trim());
+ const parts = this._parseArgs(String(raw || '').trim());
+ // `help |` and `| wc -l` produce an empty pipeline stage; parts[0] was undefined
+ // and every such invocation threw TypeError from this line (989 occurrences,
+ // first filed 2026-10-20). Classify the empty command as inert instead.
+ if (!parts.length) {
+ return { cmd: '', args: [], known: true, destructive: false, system: false,
+ unknown: false, hasSuspicious: false };
+ }
const cmd = parts[0].toLowerCase();
const args = parts.slice(1);
// Internal commands always allowed at terminal level, but security still classifies
@@ -3984,12 +3991,27 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
return parts;
}
async executePipeChain(pipeParts) {
+ // Parse every stage once, then drop the ones with no argv. `help |` splits to
+ // ['help',''], `help | ""` to ['help',''] and `| wc -l` to ['','wc -l'] — all three
+ // produced an empty argv whose parts[0] was undefined and threw TypeError from
+ // parts[0].toLowerCase() (989 occurrences, first filed 2026-10-20). Filtering on
+ // argv (not just the string) also keeps the LAST real stage final, so its output is
+ // never swallowed by a trailing empty segment.
+ const stages = pipeParts
+ .map(t => this.security._parseArgs(String(t || '').trim()))
+ .filter(argv => argv.length > 0);
+ if (!stages.length) {
+ this.print('⚠️ Empty pipeline — nothing to run', 'warning');
+ return;
+ }
let pipeInput = null;
- for (let i = 0; i < pipeParts.length; i++) {
- const cmdText = pipeParts[i];
- const parts = this.security._parseArgs(cmdText);
- const cmd = parts[0].toLowerCase();
- const args = parts.slice(1);
+ for (let i = 0; i < stages.length; i++) {
+ const argv = stages[i];
+ const cmd = argv[0].toLowerCase();
+ const args = argv.slice(1);
+ // Rebuilt from argv so the security classifier sees the same tokenisation the
+ // dispatcher will use (quotes already stripped by _parseArgs).
+ const cmdText = argv.join(' ');
// v4.5.67: Security gate for pipe stages — was completely bypassed
const cls = this.security._classify(cmdText);
if (cls.destructive && this.security.level > 0) {
@@ -4005,7 +4027,7 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
return;
}
this._pipeInput = pipeInput;
- if (i < pipeParts.length - 1) {
+ if (i < stages.length - 1) {
pipeInput = await this.capturePipeOutput(cmd, args);
} else {
await this.dispatchCommand(cmd, args);
@@ -5820,6 +5842,10 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
message: String(e.message || e.msg || '').slice(0, 300),
source: String(e.source || e.url || '').slice(0, 200),
count: Number(e.count) || 1,
+ // The stack is the only thing that makes a report actionable; it was
+ // dropped here while ingest kept it, which is why the 2026-10-20
+ // `toLowerCase` report could not be localised for three days.
+ stack: e.stack ? String(e.stack).split('\n').slice(0, 10).join('\n').slice(0, 1200) : null,
}));
} catch (_e) {}
try {
@@ -20332,7 +20358,7 @@ class DebugSupervisorModule {
// v4.6.38 (A5): auto-file fatal/error-class failures, max 3 per day, deduped by hash.
if (classification.severity === 'fatal' || classification.severity === 'error') {
- this._maybeAutoFile({ hash, message, source, type: classification.type, severity: classification.severity });
+ this._maybeAutoFile({ hash, message, source, type: classification.type, severity: classification.severity, stack });
}
}
@@ -20376,6 +20402,9 @@ class DebugSupervisorModule {
type: e.type,
severity: e.severity,
count: 1,
+ // Auto-filed reports are the majority of the inbox; without the stack they are
+ // a message with no location (the 2026-10-20 `toLowerCase` report proved it).
+ stack: e.stack ? String(e.stack).split('\n').slice(0, 10).join('\n').slice(0, 1200) : null,
}],
},
};
diff --git a/modules/core/module-debug-supervisor.js b/modules/core/module-debug-supervisor.js
index 485f4e2..78e3a46 100644
--- a/modules/core/module-debug-supervisor.js
+++ b/modules/core/module-debug-supervisor.js
@@ -325,7 +325,7 @@ class DebugSupervisorModule {
// v4.6.38 (A5): auto-file fatal/error-class failures, max 3 per day, deduped by hash.
if (classification.severity === 'fatal' || classification.severity === 'error') {
- this._maybeAutoFile({ hash, message, source, type: classification.type, severity: classification.severity });
+ this._maybeAutoFile({ hash, message, source, type: classification.type, severity: classification.severity, stack });
}
}
@@ -369,6 +369,9 @@ class DebugSupervisorModule {
type: e.type,
severity: e.severity,
count: 1,
+ // Auto-filed reports are the majority of the inbox; without the stack they are
+ // a message with no location (the 2026-10-20 `toLowerCase` report proved it).
+ stack: e.stack ? String(e.stack).split('\n').slice(0, 10).join('\n').slice(0, 1200) : null,
}],
},
};
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index a3b3a6f..ff908cd 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.54';
+ const LATEST_VER = 'v4.6.55';
const CACHE = '?v=' + LATEST_VER;
const stored = localStorage.getItem('webx11_cache_ver');
if (stored !== LATEST_VER) {
diff --git a/modules/core/module-system.js b/modules/core/module-system.js
index bedceef..e7f7e5a 100644
--- a/modules/core/module-system.js
+++ b/modules/core/module-system.js
@@ -334,6 +334,10 @@
message: String(e.message || e.msg || '').slice(0, 300),
source: String(e.source || e.url || '').slice(0, 200),
count: Number(e.count) || 1,
+ // The stack is the only thing that makes a report actionable; it was
+ // dropped here while ingest kept it, which is why the 2026-10-20
+ // `toLowerCase` report could not be localised for three days.
+ stack: e.stack ? String(e.stack).split('\n').slice(0, 10).join('\n').slice(0, 1200) : null,
}));
} catch (_e) {}
try {
diff --git a/modules/core/terminal.js b/modules/core/terminal.js
index c586538..ad4b105 100644
--- a/modules/core/terminal.js
+++ b/modules/core/terminal.js
@@ -157,7 +157,14 @@
return args;
}
_classify(raw) {
- const parts = this._parseArgs(raw.trim());
+ const parts = this._parseArgs(String(raw || '').trim());
+ // `help |` and `| wc -l` produce an empty pipeline stage; parts[0] was undefined
+ // and every such invocation threw TypeError from this line (989 occurrences,
+ // first filed 2026-10-20). Classify the empty command as inert instead.
+ if (!parts.length) {
+ return { cmd: '', args: [], known: true, destructive: false, system: false,
+ unknown: false, hasSuspicious: false };
+ }
const cmd = parts[0].toLowerCase();
const args = parts.slice(1);
// Internal commands always allowed at terminal level, but security still classifies
@@ -1532,12 +1539,27 @@
return parts;
}
async executePipeChain(pipeParts) {
+ // Parse every stage once, then drop the ones with no argv. `help |` splits to
+ // ['help',''], `help | ""` to ['help',''] and `| wc -l` to ['','wc -l'] — all three
+ // produced an empty argv whose parts[0] was undefined and threw TypeError from
+ // parts[0].toLowerCase() (989 occurrences, first filed 2026-10-20). Filtering on
+ // argv (not just the string) also keeps the LAST real stage final, so its output is
+ // never swallowed by a trailing empty segment.
+ const stages = pipeParts
+ .map(t => this.security._parseArgs(String(t || '').trim()))
+ .filter(argv => argv.length > 0);
+ if (!stages.length) {
+ this.print('⚠️ Empty pipeline — nothing to run', 'warning');
+ return;
+ }
let pipeInput = null;
- for (let i = 0; i < pipeParts.length; i++) {
- const cmdText = pipeParts[i];
- const parts = this.security._parseArgs(cmdText);
- const cmd = parts[0].toLowerCase();
- const args = parts.slice(1);
+ for (let i = 0; i < stages.length; i++) {
+ const argv = stages[i];
+ const cmd = argv[0].toLowerCase();
+ const args = argv.slice(1);
+ // Rebuilt from argv so the security classifier sees the same tokenisation the
+ // dispatcher will use (quotes already stripped by _parseArgs).
+ const cmdText = argv.join(' ');
// v4.5.67: Security gate for pipe stages — was completely bypassed
const cls = this.security._classify(cmdText);
if (cls.destructive && this.security.level > 0) {
@@ -1553,7 +1575,7 @@
return;
}
this._pipeInput = pipeInput;
- if (i < pipeParts.length - 1) {
+ if (i < stages.length - 1) {
pipeInput = await this.capturePipeOutput(cmd, args);
} else {
await this.dispatchCommand(cmd, args);
diff --git a/modules/test/test-pipe-guard.js b/modules/test/test-pipe-guard.js
new file mode 100644
index 0000000..0bd7cc9
--- /dev/null
+++ b/modules/test/test-pipe-guard.js
@@ -0,0 +1,34 @@
+// modules/test/test-pipe-guard.js — run: node modules/test/test-pipe-guard.js
+// Fails fast if either guard is removed or if a guard is placed AFTER the parts[0] read.
+// Behavioural proof is the browser matrix (plan Task 8); this test is the cheap tripwire.
+const fs = require('fs'), path = require('path');
+const src = fs.readFileSync(path.join(__dirname, '../core/terminal.js'), 'utf8');
+
+// 1. _parseArgs('') must return [] — that precondition is what made parts[0] undefined.
+// The source is a CLASS METHOD, so wrap it in an object literal (a bare `new Function('m(){…}')`
+// is a SyntaxError). It touches no `this` (verified), so a detached call is safe.
+const m = src.match(/_parseArgs\((\w+)\)\s*\{[\s\S]*?\n\s{12}\}/);
+if (!m) { console.error('FAIL: _parseArgs not found — did the method signature or indent change?'); process.exit(1); }
+const parseArgs = new Function('return ({' + m[0] + '})._parseArgs;')();
+const empty = parseArgs('');
+const blank = parseArgs(' ');
+
+// 2. guards must exist AND precede the read they protect (ordering, not just presence)
+const ci = src.indexOf('_classify(raw)');
+const cGuard = src.indexOf('if (!parts.length)', ci);
+const cRead = src.indexOf('parts[0].toLowerCase()', ci);
+const classifyGuarded = ci >= 0 && cGuard > ci && cGuard < cRead;
+
+const pi = src.indexOf('async executePipeChain(');
+const pGuard = src.indexOf('if (!stages.length)', pi);
+const pFilter = src.indexOf('.filter(argv => argv.length > 0)', pi);
+const pRead = src.indexOf('argv[0].toLowerCase()', pi);
+const pipeGuarded = pi >= 0 && pFilter > pi && pGuard > pFilter && pRead > pGuard
+ && src.indexOf('i < stages.length - 1', pi) > 0;
+
+const report = { empty, blank, classifyGuarded, pipeGuarded };
+console.log(JSON.stringify(report));
+const ok = Array.isArray(empty) && empty.length === 0
+ && Array.isArray(blank) && blank.length === 0
+ && classifyGuarded && pipeGuarded;
+process.exit(ok ? 0 : 1);
diff --git a/scripts/newos4-triage.py b/scripts/newos4-triage.py
index 20016f7..f639359 100755
--- a/scripts/newos4-triage.py
+++ b/scripts/newos4-triage.py
@@ -118,6 +118,7 @@ def build(apply_fix=False):
'top_real': sorted(
({'message': safe(e.get('message'), 200), 'type': e.get('type'),
'count': int(e.get('count') or 1), 'age_hours': age_hours(e.get('last_seen')),
+ 'frame': safe(e.get('frame'), 160),
'session': safe(e.get('session_id'), 40)} for e in buckets['real']),
key=lambda x: -x['count'])[:10],
'new_reports': [{'id': r.get('id'), 'title': safe(r.get('title'), 160),
@@ -151,6 +152,8 @@ def build(apply_fix=False):
print('check:', f)
for e in digest['top_real'][:5]:
print(f" DATA| top: {e['count']}x {e['type']} ({e['age_hours']}h) {safe(e['message'], 110)}")
+ if e.get('frame'):
+ print(f" FRAME| {e['frame']}")
for r in digest['new_reports'][:5]:
print(f" DATA| report: {r['id']} {safe(r['title'], 110)}")
return digest