summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHermes SysOp <sysop@webx11.duckdns.org>2026-11-12 17:02:47 +0100
committerHermes SysOp <sysop@webx11.duckdns.org>2026-11-12 17:02:47 +0100
commite452ce7785e5e47ec01a409d6da1f59564f8f365 (patch)
treee2dc45ecf479e55df571045390bba3d927d8a4e2
parentf140aa7f920c687d0c576763da956dd7017c1a27 (diff)
fix(newos4): stop reporting network and VFS size as facts we do not have (v4.6.161)
1. The last two `navigator.onLine`-as-fact payloads - module-aiassist.js: the debug/health payload carried `online: navigator.onLine`, consumed by downloads/dashboard.html, which rendered "๐Ÿ”ด Offline" โ€” on an Android WebView (reports false while fully connected) that told working users they were offline. The key is gone; the payload carries `network:'unknown'`, `networkKnown:false` and `networkHint`. - module-world-model.js: the snapshot had the same field, and it is also handed to the LLM through `_modelDigest()`/`ask()`, so the model was told the user was offline. Now `network:'unknown'` + `networkKnown:false`. - `_loadFromVFS()` scrubs `system.online` from a model persisted by an older build โ€” otherwise the stale boolean would survive inside the digest for the life of the VFS. - downloads/dashboard.html: `networkText()` renders reachable/unreachable/http-* only when doctor produced them ("(doctor)"), `unprobed` as "โ€” not probed", and everything else as "โ€” unknown (run doctor for a live probe)". A legacy payload's `online:false` lands in "unknown", never "Offline". The row label is "Network", not "Online". - Neither payload invents a probe: SystemModule stores no last-doctor verdict, so 'unknown' is the honest value until one exists. 2. `vfsSize` in both payloads was a different unit again - aiassist: `JSON.stringify(fs.root).length / 1024`; world-model: `JSON.stringify(fs.root).length`. Same lie v4.6.160 fixed in `vfs-size` โ€” UTF-16 code units plus serialisation structure. Both now sum UTF-8 bytes of file content (`_utf8Len`/`_measureVfs`, Blob โ†’ TextEncoder โ†’ code-point fallback, depth/node capped) and carry `sizeBytes` beside the human string so the rows reconcile. - world-model's `_gatherFileInfo().size` used a THIRD unit on an adjacent line (`JSON.stringify(fs.root).length/1024`); now the same measurement, plus `sizeBytes`. - `showModel()` renders exact bytes and tolerates a pre-fix persisted string. 3. Predictor made a guaranteed-401 request on every boot (UI-plan finding F6) - `_wdConfig()` returns `user:''`/`pass:''` when the saved WebDAV config has a URL but no credentials, and `_pullFromServer`/`_pushToServer` fetched the auth-gated `/spaces/<user>/_data/predictor.json` anyway โ€” 107 ร— 401 in one day, logged by the browser as console errors. Both now refuse without credentials (`cache-only (no credentials)`). Verified: 42/42 node tests, 2 new files (38 payload/honesty assertions, 8 predictor-store assertions); the predictor suite is 4/8 against the pre-fix source, and the payload suite fails 14 assertions against HEAD (`online:false` present, vfsSize === JSON.stringify length). Live Chromium on v4.6.161, on the served dashboard: a legacy payload with `online:false` renders "Network โ€” unknown (run doctor for a live probe)", the current payload renders "85.8 KB (87880 B)" and the same unknown, and a doctor-produced `unreachable` renders "โš ๏ธ unreachable (doctor)". Served bytes identical for core-bundle.js, main.html and downloads/dashboard.html. `maintain.py --fix` all PASS.
-rw-r--r--downloads/dashboard.html17
-rw-r--r--main.html6
-rw-r--r--modules/core/core-bundle.js152
-rw-r--r--modules/core/module-aiassist.js50
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/core/module-predictor.js7
-rw-r--r--modules/core/module-world-model.js95
-rw-r--r--modules/test/test-network-payload-honesty.js298
-rw-r--r--modules/test/test-predictor-store-auth.js80
9 files changed, 685 insertions, 22 deletions
diff --git a/downloads/dashboard.html b/downloads/dashboard.html
index 799bdbb..c217e1b 100644
--- a/downloads/dashboard.html
+++ b/downloads/dashboard.html
@@ -399,6 +399,19 @@
}
}
+ // Network is NEVER rendered as "Offline" from an unknown. The terminal payload stopped
+ // carrying an `online` boolean because navigator.onLine reports false on a fully connected
+ // Android WebView; `doctor` is the only thing that probes, and it is the only source that may
+ // produce a reachable/unreachable verdict. No verdict => 'unknown', never a red dot.
+ function networkText(health) {
+ const n = health && health.network;
+ if (n === 'reachable') return 'โœ… reachable (doctor)';
+ if (n === 'unreachable') return 'โš ๏ธ unreachable (doctor)';
+ if (typeof n === 'string' && n.indexOf('http-') === 0) return 'โš ๏ธ ' + n.replace('http-', 'HTTP ') + ' (doctor)';
+ if (n === 'unprobed') return 'โ€” not probed';
+ return 'โ€” unknown (run doctor for a live probe)';
+ }
+
// 2026-10-22: a 10 s cutoff threw away perfectly good data and the page's own auto-refresh
// then showed "Cannot connect". Keep whatever exists and let the UI label its age.
function fetchDashboardData() {
@@ -578,8 +591,8 @@
const health = data.health || {};
let healthHtml = '';
healthHtml += '<div class="metric-row"><span>Modules loaded</span><span>' + (health.modules || '?') + '</span></div>';
- healthHtml += '<div class="metric-row"><span>VFS size</span><span>' + (health.vfsSize || '?') + '</span></div>';
- healthHtml += '<div class="metric-row"><span>Online</span><span>' + (health.online ? 'โœ… Online' : '๐Ÿ”ด Offline') + '</span></div>';
+ healthHtml += '<div class="metric-row"><span>VFS size</span><span>' + (health.vfsSize || '?') + (typeof health.vfsSizeBytes === 'number' && health.vfsSizeBytes >= 1024 ? ' (' + health.vfsSizeBytes + ' B)' : '') + '</span></div>';
+ healthHtml += '<div class="metric-row"><span>Network</span><span>' + networkText(health) + '</span></div>';
healthHtml += '<div class="metric-row"><span>Errors</span><span>' + (health.errors > 0 ? 'โš ๏ธ ' + health.errors : 'โœ… 0') + '</span></div>';
healthHtml += '<div class="metric-row"><span>Predictor accuracy</span><span>' + (health.predictorAccuracy || 'N/A') + '</span></div>';
healthDiv.innerHTML = healthHtml;
diff --git a/main.html b/main.html
index da1ca8f..71e1804 100644
--- a/main.html
+++ b/main.html
@@ -1350,13 +1350,13 @@
</script>
<!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix -->
-<script src="modules/core/core-bundle.js?v=v4.6.160"></script>
+<script src="modules/core/core-bundle.js?v=v4.6.161"></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.160"></script>
+<script src="modules/core/module-loader.js?v=v4.6.161"></script>
<!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work -->
-<script src="modules/ext/critical-ext-bundle.js?v=v4.6.160"></script>
+<script src="modules/ext/critical-ext-bundle.js?v=v4.6.161"></script>
<script>
// โ•โ•โ• ONE TRUE VERSION SOURCE โ•โ•โ•
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index ddcac45..fe86c8f 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -14297,9 +14297,45 @@ maxFetchChars: this.maxFetchChars,
}
};
walk('/');
- data.vfs = { files: fileCount, dirs: dirCount, size: (JSON.stringify(fs.root || {}).length / 1024).toFixed(1) + 'KB' };
+ // Stored bytes: the UTF-8 length of each FILE's content, summed. The
+ // old JSON.stringify(fs.root||{}).length counted UTF-16 code units
+ // plus the serialisation structure (braces, quotes, keys, commas) โ€”
+ // ~2x for plain ASCII and wrong the other way for non-ASCII. This is
+ // the unit `vfs-size` and `doctor` report.
+ var u8 = function(s) {
+ if (typeof s !== 'string' || s.length === 0) return 0;
+ if (typeof Blob !== 'undefined') { try { return new Blob([s]).size; } catch (e) {} }
+ if (typeof TextEncoder !== 'undefined') { try { return new TextEncoder().encode(s).length; } catch (e) {} }
+ var n = 0;
+ for (var j = 0; j < s.length; j++) {
+ var cp = s.codePointAt(j);
+ if (cp > 0xffff) j++; // surrogate pair consumed
+ n += cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
+ }
+ return n;
+ };
+ var measure = function(node, depth) {
+ var out = { bytes: 0, files: 0 };
+ if (!node || !node.content || depth > 64) return out;
+ for (var k in node.content) {
+ if (!Object.prototype.hasOwnProperty.call(node.content, k)) continue;
+ var c = node.content[k];
+ if (!c || typeof c !== 'object') continue;
+ if (c.type === 'file') { out.bytes += u8(c.content); out.files++; }
+ else { var sub = measure(c, depth + 1); out.bytes += sub.bytes; out.files += sub.files; }
+ }
+ return out;
+ };
+ var vfsB = measure(fs.root, 0);
+ data.vfs = {
+ files: fileCount, dirs: dirCount,
+ sizeBytes: vfsB.bytes,
+ size: vfsB.bytes < 1024 ? vfsB.bytes + ' B'
+ : vfsB.bytes < 1048576 ? (vfsB.bytes / 1024).toFixed(1) + ' KB'
+ : (vfsB.bytes / 1048576).toFixed(2) + ' MB'
+ };
}
- } catch (e) { data.vfs = { files: 0, dirs: 0, size: '0KB' }; }
+ } catch (e) { data.vfs = { files: 0, dirs: 0, size: '0 B', sizeBytes: 0 }; }
// Ollama
data.ollama = {
@@ -14359,7 +14395,15 @@ maxFetchChars: this.maxFetchChars,
data.health = {
modules: data.modules.loaded + '/' + data.modules.total,
vfsSize: data.vfs ? data.vfs.size : '?',
- online: typeof navigator !== 'undefined' ? navigator.onLine : true,
+ vfsSizeBytes: data.vfs && typeof data.vfs.sizeBytes === 'number' ? data.vfs.sizeBytes : null,
+ // NO `online` boolean. `navigator.onLine` reports false on a fully
+ // connected Android WebView, which is how the debug panel and the
+ // dashboard told working users they were offline. `doctor` owns the only
+ // reachability verdict; SystemModule exposes no last verdict, so until one
+ // is probed the honest value is 'unknown'.
+ network: 'unknown',
+ networkKnown: false,
+ networkHint: 'run doctor for a live probe',
errors: totalErrors,
errorBreakdown: errCounts,
predictorAccuracy: predictorAcc
@@ -19954,6 +19998,10 @@ class PredictorModule {
async _pushToServer() {
const cfg = this._wdConfig();
if (!cfg) { this._storeMode = 'cache-only (no WebDAV config)'; return false; }
+ // The store is a /spaces/<user>/_data path, i.e. auth-gated. A config without credentials
+ // produces a guaranteed 401 on every boot (measured: 107 ร— 401 on predictor.json in one day)
+ // and that 401 is what the console reports as an error. No credentials, no request.
+ if (!cfg.user || !cfg.pass) { this._storeMode = 'cache-only (no credentials)'; return false; }
try {
const body = this.terminal.fs.readFile('/system/metrics/predictor.json') || '{}';
const put = () => fetch(this._storeUrl(cfg), { method: 'PUT', headers: this._storeHeaders(cfg), body: body });
@@ -19976,6 +20024,9 @@ class PredictorModule {
async _pullFromServer(force) {
const cfg = this._wdConfig();
if (!cfg) { this._storeMode = 'cache-only (no WebDAV config)'; return false; }
+ // Same rule as the push: an auth-gated path read without credentials is a 401, not a
+ // "server (empty)" answer, and it costs a console error on every page load.
+ if (!cfg.user || !cfg.pass) { this._storeMode = 'cache-only (no credentials)'; return false; }
if (!force && this._cacheStamp && (Date.now() - this._cacheStamp) < this._STORE_TTL_MS) return false;
try {
const resp = await fetch(this._storeUrl(cfg), { headers: this._storeHeaders(cfg) });
@@ -20173,6 +20224,65 @@ class WorldModelModule {
}
}
+ // โ”€โ”€โ”€ Measurement helpers โ”€โ”€โ”€
+
+ // UTF-8 byte length of a string. `JSON.stringify(root).length` โ€” the unit vfsSize used to
+ // report โ€” is UTF-16 string units PLUS every structural character of the serialisation
+ // (braces, quotes, keys, commas): it measured 1961 for a 1046-byte tree, and counts 'รฉ' as 1.
+ // Blob is exact in the browser and in Node 22; TextEncoder is the fallback, then a manual
+ // code-point count (never `s.length`, which would reintroduce the UTF-16 lie).
+ _utf8Len(s) {
+ if (typeof s !== 'string' || s.length === 0) return 0;
+ if (typeof Blob !== 'undefined') {
+ try { return new Blob([s]).size; } catch (e) { /* fall through */ }
+ }
+ if (typeof TextEncoder !== 'undefined') {
+ try { return new TextEncoder().encode(s).length; } catch (e) { /* fall through */ }
+ }
+ let n = 0;
+ for (let i = 0; i < s.length; i++) {
+ const cp = s.codePointAt(i);
+ if (cp > 0xffff) i++; // surrogate pair consumed
+ n += cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
+ }
+ return n;
+ }
+
+ // Stored bytes of a VFS subtree: the UTF-8 length of each FILE's content, summed. A directory
+ // node contributes nothing of its own, so a nested tree is never double counted. This is the
+ // same unit `vfs-size` and `doctor` report. Depth- and node-capped so a malformed or cyclic
+ // tree cannot hang synthesis.
+ _measureVfs(root) {
+ const out = { bytes: 0, files: 0 };
+ let nodes = 0;
+ const walk = (node, depth) => {
+ if (!node || typeof node !== 'object' || depth > 64) return;
+ const kids = node.content;
+ if (!kids || typeof kids !== 'object') return;
+ for (const name of Object.keys(kids)) {
+ if (++nodes > 20000) return;
+ const child = kids[name];
+ if (!child || typeof child !== 'object') continue;
+ if (child.type === 'file') {
+ out.bytes += this._utf8Len(child.content);
+ out.files += 1;
+ } else {
+ walk(child, depth + 1);
+ }
+ }
+ };
+ walk(root, 0);
+ return out;
+ }
+
+ // Human form for display; the exact byte count is printed beside it so the rows reconcile.
+ _fmtBytes(bytes) {
+ if (typeof bytes !== 'number' || !isFinite(bytes)) return '?';
+ if (bytes < 1024) return bytes + ' B';
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
+ return (bytes / 1024 / 1024).toFixed(2) + ' MB';
+ }
+
_gatherSystemInfo(fs, moduleCount, version) {
// Get real health data from ServerMonitorModule's _latencyLog and DebugSupervisorModule
const sm = this.terminal?.moduleManager?.modules?.['ServerMonitorModule'];
@@ -20180,11 +20290,18 @@ class WorldModelModule {
const errorCount = ds ? Object.keys(ds._errorDB?.errors || {}).length : 0;
const latencyErrors = sm?._latencyLog ? Object.values(sm._latencyLog).filter(e => e.status === 'error').length : 0;
const totalErrors = errorCount + latencyErrors;
+ const vfs = this._measureVfs(fs?.root || null);
return {
version: `newOS4 ${version}`,
modules: moduleCount || Object.keys(this.terminal?.moduleManager?.modules || {}).length || 0,
- vfsSize: JSON.stringify(fs?.root || {}).length,
- online: typeof navigator !== 'undefined' ? navigator.onLine : true,
+ // Stored bytes of file content โ€” the one unit `vfs-size` and `doctor` report.
+ vfsSize: vfs.bytes,
+ vfsFiles: vfs.files,
+ // No reachability verdict is manufactured here. `navigator.onLine` reports false on a
+ // fully connected Android WebView, and `doctor` is the only place that probes. Until
+ // SystemModule exposes a last-doctor verdict, 'unknown' is the honest value.
+ network: 'unknown',
+ networkKnown: false,
memory: typeof performance !== 'undefined' && performance.memory
? `${(performance.memory.usedJSHeapSize / 1048576).toFixed(1)}MB`
: 'N/A',
@@ -20217,7 +20334,7 @@ class WorldModelModule {
}
_gatherFileInfo(fs) {
- if (!fs) return { total: 0, size: '0KB' };
+ if (!fs) return { total: 0, size: '0 B', sizeBytes: 0 };
let fileCount = 0, dirCount = 0;
const walk = (path) => {
const entries = fs.ls(path);
@@ -20233,10 +20350,14 @@ class WorldModelModule {
}
};
walk('/');
+ // Same unit as system.vfsSize โ€” the two are printed on adjacent lines, so they must
+ // reconcile. Stored UTF-8 bytes of file content, not JSON.stringify(fs.root).length.
+ const vfs = this._measureVfs(fs.root);
return {
total: fileCount,
directories: dirCount,
- size: `${(JSON.stringify(fs.root).length / 1024).toFixed(1)}KB`
+ size: this._fmtBytes(vfs.bytes),
+ sizeBytes: vfs.bytes
};
}
@@ -20372,7 +20493,14 @@ class WorldModelModule {
this.terminal.print('๐ŸŒ World Model', 'header');
this.terminal.print(` Version: ${m.version} (last synthsized: ${new Date(m.synthesized).toLocaleTimeString()})`, 'info');
- this.terminal.print(` โš™๏ธ System โ€” ${m.system.modules} modules loaded, ${m.system.vfsSize} VFS, ${m.system.memory}`, 'info');
+ // Tolerate a model loaded from VFS that predates the byte fix (vfsSize was a "1.9KB"
+ // string then): render whatever shape is there instead of "? B".
+ const vfsLine = typeof m.system.vfsSize === 'number'
+ ? (m.system.vfsSize < 1024
+ ? `${m.system.vfsSize} bytes`
+ : `${this._fmtBytes(m.system.vfsSize)} (${m.system.vfsSize} bytes)`)
+ : String(m.system.vfsSize);
+ this.terminal.print(` โš™๏ธ System โ€” ${m.system.modules} modules loaded, ${vfsLine} VFS, ${m.system.memory}`, 'info');
this.terminal.print(` ๐Ÿงฉ Modules โ€” ${m.modules.loaded} loaded (${m.modules.modifiedCount} modified)`, 'info');
this.terminal.print(` ๐Ÿ“ Files โ€” ${m.files.total} files, ${m.files.directories} dirs, ${m.files.size}`, 'info');
this.terminal.print(` ๐Ÿ“š Skills โ€” ${m.skills.count} installed`, 'info');
@@ -20616,6 +20744,14 @@ class WorldModelModule {
if (!fs) return;
const data = fs.fsReadJSON('/system/world-model.json');
if (data) {
+ // A model persisted by a pre-v4.6.161 build carries `system.online` โ€” a
+ // navigator.onLine boolean the digest would hand to the LLM as fact. Scrub it on
+ // load: no probe has run in this session, so 'unknown' is the honest value.
+ if (data.system && typeof data.system === 'object' && 'online' in data.system) {
+ delete data.system.online;
+ if (typeof data.system.network === 'undefined') data.system.network = 'unknown';
+ data.system.networkKnown = false;
+ }
this._model = data;
this._lastRefresh = data.synthesized;
}
diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js
index a9b8fcc..84127d2 100644
--- a/modules/core/module-aiassist.js
+++ b/modules/core/module-aiassist.js
@@ -5118,9 +5118,45 @@ maxFetchChars: this.maxFetchChars,
}
};
walk('/');
- data.vfs = { files: fileCount, dirs: dirCount, size: (JSON.stringify(fs.root || {}).length / 1024).toFixed(1) + 'KB' };
+ // Stored bytes: the UTF-8 length of each FILE's content, summed. The
+ // old JSON.stringify(fs.root||{}).length counted UTF-16 code units
+ // plus the serialisation structure (braces, quotes, keys, commas) โ€”
+ // ~2x for plain ASCII and wrong the other way for non-ASCII. This is
+ // the unit `vfs-size` and `doctor` report.
+ var u8 = function(s) {
+ if (typeof s !== 'string' || s.length === 0) return 0;
+ if (typeof Blob !== 'undefined') { try { return new Blob([s]).size; } catch (e) {} }
+ if (typeof TextEncoder !== 'undefined') { try { return new TextEncoder().encode(s).length; } catch (e) {} }
+ var n = 0;
+ for (var j = 0; j < s.length; j++) {
+ var cp = s.codePointAt(j);
+ if (cp > 0xffff) j++; // surrogate pair consumed
+ n += cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
+ }
+ return n;
+ };
+ var measure = function(node, depth) {
+ var out = { bytes: 0, files: 0 };
+ if (!node || !node.content || depth > 64) return out;
+ for (var k in node.content) {
+ if (!Object.prototype.hasOwnProperty.call(node.content, k)) continue;
+ var c = node.content[k];
+ if (!c || typeof c !== 'object') continue;
+ if (c.type === 'file') { out.bytes += u8(c.content); out.files++; }
+ else { var sub = measure(c, depth + 1); out.bytes += sub.bytes; out.files += sub.files; }
+ }
+ return out;
+ };
+ var vfsB = measure(fs.root, 0);
+ data.vfs = {
+ files: fileCount, dirs: dirCount,
+ sizeBytes: vfsB.bytes,
+ size: vfsB.bytes < 1024 ? vfsB.bytes + ' B'
+ : vfsB.bytes < 1048576 ? (vfsB.bytes / 1024).toFixed(1) + ' KB'
+ : (vfsB.bytes / 1048576).toFixed(2) + ' MB'
+ };
}
- } catch (e) { data.vfs = { files: 0, dirs: 0, size: '0KB' }; }
+ } catch (e) { data.vfs = { files: 0, dirs: 0, size: '0 B', sizeBytes: 0 }; }
// Ollama
data.ollama = {
@@ -5180,7 +5216,15 @@ maxFetchChars: this.maxFetchChars,
data.health = {
modules: data.modules.loaded + '/' + data.modules.total,
vfsSize: data.vfs ? data.vfs.size : '?',
- online: typeof navigator !== 'undefined' ? navigator.onLine : true,
+ vfsSizeBytes: data.vfs && typeof data.vfs.sizeBytes === 'number' ? data.vfs.sizeBytes : null,
+ // NO `online` boolean. `navigator.onLine` reports false on a fully
+ // connected Android WebView, which is how the debug panel and the
+ // dashboard told working users they were offline. `doctor` owns the only
+ // reachability verdict; SystemModule exposes no last verdict, so until one
+ // is probed the honest value is 'unknown'.
+ network: 'unknown',
+ networkKnown: false,
+ networkHint: 'run doctor for a live probe',
errors: totalErrors,
errorBreakdown: errCounts,
predictorAccuracy: predictorAcc
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index 801bdb7..ce42652 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.160';
+ const LATEST_VER = 'v4.6.161';
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/module-predictor.js b/modules/core/module-predictor.js
index b534d34..416b06b 100644
--- a/modules/core/module-predictor.js
+++ b/modules/core/module-predictor.js
@@ -444,6 +444,10 @@ class PredictorModule {
async _pushToServer() {
const cfg = this._wdConfig();
if (!cfg) { this._storeMode = 'cache-only (no WebDAV config)'; return false; }
+ // The store is a /spaces/<user>/_data path, i.e. auth-gated. A config without credentials
+ // produces a guaranteed 401 on every boot (measured: 107 ร— 401 on predictor.json in one day)
+ // and that 401 is what the console reports as an error. No credentials, no request.
+ if (!cfg.user || !cfg.pass) { this._storeMode = 'cache-only (no credentials)'; return false; }
try {
const body = this.terminal.fs.readFile('/system/metrics/predictor.json') || '{}';
const put = () => fetch(this._storeUrl(cfg), { method: 'PUT', headers: this._storeHeaders(cfg), body: body });
@@ -466,6 +470,9 @@ class PredictorModule {
async _pullFromServer(force) {
const cfg = this._wdConfig();
if (!cfg) { this._storeMode = 'cache-only (no WebDAV config)'; return false; }
+ // Same rule as the push: an auth-gated path read without credentials is a 401, not a
+ // "server (empty)" answer, and it costs a console error on every page load.
+ if (!cfg.user || !cfg.pass) { this._storeMode = 'cache-only (no credentials)'; return false; }
if (!force && this._cacheStamp && (Date.now() - this._cacheStamp) < this._STORE_TTL_MS) return false;
try {
const resp = await fetch(this._storeUrl(cfg), { headers: this._storeHeaders(cfg) });
diff --git a/modules/core/module-world-model.js b/modules/core/module-world-model.js
index 6e8f3c3..f7e2bbb 100644
--- a/modules/core/module-world-model.js
+++ b/modules/core/module-world-model.js
@@ -153,6 +153,65 @@ class WorldModelModule {
}
}
+ // โ”€โ”€โ”€ Measurement helpers โ”€โ”€โ”€
+
+ // UTF-8 byte length of a string. `JSON.stringify(root).length` โ€” the unit vfsSize used to
+ // report โ€” is UTF-16 string units PLUS every structural character of the serialisation
+ // (braces, quotes, keys, commas): it measured 1961 for a 1046-byte tree, and counts 'รฉ' as 1.
+ // Blob is exact in the browser and in Node 22; TextEncoder is the fallback, then a manual
+ // code-point count (never `s.length`, which would reintroduce the UTF-16 lie).
+ _utf8Len(s) {
+ if (typeof s !== 'string' || s.length === 0) return 0;
+ if (typeof Blob !== 'undefined') {
+ try { return new Blob([s]).size; } catch (e) { /* fall through */ }
+ }
+ if (typeof TextEncoder !== 'undefined') {
+ try { return new TextEncoder().encode(s).length; } catch (e) { /* fall through */ }
+ }
+ let n = 0;
+ for (let i = 0; i < s.length; i++) {
+ const cp = s.codePointAt(i);
+ if (cp > 0xffff) i++; // surrogate pair consumed
+ n += cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
+ }
+ return n;
+ }
+
+ // Stored bytes of a VFS subtree: the UTF-8 length of each FILE's content, summed. A directory
+ // node contributes nothing of its own, so a nested tree is never double counted. This is the
+ // same unit `vfs-size` and `doctor` report. Depth- and node-capped so a malformed or cyclic
+ // tree cannot hang synthesis.
+ _measureVfs(root) {
+ const out = { bytes: 0, files: 0 };
+ let nodes = 0;
+ const walk = (node, depth) => {
+ if (!node || typeof node !== 'object' || depth > 64) return;
+ const kids = node.content;
+ if (!kids || typeof kids !== 'object') return;
+ for (const name of Object.keys(kids)) {
+ if (++nodes > 20000) return;
+ const child = kids[name];
+ if (!child || typeof child !== 'object') continue;
+ if (child.type === 'file') {
+ out.bytes += this._utf8Len(child.content);
+ out.files += 1;
+ } else {
+ walk(child, depth + 1);
+ }
+ }
+ };
+ walk(root, 0);
+ return out;
+ }
+
+ // Human form for display; the exact byte count is printed beside it so the rows reconcile.
+ _fmtBytes(bytes) {
+ if (typeof bytes !== 'number' || !isFinite(bytes)) return '?';
+ if (bytes < 1024) return bytes + ' B';
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
+ return (bytes / 1024 / 1024).toFixed(2) + ' MB';
+ }
+
_gatherSystemInfo(fs, moduleCount, version) {
// Get real health data from ServerMonitorModule's _latencyLog and DebugSupervisorModule
const sm = this.terminal?.moduleManager?.modules?.['ServerMonitorModule'];
@@ -160,11 +219,18 @@ class WorldModelModule {
const errorCount = ds ? Object.keys(ds._errorDB?.errors || {}).length : 0;
const latencyErrors = sm?._latencyLog ? Object.values(sm._latencyLog).filter(e => e.status === 'error').length : 0;
const totalErrors = errorCount + latencyErrors;
+ const vfs = this._measureVfs(fs?.root || null);
return {
version: `newOS4 ${version}`,
modules: moduleCount || Object.keys(this.terminal?.moduleManager?.modules || {}).length || 0,
- vfsSize: JSON.stringify(fs?.root || {}).length,
- online: typeof navigator !== 'undefined' ? navigator.onLine : true,
+ // Stored bytes of file content โ€” the one unit `vfs-size` and `doctor` report.
+ vfsSize: vfs.bytes,
+ vfsFiles: vfs.files,
+ // No reachability verdict is manufactured here. `navigator.onLine` reports false on a
+ // fully connected Android WebView, and `doctor` is the only place that probes. Until
+ // SystemModule exposes a last-doctor verdict, 'unknown' is the honest value.
+ network: 'unknown',
+ networkKnown: false,
memory: typeof performance !== 'undefined' && performance.memory
? `${(performance.memory.usedJSHeapSize / 1048576).toFixed(1)}MB`
: 'N/A',
@@ -197,7 +263,7 @@ class WorldModelModule {
}
_gatherFileInfo(fs) {
- if (!fs) return { total: 0, size: '0KB' };
+ if (!fs) return { total: 0, size: '0 B', sizeBytes: 0 };
let fileCount = 0, dirCount = 0;
const walk = (path) => {
const entries = fs.ls(path);
@@ -213,10 +279,14 @@ class WorldModelModule {
}
};
walk('/');
+ // Same unit as system.vfsSize โ€” the two are printed on adjacent lines, so they must
+ // reconcile. Stored UTF-8 bytes of file content, not JSON.stringify(fs.root).length.
+ const vfs = this._measureVfs(fs.root);
return {
total: fileCount,
directories: dirCount,
- size: `${(JSON.stringify(fs.root).length / 1024).toFixed(1)}KB`
+ size: this._fmtBytes(vfs.bytes),
+ sizeBytes: vfs.bytes
};
}
@@ -352,7 +422,14 @@ class WorldModelModule {
this.terminal.print('๐ŸŒ World Model', 'header');
this.terminal.print(` Version: ${m.version} (last synthsized: ${new Date(m.synthesized).toLocaleTimeString()})`, 'info');
- this.terminal.print(` โš™๏ธ System โ€” ${m.system.modules} modules loaded, ${m.system.vfsSize} VFS, ${m.system.memory}`, 'info');
+ // Tolerate a model loaded from VFS that predates the byte fix (vfsSize was a "1.9KB"
+ // string then): render whatever shape is there instead of "? B".
+ const vfsLine = typeof m.system.vfsSize === 'number'
+ ? (m.system.vfsSize < 1024
+ ? `${m.system.vfsSize} bytes`
+ : `${this._fmtBytes(m.system.vfsSize)} (${m.system.vfsSize} bytes)`)
+ : String(m.system.vfsSize);
+ this.terminal.print(` โš™๏ธ System โ€” ${m.system.modules} modules loaded, ${vfsLine} VFS, ${m.system.memory}`, 'info');
this.terminal.print(` ๐Ÿงฉ Modules โ€” ${m.modules.loaded} loaded (${m.modules.modifiedCount} modified)`, 'info');
this.terminal.print(` ๐Ÿ“ Files โ€” ${m.files.total} files, ${m.files.directories} dirs, ${m.files.size}`, 'info');
this.terminal.print(` ๐Ÿ“š Skills โ€” ${m.skills.count} installed`, 'info');
@@ -596,6 +673,14 @@ class WorldModelModule {
if (!fs) return;
const data = fs.fsReadJSON('/system/world-model.json');
if (data) {
+ // A model persisted by a pre-v4.6.161 build carries `system.online` โ€” a
+ // navigator.onLine boolean the digest would hand to the LLM as fact. Scrub it on
+ // load: no probe has run in this session, so 'unknown' is the honest value.
+ if (data.system && typeof data.system === 'object' && 'online' in data.system) {
+ delete data.system.online;
+ if (typeof data.system.network === 'undefined') data.system.network = 'unknown';
+ data.system.networkKnown = false;
+ }
this._model = data;
this._lastRefresh = data.synthesized;
}
diff --git a/modules/test/test-network-payload-honesty.js b/modules/test/test-network-payload-honesty.js
new file mode 100644
index 0000000..1f18725
--- /dev/null
+++ b/modules/test/test-network-payload-honesty.js
@@ -0,0 +1,298 @@
+// node modules/test/test-network-payload-honesty.js
+// Payload honesty: no payload may turn `navigator.onLine` into a network verdict, and the
+// world-model VFS size must be STORED UTF-8 BYTES.
+//
+// Why: an Android WebView reports navigator.onLine === false while the page is fully connected.
+// v4.6.159/160 removed that boolean from `status`, `doctor`, the status bar and the net test;
+// these are the two payloads that were left, plus every reader that printed "Offline" from them.
+//
+// Asserts (all on OUTPUT, never on a regex over source):
+// ยท navigator.onLine === false must not produce `online: false` in the aiassist health payload,
+// nor any online/offline claim in the world-model snapshot or its LLM digest;
+// ยท world-model `vfsSize` == UTF-8 byte sum ('รฉ' 2 + 'โ˜ƒ' 3 + 1000 ASCII + 'abc' 3 = 1008),
+// and != JSON.stringify(root).length (UTF-16 units + serialisation structure);
+// ยท the consumer renders 'unknown': the real dashboard script is executed against a stub DOM
+// and the actual health panel HTML is inspected; world-model's showModel output is captured;
+// ยท a missing `navigator` does not throw.
+//
+// N4_CORE / N4_DASH point the same assertions at another build (mutation check).
+const fsMod = require('fs'), path = require('path');
+
+const CORE = process.env.N4_CORE || path.join(__dirname, '..', 'core');
+const DASH = process.env.N4_DASH || path.join(__dirname, '..', '..', 'downloads', 'dashboard.html');
+
+// โ”€โ”€ browser globals the modules reach for โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+const store = {};
+global.window = global;
+global.localStorage = {
+ getItem: k => (k in store ? store[k] : null),
+ setItem: (k, v) => { store[k] = String(v); },
+ removeItem: k => { delete store[k]; },
+};
+global.indexedDB = { open() { throw new Error('no idb'); }, deleteDatabase() { throw new Error('no idb'); } };
+global.NEWOS4_VERSION = 'v4.6.160-test';
+
+function setNavigator(onLine) {
+ if (onLine === 'missing') {
+ try { delete globalThis.navigator; } catch (e) { /* older node: shadow below */ }
+ try { Object.defineProperty(globalThis, 'navigator', { configurable: true, value: undefined }); } catch (e) {}
+ return;
+ }
+ Object.defineProperty(globalThis, 'navigator', {
+ configurable: true,
+ value: { onLine: onLine, userAgent: 'test-agent', storage: undefined },
+ });
+}
+
+// โ”€โ”€ classes, loaded from source exactly as the bundle does โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+const WorldModelModule = new Function(
+ fsMod.readFileSync(path.join(CORE, 'module-world-model.js'), 'utf8') + '\nreturn WorldModelModule;')();
+const AI = new Function(
+ fsMod.readFileSync(path.join(CORE, 'module-aiassist.js'), 'utf8') + '\nreturn WebX11AIAssistantModule;')();
+
+let fails = 0, passes = 0;
+function ok(name, cond, detail) {
+ if (cond) { passes++; return; }
+ fails++;
+ console.log('FAIL ' + name + (detail ? ' โ€” ' + detail : ''));
+}
+
+// โ”€โ”€ the honest tree: 'รฉ' = 2 B, 'โ˜ƒ' = 3 B, 1000 ASCII, nested 'abc' = 3 B โ”€โ”€
+const EXPECTED_BYTES = 2 + 3 + 1000 + 3; // 1008 stored bytes
+function makeTree() {
+ return {
+ type: 'dir',
+ content: {
+ 'e.txt': { type: 'file', content: 'รฉ' },
+ 's.txt': { type: 'file', content: 'โ˜ƒ' },
+ 'ascii.txt': { type: 'file', content: 'x'.repeat(1000) },
+ 'sub': { type: 'dir', content: { 'n.txt': { type: 'file', content: 'abc' } } },
+ },
+ };
+}
+
+// โ”€โ”€ an in-memory VFS with only the methods these modules call โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+function mkFs(root) {
+ const writes = new Map(), json = new Map();
+ const resolve = (p) => {
+ if (!p || p === '/') return root;
+ const parts = String(p).replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
+ let node = root;
+ for (const part of parts) {
+ node = node && node.content && node.content[part];
+ if (!node) return null;
+ }
+ return node || null;
+ };
+ const fs = {
+ root,
+ currentPath: [],
+ writes,
+ ls(p) {
+ const node = resolve(p || '/');
+ if (!node || !node.content) return [];
+ return Object.keys(node.content).map(name => ({
+ name, type: node.content[name].type, _virtual: false,
+ }));
+ },
+ readDir(p) { return fs.ls(p); },
+ readFile(p) {
+ if (writes.has(p)) return writes.get(p);
+ const n = resolve(p);
+ return n && n.type === 'file' ? (n.content || '') : null;
+ },
+ writeFile(p, c) { writes.set(p, String(c)); return true; },
+ deleteFile(p) { writes.delete(p); return true; },
+ mkdir() { return true; },
+ fsWriteJSON(p, obj) { json.set(p, obj); writes.set(p, JSON.stringify(obj)); return true; },
+ fsReadJSON(p) {
+ if (json.has(p)) return json.get(p);
+ if (writes.has(p)) { try { return JSON.parse(writes.get(p)); } catch (e) { return null; } }
+ const n = resolve(p);
+ if (n && n.type === 'file') { try { return JSON.parse(n.content); } catch (e) { return null; } }
+ return null;
+ },
+ };
+ return fs;
+}
+
+function mkTerminal(fs, wm) {
+ const out = [];
+ return {
+ out,
+ text: () => out.map(l => l.msg).join('\n'),
+ fs,
+ ollamaManager: { connected: false, currentModel: null, currentProvider: null },
+ moduleManager: {
+ modules: wm ? { WorldModelModule: wm } : {},
+ moduleRegistry: [{ name: 'A' }, { name: 'B' }],
+ getAllCommands: () => ({}),
+ },
+ countErrors: () => ({ total: 0, debug: 0, latency: 0, log: 0 }),
+ print: (msg, cls) => out.push({ msg: String(msg), cls: cls || '' }),
+ };
+}
+
+function newWorldModel() {
+ const wm = new WorldModelModule();
+ wm.terminal = mkTerminal(mkFs(makeTree()));
+ wm._synthesize();
+ return wm;
+}
+
+async function aiassistPayload(wm) {
+ const ai = Object.create(AI.prototype);
+ ai.terminal = mkTerminal(mkFs(makeTree()), wm);
+ return await ai._collectDashboardData();
+}
+
+// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
+// A/B. navigator.onLine === false must not become a payload fact
+// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
+(async function main() {
+ setNavigator(false);
+ const wm = newWorldModel();
+ const data = await aiassistPayload(wm);
+
+ // โ”€โ”€ A. aiassist health payload (debug panel / dashboard source) โ”€โ”€
+ const health = (data && data.health) || {};
+ ok('A1 health payload has no `online` key',
+ !Object.prototype.hasOwnProperty.call(health, 'online'), JSON.stringify(health));
+ ok('A2 serialised health carries no "online"',
+ JSON.stringify(health).indexOf('"online"') === -1, JSON.stringify(health));
+ ok('A3 health.network is "unknown"', health.network === 'unknown', String(health.network));
+ ok('A4 health.networkKnown is false', health.networkKnown === false, String(health.networkKnown));
+ ok('A5 health.vfsSizeBytes is the UTF-8 sum',
+ health.vfsSizeBytes === EXPECTED_BYTES, String(health.vfsSizeBytes));
+ ok('A6 health.vfsSize is a byte figure, not JSON length',
+ typeof health.vfsSize === 'string' && /B$/.test(health.vfsSize.trim()), String(health.vfsSize));
+ ok('A7 whole payload contains no "online":false',
+ JSON.stringify(data).indexOf('"online":false') === -1);
+ ok('A8 aiassist vfs bytes differ from JSON.stringify(root).length',
+ health.vfsSizeBytes !== JSON.stringify(makeTree()).length,
+ health.vfsSizeBytes + ' vs ' + JSON.stringify(makeTree()).length);
+
+ // โ”€โ”€ B. world-model snapshot + LLM digest โ”€โ”€
+ const sys = (wm._model && wm._model.system) || {};
+ ok('B1 world-model system has no `online` key',
+ !Object.prototype.hasOwnProperty.call(sys, 'online'), JSON.stringify(sys));
+ ok('B2 world-model network is "unknown"', sys.network === 'unknown', String(sys.network));
+ ok('B3 world-model networkKnown is false', sys.networkKnown === false, String(sys.networkKnown));
+ const snap = JSON.stringify(wm._modelSnapshot());
+ ok('B4 snapshot has no "online" field', snap.indexOf('"online"') === -1);
+ ok('B5 snapshot states network unknown', /"network":"unknown"/.test(snap));
+ const digest = wm._modelDigest();
+ ok('B6 digest makes no Online/Offline claim',
+ !/\b(Online|Offline)\b/.test(digest), digest.slice(0, 200));
+ ok('B7 world-model vfsSize is the UTF-8 sum',
+ sys.vfsSize === EXPECTED_BYTES, String(sys.vfsSize));
+ ok('B8 vfsSize differs from JSON.stringify(root).length',
+ sys.vfsSize !== JSON.stringify(makeTree()).length,
+ sys.vfsSize + ' vs ' + JSON.stringify(makeTree()).length);
+ ok('B9 files.sizeBytes reconciles with system.vfsSize',
+ wm._model.files.sizeBytes === sys.vfsSize, wm._model.files.sizeBytes + ' vs ' + sys.vfsSize);
+
+ // โ”€โ”€ C. the UTF-8 unit itself, one file at a time (no averaging) โ”€โ”€
+ // Guarded so a missing helper FAILS an assertion instead of aborting the run.
+ const u8len = (s) => (typeof wm._utf8Len === 'function' ? wm._utf8Len(s) : NaN);
+ const measure = (n) => (typeof wm._measureVfs === 'function' ? wm._measureVfs(n) : { bytes: NaN, files: NaN });
+ ok('C1 รฉ counts as 2 bytes', u8len('รฉ') === 2, String(u8len('รฉ')));
+ ok('C2 โ˜ƒ counts as 3 bytes', u8len('โ˜ƒ') === 3, String(u8len('โ˜ƒ')));
+ ok('C3 a nested dir adds only its own file content',
+ measure(makeTree().content['sub']).bytes === 3, String(measure(makeTree().content['sub']).bytes));
+ ok('C4 measure counts files, not directories',
+ measure(makeTree()).files === 4, String(measure(makeTree()).files));
+
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
+ // D. the consumer renders 'unknown' โ€” real dashboard script, stub DOM
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
+ const dashHtml = fsMod.readFileSync(DASH, 'utf8');
+ let dashScript = null;
+ for (const m of dashHtml.matchAll(/<script>([\s\S]*?)<\/script>/g)) {
+ if (m[1].includes('function networkText')) { dashScript = m[1]; break; }
+ }
+ ok('D0 dashboard script with networkText found', !!dashScript, DASH);
+
+ function renderHealth(healthPayload) {
+ const els = new Map();
+ const el = (id) => {
+ if (!els.has(id)) els.set(id, { id, textContent: '', innerHTML: '', className: '', style: {} });
+ return els.get(id);
+ };
+ const payload = {
+ version: 'v4.6.160-test',
+ timestamp: Date.now(),
+ modules: { loaded: 2, total: 2, modifiedCount: 0 },
+ agents: { total: 0, busy: 0, idle: 0, list: [] },
+ memory: { heap: '1MB', graphNodes: 0 },
+ vfs: { files: 4, dirs: 2, size: healthPayload.vfsSize, sizeBytes: healthPayload.vfsSizeBytes },
+ ollama: { connected: false },
+ skills: { count: 0 },
+ latency: [],
+ health: healthPayload,
+ };
+ const store2 = { webx11_dashboard_data: JSON.stringify(payload) };
+ const ls = {
+ getItem: k => (k in store2 ? store2[k] : null),
+ setItem: (k, v) => { store2[k] = String(v); },
+ removeItem: k => { delete store2[k]; },
+ };
+ const win = { addEventListener() {}, refreshNow: null };
+ const run = new Function('document', 'window', 'localStorage', 'setInterval', 'clearInterval', dashScript);
+ run({ getElementById: el }, win, ls, () => 0, () => {});
+ return el('health-metrics').innerHTML;
+ }
+
+ if (dashScript) {
+ const h1 = renderHealth({ modules: '2/2', vfsSize: '1008 B', vfsSizeBytes: 1008, network: 'unknown', networkKnown: false, errors: 0 });
+ ok('D1 unknown renders as unknown', /unknown/.test(h1), h1);
+ ok('D2 unknown never renders Offline', !/Offline/.test(h1), h1);
+ ok('D3 unknown never renders Online', !/Online/.test(h1), h1);
+ ok('D4 health panel still labels the row Network', /<span>Network<\/span>/.test(h1), h1);
+ ok('D5 VFS row shows the byte figure', /1008 B/.test(h1), h1);
+
+ // A payload from an older bundle: `online: false` must not become "Offline".
+ const h2 = renderHealth({ modules: '2/2', vfsSize: '1.9KB', online: false, errors: 0 });
+ ok('D6 legacy online:false does not render Offline', !/Offline/.test(h2), h2);
+ ok('D7 legacy online:false renders unknown', /unknown/.test(h2), h2);
+
+ // The doctor verdict path still works when a verdict exists.
+ const h3 = renderHealth({ modules: '2/2', vfsSize: '1008 B', network: 'reachable', networkKnown: true, errors: 0 });
+ ok('D8 a doctor verdict is shown as reachable', /reachable/.test(h3), h3);
+ ok('D9 reachable is not rendered as Offline', !/Offline/.test(h3), h3);
+ }
+
+ // โ”€โ”€ E. world-model's own renderer โ”€โ”€
+ wm.showModel();
+ const shown = wm.terminal.text();
+ ok('E1 world-model output makes no Online/Offline claim',
+ !/\b(Online|Offline)\b/.test(shown), shown);
+ ok('E2 world-model output prints the byte count', /1008 bytes/.test(shown), shown);
+
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
+ // F. no navigator at all must not throw, and must not invent a verdict
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
+ setNavigator('missing');
+ ok('F0 navigator really is absent', typeof navigator === 'undefined', typeof navigator);
+ let thrown = null, data2 = null, wm2 = null;
+ try {
+ wm2 = newWorldModel();
+ data2 = await aiassistPayload(wm2);
+ } catch (e) { thrown = e; }
+ ok('F1 missing navigator does not throw', thrown === null, thrown && thrown.message);
+ ok('F2 aiassist health still says unknown without navigator',
+ data2 && data2.health && data2.health.network === 'unknown' && data2.health.networkKnown === false);
+ ok('F3 world-model snapshot still says unknown without navigator',
+ wm2 && wm2._model && wm2._model.system.network === 'unknown' && wm2._model.system.networkKnown === false);
+ ok('F4 no online key appears when navigator is absent',
+ !data2 || JSON.stringify(data2).indexOf('"online"') === -1);
+
+ console.log('');
+ console.log(passes + '/' + (passes + fails) + ' assertions passed');
+ if (fails > 0) {
+ console.log('RESULT: FAIL โ€” ' + fails + ' assertion(s) failed');
+ process.exit(1);
+ }
+ console.log('RESULT: PASS โ€” no payload or reader turns navigator.onLine into a verdict, vfsSize is UTF-8 bytes');
+ process.exit(0);
+})();
diff --git a/modules/test/test-predictor-store-auth.js b/modules/test/test-predictor-store-auth.js
new file mode 100644
index 0000000..6c37812
--- /dev/null
+++ b/modules/test/test-predictor-store-auth.js
@@ -0,0 +1,80 @@
+// node modules/test/test-predictor-store-auth.js
+// F6 (2026-11-09): the predictor store is an auth-gated /spaces/<user>/_data path, but _wdConfig()
+// returns user:''/pass:'' whenever the saved WebDAV config has no credentials โ€” so every page load
+// made a guaranteed-401 request (measured 107 in one day) and the console reported it as an error.
+// No credentials must mean no request.
+const fs = require('fs');
+const path = require('path');
+const SRC = process.env.PRED_SRC || path.join(__dirname, '..', 'core', 'module-predictor.js');
+const src = fs.readFileSync(SRC, 'utf8');
+
+let pass = 0, fail = 0;
+const ok = (name, cond, detail) => {
+ if (cond) { pass++; console.log('ok ' + name); }
+ else { fail++; console.log('FAIL ' + name + (detail !== undefined ? ' โ€” ' + detail : '')); }
+};
+
+function load(localStore) {
+ const store = Object.assign({}, localStore);
+ global.localStorage = {
+ getItem: (k) => (k in store ? store[k] : null),
+ setItem: (k, v) => { store[k] = String(v); },
+ removeItem: (k) => { delete store[k]; },
+ };
+ global.window = global;
+ global.setTimeout = global.setTimeout;
+ const Mod = new Function(src + '\nreturn PredictorModule;')();
+ const m = new Mod();
+ m.terminal = {
+ print: () => {},
+ fs: {
+ readFile: () => '{}',
+ fsReadJSON: () => null,
+ fsWriteJSON: () => true,
+ readDir: () => [],
+ mkdirRecursive: () => true,
+ },
+ };
+ return m;
+}
+
+(async () => {
+ const cfgNoCreds = JSON.stringify({ url: 'https://webx11.duckdns.org/spaces/agando', user: '', password: '' });
+ const cfgWithCreds = JSON.stringify({ url: 'https://webx11.duckdns.org/spaces/agando', user: 'agando', password: 'pw' });
+
+ // ---- 1. no credentials โ†’ zero requests, both directions ----
+ let calls = 0;
+ global.fetch = () => { calls++; return Promise.resolve({ ok: true, status: 200, json: async () => ({}) }); };
+ let m = load({ webx11_webdav_config: cfgNoCreds });
+ const pulled = await m._pullFromServer(true);
+ const pushed = await m._pushToServer();
+ ok('pull without credentials returns false', pulled === false);
+ ok('push without credentials returns false', pushed === false);
+ ok('no fetch was made without credentials', calls === 0, 'calls=' + calls);
+ ok('store mode names the reason', /no credentials/.test(m._storeMode), m._storeMode);
+
+ // ---- 2. credentials present โ†’ still talks to the server ----
+ calls = 0;
+ let sawAuth = null;
+ global.fetch = (url, opts) => {
+ calls++;
+ sawAuth = opts && opts.headers && opts.headers.Authorization;
+ return Promise.resolve({ ok: true, status: 200, json: async () => ({ dataPoints: [], accuracy: { total: 0 } }) });
+ };
+ m = load({ webx11_webdav_config: cfgWithCreds });
+ await m._pullFromServer(true);
+ ok('pull with credentials reaches the server', calls === 1, 'calls=' + calls);
+ ok('pull sends an Authorization header', /^Basic /.test(String(sawAuth)), String(sawAuth));
+ ok('store mode becomes server', m._storeMode === 'server', m._storeMode);
+
+ // ---- 3. no config at all โ†’ no request (unchanged behaviour) ----
+ calls = 0;
+ global.fetch = () => { calls++; return Promise.resolve({ ok: true, status: 200 }); };
+ m = load({});
+ await m._pullFromServer(true);
+ await m._pushToServer();
+ ok('no config still makes no request', calls === 0, 'calls=' + calls);
+
+ console.log('\ntest-predictor-store-auth: ' + pass + ' passed, ' + fail + ' failed');
+ process.exit(fail ? 1 : 0);
+})().catch((e) => { console.error('HARNESS ERROR', e); process.exit(2); });