diff options
| author | Agando <roduccomm@gmail.com> | 2026-10-23 15:55:51 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-10-23 15:55:51 +0200 |
| commit | 8d00c6949d19338e949618ddae4f1aef592cf58b (patch) | |
| tree | 635ec4466ac23fdf15623cb2d066a1a3838ee5e5 | |
| parent | 921e7bada5703eb3a5058e8d0f89dffa44b82efc (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.php | 7 | ||||
| -rw-r--r-- | api/debug/ingest.php | 16 | ||||
| -rw-r--r-- | api/debug/report.php | 24 | ||||
| -rw-r--r-- | main.html | 6 | ||||
| -rw-r--r-- | modules/core/core-bundle.js | 45 | ||||
| -rw-r--r-- | modules/core/module-debug-supervisor.js | 5 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 2 | ||||
| -rw-r--r-- | modules/core/module-system.js | 4 | ||||
| -rw-r--r-- | modules/core/terminal.js | 36 | ||||
| -rw-r--r-- | modules/test/test-pipe-guard.js | 34 | ||||
| -rwxr-xr-x | scripts/newos4-triage.py | 3 |
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); @@ -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 |