summaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/lint-no-undef.py83
-rw-r--r--scripts/test-agents-lifecycle.js87
2 files changed, 170 insertions, 0 deletions
diff --git a/scripts/lint-no-undef.py b/scripts/lint-no-undef.py
new file mode 100644
index 0000000..9f64931
--- /dev/null
+++ b/scripts/lint-no-undef.py
@@ -0,0 +1,83 @@
+#!/usr/bin/env python3
+"""Static no-undef gate for newOS4 browser scripts.
+
+The /agents/ leak fix and the `tc is not defined` crash are the same class of bug:
+a refactor moves code and leaves a stale identifier behind. node --check can't see it
+(it's a runtime ReferenceError). ESLint's no-undef can.
+
+Cross-file globals (classes/functions declared in *other* bundled files) are collected
+from top-level declarations across all core+ext files and passed as --global, so only
+genuinely-undefined identifiers are reported.
+
+Usage: python3 scripts/lint-no-undef.py [--changed module-aiassist.js ...]
+Exit: 0 = clean, 1 = undefined identifiers found
+"""
+import os
+import re
+import subprocess
+import sys
+
+ROOT = '/var/www/webx11.duckdns.org/spaces/newos4'
+# eslint is not packaged with newOS4; reuse a local install or bootstrap one:
+# mkdir -p /tmp/lint && cd /tmp/lint && npm i eslint@8
+def _eslint():
+ import shutil
+ for cand in (os.environ.get('ESLINT_BIN'), '/tmp/lint/node_modules/.bin/eslint', shutil.which('eslint')):
+ if cand and os.path.exists(cand):
+ return cand
+ sys.exit('eslint not found — run: mkdir -p /tmp/lint && cd /tmp/lint && npm i eslint@8')
+
+
+ESLINT = None # resolved in main()
+
+CORE = os.path.join(ROOT, 'modules/core')
+EXT = os.path.join(ROOT, 'modules/ext')
+
+DECL = re.compile(r'^(?:class|function|async function|const|let|var)\s+([A-Za-z_$][\w$]*)', re.M)
+
+
+def all_js(d):
+ return [os.path.join(d, f) for f in sorted(os.listdir(d)) if f.endswith('.js') and not f.endswith('.bak')]
+
+
+def collect_globals(files):
+ names = set()
+ for p in files:
+ with open(p, encoding='utf-8', errors='replace') as fh:
+ src = fh.read()
+ names.update(DECL.findall(src))
+ # window.Foo = ... / globalThis.Foo = ...
+ names.update(re.findall(r'(?:window|globalThis)\.([A-Za-z_$][\w$]*)\s*=', src))
+ return {n for n in names if len(n) > 1}
+
+
+def main():
+ global ESLINT
+ ESLINT = _eslint()
+ files = all_js(CORE) + all_js(EXT)
+ args = sys.argv[1:]
+ if args:
+ want = set(args)
+ files = [f for f in files if os.path.basename(f) in want]
+ if not files:
+ print('no matching files'); return 1
+
+ globs = collect_globals(all_js(CORE) + all_js(EXT))
+ cmd = [ESLINT, '--no-eslintrc', '--env', 'browser,es2022',
+ '--rule', '{"no-undef":"error"}',
+ '--global', ' '.join(sorted(globs))] + files
+ r = subprocess.run(cmd, capture_output=True, text=True)
+ out = (r.stdout + r.stderr).strip()
+
+ findings = re.findall(r"^(.+?):(\d+):(\d+): error '(.+?)' is not defined", out, re.M)
+ if not findings:
+ print(f'✅ no-undef clean — {len(files)} file(s), {len(globs)} cross-file globals declared')
+ return 0
+ print(f'❌ {len(findings)} undefined identifier(s):\n')
+ for path, line, col, name in findings:
+ print(f' {os.path.relpath(path, ROOT)}:{line}:{col} → {name} is not defined')
+ return 1
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/scripts/test-agents-lifecycle.js b/scripts/test-agents-lifecycle.js
new file mode 100644
index 0000000..4f011a1
--- /dev/null
+++ b/scripts/test-agents-lifecycle.js
@@ -0,0 +1,87 @@
+#!/usr/bin/env node
+/**
+ * scripts/test-agents-lifecycle.js — regression test for the /agents/ VFS leak.
+ *
+ * Bug history: killAgent() / AgentRegistry.unregisterAgent() called
+ * fs.deleteFile('agents/<id>') on a DIRECTORY. VirtualFileSystem.deleteFile()
+ * is file-only (returns false for dirs), so the call silently no-op'd and
+ * /agents/<id>/ (status.json, inbox.json, outbox.json) leaked forever.
+ * Fixed by using fs.rm() (dispatches file|dir → trash) + bounded .trash.
+ *
+ * Loads the PRODUCTION artifact (modules/core/core-bundle.js), not the sources,
+ * because main.html serves the bundle — a source-only fix is invisible in prod.
+ *
+ * Run: node scripts/test-agents-lifecycle.js (exit 0 = pass)
+ */
+const fs = require('fs');
+const path = require('path');
+
+const CORE = path.join(__dirname, '..', 'modules', 'core');
+
+// ── browser shims ─────────────────────────────────────────────────────
+const store = new Map();
+global.localStorage = {
+ getItem: k => (store.has(k) ? store.get(k) : null),
+ setItem: (k, v) => store.set(k, String(v)),
+ removeItem: k => store.delete(k),
+ clear: () => store.clear(),
+};
+global.indexedDB = undefined;
+global.fetch = async () => ({ ok: false, json: async () => ({}) });
+const el = { style: {}, classList: { add() {}, remove() {} }, addEventListener() {}, appendChild() {}, textContent: '', innerHTML: '' };
+global.document = {
+ readyState: 'complete', addEventListener() {}, getElementById: () => null,
+ querySelector: () => null, querySelectorAll: () => [], createElement: () => ({ ...el }),
+ body: { ...el, appendChild() {} }, head: { ...el, appendChild() {} },
+};
+global.window = global;
+global.navigator = { userAgent: 'node' };
+global.performance = { now: () => Date.now() };
+global.WebSocket = class { constructor() {} close() {} send() {} };
+
+const src = fs.readFileSync(path.join(CORE, 'core-bundle.js'), 'utf8');
+const { OrchestratorModule, VirtualFileSystem } = new Function('window', src +
+ '\n;return { OrchestratorModule: (typeof OrchestratorModule!=="undefined"?OrchestratorModule:null), VirtualFileSystem: (typeof VirtualFileSystem!=="undefined"?VirtualFileSystem:window.VirtualFileSystem) };'
+)(global.window);
+
+let failures = 0;
+const check = (label, cond, detail = '') => {
+ console.log(`${cond ? '✅ PASS' : '❌ FAIL'} ${label}${detail ? ' — ' + detail : ''}`);
+ if (!cond) failures++;
+};
+
+const log = [];
+const vfs = new VirtualFileSystem();
+const terminal = { fs: vfs, print: m => log.push(String(m)), moduleManager: { modules: {}, _setInterval: () => null, _clearInterval: () => {} } };
+const orch = new OrchestratorModule();
+orch.terminal = terminal;
+const dirs = () => (vfs.readDir('/agents/') || []).filter(e => e.type === 'dir').map(e => e.name);
+
+// ── 1. spawn/kill cycle leaves nothing behind ─────────────────────────
+const ids = [];
+for (let i = 0; i < 5; i++) ids.push(orch.spawnAgent('worker', ['read_file']).id);
+check('spawn creates 5 agent dirs', dirs().length === 5, `/agents/=${dirs().length}`);
+ids.forEach(id => orch.killAgent(id));
+check('kill frees all in-memory agents', Object.keys(orch._agents).length === 0);
+check('kill removes all /agents/ dirs (the leak)', dirs().length === 0, dirs().length ? 'LEAKED: ' + dirs().join(',') : 'clean');
+check('no VFS cleanup warnings', !log.some(l => l.includes('VFS cleanup failed')));
+
+// ── 2. registry unregister path (second call site) ───────────────────
+const AgentRegistryModule = new Function('window', fs.readFileSync(path.join(CORE, 'module-agent-registry.js'), 'utf8') +
+ '\n;return AgentRegistryModule;')(global.window);
+const registry = Object.create(AgentRegistryModule.prototype);
+registry.terminal = terminal;
+registry.a2aUnregister = () => {};
+const a2 = orch.spawnAgent('searcher', ['read_file']);
+registry.unregisterAgent(a2.id);
+check('registry.unregisterAgent removes the dir', !dirs().includes(a2.id));
+
+// ── 3. .trash stays bounded ──────────────────────────────────────────
+orch._SPAWN_LIMIT = 200;
+for (let i = 0; i < 60; i++) { const a = orch.spawnAgent('w', ['read_file']); if (a) orch.killAgent(a.id); }
+const trash = vfs.readDir('/.trash/') || [];
+check('/agents/ still empty after 60 cycles', dirs().length === 0, `${dirs().length} dirs`);
+check('.trash capped at 50 entries', trash.length <= 50, `${trash.length} entries`);
+
+console.log(failures === 0 ? '\nALL CHECKS PASSED' : `\n${failures} CHECK(S) FAILED`);
+process.exit(failures === 0 ? 0 : 1);