aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CryptoGzipModule-draft.js315
-rw-r--r--help.html134
-rw-r--r--main.html2
-rw-r--r--modules/core/module-aiassist.js49
-rw-r--r--modules/core/module-fs.js49
-rw-r--r--modules/core/module-loader.js7
-rw-r--r--modules/core/module-manager.js24
-rw-r--r--modules/core/module-skills.js68
-rw-r--r--modules/core/module-system.js95
-rw-r--r--modules/core/terminal.js452
-rw-r--r--modules/core/virtual-fs.js440
-rw-r--r--modules/ext/busybox.js407
-rw-r--r--modules/ext/cryptogzip.js281
-rw-r--r--modules/ext/mindmap.js21
-rw-r--r--modules/ext/shell.js517
-rw-r--r--modules/ext/skill-pack-core.js2
-rw-r--r--modules/ext/webdav.js210
-rw-r--r--modules/test/test-suite.js419
-rw-r--r--packages.json32
-rw-r--r--skills/backup-and-restore.md27
-rw-r--r--skills/index.json149
-rw-r--r--skills/memory-and-profile.md30
-rw-r--r--skills/memory-map.md51
-rw-r--r--skills/mindmap-user-guide.md40
-rw-r--r--skills/mindmap-workflow.md39
-rw-r--r--skills/module-management.md32
-rw-r--r--skills/navigating-the-terminal.md27
-rw-r--r--skills/skills-system-guide.md35
-rw-r--r--skills/system-maintenance.md35
-rw-r--r--skills/web-search-and-fetch.md27
-rw-r--r--skills/webdav-sync.md40
31 files changed, 3764 insertions, 292 deletions
diff --git a/CryptoGzipModule-draft.js b/CryptoGzipModule-draft.js
new file mode 100644
index 0000000..2c1e45f
--- /dev/null
+++ b/CryptoGzipModule-draft.js
@@ -0,0 +1,315 @@
+/**
+ * CryptoGzipModule — Draft for Dev Agent
+ *
+ * Commands:
+ * gzip <path> → creates <path>.gz
+ * gunzip <path.gz> → restores <path>
+ * encrypt <path> → prompts passphrase → creates <path>.enc
+ * decrypt <path.enc> → prompts passphrase → restores <path>
+ *
+ * Wires into WebX11 module system. Copy into modules/ext/ or modules/tools/.
+ */
+
+class CryptoGzipModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this._passphraseCache = new Map(); // session-only, never persisted
+ }
+
+ requirements() {
+ const hasCrypto = !!(window.crypto && crypto.subtle);
+ const hasCompression = !!(window.CompressionStream);
+ const missing = [];
+ if (!hasCrypto) missing.push('Web Crypto API not available (requires secure context)');
+ if (!hasCompression) missing.push('CompressionStream not available (requires Chromium-based browser or recent Firefox)');
+ return { met: missing.length === 0, missing };
+ }
+
+ getHelpInfo() {
+ return {
+ 'gzip': 'gzip <path> — Compress a VFS file to .gz',
+ 'gunzip': 'gunzip <path.gz> — Decompress a .gz file in VFS',
+ 'encrypt':'encrypt <path> — AES-256-GCM encrypt a VFS file (interactive passphrase)',
+ 'decrypt':'decrypt <path.enc> — Decrypt a .enc file (interactive passphrase)',
+ };
+ }
+
+ init(terminal) {
+ this.terminal = terminal;
+ this.commands = {
+ 'gzip': (args) => this._cmdGzip(args),
+ 'gunzip': (args) => this._cmdGunzip(args),
+ 'encrypt':(args) => this._cmdEncrypt(args),
+ 'decrypt':(args) => this._cmdDecrypt(args),
+ };
+ }
+
+ // ============================================================
+ // VFS WIRE POINTS — Dev Agent: replace with real VFS calls
+ // ============================================================
+
+ async _vfsRead(path) {
+ // WIRE: return await this.terminal.vfs.readFile(path, 'string');
+ // OR: return window.webx11_vfs.read(path);
+ throw new Error('_vfsRead not wired');
+ }
+
+ async _vfsWrite(path, content) {
+ // WIRE: await this.terminal.vfs.writeFile(path, content);
+ // content is a string (base64 for binary-ish data)
+ throw new Error('_vfsWrite not wired');
+ }
+
+ async _vfsExists(path) {
+ // WIRE: return this.terminal.vfs.exists(path);
+ throw new Error('_vfsExists not wired');
+ }
+
+ async _vfsDelete(path) {
+ // WIRE: await this.terminal.vfs.delete(path);
+ throw new Error('_vfsDelete not wired');
+ }
+
+ // ============================================================
+ // Prompt Utility
+ // ============================================================
+
+ _promptPassphrase(message = 'Enter passphrase: ') {
+ return new Promise((resolve) => {
+ // WIRE: adapt to WebX11 terminal prompt
+ // Option A: if terminal has hidden prompt:
+ // this.terminal.prompt(message, { hidden: true }).then(resolve);
+ // Option B: temporary override:
+ const savedHandler = this.terminal.onCommand;
+ this.terminal.print(message);
+ this.terminal.onCommand = (line) => {
+ this.terminal.onCommand = savedHandler;
+ resolve(line);
+ };
+ });
+ }
+
+ _confirmPrompt(message) {
+ return new Promise((resolve) => {
+ this.terminal.print(message + ' (yes/no): ');
+ const savedHandler = this.terminal.onCommand;
+ this.terminal.onCommand = (line) => {
+ this.terminal.onCommand = savedHandler;
+ resolve(line.trim().toLowerCase() === 'yes');
+ };
+ });
+ }
+
+ // ============================================================
+ // GZIP
+ // ============================================================
+
+ async _cmdGzip(args) {
+ const path = args[0];
+ if (!path) throw new Error('Usage: gzip <path>');
+
+ const exists = await this._vfsExists(path);
+ if (!exists) throw new Error(`File not found: ${path}`);
+
+ const outPath = path + '.gz';
+ if (await this._vfsExists(outPath)) {
+ const ok = await this._confirmPrompt(`Overwrite ${outPath}?`);
+ if (!ok) return 'Cancelled.';
+ }
+
+ const content = await this._vfsRead(path);
+ const compressed = await this._gzipString(content);
+ await this._vfsWrite(outPath, compressed);
+ await this._vfsDelete(path);
+
+ const ratio = (compressed.length / Math.max(content.length, 1)).toFixed(2);
+ return `gzipped: ${path} → ${outPath} (${content.length} → ${compressed.length} chars, ratio ${ratio})`;
+ }
+
+ async _cmdGunzip(args) {
+ const path = args[0];
+ if (!path || !path.endsWith('.gz')) throw new Error('Usage: gunzip <path.gz>');
+
+ const exists = await this._vfsExists(path);
+ if (!exists) throw new Error(`File not found: ${path}`);
+
+ const outPath = path.replace(/\.gz$/, '');
+ if (await this._vfsExists(outPath)) {
+ const ok = await this._confirmPrompt(`Overwrite ${outPath}?`);
+ if (!ok) return 'Cancelled.';
+ }
+
+ const compressed = await this._vfsRead(path);
+ const content = await this._gunzipString(compressed);
+ await this._vfsWrite(outPath, content);
+ await this._vfsDelete(path);
+
+ return `gunzipped: ${path} → ${outPath} (${compressed.length} chars)`;
+ }
+
+ async _gzipString(str) {
+ const encoder = new TextEncoder();
+ const bytes = encoder.encode(str);
+ const cs = new CompressionStream('gzip');
+ const writer = cs.writable.getWriter();
+ writer.write(bytes);
+ writer.close();
+ const compressed = await new Response(cs.readable).arrayBuffer();
+ return this._bytesToBase64(new Uint8Array(compressed));
+ }
+
+ async _gunzipString(b64) {
+ const compressed = this._base64ToBytes(b64);
+ const ds = new DecompressionStream('gzip');
+ const writer = ds.writable.getWriter();
+ writer.write(compressed);
+ writer.close();
+ const decompressed = await new Response(ds.readable).arrayBuffer();
+ return new TextDecoder().decode(decompressed);
+ }
+
+ // ============================================================
+ // ENCRYPT / DECRYPT (AES-256-GCM)
+ // ============================================================
+
+ async _cmdEncrypt(args) {
+ const path = args[0];
+ if (!path) throw new Error('Usage: encrypt <path>');
+
+ const exists = await this._vfsExists(path);
+ if (!exists) throw new Error(`File not found: ${path}`);
+
+ const outPath = path + '.enc';
+ if (await this._vfsExists(outPath)) {
+ const ok = await this._confirmPrompt(`Overwrite ${outPath}?`);
+ if (!ok) return 'Cancelled.';
+ }
+
+ const passphrase = await this._promptPassphrase('Enter passphrase: ');
+ if (!passphrase || passphrase.length < 4) throw new Error('Passphrase too short (min 4 chars)');
+
+ const plaintext = await this._vfsRead(path);
+ const envelope = await this._encryptString(plaintext, passphrase);
+ await this._vfsWrite(outPath, JSON.stringify(envelope, null, 0));
+ await this._vfsDelete(path);
+
+ return `encrypted: ${path} → ${outPath}`;
+ }
+
+ async _cmdDecrypt(args) {
+ const path = args[0];
+ if (!path || !path.endsWith('.enc')) throw new Error('Usage: decrypt <path.enc>');
+
+ const exists = await this._vfsExists(path);
+ if (!exists) throw new Error(`File not found: ${path}`);
+
+ const outPath = path.replace(/\.enc$/, '');
+ if (await this._vfsExists(outPath)) {
+ const ok = await this._confirmPrompt(`Overwrite ${outPath}?`);
+ if (!ok) return 'Cancelled.';
+ }
+
+ const passphrase = await this._promptPassphrase('Enter passphrase: ');
+ if (!passphrase) throw new Error('Passphrase required');
+
+ const envelopeJson = await this._vfsRead(path);
+ let envelope;
+ try {
+ envelope = JSON.parse(envelopeJson);
+ } catch (e) {
+ throw new Error('Invalid .enc file format (expected JSON envelope)');
+ }
+
+ const plaintext = await this._decryptEnvelope(envelope, passphrase);
+ await this._vfsWrite(outPath, plaintext);
+ await this._vfsDelete(path);
+
+ return `decrypted: ${path} → ${outPath}`;
+ }
+
+ async _encryptString(plaintext, passphrase) {
+ const salt = crypto.getRandomValues(new Uint8Array(16));
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const key = await this._deriveKey(passphrase, salt);
+
+ const encoder = new TextEncoder();
+ const ciphertext = await crypto.subtle.encrypt(
+ { name: 'AES-GCM', iv },
+ key,
+ encoder.encode(plaintext)
+ );
+
+ return {
+ v: 1, // format version
+ alg: 'AES-256-GCM+PBKDF2',
+ salt: this._bytesToBase64(salt),
+ iv: this._bytesToBase64(iv),
+ ct: this._bytesToBase64(new Uint8Array(ciphertext)),
+ };
+ }
+
+ async _decryptEnvelope(envelope, passphrase) {
+ if (!envelope || envelope.v !== 1) throw new Error('Unsupported envelope version');
+
+ const salt = this._base64ToBytes(envelope.salt);
+ const iv = this._base64ToBytes(envelope.iv);
+ const ct = this._base64ToBytes(envelope.ct);
+ const key = await this._deriveKey(passphrase, salt);
+
+ try {
+ const decrypted = await crypto.subtle.decrypt(
+ { name: 'AES-GCM', iv },
+ key,
+ ct
+ );
+ return new TextDecoder().decode(decrypted);
+ } catch (e) {
+ throw new Error('Decryption failed — wrong passphrase or corrupted file');
+ }
+ }
+
+ async _deriveKey(passphrase, salt) {
+ const encoder = new TextEncoder();
+ const baseKey = await crypto.subtle.importKey(
+ 'raw',
+ encoder.encode(passphrase),
+ { name: 'PBKDF2' },
+ false,
+ ['deriveKey']
+ );
+ return crypto.subtle.deriveKey(
+ {
+ name: 'PBKDF2',
+ salt: salt,
+ iterations: 100000,
+ hash: 'SHA-256',
+ },
+ baseKey,
+ { name: 'AES-GCM', length: 256 },
+ false,
+ ['encrypt', 'decrypt']
+ );
+ }
+
+ // ============================================================
+ // Base64 Helpers (binary-safe, no deprecated btoa/unescape)
+ // ============================================================
+
+ _bytesToBase64(bytes) {
+ // Works in all modern browsers + WebX11
+ const bin = Array.from(bytes, (b) => String.fromCharCode(b)).join('');
+ return btoa(bin);
+ }
+
+ _base64ToBytes(b64) {
+ const bin = atob(b64);
+ return Uint8Array.from(bin, (c) => c.charCodeAt(0));
+ }
+}
+
+// ================================================================
+// EXPORTS — uncomment the appropriate line for your loader:
+// ================================================================
+// window.CryptoGzipModule = CryptoGzipModule; // global registration
+// export { CryptoGzipModule }; // ES module
diff --git a/help.html b/help.html
index 8d30b4e..cc9c3b5 100644
--- a/help.html
+++ b/help.html
@@ -218,8 +218,8 @@ footer .tagline{color:var(--accent2);margin-top:10px;font-weight:500}
</div>
<div class="header-meta">
<span class="badge badge-v">v4.2</span>
- <span class="badge badge-new">24 core · 12 ext</span>
- <span style="color:var(--text2)">110+ commands</span>
+ <span class="badge badge-new">26 core · 15 ext</span>
+ <span style="color:var(--text2)">135+ commands</span>
</div>
</div>
</header>
@@ -238,6 +238,8 @@ footer .tagline{color:var(--accent2);margin-top:10px;font-weight:500}
<a href="#config" class="nav-link">Config</a>
<a href="#modules" class="nav-link">Modules</a>
<a href="#extras" class="nav-link">Extras</a>
+ <a href="#busybox" class="nav-link">BusyBox</a>
+ <a href="#shell" class="nav-link">Shell</a>
<a href="#sessions" class="nav-link">Sessions</a>
<a href="#storage" class="nav-link">Storage</a>
<a href="#security" class="nav-link">Security</a>
@@ -259,7 +261,7 @@ footer .tagline{color:var(--accent2);margin-top:10px;font-weight:500}
<h2>⛅ Overview</h2>
<p>WebX11 v4.2 is a browser-based Unix-like terminal — dynamic modules, AI assistant (Ollama), persistent virtual filesystem, web search, voice input, code editor, and a full skill system. Everything runs client-side in your browser.</p>
<div class="feature-grid">
- <div class="feature-card"><h4>🧩 24 Core + 12 Ext</h4><p>Modules loaded at startup. Load new ones from any URL at runtime.</p></div>
+ <div class="feature-card"><h4>🧩 26 Core + 14 Ext</h4><p>Modules loaded at startup. Shell with pipes, redirects, vars, and scripts.</p></div>
<div class="feature-card"><h4>🤖 Agent with Tools</h4><p>15+ tools — file ops, web search, skills, modules. Destructive action confirmations with inline bar + countdown.</p></div>
<div class="feature-card"><h4>📚 Skills System</h4><p>Persistent procedural memory. Create, patch, search, export skills for repeatable workflows.</p></div>
<div class="feature-card"><h4>💾 VFS + Sessions</h4><p>Virtual filesystem persists in localStorage. Save/load/export sessions.</p></div>
@@ -628,7 +630,7 @@ ollamaconnect</pre>
<section id="modules">
<h2>🧩 Module System</h2>
<div class="callout callout-info">
- <span class="callout-title">24 Core + 12 Ext</span>
+ <span class="callout-title">26 Core + 14 Ext</span>
Modules loaded in dependency order at startup. Toggle ext modules with ⚙ gear on splash.
</div>
<h3>Commands</h3>
@@ -714,6 +716,106 @@ ollamaconnect</pre>
<p>Built-in: <code>ll</code> = <code>ls</code>, <code>h</code> = <code>help</code>, <code>cls</code> = <code>clear</code>. Define custom: <code>alias x=ls -la</code>. Remove: <code>unalias x</code>. Persists in localStorage.</p>
</section>
+ <!-- ── BusyBox ── -->
+ <section id="busybox">
+ <h2>🔧 BusyBox Utilities</h2>
+ <div class="callout callout-success">
+ <span class="callout-title">16 Unix Utility Commands</span>
+ Lightweight utility commands for everyday terminal work — identity, path, text, and system info tools. All work standalone and with shell pipes.
+ </div>
+ <div class="table-wrap"><table>
+ <tr><th>Command</th><th>Description</th><th>Example</th></tr>
+ <tr><td><code>which</code></td><td>Locate command in module registry + fuzzy suggestions</td><td><code>which ls</code></td></tr>
+ <tr><td><code>env</code></td><td>List local env vars (webx11_, ollama, user profile)</td><td><code>env</code></td></tr>
+ <tr><td><code>date</code></td><td>Current time (local, ISO, UTC, Unix timestamp)</td><td><code>date</code></td></tr>
+ <tr><td><code>cal</code></td><td>ASCII calendar for any month/year</td><td><code>cal 12 2026</code></td></tr>
+ <tr><td><code>yes</code></td><td>Output repeated string (capped at 50)</td><td><code>yes test</code></td></tr>
+ <tr><td><code>seq</code></td><td>Generate number sequence</td><td><code>seq 1 5</code></td></tr>
+ <tr><td><code>base64</code></td><td>Base64 encode/decode</td><td><code>base64 -d aGVsbG8=</code></td></tr>
+ <tr><td><code>printf</code></td><td>Formatted print (%s, %d, %f)</td><td><code>printf "hello %s" world</code></td></tr>
+ <tr><td><code>cut</code></td><td>Extract column from file</td><td><code>cut -f2 -d, data.csv</code></td></tr>
+ <tr><td><code>uniq</code></td><td>Filter adjacent duplicates</td><td><code>uniq lines.txt</code></td></tr>
+ <tr><td><code>hostname</code></td><td>Show host/platform info</td><td><code>hostname</code></td></tr>
+ <tr><td><code>whoami</code></td><td>Show current user</td><td><code>whoami</code></td></tr>
+ <tr><td><code>basename</code></td><td>Strip directory from path</td><td><code>basename /a/b/c.txt</code></td></tr>
+ <tr><td><code>dirname</code></td><td>Strip filename from path</td><td><code>dirname /a/b/c.txt</code></td></tr>
+ <tr><td><code>sleep</code></td><td>Countdown delay (1-30s)</td><td><code>sleep 3</code></td></tr>
+ <tr><td><code>expand</code></td><td>Convert tabs to spaces</td><td><code>expand code.txt</code></td></tr>
+ </table></div>
+ <p>All file-reading commands (<code>cut</code>, <code>uniq</code>, <code>expand</code>) work with shell pipes: <code>cat data.csv | cut -f2 -d, | uniq</code>.</p>
+ </section>
+
+ <!-- ── Shell ── -->
+ <section id="shell">
+ <h2>🐚 Shell</h2>
+ <div class="callout callout-success">
+ <span class="callout-title">Pipes · Redirects · Variables · Scripts</span>
+ The shell module sits on top of every command — pipes, redirections, variable substitution, command chaining, and VFS script execution work automatically without any prefix.
+ </div>
+
+ <h3>Pipes <code>|</code></h3>
+ <p>Chain commands by piping stdout to the next command's stdin. Pipe-aware commands (<code>grep</code>, <code>head</code>, <code>tail</code>, <code>sort</code>, <code>wc</code>, <code>cut</code>, <code>uniq</code>, <code>expand</code>) auto-read from pipe when no file argument is given.</p>
+ <pre><span class="cmt"># Filter file listing</span>
+ls | grep .md
+
+<span class="cmt"># Count lines matching pattern</span>
+cat notes.txt | grep error | wc
+
+<span class="cmt"># Pipeline: filter → sort → head</span>
+cat data.csv | grep 2026 | sort | head -5</pre>
+
+ <h3>Redirects <code>&gt;</code> <code>&gt;&gt;</code> <code>&lt;</code></h3>
+ <pre><span class="cmt"># Write output to file</span>
+cal &gt; /tmp/calendar.txt
+
+<span class="cmt"># Append to file</span>
+echo "new line" &gt;&gt; log.txt
+
+<span class="cmt"># Read file as stdin</span>
+grep error &lt; log.txt</pre>
+
+ <h3>Command Chaining</h3>
+ <pre><span class="cmt"># AND: next runs only if previous succeeds</span>
+touch test.txt &amp;&amp; echo "created"
+
+<span class="cmt"># OR: next runs only if previous fails</span>
+rm nonexistent || echo "failed"
+
+<span class="cmt"># Sequence: runs in order regardless</span>
+cd /skills; ls</pre>
+
+ <h3>Variables</h3>
+ <pre><span class="cmt"># Set a variable</span>
+export GREET=hello
+
+<span class="cmt"># Reference in any command</span>
+echo $GREET
+
+<span class="cmt"># List all vars</span>
+set
+
+<span class="cmt"># Remove a var</span>
+unset GREET</pre>
+
+ <h3>Scripts</h3>
+ <p>VFS files with <code>.sh</code> extension can be executed line by line. Variables persist across lines.</p>
+ <pre><span class="cmt"># Create a script in VFS</span>
+echo 'export NAME=WebX11' &gt; greet.sh
+echo 'echo "Hello, $NAME!"' &gt;&gt; greet.sh
+
+<span class="cmt"># Run it</span>
+script greet.sh</pre>
+
+ <h3>Shell Commands</h3>
+ <div class="table-wrap"><table>
+ <tr><th>Command</th><th>Description</th></tr>
+ <tr><td><code>export &lt;var&gt;=&lt;val&gt;</code></td><td>Set a shell variable</td></tr>
+ <tr><td><code>unset &lt;var&gt;</code></td><td>Remove a shell variable</td></tr>
+ <tr><td><code>set</code></td><td>List all shell variables</td></tr>
+ <tr><td><code>script &lt;path.sh&gt;</code></td><td>Run a VFS script file</td></tr>
+ </table></div>
+ </section>
+
<!-- ── Sessions ── -->
<section id="sessions">
<h2>💬 Sessions</h2>
@@ -814,6 +916,17 @@ listmods</pre>
<div class="quick-ref-item"><span class="cmd">alias</span><span class="desc">List aliases</span></div>
<div class="quick-ref-item"><span class="cmd">preview</span><span class="desc">HTML preview</span></div>
<div class="quick-ref-item"><span class="cmd">upload</span><span class="desc">Upload file</span></div>
+ <div class="quick-ref-item"><span class="cmd">&#124; pipe</span><span class="desc">Chain commands</span></div>
+ <div class="quick-ref-item"><span class="cmd">&gt; file</span><span class="desc">Redirect output</span></div>
+ <div class="quick-ref-item"><span class="cmd">which</span><span class="desc">Locate command</span></div>
+ <div class="quick-ref-item"><span class="cmd">env</span><span class="desc">Show env vars</span></div>
+ <div class="quick-ref-item"><span class="cmd">script</span><span class="desc">Run .sh file</span></div>
+ <div class="quick-ref-item"><span class="cmd">date</span><span class="desc">Show time</span></div>
+ <div class="quick-ref-item"><span class="cmd">cal</span><span class="desc">Show calendar</span></div>
+ <div class="quick-ref-item"><span class="cmd">base64</span><span class="desc">Encode/decode</span></div>
+ <div class="quick-ref-item"><span class="cmd">seq</span><span class="desc">Number sequence</span></div>
+ <div class="quick-ref-item"><span class="cmd">whoami</span><span class="desc">Show user</span></div>
+ <div class="quick-ref-item"><span class="cmd">basename</span><span class="desc">Strip dir path</span></div>
</div>
</div>
</section>
@@ -823,6 +936,19 @@ listmods</pre>
<h2>📋 Changelog</h2>
<div class="version-item">
+ <div class="v-header"><span class="v-num">v4.2.1 — May 2026</span><span class="v-date">2026-05-27</span></div>
+ <ul>
+ <li><strong>BusyBox</strong> — 16 new utility commands: <code>which</code>, <code>env</code>, <code>date</code>, <code>cal</code>, <code>yes</code>, <code>seq</code>, <code>base64</code>, <code>printf</code>, <code>cut</code>, <code>uniq</code>, <code>hostname</code>, <code>whoami</code>, <code>basename</code>, <code>dirname</code>, <code>sleep</code>, <code>expand</code></li>
+ <li><strong>Shell</strong> — Full pipe (<code>|</code>), redirect (<code>&gt;</code>, <code>&gt;&gt;</code>, <code>&lt;</code>), chain (<code>&amp;&amp;</code>, <code>||</code>, <code>;</code>), variable (<code>$VAR</code>), and VFS script execution (<code>script</code>)</li>
+ <li>Pipe-aware commands (<code>grep</code>, <code>head</code>, <code>tail</code>, <code>sort</code>, <code>wc</code>, <code>cut</code>, <code>uniq</code>, <code>expand</code>) auto-read from pipe via temp file injection</li>
+ <li><code>export</code>, <code>unset</code>, <code>set</code> — shell variable management</li>
+ <li>Multiple ASI and VFS API bug fixes (<code>fs.fsWrite</code> → <code>fs.writeFile</code> across 3 modules)</li>
+ <li><strong>Test Suite</strong> — 32 automated regression tests. Run with <code>load-local modules/test/test-suite.js</code> then <code>test</code>, <code>test auto</code>, or <code>test list</code></li>
+ <li>Cache v34 — hard refresh required</li>
+ </ul>
+ </div>
+
+ <div class="version-item">
<div class="v-header"><span class="v-num">v4.2 — May 2026</span><span class="v-date">2026-05-25</span></div>
<ul>
<li><strong>Editor</strong> — <code>edit &lt;path&gt;</code> opens inline VFS editor modal</li>
diff --git a/main.html b/main.html
index f1f315e..5b88872 100644
--- a/main.html
+++ b/main.html
@@ -804,7 +804,7 @@
</script>
<!-- Async dependency-ordered module loader (replaces 26 synchronous script tags) -->
- <script src="modules/core/module-loader.js?v=19"></script>
+ <script src="modules/core/module-loader.js?v=34"></script>
</body>
</html>
diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js
index f9e1e98..e983e6a 100644
--- a/modules/core/module-aiassist.js
+++ b/modules/core/module-aiassist.js
@@ -538,6 +538,22 @@ RULES:
}
return null;
}
+ _trackToolFailure(toolName, error) {
+ try {
+ const key = 'webx11_tool_failures';
+ const raw = localStorage.getItem(key) || '{}';
+ const map = JSON.parse(raw);
+ if (!map[toolName]) map[toolName] = { count: 0, last: '', errors: [] };
+ map[toolName].count++;
+ map[toolName].last = new Date().toISOString();
+ map[toolName].errors.push(error);
+ if (map[toolName].errors.length > 5) map[toolName].errors.shift();
+ localStorage.setItem(key, JSON.stringify(map));
+ if (map[toolName].count >= 3) {
+ this.terminal.print(`⚠️ Tool '${toolName}' has failed ${map[toolName].count} times. Consider creating a workaround skill.`, 'warning');
+ }
+ } catch (e) { /* ignore */ }
+ }
async agenticLoop(messages) {
let toolTurns = 0;
while (toolTurns < this.MAX_TOOL_TURNS) {
@@ -579,8 +595,11 @@ RULES:
const obs = this._summarizeObservation(toolResult, 150);
const err = this._isToolError(toolResult);
if (err) {
+ this.terminal.log('WARN', 'AI', `Tool ${fn.name} failed: ${err} (${t1}s)`);
+ this._trackToolFailure(fn.name, err);
this.terminal.print(`📊 Result (${t1}s): ⚠️ Error: ${err}`, 'warning');
} else {
+ this.terminal.log('INFO', 'AI', `Tool ${fn.name} ok (${t1}s)`);
this.terminal.print(`📊 Result (${t1}s): ${obs}`, 'info');
}
messages.push({ role: 'tool', content: toolResult });
@@ -598,8 +617,11 @@ RULES:
const dObs = this._summarizeObservation(toolResult, 150);
const dErr = this._isToolError(toolResult);
if (dErr) {
+ this.terminal.log('WARN', 'AI', `Tool ${fn.name} failed: ${dErr} (${t1}s)`);
+ this._trackToolFailure(fn.name, dErr);
this.terminal.print(`📊 Result (${t1}s): ⚠️ Error: ${dErr}`, 'warning');
} else {
+ this.terminal.log('INFO', 'AI', `Tool ${fn.name} ok (${t1}s)`);
this.terminal.print(`📊 Result (${t1}s): ${dObs}`, 'info');
}
messages.push({ role: 'tool', content: toolResult });
@@ -803,8 +825,9 @@ RULES:
return name === 'write_file' || name === 'delete_file' || name === 'run_command' || name === 'make_directory' || name === 'edit_module';
}
async runTool(name, argsStr) {
+ let a = {};
try {
- const a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {});
+ a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {});
switch (name) {
case 'list_files': {
const originalPath = a.path || '';
@@ -1058,7 +1081,7 @@ RULES:
for (const [name, meta] of Object.entries(index)) {
const score = (name.includes(query) ? 3 : 0) + (meta.description.toLowerCase().includes(query) ? 2 : 0) + ((meta.tags || []).some(t => t.toLowerCase().includes(query)) ? 2 : 0);
if (score > 0) {
- const content = fs.fsRead(`/skills/${name}.md`) || '';
+ const content = fs.readFile(`/skills/${name}.md`) || '';
matches.push({ name, description: meta.description, category: meta.category, score, content: content.substring(0, 2000) });
}
}
@@ -1078,7 +1101,7 @@ RULES:
}
const fs = this.terminal.fs;
fs.mkdirRecursive('/skills');
- fs.fsWrite(`/skills/${name}.md`, content);
+ fs.writeFile(`/skills/${name}.md`, content);
const index = fs.fsReadJSON('/skills/index.json') || {};
index[name] = { name, description, tags, category, created: index[name]?.created || new Date().toISOString(), updated: new Date().toISOString() };
fs.fsWriteJSON('/skills/index.json', index);
@@ -1093,7 +1116,7 @@ RULES:
const fs = this.terminal.fs;
const index = fs.fsReadJSON('/skills/index.json') || {};
if (!index[pname]) return JSON.stringify({ error: `Skill "${pname}" not found` });
- fs.fsWrite(`/skills/${pname}.md`, pcontent);
+ fs.writeFile(`/skills/${pname}.md`, pcontent);
index[pname].updated = new Date().toISOString();
// Re-parse frontmatter for metadata updates
const metaMatch = pcontent.match(/^---\n([\s\S]*?)\n---/);
@@ -1134,8 +1157,10 @@ RULES:
}
}
async runModuleTool(name, argsStr) {
- const a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {});
- switch (name) {
+ let a = {};
+ try {
+ a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {});
+ switch (name) {
case 'view_module': {
const modName = a.name || '';
const entry = this.terminal.moduleManager.moduleRegistry.find(m => m.name === modName);
@@ -1211,6 +1236,10 @@ RULES:
return JSON.stringify({ modules, total: modules.length, hasLocalEdits: hasMods });
}
default: return JSON.stringify({ error: `Unknown module tool: ${name}` });
+ }
+ } catch (e) {
+ this._errorLog.push({ tool: name, args: a, error: e.message, timestamp: Date.now() });
+ return JSON.stringify({ error: e.message, stack: e.stack?.substring(0, 200) });
}
}
clearContext() {
@@ -1358,11 +1387,11 @@ category: productivity
- User says "map out", "diagram", "brainstorm", "organize thoughts"
- Visualizing project structure or task decomposition`;
- fs.fsWrite('/skills/mindmap-user-guide.md', mindmapSkill);
+ fs.writeFile('/skills/mindmap-user-guide.md', mindmapSkill);
const existingIndex = fs.fsReadJSON('/skills/index.json') || {};
existingIndex['mindmap-user-guide'] = { name: 'mindmap-user-guide', description: 'How to create, view, and manage brainstorming mindmaps using the mindmap terminal module', tags: ['mindmap', 'brainstorming', 'organization', 'planning'], category: 'productivity', created: existingIndex['mindmap-user-guide']?.created || new Date().toISOString(), updated: new Date().toISOString() };
fs.fsWriteJSON('/skills/index.json', existingIndex);
- fs.fsWrite('/skills/_seeded', '1');
+ fs.writeFile('/skills/_seeded', '1');
}
_trackPattern(userMessage) {
const lower = userMessage.toLowerCase();
@@ -1381,7 +1410,7 @@ category: productivity
for (const [tool, errors] of Object.entries(grouped)) {
if (errors.length >= 3) {
const skillName = 'tool-' + tool + '-workaround';
- const existing = this.terminal.fs.fsRead('/skills/' + skillName + '.md');
+ const existing = this.terminal.fs.readFile('/skills/' + skillName + '.md');
if (existing) continue;
const steps = errors.map((e, i) => (i + 1) + '. When calling ' + tool + ', avoid: "' + JSON.stringify(e.args) + '" which caused: ' + e.error).join('\n');
const content = '---\nname: ' + skillName + '\ndescription: Workarounds for ' + tool + ' failures\ntags: [' + tool + ', error, workaround]\ncategory: system\n---\n## Steps\n' + steps;
@@ -1397,7 +1426,7 @@ category: productivity
const now = Date.now();
const issues = [];
for (const [name, meta] of Object.entries(index)) {
- const content = fs.fsRead('/skills/' + name + '.md');
+ const content = fs.readFile('/skills/' + name + '.md');
if (!content || content.length < 50) {
issues.push({ name, issue: 'empty or too short' });
continue;
diff --git a/modules/core/module-fs.js b/modules/core/module-fs.js
index c4bc786..4ba34b0 100644
--- a/modules/core/module-fs.js
+++ b/modules/core/module-fs.js
@@ -5,7 +5,11 @@ class FileSystemModule {
'ls': 'ls [path] [-la] — List files and directories',
'cat': 'cat <file> — Display file contents',
'put': 'put <file> <content> — Create or overwrite a file',
- 'rm': 'rm <file> — Remove a file; use -r/-rf for directories',
+ 'rm': 'rm <file> — Remove a file (moved to trash); use -r/-rf for directories',
+ 'trash': 'trash <file> — Move a file to trash',
+ 'trash-dir': 'trash-dir <dir> — Move a directory to trash',
+ 'ls-trash': 'ls-trash — List items in trash',
+ 'empty-trash': 'empty-trash — Permanently delete all trash items',
'mkdir': 'mkdir <dir> [-p] — Create a directory; -p creates parent paths',
'rmdir': 'rmdir <dir> — Remove a directory',
'mv': 'mv <source> <dest> — Move file or directory',
@@ -80,13 +84,52 @@ class FileSystemModule {
if(!args[0]) return this.terminal.print('Usage: rm <filename>', 'warning');
if(args[0] === '-r' || args[0] === '-rf') {
const t = args[1]; if (!t) return this.terminal.print('Usage: rm -r <dirname>', 'warning');
- if (this.terminal.fs.deleteDirRecursive(t)) this.terminal.print('Removed: ' + t, 'success');
+ // Use trash for recursive dir removal too
+ if (this.terminal.fs.trashDir(t)) this.terminal.print('Moved to trash: ' + t, 'success');
else this.terminal.print('rm: ' + t + ': No such file or directory', 'error');
return;
}
- if(this.terminal.fs.deleteFile(args[0])) this.terminal.print('Removed: ' + args[0], 'success');
+ if(this.terminal.fs.deleteFile(args[0])) this.terminal.print('Moved to trash: ' + args[0], 'success');
else this.terminal.print('rm: ' + args[0] + ': No such file', 'error');
};
+ this.commands['trash'] = (args) => {
+ const fs = this.terminal.fs;
+ if(!args[0]) return this.terminal.print('Usage: trash <filename>', 'warning');
+ const node = fs.readFile(args[0]) !== null ? { type: 'file' } : fs.readDir(args[0]);
+ if (!node) return this.terminal.print('trash: ' + args[0] + ': No such file', 'error');
+ if (node.type === 'dir') {
+ if (fs.trashDir(args[0])) this.terminal.print('Moved to trash: ' + args[0], 'success');
+ else this.terminal.print('trash: ' + args[0] + ': Failed', 'error');
+ } else {
+ const content = fs.readFile(args[0]);
+ if (content !== null) {
+ fs.trashFile(args[0], { type: 'file', content, modified: new Date() });
+ this.terminal.print('Moved to trash: ' + args[0], 'success');
+ } else {
+ this.terminal.print('trash: ' + args[0] + ': No such file', 'error');
+ }
+ }
+ };
+ this.commands['trash-dir'] = (args) => {
+ if(!args[0]) return this.terminal.print('Usage: trash-dir <dirname>', 'warning');
+ if (this.terminal.fs.trashDir(args[0])) this.terminal.print('Moved to trash: ' + args[0], 'success');
+ else this.terminal.print('trash-dir: ' + args[0] + ': No such directory', 'error');
+ };
+ this.commands['ls-trash'] = () => {
+ const fs = this.terminal.fs;
+ const items = fs.listTrash ? fs.listTrash() : [];
+ if (!items.length) return this.terminal.print('Trash is empty', 'info');
+ this.terminal.print('Trash items:', 'info');
+ items.forEach(it => {
+ const date = new Date(it.ts).toLocaleString();
+ this.terminal.print(` ${it.name} [${it.type}] ${date}`);
+ });
+ };
+ this.commands['empty-trash'] = () => {
+ const fs = this.terminal.fs;
+ const removed = fs.emptyTrash ? fs.emptyTrash(0) : 0;
+ this.terminal.print(`Emptied trash: ${removed} item(s) permanently deleted`, removed > 0 ? 'warning' : 'info');
+ };
this.commands['mkdir'] = (args) => {
if(!args[0]) return this.terminal.print('Usage: mkdir <dirname>', 'warning');
if (args[0] === '-p') {
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index 850d627..2510fde 100644
--- a/modules/core/module-loader.js
+++ b/modules/core/module-loader.js
@@ -99,7 +99,10 @@
window.__moduleLoaderReload = reloadWithSelection;
- const CACHE = '?v=18';
+ const CACHE = '?v=' + (localStorage.getItem('webx11_cache_ver') || '2026-05-27-14');
+ if (!localStorage.getItem('webx11_cache_ver')) {
+ localStorage.setItem('webx11_cache_ver', '2026-05-27-14');
+ }
async function init() {
try {
@@ -153,7 +156,7 @@
// Phase 4: Ext modules (filtered)
if (typeof webx11._loadExtModules === 'function') {
- const extKeys = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr'];
+ const extKeys = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr', 'cryptogzip'];
const extSelected = selected
? extKeys.filter(k => isSelected(k + '.js', selected))
: extKeys;
diff --git a/modules/core/module-manager.js b/modules/core/module-manager.js
index 8e9e911..c508778 100644
--- a/modules/core/module-manager.js
+++ b/modules/core/module-manager.js
@@ -42,10 +42,12 @@
}
createModuleClass(code, url) {
try {
- const classMatch = code.match(/class\s+(\w+)\s+(extends\s+\S+)?\s*\{/);
+ // Strip leading comments to avoid ASI: `return\n/**` → `return; /**`
+ const cleanCode = code.replace(/^\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*\s*/, '');
+ const classMatch = cleanCode.match(/class\s+(\w+)\s+(extends\s+\S+)?\s*\{/);
if (classMatch) {
const className = classMatch[1];
- const mod = new Function('return ' + code + '\nreturn ' + className)();
+ const mod = new Function('return ' + cleanCode + '\nreturn ' + className)();
return mod;
}
throw new Error('No valid module class found');
@@ -78,6 +80,24 @@
}
return false;
}
+ loadModuleFromCode(code, url) {
+ try {
+ this.terminal.print('Loading module from editor...', 'info');
+ const ModuleClass = this.createModuleClass(code, url);
+ if (ModuleClass) {
+ const instance = new ModuleClass();
+ this.registerModule(instance, url);
+ if (instance.init) {
+ instance.init();
+ }
+ this.terminal.print(`Module loaded from editor: ${instance.constructor.name}`, 'success');
+ return true;
+ }
+ } catch (error) {
+ this.terminal.print(`Failed to load module from code: ${error.message}`, 'error');
+ return false;
+ }
+ }
getModuleList() {
const allNames = new Set([...this.moduleRegistry.map(m => m.name), ...Object.keys(this.modules)]);
return Array.from(allNames).map(name => ({
diff --git a/modules/core/module-skills.js b/modules/core/module-skills.js
index ed6e14f..9d0adec 100644
--- a/modules/core/module-skills.js
+++ b/modules/core/module-skills.js
@@ -1,6 +1,30 @@
// Extracted from newOS4 index.html — SkillsModule (v4.2 — Self-improving AI)
class SkillsModule {
- constructor() { this.commands = {}; this.terminal = null; }
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this._skillIndexCache = null;
+ this._skillIndexDirty = false;
+ this._skillIndexFlushTimer = null;
+ }
+ _readSkillIndex() {
+ if (this._skillIndexCache !== null) return this._skillIndexCache;
+ const fs = this.terminal.fs;
+ this._skillIndexCache = fs.fsReadJSON('/skills/index.json') || {};
+ return this._skillIndexCache;
+ }
+ _markSkillIndexDirty() {
+ this._skillIndexDirty = true;
+ if (this._skillIndexFlushTimer) clearTimeout(this._skillIndexFlushTimer);
+ this._skillIndexFlushTimer = setTimeout(() => this._flushSkillIndex(), 2000);
+ }
+ _flushSkillIndex() {
+ if (!this._skillIndexDirty || this._skillIndexCache === null) return;
+ const fs = this.terminal.fs;
+ fs.fsWriteJSON('/skills/index.json', this._skillIndexCache);
+ this._skillIndexDirty = false;
+ this._skillIndexFlushTimer = null;
+ }
getHelpInfo() {
return {
'skill-list': 'skill-list — List all installed skills',
@@ -35,7 +59,7 @@
}
listSkills() {
this.ensureSkillsDir();
- const index = this.terminal.fs.fsReadJSON('/skills/index.json') || {};
+ const index = this._readSkillIndex();
const names = Object.keys(index);
if (names.length === 0) {
this.terminal.print('No skills found.', 'info');
@@ -54,7 +78,7 @@
this.terminal.print('Usage: skill-view <name>', 'warning');
return;
}
- const content = this.terminal.fs.fsRead(`/skills/${name}.md`);
+ const content = this.terminal.fs.readFile(`/skills/${name}.md`);
if (!content) {
this.terminal.print(`Skill '${name}' not found`, 'error');
return;
@@ -73,15 +97,15 @@
const category = args[2] || 'general';
const tags = (args[3] || '').split(',').filter(Boolean);
const fs = this.terminal.fs;
- if (fs.fsRead(`/skills/${name}.md`)) {
+ if (fs.readFile(`/skills/${name}.md`)) {
this.terminal.print(`Skill '${name}' already exists. Use skill-patch to modify it.`, 'warning');
return;
}
const content = '---\nname: ' + name + '\ndescription: ' + description + '\ntags: [' + tags.join(', ') + ']\ncategory: ' + category + '\n---\n## Steps\n1. (to be filled)\n';
- fs.fsWrite(`/skills/${name}.md`, content);
- const index = fs.fsReadJSON('/skills/index.json') || {};
+ fs.writeFile(`/skills/${name}.md`, content);
+ const index = this._readSkillIndex();
index[name] = { name, description, category, tags };
- fs.fsWriteJSON('/skills/index.json', index);
+ this._markSkillIndexDirty();
this.terminal.print(`Skill '${name}' created. Edit its content with skill-patch ${name}`, 'success');
}
patchSkill(args) {
@@ -91,7 +115,7 @@
return;
}
const fs = this.terminal.fs;
- const existing = fs.fsRead(`/skills/${name}.md`);
+ const existing = fs.readFile(`/skills/${name}.md`);
if (!existing) {
this.terminal.print(`Skill '${name}' not found. Use skill-create first.`, 'error');
return;
@@ -105,7 +129,7 @@
return;
}
const newContent = args.slice(1).join(' ');
- fs.fsWrite(`/skills/${name}.md`, newContent);
+ fs.writeFile(`/skills/${name}.md`, newContent);
this.terminal.print(`Skill '${name}' patched`, 'success');
}
deleteSkill(args) {
@@ -115,14 +139,14 @@
return;
}
const fs = this.terminal.fs;
- if (!fs.fsRead(`/skills/${name}.md`)) {
+ if (!fs.readFile(`/skills/${name}.md`)) {
this.terminal.print(`Skill '${name}' not found`, 'error');
return;
}
- fs.fsWrite(`/skills/${name}.md`, '');
- const index = fs.fsReadJSON('/skills/index.json') || {};
+ fs.writeFile(`/skills/${name}.md`, '');
+ const index = this._readSkillIndex();
delete index[name];
- fs.fsWriteJSON('/skills/index.json', index);
+ this._markSkillIndexDirty();
this.terminal.print(`Skill '${name}' deleted`, 'success');
}
getWebDAVModule() {
@@ -141,12 +165,12 @@
}
this.ensureSkillsDir();
const fs = this.terminal.fs;
- const content = fs.fsRead(`/skills/${name}.md`);
+ const content = fs.readFile(`/skills/${name}.md`);
if (!content) {
this.terminal.print(`Skill '${name}' not found in VFS`, 'error');
return;
}
- const index = fs.fsReadJSON('/skills/index.json') || {};
+ const index = this._readSkillIndex();
const meta = index[name];
try {
// Ensure remote skills dir
@@ -188,7 +212,7 @@
const content = await resp.text();
this.ensureSkillsDir();
const fs = this.terminal.fs;
- fs.fsWrite(`/skills/${name}.md`, content);
+ fs.writeFile(`/skills/${name}.md`, content);
// Try to pull the index to get metadata; if fails, create minimal entry
let meta = { name, description: 'Imported from WebDAV', category: 'general', tags: [] };
try {
@@ -200,9 +224,9 @@
}
}
} catch (e) { /* use default meta */ }
- const index = fs.fsReadJSON('/skills/index.json') || {};
+ const index = this._readSkillIndex();
index[name] = meta;
- fs.fsWriteJSON('/skills/index.json', index);
+ this._markSkillIndexDirty();
this.terminal.print(`Skill '${name}' imported from WebDAV (${content.length} bytes)`, 'success');
} catch (e) {
this.terminal.print('Import failed: ' + e.message, 'error');
@@ -216,7 +240,7 @@
}
this.ensureSkillsDir();
const fs = this.terminal.fs;
- const localIndex = fs.fsReadJSON('/skills/index.json') || {};
+ const localIndex = this._readSkillIndex();
const localNames = new Set(Object.keys(localIndex));
this.terminal.print('Syncing skills with WebDAV...', 'info');
let imported = 0, exported = 0;
@@ -233,7 +257,7 @@
// Export local-only skills to remote
for (const name of localNames) {
if (!remoteNames.has(name)) {
- const content = fs.fsRead(`/skills/${name}.md`);
+ const content = fs.readFile(`/skills/${name}.md`);
if (content) {
await wd._ensureDir('skills');
await wd._webdavRequest('PUT', 'skills/' + name + '.md', {
@@ -250,14 +274,14 @@
const resp = await wd._webdavRequest('GET', 'skills/' + name + '.md');
if (resp.ok) {
const content = await resp.text();
- fs.fsWrite(`/skills/${name}.md`, content);
+ fs.writeFile(`/skills/${name}.md`, content);
localIndex[name] = remoteIndex[name];
imported++;
}
}
}
// Push updated local index back to remote
- fs.fsWriteJSON('/skills/index.json', localIndex);
+ this._markSkillIndexDirty();
await wd._ensureDir('skills');
await wd._webdavRequest('PUT', 'skills/index.json', {
body: JSON.stringify(localIndex, null, 2),
diff --git a/modules/core/module-system.js b/modules/core/module-system.js
index f21946a..b561ef2 100644
--- a/modules/core/module-system.js
+++ b/modules/core/module-system.js
@@ -14,6 +14,9 @@
'factory-reset': 'factory-reset [--confirm] — Wipe all webx11_ data (VFS, config, AI profile, mindmaps, themes, all localStorage)',
'reset-vfs': 'reset-vfs [--confirm] — Clear only the virtual filesystem (keeps settings, modules, AI profile)',
'logs': 'logs [n] [LEVEL] — Show last n log entries (optionally filtered by level: INFO/WARN/ERROR)',
+ 'logrotate': 'logrotate — Rotate current.log to a dated archive (auto-cleans old)',
+ 'health-check': 'health-check — Audit VFS size, modules, logs, trash, skills, tool failures',
+ 'memory-map-export': 'memory-map-export — Generate memory map markdown from memory/index.json',
'font-size': 'font-size <8-32> — Set terminal font size (persists across sessions)',
'setfont': 'setfont <8-32> — Alias for font-size',
'smart-prompt': 'smart-prompt [on|off] — Toggle context-aware prompt (shows name, skills, sessions, memory, edits)',
@@ -92,6 +95,93 @@
this.terminal.print('--- Logs (last ' + tail.length + ') ---', 'info');
tail.forEach(l => this.terminal.print(l, l.includes('[ERROR]') ? 'error' : 'info'));
};
+ // Auto-rotate logs on startup (silently)
+ this._autoRotateLogs();
+ this.commands['logrotate'] = () => {
+ const rotated = this._autoRotateLogs(true);
+ if (rotated) {
+ this.terminal.print('Log rotated: ' + rotated, 'success');
+ } else {
+ this.terminal.print('Log rotation not needed (<50KB, <500 lines)', 'info');
+ }
+ };
+ this.commands['health-check'] = () => this._healthCheck();
+ this.commands['memory-map-export'] = () => this._memoryMapExport();
+ }
+ _autoRotateLogs(force = false) {
+ try {
+ const content = this.terminal.fs.readFile('/logs/current.log') || '';
+ const lines = content.split('\n').filter(l => l);
+ const size = content.length;
+ if (!force && size < 51200 && lines.length < 500) return null;
+ const now = new Date();
+ const stamp = now.getFullYear() + '-' +
+ String(now.getMonth()+1).padStart(2,'0') + '-' +
+ String(now.getDate()).padStart(2,'0') + '_' +
+ String(now.getHours()).padStart(2,'0') + '00';
+ this.terminal.fs.writeFile('/logs/' + stamp + '.log', content);
+ this.terminal.fs.writeFile('/logs/current.log', '');
+ // Clean oldest rotated logs (>10)
+ const logsDir = this.terminal.fs.readDir('/logs');
+ if (logsDir && logsDir.content) {
+ const rotated = Object.keys(logsDir.content)
+ .filter(k => k.endsWith('.log') && k !== 'current.log')
+ .sort();
+ while (rotated.length > 10) {
+ const oldest = rotated.shift();
+ this.terminal.fs.deleteFile('/logs/' + oldest);
+ }
+ }
+ return stamp + '.log';
+ } catch (e) { return null; }
+ }
+ _healthCheck() {
+ const fs = this.terminal.fs;
+ let warnings = 0;
+ this.terminal.print('╔══════════════════════════════════════╗', 'info');
+ this.terminal.print('║ 🔍 SYSTEM HEALTH CHECK ║', 'info');
+ this.terminal.print('╚══════════════════════════════════════╝', 'info');
+ // VFS size
+ const dump = JSON.stringify(fs.root).length;
+ const vfsOk = dump < 524288;
+ this.terminal.print(`VFS size: ${(dump/1024).toFixed(1)}KB ${vfsOk ? '✅' : '⚠️ > 512KB'}`, vfsOk ? 'info' : 'warning');
+ if (!vfsOk) warnings++;
+ // Modules
+ const modTotal = Object.keys(this.terminal.moduleManager.modules || {}).length;
+ this.terminal.print(`Modules loaded: ${modTotal} ✅`, 'info');
+ // Logs
+ const logContent = fs.readFile('/logs/current.log') || '';
+ const logLines = logContent.split('\n').filter(l => l).length;
+ this.terminal.print(`Current log: ${logLines} lines`, logLines > 500 ? 'warning' : 'info');
+ if (logLines > 500) warnings++;
+ // Trash
+ const trashList = fs.listTrash ? fs.listTrash() : [];
+ this.terminal.print(`Trash items: ${trashList.length}`, 'info');
+ // Skills
+ const skillIdx = fs.fsReadJSON ? (fs.fsReadJSON('/skills/index.json') || {}) : {};
+ const skillCount = Object.keys(skillIdx).length;
+ this.terminal.print(`Skills: ${skillCount}`, skillCount > 0 ? 'info' : 'warning');
+ if (skillCount === 0) warnings++;
+ // Summary
+ this.terminal.print('─'.repeat(40), 'text-muted');
+ if (warnings === 0) {
+ this.terminal.print('All checks passed ✅', 'success');
+ } else {
+ this.terminal.print(`${warnings} warning(s) found`, 'warning');
+ }
+ }
+ _memoryMapExport() {
+ const fs = this.terminal.fs;
+ const idx = fs.fsReadJSON ? (fs.fsReadJSON('/memory/index.json') || {}) : null;
+ if (!idx) {
+ this.terminal.print('No memory index found at /memory/index.json', 'error');
+ return;
+ }
+ this.terminal.print('---\nname: adaptive-memory-map\ndescription: Auto-generated from memory/index.json\ntags: [memory, context, tracking, adaptation]\ncategory: system\n---\n', 'info');
+ for (const [k,v] of Object.entries(idx)) {
+ this.terminal.print(`## ${k}\n- Tasks: ${v.tasks || 0}\n- Updated: ${v.updated || 'unknown'}\n- Summary: ${(v.summary || '').substring(0,100)}...`, 'info');
+ }
+ this.terminal.print('(Not saved — render to file with memory-map-export > /memory/reference.md if desired)', 'text-muted');
}
handleHelp(args) {
const helpMap = this.terminal.moduleManager.getAllHelp();
@@ -116,8 +206,9 @@
files: ['ls','ll','cat','put','rm','mv','cp','mkdir','rmdir','cd','pwd','find','grep','head','tail','sort','wc','touch','tree','du','upload','download','vfs-export','vfs-import','edit','diff'],
ai: ['ai','ask','ai-clear','ai-system','ai-context','ai-allow','ai-deny','ai-stop','ai-model'],
search: ['search','s','ddg','duck','fetch','web','extract'],
- tools: ['calc','cron-every','cron-list','cron-stop','cron-clear','wpm','mindmap','theme','voice','alias','unalias'],
- system: ['help','clear','cls','echo','mem','status','font-size','setfont','smart-prompt','logs','preview','factory-reset','reset-vfs'],
+ tools: ['calc','cron-every','cron-list','cron-stop','cron-clear','wpm','mindmap','theme','voice','alias','unalias',
+ 'which','env','date','cal','yes','seq','base64','printf','cut','uniq','hostname','whoami','basename','dirname','sleep','expand'],
+ system: ['help','clear','cls','echo','mem','status','font-size','setfont','smart-prompt','logs','preview','factory-reset','reset-vfs','export','unset','script','set','test'],
net: ['server','servers','server-check','server-remove','monitor'],
mods: ['modules','listmods','loadmod','removemod','load-local','loadlocal','startup-modules'],
ollama: ['ollama','ollamaconnect','ollamadisconnect','ollamamodels','ollamasetmodel','ollamasetproxy','models','model-info','model-pull','model-delete','model-run'],
diff --git a/modules/core/terminal.js b/modules/core/terminal.js
index ee1afa4..2617f79 100644
--- a/modules/core/terminal.js
+++ b/modules/core/terminal.js
@@ -1,4 +1,184 @@
// Extracted from newOS4 index.html — WebX11Terminal
+ class WebX11SecurityManager {
+ constructor(terminal, level = 1) {
+ this.terminal = terminal;
+ this.level = level;
+ this.commandMeta = new Map();
+ this.lastRouteTime = 0;
+ this.lastRouteCmd = '';
+ this.blockedCount = 0;
+ this.destructiveCmds = new Set(['rm','delete','del','edit','write','save','overwrite','format','kill','stop','shutdown','reboot']);
+ this.systemCmds = new Set(['reboot','shutdown','format','kill','halt','poweroff']);
+ this.suspiciousFlags = new Set(['--dump','--execute','--eval','--exec','--shell','--script']);
+ this.whitelist = new Set(['help','clear','ls','cat','pwd','whoami','df','free','top','ps','echo','date','env','history']);
+ this._registerBuiltIn();
+ }
+ _registerBuiltIn() {
+ const internals = ['clear','help','ls','cat','df','free','env','history','cd','pwd','touch','mkdir','rm','cp','mv','edit','cat','run','font-size','load-local','loadlocal','mem','security','security-level','startup-modules','smart-prompt','cmd-allow','cmd-deny'];
+ internals.forEach(c => this.registerCommand(c, { category: 'builtin' }));
+ }
+ setLevel(n) {
+ if (n >= 0 && n <= 3) {
+ this.level = n;
+ this.terminal.print(`Security level set to ${n} (${this.getLevelName()})`, 'success');
+ if (n === 0) {
+ this.terminal.print('Warning: Permissive mode — commands will not be blocked or confirmed.', 'warning');
+ }
+ }
+ else { this.terminal.print('Level must be 0-3', 'error'); }
+ }
+ getLevel() { return this.level; }
+ getLevelName() {
+ return ['Permissive','Standard','Paranoid','Audit'][this.level] || 'Unknown';
+ }
+ registerCommand(name, meta = {}) {
+ this.commandMeta.set(name, { destructive: false, category: 'user', ...meta });
+ }
+ _collectAllCommands() {
+ const cmds = new Set([...this.commandMeta.keys()]);
+ for (const modName in this.terminal.moduleManager.modules) {
+ const mod = this.terminal.moduleManager.modules[modName];
+ if (mod.commands) {
+ for (const c in mod.commands) cmds.add(c);
+ }
+ }
+ const builtins = ['clear','help','ls','df','security','security-level'];
+ builtins.forEach(c => cmds.add(c));
+ return [...cmds];
+ }
+ _levenshtein(a, b) {
+ a = a.toLowerCase(); b = b.toLowerCase();
+ const m = a.length, n = b.length;
+ if (!m) return n; if (!n) return m;
+ let prev = new Uint16Array(n + 1);
+ let curr = new Uint16Array(n + 1);
+ for (let j = 0; j <= n; j++) prev[j] = j;
+ for (let i = 1; i <= m; i++) {
+ let next = new Uint16Array(n + 1);
+ next[0] = i;
+ for (let j = 1; j <= n; j++) {
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
+ next[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
+ }
+ [prev, curr] = [curr, next];
+ }
+ return prev[n];
+ }
+ suggest(input, maxDist = 2) {
+ const all = this._collectAllCommands();
+ const lower = input.toLowerCase();
+ let best = '', bestDist = Infinity;
+ for (const c of all) {
+ const d = this._levenshtein(lower, c);
+ if (d < bestDist && d <= maxDist) { bestDist = d; best = c; }
+ }
+ return best ? { cmd: best, dist: bestDist } : null;
+ }
+ _classify(raw) {
+ const parts = raw.trim().split(/\s+/);
+ const cmd = parts[0].toLowerCase();
+ const args = parts.slice(1);
+ // Internal commands always allowed at terminal level, but security still classifies
+ const all = this._collectAllCommands();
+ const known = all.includes(cmd);
+ const hasSuspicious = args.some(a => this.suspiciousFlags.has(a.toLowerCase()));
+ const destructive = this.destructiveCmds.has(cmd) || hasSuspicious;
+ const system = this.systemCmds.has(cmd);
+ const unknown = !known;
+ return { cmd, args, known, destructive, system, unknown, hasSuspicious };
+ }
+ async checkAndRun(rawInput) {
+ const { cmd, args, known, destructive, system, unknown, hasSuspicious } = this._classify(rawInput);
+ // Level 3 Audit: just log and allow
+ if (this.level === 3) {
+ this.terminal.print(`[AUDIT] ${rawInput} → known=${known} destructive=${destructive} system=${system}`, 'info');
+ return this._dispatch(cmd, args);
+ }
+ // Always allow builtins that are safe, regardless of level
+ if (['help','security','security-level'].includes(cmd)) {
+ return this._dispatch(cmd, args);
+ }
+ // Unknown command handling
+ if (unknown && !this.whitelist.has(cmd)) {
+ this.blockedCount++;
+ const sug = this.suggest(cmd);
+ if (sug) {
+ this.terminal.print(`Security: Unknown command "${cmd}". Did you mean: "${sug.cmd}"?`, 'warning');
+ } else {
+ this.terminal.print(`Security: Unknown command "${cmd}" — blocked. Use 'security-level 0' to disable.`, 'warning');
+ }
+ // At levels 0-1: suggest/fix but still route to AI (terminal is also a chat interface)
+ // At level 2+: block entirely (paranoid)
+ if (this.level >= 2) {
+ this.terminal.print(`Security: Unknown command "${cmd}" blocked (level ${this.level}).`, 'error');
+ return;
+ }
+ if (this.level >= 1) {
+ if (sug) {
+ this.terminal.print(`Security: Did you mean "${sug.cmd}"? Routing to AI...`, 'warning');
+ } else {
+ this.terminal.print(`Security: Routing to AI...`, 'info');
+ }
+ }
+ return this._tryAIRoute(rawInput);
+ }
+ // Suspicious flags (e.g. --execute) at any level > 0
+ if (hasSuspicious && this.level > 0) {
+ this.terminal.print(`Security: Suspicious flag detected in "${rawInput}" — blocked.`, 'error');
+ this.blockedCount++;
+ return;
+ }
+ // Destructive commands
+ if (destructive) {
+ if (this.level === 0) {
+ this.terminal.print(`Warning: destructive command "${cmd}"`, 'warning');
+ return this._dispatch(cmd, args);
+ }
+ if (this.level >= 1) {
+ // Reuse your existing confirm UI if present, otherwise print confirm message
+ if (this.terminal.showConfirmationBar) {
+ this.terminal._pendingCommand = () => this._dispatch(cmd, args);
+ this.terminal.showConfirmationBar(`Run destructive: ${rawInput}`);
+ return;
+ } else {
+ this.terminal.print(`Security: Destructive command "${cmd}" requires confirmation.`, 'warning');
+ return;
+ }
+ }
+ }
+ // System commands at level >=1
+ if (system && this.level >= 1) {
+ this.terminal.print(`Security: System command "${cmd}" blocked (level ${this.level}).`, 'error');
+ this.blockedCount++;
+ return;
+ }
+ // Standard dispatch for everything else
+ return this._dispatch(cmd, args);
+ }
+ async _dispatch(cmd, args) {
+ return this.terminal.dispatchCommand(cmd, args);
+ }
+ async _tryAIRoute(rawInput) {
+ // AI routing cooldown guard
+ const now = Date.now();
+ if (this.lastRouteCmd === rawInput && (now - this.lastRouteTime) < 5000) {
+ this.terminal.print(`Security: Already routed "${rawInput}" to AI recently. Use 'security-level 0' to force.`, 'warning');
+ return;
+ }
+ this.lastRouteCmd = rawInput;
+ this.lastRouteTime = now;
+ try {
+ const aiMod = this.terminal.moduleManager.modules['WebX11AIAssistantModule'];
+ if (aiMod) {
+ this.terminal.print('→ Routing to AI assistant...', 'info');
+ await aiMod.chat([rawInput.trim()]);
+ }
+ } catch (e) {
+ this.terminal.print(`Security: AI error: ${e.message}`, 'error');
+ }
+ }
+ }
+
class WebX11Terminal {
constructor() {
this.outputContainer = document.getElementById('terminal-container');
@@ -15,6 +195,7 @@
}
} catch (e) { console.warn('SecretVault init:', e.message); }
this.moduleManager = new ModuleManager(this);
+ this.security = new WebX11SecurityManager(this, parseInt(localStorage.getItem('webx11_security_level')) || 1);
this.ollamaManager = new OllamaManager(this);
this._pipeInput = null;
this._pipeBufferSize = 5000;
@@ -267,6 +448,53 @@
this.outputContainer.appendChild(line);
this.scrollToBottom();
}
+ showConfirmationBar(desc) {
+ const area = document.getElementById('ai-confirm-area');
+ if (!area) {
+ this.print('Confirmation UI unavailable — command blocked.', 'error');
+ return;
+ }
+ this._pendingCommandDesc = desc;
+ area.innerHTML = `
+ <span class="confirm-desc">⚠️ ${desc}</span>
+ <button class="confirm-btn" id="cmd-confirm-allow" onclick="(function(){var a=document.getElementById('cmd-confirm-allow');if(a.__allow) a.__allow();}).call(this)">✅ Allow</button>
+ <button class="confirm-btn deny" id="cmd-confirm-deny" onclick="(function(){var d=document.getElementById('cmd-confirm-deny');if(d.__deny) d.__deny();}).call(this)">❌ Deny</button>
+ <span style="font-size:0.75em;color:var(--text-muted);width:100%;text-align:center">Type: cmd-allow or cmd-deny</span>
+ `;
+ area.classList.add('visible');
+ const allowBtn = document.getElementById('cmd-confirm-allow');
+ const denyBtn = document.getElementById('cmd-confirm-deny');
+ const onAllow = () => {
+ this._resolveCmdConfirm(true);
+ };
+ const onDeny = () => {
+ this._resolveCmdConfirm(false);
+ };
+ allowBtn.addEventListener('click', onAllow);
+ denyBtn.addEventListener('click', onDeny);
+ allowBtn.__allow = onAllow;
+ denyBtn.__deny = onDeny;
+ }
+ _resolveCmdConfirm(approved) {
+ const area = document.getElementById('ai-confirm-area');
+ if (area) {
+ area.classList.remove('visible');
+ area.innerHTML = '';
+ }
+ if (approved) {
+ this.print(`Allowed: ${this._pendingCommandDesc || 'command'}`, 'success');
+ if (this._pendingCommand) {
+ const fn = this._pendingCommand;
+ this._pendingCommand = null;
+ this._pendingCommandDesc = null;
+ fn();
+ }
+ } else {
+ this.print(`Denied: ${this._pendingCommandDesc || 'command'}`, 'warning');
+ this._pendingCommand = null;
+ this._pendingCommandDesc = null;
+ }
+ }
_loadHistory() {
try {
const saved = localStorage.getItem('webx11_terminal_output');
@@ -331,7 +559,7 @@
setTimeout(() => {
this._historySavePending = false;
try {
- const slice = this._historyBuffer.slice(-30);
+ const slice = this._historyBuffer.slice(-this._historyMaxLines);
localStorage.setItem('webx11_terminal_output', JSON.stringify(slice));
} catch (e) {
// Silently ignore storage errors (e.g. quota exceeded)
@@ -425,6 +653,55 @@
scrollToBottom() {
this.outputContainer.scrollTop = this.outputContainer.scrollHeight;
}
+ _getPathSuggestions(partial, type) {
+ // type: 'dir' = dirs only, 'file' = files + dirs (dirs get trailing /), 'session' = _sessions/*.json
+ if (type === 'session') {
+ this.fs.mkdirRecursive('_sessions');
+ const all = this.fs.ls('_sessions');
+ const names = all
+ .filter(e => e.type === 'file' && e.name.endsWith('.json'))
+ .map(e => e.name.replace('.json', ''));
+ return partial
+ ? names.filter(n => n.startsWith(partial)).map(n => n)
+ : names;
+ }
+ // Resolve the partial path to determine which directory to list
+ let dirPath = '';
+ let filterPrefix = '';
+ let entries = [];
+ let prefix = '';
+
+ if (partial.includes('/')) {
+ // "dir1/dir2" or "dir1/dir2/" or "dir1/"
+ const lastSlash = partial.lastIndexOf('/');
+ const beforeSlash = partial.slice(0, lastSlash); // dir1
+ filterPrefix = partial.slice(lastSlash + 1); // dir2 (or '' if ends with /)
+ // Resolve "dir1" relative to current path
+ const resolvedDir = this.fs._resolveDir(beforeSlash || '.');
+ if (!resolvedDir) return [];
+ entries = Object.keys(resolvedDir.content).map(name => ({
+ name,
+ type: resolvedDir.content[name].type,
+ size: resolvedDir.content[name].content ? resolvedDir.content[name].content.length : 0
+ }));
+ prefix = beforeSlash + '/';
+ } else {
+ // No slash — list current dir
+ entries = this.fs.ls();
+ filterPrefix = partial;
+ prefix = '';
+ }
+
+ if (type === 'dir') {
+ return entries
+ .filter(e => e.type === 'dir' && e.name.startsWith(filterPrefix))
+ .map(e => prefix + e.name + '/');
+ }
+ // 'file' — show files + dirs (dirs with trailing /)
+ return entries
+ .filter(e => e.name.startsWith(filterPrefix))
+ .map(e => e.type === 'dir' ? prefix + e.name + '/' : prefix + e.name);
+ }
showAutocomplete() {
const input = this.inputField.value;
const trimmed = input.trim();
@@ -442,11 +719,24 @@
const cmdPart = parts[0].toLowerCase();
const lowerInput = input.toLowerCase();
- // Commands that take file/dir arguments for autocomplete
- const fileArgCommands = ['cat', 'preview', 'rm', 'rmdir', 'edit', 'view'];
- const dirArgCommands = ['ls', 'cd'];
- // Session commands that autocomplete from _sessions/*.json
- const sessionArgCommands = ['session-load', 'session-delete', 'session-export', 'session-save'];
+ // Expanded command lists for autocomplete
+ const fileArgCommands = ['cat', 'preview', 'rm', 'rmdir', 'edit', 'view', 'cp', 'mv', 'touch', 'put', 'run', 'chmod', 'gzip', 'gunzip', 'encrypt', 'decrypt', 'webdav-backup', 'webdav-restore', 'webdav-upload', 'webdav-download', 'webdav-list'];
+ const dirArgCommands = ['ls', 'cd', 'mkdir', 'rmdir'];
+ const sessionArgCommands = ['session-load', 'session-delete', 'session-export', 'session-save', 'session-list', 'session-import'];
+ const pathArgCommands = ['cat', 'preview', 'rm', 'edit', 'view', 'cp', 'mv', 'touch', 'put', 'run', 'chmod', 'gzip', 'gunzip', 'encrypt', 'decrypt', 'webdav-backup', 'webdav-restore', 'webdav-upload', 'webdav-download', 'webdav-list', 'ls', 'cd', 'mkdir', 'rmdir', 'cat', 'preview'];
+
+ // Collect commands that take arguments from ext module metadata
+ const extFileCmds = new Set();
+ const extDirCmds = new Set();
+ for (const modName in this.moduleManager.modules) {
+ const mod = this.moduleManager.modules[modName];
+ if (mod.commandsMeta) {
+ for (const [c, meta] of Object.entries(mod.commandsMeta)) {
+ if (meta.argType === 'file' || meta.argType === 'path') extFileCmds.add(c);
+ if (meta.argType === 'dir') extDirCmds.add(c);
+ }
+ }
+ }
// If we have more than one word, we're past the command name — autocomplete file/dir/session names
if (parts.length > 1 && parts[1].length > 0) {
@@ -458,25 +748,13 @@
return;
}
- // Determine if we should show session names, dirs only, or both
if (sessionArgCommands.includes(cmdPart)) {
- this.fs.mkdirRecursive('_sessions');
- const all = this.fs.ls('_sessions');
- this.suggestions = all
- .filter(e => e.type === 'file' && e.name.endsWith('.json'))
- .map(e => e.name.replace('.json', ''))
- .filter(n => n.startsWith(partialTrimmed));
- } else if (cmdPart === 'cd' || cmdPart === 'rmdir' || dirArgCommands.includes(cmdPart)) {
- // Dir-only commands
- const entries = this.fs.ls().filter(f => f.type === 'dir');
- this.suggestions = entries
- .filter(e => e.name.startsWith(partialTrimmed))
- .map(e => e.name + '/');
- } else if (fileArgCommands.includes(cmdPart)) {
- const all = this.fs.ls();
- this.suggestions = all
- .filter(e => e.name.startsWith(partialTrimmed))
- .map(e => e.type === 'dir' ? e.name + '/' : e.name);
+ this.suggestions = this._getPathSuggestions(partialTrimmed, 'session');
+ } else if (dirArgCommands.includes(cmdPart) || extDirCmds.has(cmdPart)) {
+ this.suggestions = this._getPathSuggestions(partialTrimmed, 'dir');
+ this._isCdAutocomplete = true;
+ } else if (fileArgCommands.includes(cmdPart) || extFileCmds.has(cmdPart)) {
+ this.suggestions = this._getPathSuggestions(partialTrimmed, 'file');
} else {
this.hideAutocomplete();
return;
@@ -484,7 +762,6 @@
this._autocompletePrefix = cmdPart + ' ';
if (this.suggestions.length > 0) {
- this._isCdAutocomplete = (cmdPart === 'cd' || cmdPart === 'rmdir');
this.renderAutocomplete();
} else {
this.hideAutocomplete();
@@ -495,24 +772,18 @@
// Just typed a space after command — show all matching entries
if (parts.length > 1) {
if (sessionArgCommands.includes(cmdPart)) {
- this.fs.mkdirRecursive('_sessions');
- const all = this.fs.ls('_sessions');
- this.suggestions = all
- .filter(e => e.type === 'file' && e.name.endsWith('.json'))
- .map(e => e.name.replace('.json', ''));
- } else if (cmdPart === 'cd' || cmdPart === 'rmdir' || dirArgCommands.includes(cmdPart)) {
- const dirs = this.fs.ls().filter(f => f.type === 'dir');
- this.suggestions = dirs.map(d => d.name + '/');
- } else if (fileArgCommands.includes(cmdPart)) {
- const all = this.fs.ls();
- this.suggestions = all.map(e => e.type === 'dir' ? e.name + '/' : e.name);
+ this.suggestions = this._getPathSuggestions('', 'session');
+ } else if (dirArgCommands.includes(cmdPart) || extDirCmds.has(cmdPart)) {
+ this.suggestions = this._getPathSuggestions('', 'dir');
+ this._isCdAutocomplete = true;
+ } else if (fileArgCommands.includes(cmdPart) || extFileCmds.has(cmdPart)) {
+ this.suggestions = this._getPathSuggestions('', 'file');
} else {
this.hideAutocomplete();
return;
}
this._autocompletePrefix = cmdPart + ' ';
if (this.suggestions.length > 0) {
- this._isCdAutocomplete = (cmdPart === 'cd' || cmdPart === 'rmdir');
this.renderAutocomplete();
} else {
this.hideAutocomplete();
@@ -520,10 +791,9 @@
return;
}
- // Directory autocomplete for bare 'cd ' (no partial yet) — should be unreachable due to above block
+ // Directory autocomplete for bare 'cd ' (no partial yet)
if (lowerInput.startsWith('cd ')) {
- const dirs = this.fs.ls().filter(f => f.type === 'dir');
- this.suggestions = dirs.map(d => d.name + '/');
+ this.suggestions = this._getPathSuggestions('', 'dir');
if (this.suggestions.length > 0) {
this._isCdAutocomplete = true;
this.renderAutocomplete();
@@ -623,15 +893,17 @@
catch (e) { console.warn(`[SafeRegister] Skipped ${label}: ${e.message}`); }
}
async _loadExtModules(filterKeys) {
- const autoLoadKeys = filterKeys || ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder'];
+ const autoLoadKeys = filterKeys || ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr', 'cryptogzip', 'shell'];
+ const cacheVer = localStorage.getItem('webx11_cache_ver') || '2026-05-27-14';
try {
- const res = await fetch('packages.json');
+ const res = await fetch('packages.json?v=' + cacheVer);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const index = await res.json();
for (const key of autoLoadKeys) {
const pkg = index.packages[key];
if (pkg) {
- await this.moduleManager.loadModuleFromURL(pkg.url);
+ const url = pkg.url + (pkg.url.includes('?') ? '&v=' : '?v=') + cacheVer;
+ await this.moduleManager.loadModuleFromURL(url);
}
}
} catch (err) {
@@ -640,7 +912,7 @@
}
async reloadPersistedModules() {
const registry = this.moduleManager.moduleRegistry;
- const skipList = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder'];
+ const skipList = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr', 'cryptogzip', 'shell'];
for (const mod of registry) {
if (mod.enabled && mod.url) {
// Skip ext modules — they load via _loadExtModules
@@ -659,65 +931,8 @@
await this.executePipeChain(pipeParts);
return;
}
- const parts = rawInput.trim().split(/\s+/);
- const cmd = parts[0].toLowerCase();
- const args = parts.slice(1);
- if (cmd === 'startup-modules') {
- this.openStartupModules();
- return;
- }
- if (cmd === 'font-size' || cmd === 'setfont') {
- this.setFontSize(args[0]);
- return;
- }
- if (cmd === 'load-local' || cmd === 'loadlocal') {
- this.loadLocalModule(args[0]);
- return;
- }
- if (cmd === 'smart-prompt') {
- const val = args[0] && args[0] === 'off' ? 'off' : 'on';
- localStorage.setItem('webx11_smart_prompt', val);
- this.print('Smart prompt ' + val, 'success');
- this.updatePrompt();
- return;
- }
- if (cmd === 'mem') {
- this.printMemoryStats();
- return;
- }
- if (cmd === 'clear') {
- this.outputContainer.innerHTML = '';
- this._historyBuffer = [];
- localStorage.removeItem('webx11_terminal_output');
- return;
- }
- let executed = false;
- for (const modName in this.moduleManager.modules) {
- const mod = this.moduleManager.modules[modName];
- if (mod.commands && mod.commands[cmd]) {
- try {
- await mod.commands[cmd](args);
- executed = true;
- } catch (err) {
- this.print(`Error in ${modName}.${cmd}: ${err.message}`, 'error');
- }
- break;
- }
- }
- if (!executed) {
- // Route unknown commands to the AI agent
- try {
- const aiMod = this.moduleManager.modules['WebX11AIAssistantModule'];
- if (aiMod) {
- this.print('→', 'info');
- await aiMod.chat([rawInput.trim()]);
- return;
- }
- } catch (e) {
- this.print(`Agent error: ${e.message}`, 'error');
- return;
- }
- }
+ // Security gate for all single commands and internal builtins
+ await this.security.checkAndRun(rawInput);
}
splitPipe(input) {
const parts = [];
@@ -761,6 +976,25 @@
}
async dispatchCommand(cmd, args) {
if (cmd === 'clear') { this.outputContainer.innerHTML = ''; this._historyBuffer = []; localStorage.removeItem('webx11_terminal_output'); return; }
+ if (cmd === 'startup-modules') { this.openStartupModules(); return; }
+ if (cmd === 'font-size' || cmd === 'setfont') { this.setFontSize(args[0]); return; }
+ if (cmd === 'load-local' || cmd === 'loadlocal') { this.loadLocalModule(args[0]); return; }
+ if (cmd === 'smart-prompt') { const val = args[0] && args[0] === 'off' ? 'off' : 'on'; localStorage.setItem('webx11_smart_prompt', val); this.print('Smart prompt ' + val, 'success'); this.updatePrompt(); return; }
+ if (cmd === 'mem') { this.printMemoryStats(); return; }
+ if (cmd === 'security') {
+ this.print(`Security level: ${this.security.getLevel()} (${this.security.getLevelName()})`, 'info');
+ this.print(`Blocked commands: ${this.security.blockedCount}`, 'info');
+ return;
+ }
+ if (cmd === 'security-level') {
+ const lv = parseInt(args[0]);
+ if (isNaN(lv)) { this.print('Usage: security-level 0|1|2|3', 'warning'); return; }
+ this.security.setLevel(lv);
+ localStorage.setItem('webx11_security_level', lv);
+ return;
+ }
+ if (cmd === 'cmd-allow') { this._resolveCmdConfirm(true); return; }
+ if (cmd === 'cmd-deny') { this._resolveCmdConfirm(false); return; }
for (const modName in this.moduleManager.modules) {
const mod = this.moduleManager.modules[modName];
if (mod.commands && mod.commands[cmd]) {
@@ -768,17 +1002,7 @@
catch (err) { this.print(`Error in ${modName}.${cmd}: ${err.message}`, 'error'); return; }
}
}
- // Route unknown commands to the AI agent
- try {
- const aiMod = this.moduleManager.modules['WebX11AIAssistantModule'];
- if (aiMod) {
- this.print('→ Routing to AI assistant...', 'info');
- await aiMod.chat([cmd + ' ' + args.join(' ')]);
- return;
- }
- } catch (e) {
- this.print(`Agent error: ${e.message}`, 'error');
- }
+ // No AI routing here — security manager handles that via checkAndRun
this.print(`Command not found: ${cmd}`, 'error');
}
openFileBrowser() {
diff --git a/modules/core/virtual-fs.js b/modules/core/virtual-fs.js
index 68d02bf..d2d3103 100644
--- a/modules/core/virtual-fs.js
+++ b/modules/core/virtual-fs.js
@@ -2,33 +2,83 @@ class VirtualFileSystem {
constructor() {
this.root = { type: 'dir', content: {} };
this.currentPath = [];
- this.loadFromStorage();
- }
- loadFromStorage() {
+ this._ready = this.loadFromStorage();
+ }
+ _u8ToBase64(u8) {
+ let s = '';
+ for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]);
+ return btoa(s);
+ }
+ _base64ToU8(b64) {
+ const s = atob(b64);
+ const u8 = new Uint8Array(s.length);
+ for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i);
+ return u8;
+ }
+ async _compress(text) {
+ if (typeof CompressionStream === 'undefined') return null;
+ const blob = new Blob([text]);
+ const stream = blob.stream().pipeThrough(new CompressionStream('gzip'));
+ const resp = new Response(stream);
+ const buf = await resp.arrayBuffer();
+ return this._u8ToBase64(new Uint8Array(buf));
+ }
+ async _decompress(b64) {
+ if (typeof DecompressionStream === 'undefined') throw new Error('DecompressionStream not available');
+ const u8 = this._base64ToU8(b64);
+ const blob = new Blob([u8]);
+ const stream = blob.stream().pipeThrough(new DecompressionStream('gzip'));
+ const resp = new Response(stream);
+ return await resp.text();
+ }
+ async loadFromStorage() {
try {
- const saved = localStorage.getItem('webx11_filesystem');
- if (saved) {
- // Safety: refuse to load if data is excessively large (>512KB raw)
- if (saved.length > 524288) {
- console.warn('FS data too large (' + (saved.length/1024).toFixed(0) + 'KB), skipping load');
- localStorage.removeItem('webx11_filesystem');
- return;
+ const v2 = localStorage.getItem('webx11_filesystem_v2');
+ if (v2) {
+ try {
+ const text = await this._decompress(v2);
+ const data = JSON.parse(text);
+ this.root = data.root || this.root;
+ this.currentPath = data.currentPath || [];
+ return true;
+ } catch (e) {
+ console.error('FS v2 decompress error:', e);
+ return this._loadLegacy();
}
- const data = JSON.parse(saved);
- this.root = data.root || this.root;
- this.currentPath = data.currentPath || [];
}
- } catch (e) { console.error('FS load error:', e); }
+ return this._loadLegacy();
+ } catch (e) { console.error('FS load error:', e); return false; }
+ }
+ _loadLegacy() {
+ const saved = localStorage.getItem('webx11_filesystem');
+ if (saved) {
+ if (saved.length > 524288) {
+ console.warn('FS data too large (' + (saved.length/1024).toFixed(0) + 'KB), skipping load');
+ localStorage.removeItem('webx11_filesystem');
+ return false;
+ }
+ const data = JSON.parse(saved);
+ this.root = data.root || this.root;
+ this.currentPath = data.currentPath || [];
+ console.info('Loaded legacy FS; will migrate to compressed on next save.');
+ return true;
+ }
+ return false;
}
saveToStorage() {
if (this._storageSavePending) return;
this._storageSavePending = true;
- setTimeout(() => {
+ setTimeout(async () => {
this._storageSavePending = false;
try {
- localStorage.setItem('webx11_filesystem', JSON.stringify({
- root: this.root, currentPath: this.currentPath
- }));
+ const json = JSON.stringify({ root: this.root, currentPath: this.currentPath });
+ const compressed = await this._compress(json);
+ if (compressed) {
+ localStorage.setItem('webx11_filesystem_v2', compressed);
+ localStorage.removeItem('webx11_filesystem');
+ } else {
+ localStorage.setItem('webx11_filesystem', json);
+ }
} catch (e) { console.error('FS save error:', e); }
}, 2000);
}
@@ -122,25 +172,62 @@ class VirtualFileSystem {
if (!resolved || !resolved.name) return false;
const node = resolved.dir.content[resolved.name];
if (node && node.type === 'file') {
- delete resolved.dir.content[resolved.name];
- this.saveToStorage();
+ this.trashFile(path, node);
return true;
}
return false;
}
- mkdir(path) {
+ trashFile(path, node) {
+ try {
+ const name = path.split('/').pop() || 'unknown';
+ const ts = Date.now();
+ if (!this.root.content['.trash']) {
+ this.root.content['.trash'] = { type: 'dir', content: {} };
+ }
+ const trash = this.root.content['.trash'];
+ const trashName = ts + '-' + name;
+ trash.content[trashName] = JSON.parse(JSON.stringify(node));
+ const resolved = this._resolvePath(path);
+ if (resolved) delete resolved.dir.content[resolved.name];
+ this.saveToStorage();
+ } catch (e) { console.error('Trash error:', e); }
+ }
+ trashDir(path) {
const resolved = this._resolvePath(path);
if (!resolved || !resolved.name) return false;
- const name = resolved.name;
- if (name === '.' || name === '..') return false;
- if (resolved.dir.content[name]) return false;
- resolved.dir.content[name] = { type: 'dir', content: {} };
- this.saveToStorage();
- return true;
+ const node = resolved.dir.content[resolved.name];
+ if (node && node.type === 'dir') {
+ try {
+ const name = resolved.name;
+ const ts = Date.now();
+ if (!this.root.content['.trash']) {
+ this.root.content['.trash'] = { type: 'dir', content: {} };
+ }
+ const trash = this.root.content['.trash'];
+ const flatten = (n, prefix) => {
+ if (n.type === 'file') {
+ const flatName = prefix + '-' + name;
+ let ctr = 0;
+ let unique = flatName;
+ while (trash.content[unique]) { unique = flatName + '-' + (++ctr); }
+ trash.content[unique] = JSON.parse(JSON.stringify(n));
+ } else if (n.type === 'dir') {
+ for (const [childName, child] of Object.entries(n.content || {})) {
+ flatten(child, prefix + '-' + childName);
+ }
+ }
+ };
+ flatten(node, ts + '-dir-' + name);
+ delete resolved.dir.content[resolved.name];
+ this.saveToStorage();
+ return true;
+ } catch (e) { console.error('Trash dir error:', e); }
+ }
+ return false;
}
mkdirRecursive(path) {
const segs = this._toAbsoluteSegments(path);
- if (!segs || segs.length === 0) return true;
+ if (!segs) return false;
let dir = this.root;
for (const seg of segs) {
if (!dir.content[seg]) {
@@ -153,122 +240,209 @@ class VirtualFileSystem {
this.saveToStorage();
return true;
}
- readDir(path) {
- const resolved = this._resolvePath(path);
- if (!resolved) return null;
- if (resolved.name) {
- const node = resolved.dir.content[resolved.name];
- return (node && node.type === 'dir') ? node : null;
- }
- return resolved.dir;
- }
- copyFile(srcPath, dstPath) {
- const src = this._resolvePath(srcPath);
+ rename(oldPath, newPath) {
+ const src = this._resolvePath(oldPath);
if (!src || !src.name) return false;
- const srcNode = src.dir.content[src.name];
- if (!srcNode || srcNode.type !== 'file') return false;
- const dst = this._resolvePath(dstPath);
+ const dst = this._resolvePath(newPath);
if (!dst || !dst.name) return false;
- const copy = JSON.parse(JSON.stringify(srcNode));
- dst.dir.content[dst.name] = copy;
+ const node = src.dir.content[src.name];
+ if (!node) return false;
+ dst.dir.content[dst.name] = node;
+ delete src.dir.content[src.name];
this.saveToStorage();
return true;
}
- copyDir(srcPath, dstPath) {
+ copy(srcPath, dstPath) {
const src = this._resolvePath(srcPath);
if (!src || !src.name) return false;
- const srcNode = src.dir.content[src.name];
- if (!srcNode || srcNode.type !== 'dir') return false;
const dst = this._resolvePath(dstPath);
if (!dst || !dst.name) return false;
- if (dst.dir.content[dst.name]) return false;
- const copy = JSON.parse(JSON.stringify(srcNode));
- dst.dir.content[dst.name] = copy;
+ const node = src.dir.content[src.name];
+ if (!node) return false;
+ dst.dir.content[dst.name] = JSON.parse(JSON.stringify(node));
this.saveToStorage();
return true;
}
- moveFile(srcPath, dstPath) {
- if (this.copyFile(srcPath, dstPath)) {
- const src = this._resolvePath(srcPath);
- if (src && src.name) {
- delete src.dir.content[src.name];
- this.saveToStorage();
- return true;
- }
+ move(srcPath, dstPath) {
+ return this.rename(srcPath, dstPath);
+ }
+ cp(srcPath, dstPath) {
+ return this.copy(srcPath, dstPath);
+ }
+ mv(srcPath, dstPath) {
+ return this.rename(srcPath, dstPath);
+ }
+ rm(path) {
+ const resolved = this._resolvePath(path);
+ if (!resolved || !resolved.name) return false;
+ const node = resolved.dir.content[resolved.name];
+ if (!node) return false;
+ if (node.type === 'file') {
+ this.trashFile(path, node);
+ return true;
+ }
+ if (node.type === 'dir') {
+ this.trashDir(path);
+ return true;
}
return false;
}
- moveDir(srcPath, dstPath) {
- if (this.copyDir(srcPath, dstPath)) {
- const src = this._resolvePath(srcPath);
- if (src && src.name) {
- delete src.dir.content[src.name];
- this.saveToStorage();
- return true;
- }
+ rmdir(path) {
+ const resolved = this._resolvePath(path);
+ if (!resolved || !resolved.name) return false;
+ const node = resolved.dir.content[resolved.name];
+ if (node && node.type === 'dir') {
+ if (Object.keys(node.content).length > 0) return false;
+ delete resolved.dir.content[resolved.name];
+ this.saveToStorage();
+ return true;
}
return false;
}
- // Old-compat aliases for agent tools and SkillsModule
- fsRead(path) {
- const content = this.readFile(path);
- return content !== null ? content : null;
+ touch(path) {
+ return this.writeFile(path, '');
}
- fsWrite(path, content) {
- return this.writeFile(path, content);
+ readDir(path) {
+ const resolved = this._resolvePath(path);
+ if (!resolved) return null;
+ let dir;
+ if (resolved.name) {
+ const node = resolved.dir.content[resolved.name];
+ if (node && node.type === 'dir') dir = node;
+ else return null;
+ } else {
+ dir = resolved.dir;
+ }
+ return Object.keys(dir.content).map(name => ({
+ name,
+ type: dir.content[name].type,
+ size: dir.content[name].content ? dir.content[name].content.length : 0
+ }));
+ }
+ fsWriteJSON(path, obj) {
+ return this.writeFile(path, JSON.stringify(obj));
}
fsReadJSON(path) {
- const c = this.fsRead(path);
- if (c === null) return null;
- try { return JSON.parse(c); } catch (e) { return null; }
+ const content = this.readFile(path);
+ if (!content) return null;
+ try {
+ return JSON.parse(content);
+ } catch (e) {
+ return null;
+ }
}
- fsWriteJSON(path, obj) {
- return this.fsWrite(path, JSON.stringify(obj));
+ tree(path = '', prefix = '') {
+ const resolved = this._resolvePath(path);
+ if (!resolved) return '';
+ let dir;
+ if (resolved.name) {
+ const node = resolved.dir.content[resolved.name];
+ if (node && node.type === 'dir') dir = node;
+ else return '';
+ } else {
+ dir = resolved.dir;
+ }
+ let result = '';
+ const entries = Object.keys(dir.content).sort();
+ for (let i = 0; i < entries.length; i++) {
+ const name = entries[i];
+ const isLast = i === entries.length - 1;
+ const connector = isLast ? '└── ' : '├── ';
+ result += prefix + connector + name + '\n';
+ if (dir.content[name].type === 'dir') {
+ const ext = isLast ? ' ' : '│ ';
+ result += this.tree(path + '/' + name, prefix + ext);
+ }
+ }
+ return result;
}
- findFiles(pattern) {
+ find(pattern, path = '') {
+ const resolved = this._resolvePath(path);
+ if (!resolved) return [];
+ let dir;
+ if (resolved.name) {
+ const node = resolved.dir.content[resolved.name];
+ if (node && node.type === 'dir') dir = node;
+ else return [];
+ } else {
+ dir = resolved.dir;
+ }
const results = [];
- const search = (node, path) => {
- if (node.type === 'dir' && node.content) {
- for (const k of Object.keys(node.content)) {
- const sub = node.content[k];
- const subPath = path ? path + '/' + k : k;
- if (sub.type === 'dir') search(sub, subPath);
- else if (sub.type === 'file' && k.toLowerCase().includes(pattern)) results.push(subPath);
- }
+ const regex = new RegExp(pattern, 'i');
+ for (const name of Object.keys(dir.content)) {
+ const fullPath = path ? path + '/' + name : name;
+ if (regex.test(name)) {
+ results.push({ path: fullPath, type: dir.content[name].type });
}
- };
- search(this.root, '');
- return results.sort();
+ if (dir.content[name].type === 'dir') {
+ results.push(...this.find(pattern, fullPath));
+ }
+ }
+ return results;
+ }
+ grep(pattern, path = '') {
+ const results = [];
+ const regex = new RegExp(pattern, 'i');
+ const files = this.find('.', path).filter(e => e.type === 'file');
+ for (const file of files) {
+ const content = this.readFile(file.path);
+ if (content && regex.test(content)) {
+ results.push(file.path);
+ }
+ }
+ return results;
}
- deleteDirRecursive(path) {
+ chmod(path, mode) {
const resolved = this._resolvePath(path);
if (!resolved || !resolved.name) return false;
const node = resolved.dir.content[resolved.name];
- if (!node || node.type !== 'dir') return false;
- delete resolved.dir.content[resolved.name];
+ if (!node) return false;
+ node.mode = mode;
this.saveToStorage();
return true;
}
- // Full state serialization for session save/restore
- serializeState() {
- return JSON.stringify({
- root: this.root,
- currentPath: this.currentPath,
- version: 1
- });
+ du(path = '') {
+ const resolved = this._resolvePath(path);
+ if (!resolved) return 0;
+ let dir;
+ if (resolved.name) {
+ const node = resolved.dir.content[resolved.name];
+ if (node && node.type === 'dir') dir = node;
+ else return node.content ? node.content.length : 0;
+ } else {
+ dir = resolved.dir;
+ }
+ let size = 0;
+ for (const name of Object.keys(dir.content)) {
+ const node = dir.content[name];
+ if (node.type === 'file') {
+ size += node.content ? node.content.length : 0;
+ } else if (node.type === 'dir') {
+ size += this.du(path ? path + '/' + name : name);
+ }
+ }
+ return size;
+ }
+ getSize(path) {
+ const resolved = this._resolvePath(path);
+ if (!resolved || !resolved.name) return 0;
+ const node = resolved.dir.content[resolved.name];
+ if (!node) return 0;
+ if (node.type === 'file') return node.content ? node.content.length : 0;
+ return this.du(path);
}
- restoreState(json) {
- try {
- const data = JSON.parse(json);
- if (!data || !data.root || !data.root.type) return false;
- this.root = data.root;
- this.currentPath = data.currentPath || [];
- this.saveToStorage();
- return true;
- } catch (e) {
- console.error('FS restore error:', e);
- return false;
+ exportToJSON(path = '') {
+ const resolved = this._resolvePath(path);
+ if (!resolved) return null;
+ let dir;
+ if (resolved.name) {
+ const node = resolved.dir.content[resolved.name];
+ if (node && node.type === 'dir') dir = node;
+ else return JSON.stringify(node);
+ } else {
+ dir = resolved.dir;
}
+ return JSON.stringify(dir, null, 2);
}
// Restore VFS from an exported dump file (replaces entire root)
restoreFromExport(content) {
@@ -276,8 +450,15 @@ class VirtualFileSystem {
try { content = JSON.parse(content); } catch (e) { return false; }
}
if (!content || !content.type) return false;
- this.root = content;
- this.currentPath = [];
+ // Handle v2 structured export: { type, version, root, currentPath, ... }
+ if (content.version === 2 || content.root) {
+ this.root = content.root;
+ this.currentPath = content.currentPath || [];
+ } else {
+ // Legacy format: content is just the root object
+ this.root = content;
+ this.currentPath = [];
+ }
this.saveToStorage();
return true;
}
@@ -309,7 +490,10 @@ class VirtualFileSystem {
}
rollback(name) {
const raw = this.fsReadJSON('/_snapshots/checkpoint-' + name + '.json');
- if (!raw || !raw.root) return false;
+ if (!raw || !raw.root) {
+ console.error('Rollback failed: checkpoint not found or invalid');
+ return false;
+ }
this.root = raw.root;
this.currentPath = raw.currentPath || [];
this.saveToStorage();
@@ -317,15 +501,29 @@ class VirtualFileSystem {
}
listCheckpoints() {
const dir = this.readDir('/_snapshots');
- if (!dir || !dir.content) return [];
- const prefix = 'checkpoint-';
- const suffix = '.json';
- return Object.keys(dir.content)
- .filter(k => k.startsWith(prefix) && k.endsWith(suffix))
- .map(k => k.slice(prefix.length, k.length - suffix.length))
- .sort();
+ if (!dir) return [];
+ return dir
+ .filter(e => e.type === 'file' && e.name.startsWith('checkpoint-') && e.name.endsWith('.json'))
+ .map(e => e.name.replace('checkpoint-', '').replace('.json', ''));
}
deleteCheckpoint(name) {
- return this.deleteFile('/_snapshots/checkpoint-' + name + '.json');
+ const path = '/_snapshots/checkpoint-' + name + '.json';
+ const resolved = this._resolvePath(path);
+ if (resolved && resolved.name) {
+ delete resolved.dir.content[resolved.name];
+ this.saveToStorage();
+ return true;
+ }
+ return false;
}
+ // Old-compat aliases for agent tools and SkillsModule
+ read(path) { return this.readFile(path); }
+ write(path, content) { return this.writeFile(path, content); }
+ delete(path) { return this.deleteFile(path); }
+ mkdir(path) { return this.mkdirRecursive(path); }
+ list(path) { return this.ls(path); }
+ dir(path) { return this.ls(path); }
}
+
+// Also expose for module system
+window.VirtualFileSystem = VirtualFileSystem;
diff --git a/modules/ext/busybox.js b/modules/ext/busybox.js
new file mode 100644
index 0000000..f143087
--- /dev/null
+++ b/modules/ext/busybox.js
@@ -0,0 +1,407 @@
+class BusyBoxModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ }
+
+ requirements() {
+ return { met: true, missing: [] };
+ }
+
+ getHelpInfo() {
+ return {
+ 'which': 'which <name> — Locate a command in the module registry',
+ 'env': 'env — List environment variables (webx11_ keys + user profile)',
+ 'date': 'date — Show current date/time (ISO, local, UTC, timestamp)',
+ 'cal': 'cal [month] [year] — Display an ASCII calendar',
+ 'yes': 'yes [string] — Output repeated string (default "y", capped at 50)',
+ 'seq': 'seq <last> or seq <first> <last> — Generate sequence of numbers',
+ 'base64': 'base64 [-d] <string> — Base64 encode/decode',
+ 'printf': 'printf <format> [args...] — Formatted print (%s, %d, %f, \\n, \\t)',
+ 'cut': 'cut -f<N> [-d<delim>] <file> — Extract column from file',
+ 'uniq': 'uniq <file> — Filter adjacent duplicate lines from a file',
+ 'hostname': 'hostname — Show host/user-agent info',
+ 'whoami': 'whoami — Show current user name',
+ 'basename': 'basename <path> — Strip directory from path',
+ 'dirname': 'dirname <path> — Strip filename from path',
+ 'sleep': 'sleep <seconds> — Delay execution with countdown',
+ 'expand': 'expand <file> — Convert tabs to spaces (default 4) in file',
+ };
+ }
+
+ registerCommands() {
+ this.commands['which'] = (args) => this._cmdWhich(args);
+ this.commands['env'] = () => this._cmdEnv();
+ this.commands['date'] = (args) => this._cmdDate(args);
+ this.commands['cal'] = (args) => this._cmdCal(args);
+ this.commands['yes'] = (args) => this._cmdYes(args);
+ this.commands['seq'] = (args) => this._cmdSeq(args);
+ this.commands['base64'] = (args) => this._cmdBase64(args);
+ this.commands['printf'] = (args) => this._cmdPrintf(args);
+ this.commands['cut'] = (args) => this._cmdCut(args);
+ this.commands['uniq'] = (args) => this._cmdUniq(args);
+ this.commands['hostname'] = () => this._cmdHostname();
+ this.commands['whoami'] = () => this._cmdWhoami();
+ this.commands['basename'] = (args) => this._cmdBasename(args);
+ this.commands['dirname'] = (args) => this._cmdDirname(args);
+ this.commands['sleep'] = (args) => this._cmdSleep(args);
+ this.commands['expand'] = (args) => this._cmdExpand(args);
+ }
+
+ // ── which ──
+
+ _cmdWhich(args) {
+ if (!args[0]) return this.terminal.print('Usage: which <name>', 'warning');
+ const query = args[0].toLowerCase();
+ const all = this.terminal.moduleManager.getAllCommands();
+ const keys = Object.keys(all);
+
+ if (all[query]) {
+ const mod = this.terminal.moduleManager._findModuleForCommand(query);
+ const src = mod ? ` (from ${mod.constructor.name})` : '';
+ return this.terminal.print(`${query}${src}`, 'info');
+ }
+
+ // Fuzzy match: check Levenshtein distance up to 2
+ let best = null, bestDist = 3;
+ for (const k of keys) {
+ const d = this._levenshtein(query, k);
+ if (d < bestDist) { best = k; bestDist = d; }
+ }
+ if (best) {
+ this.terminal.print(`which: no '${query}' in registry`, 'error');
+ this.terminal.print(`Did you mean '${best}'?`, 'info');
+ } else {
+ this.terminal.print(`which: no '${query}' in registry`, 'error');
+ }
+ }
+
+ _levenshtein(a, b) {
+ const m = a.length, n = b.length;
+ const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
+ for (let i = 0; i <= m; i++) dp[i][0] = i;
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
+ for (let i = 1; i <= m; i++) {
+ for (let j = 1; j <= n; j++) {
+ dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1]
+ : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
+ }
+ }
+ return dp[m][n];
+ }
+
+ // ── env ──
+
+ _cmdEnv() {
+ const lines = [];
+ // webx11_ localStorage keys
+ for (let i = 0; i < localStorage.length; i++) {
+ const k = localStorage.key(i);
+ if (k && k.startsWith('webx11_')) {
+ let v = localStorage.getItem(k) || '';
+ if (v.length > 80) v = v.substring(0, 77) + '...';
+ lines.push(`${k}=${v}`);
+ }
+ }
+ // Runtime info
+ if (this.terminal.ollamaManager) {
+ lines.push(`OLLAMA_MODEL=${this.terminal.ollamaManager.currentModel || 'none'}`);
+ lines.push(`OLLAMA_CONNECTED=${this.terminal.ollamaManager.connected}`);
+ }
+ lines.push(`PWD=/${this.terminal.fs.currentPath.join('/')}`);
+ lines.push(`SHELL=webx11-terminal`);
+ lines.push(`USER=darkbot`);
+ lines.push(`HOME=/`);
+ lines.push(`TERM=xterm-256color`);
+ const prof = this.terminal.moduleManager.modules['WebX11AIAssistantModule'];
+ if (prof && prof._userProfile) {
+ const facts = prof._userProfile.knownFacts;
+ if (facts) {
+ for (const [k, v] of Object.entries(facts)) {
+ lines.push(`USER_${k.toUpperCase()}=${v}`);
+ }
+ }
+ }
+ this.terminal.print(lines.join('\n'), 'info');
+ }
+
+ // ── date ──
+
+ _cmdDate(args) {
+ const n = new Date();
+ if (args.length > 0) {
+ const flag = args[0].toLowerCase();
+ if (flag === '-u' || flag === '--utc' || flag === 'utc')
+ return this.terminal.print(n.toUTCString(), 'info');
+ if (flag === '-i' || flag === '--iso' || flag === 'iso')
+ return this.terminal.print(n.toISOString(), 'info');
+ if (flag === '-t' || flag === '--ts' || flag === 'timestamp')
+ return this.terminal.print(String(n.getTime()), 'info');
+ if (flag === '-r' || flag === '--rfc' || flag === 'rfc')
+ return this.terminal.print(n.toUTCString(), 'info');
+ }
+ const local = n.toLocaleString('en-US', {
+ weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
+ timeZoneName: 'short'
+ });
+ this.terminal.print(local, 'info');
+ this.terminal.print(`ISO: ${n.toISOString()}`, 'info');
+ this.terminal.print(`UTC: ${n.toUTCString()}`, 'info');
+ this.terminal.print(`Unix: ${Math.floor(n.getTime() / 1000)}`, 'info');
+ }
+
+ // ── cal ──
+
+ _cmdCal(args) {
+ const now = new Date();
+ let year = now.getFullYear();
+ let month = now.getMonth() + 1;
+
+ if (args.length >= 2) {
+ month = parseInt(args[0]);
+ year = parseInt(args[1]);
+ } else if (args.length === 1) {
+ // If only one arg, could be year (4 digits) or month
+ const v = parseInt(args[0]);
+ if (args[0].length === 4 || v > 12) {
+ year = v;
+ } else {
+ month = v;
+ }
+ }
+
+ if (isNaN(month) || isNaN(year) || month < 1 || month > 12) {
+ return this.terminal.print('cal: invalid month or year', 'error');
+ }
+
+ const monthNames = ['January','February','March','April','May','June',
+ 'July','August','September','October','November','December'];
+
+ const first = new Date(year, month - 1, 1);
+ const last = new Date(year, month, 0);
+ const daysInMonth = last.getDate();
+ const startDay = first.getDay(); // 0=Sun
+
+ const header = ` ${monthNames[month-1]} ${year}`;
+ const weekdays = 'Su Mo Tu We Th Fr Sa';
+
+ let cal = header + '\n' + weekdays + '\n';
+
+ let line = ' '.repeat(startDay);
+ for (let d = 1; d <= daysInMonth; d++) {
+ line += String(d).padStart(2) + ' ';
+ if ((startDay + d) % 7 === 0 || d === daysInMonth) {
+ cal += line.trimEnd() + '\n';
+ line = '';
+ }
+ }
+
+ this.terminal.print(cal, 'info');
+ }
+
+ // ── yes ──
+
+ _cmdYes(args) {
+ const text = args.length > 0 ? args.join(' ') : 'y';
+ const count = 50;
+ for (let i = 0; i < count; i++) {
+ this.terminal.print(text);
+ }
+ this.terminal.print(`(repeated ${count} times, capped)`, 'text-muted');
+ }
+
+ // ── seq ──
+
+ _cmdSeq(args) {
+ if (args.length === 0) return this.terminal.print('Usage: seq <last> or seq <first> <last>', 'warning');
+ let first = 1, last;
+ if (args.length === 1) {
+ last = parseInt(args[0]);
+ } else {
+ first = parseInt(args[0]);
+ last = parseInt(args[1]);
+ }
+ if (isNaN(first) || isNaN(last)) return this.terminal.print('seq: non-integer argument', 'error');
+ const max = 1000;
+ if (last - first > max) return this.terminal.print(`seq: range too large (max ${max})`, 'error');
+ const output = [];
+ if (first <= last) {
+ for (let i = first; i <= last; i++) output.push(String(i));
+ } else {
+ for (let i = first; i >= last; i--) output.push(String(i));
+ }
+ this.terminal.print(output.join('\n'), 'info');
+ }
+
+ // ── base64 ──
+
+ _cmdBase64(args) {
+ if (args.length === 0) return this.terminal.print('Usage: base64 [-d] <string>', 'warning');
+ if (args[0] === '-d' || args[0] === '--decode') {
+ if (args.length < 2) return this.terminal.print('Usage: base64 -d <encoded>', 'warning');
+ const encoded = args.slice(1).join(' ');
+ try {
+ const decoded = atob(encoded);
+ this.terminal.print(decoded, 'info');
+ } catch (e) {
+ this.terminal.print('base64: invalid input (not valid base64)', 'error');
+ }
+ } else {
+ const input = args.join(' ');
+ this.terminal.print(btoa(input), 'info');
+ }
+ }
+
+ // ── printf ──
+
+ _cmdPrintf(args) {
+ if (args.length === 0) return this.terminal.print('Usage: printf <format> [args...]', 'warning');
+ let fmt = args[0];
+ const vals = args.slice(1);
+ let vi = 0;
+ // Replace format specifiers step by step
+ const result = fmt
+ .replace(/\\n/g, '\n')
+ .replace(/\\t/g, '\t')
+ .replace(/\\\\/g, '\\')
+ .replace(/%s/g, () => vals[vi++] || '')
+ .replace(/%d/g, () => {
+ const v = parseInt(vals[vi++]);
+ return isNaN(v) ? '0' : String(v);
+ })
+ .replace(/%f/g, () => {
+ const v = parseFloat(vals[vi++]);
+ return isNaN(v) ? '0.000000' : v.toFixed(6);
+ })
+ .replace(/%[^sdf%]/g, (m) => `(unsupported: ${m})`);
+ this.terminal.print(result, 'info');
+ }
+
+ // ── cut ──
+
+ _cmdCut(args) {
+ if (args.length < 2) return this.terminal.print('Usage: cut -f<N> [-d<delim>] <file>', 'warning');
+ let field = null, delim = '\t', file = '';
+ for (const a of args) {
+ if (a.startsWith('-f')) field = parseInt(a.substring(2));
+ else if (a.startsWith('-d')) delim = a.substring(2);
+ else file = a;
+ }
+ if (!field || isNaN(field)) return this.terminal.print('cut: -f flag required', 'error');
+ if (!file) return this.terminal.print('cut: file argument required', 'error');
+
+ const content = this.terminal.fs.readFile(file);
+ if (content === null) return this.terminal.print(`cut: ${file}: No such file`, 'error');
+
+ const lines = content.split('\n').filter(l => l);
+ const output = [];
+ for (const line of lines) {
+ const parts = line.split(delim);
+ const idx = field - 1;
+ if (idx >= 0 && idx < parts.length) {
+ output.push(parts[idx]);
+ }
+ }
+ this.terminal.print(output.join('\n'), 'info');
+ }
+
+ // ── uniq ──
+
+ _cmdUniq(args) {
+ if (!args[0]) return this.terminal.print('Usage: uniq <file>', 'warning');
+ const content = this.terminal.fs.readFile(args[0]);
+ if (content === null) return this.terminal.print(`uniq: ${args[0]}: No such file`, 'error');
+ const lines = content.split('\n');
+ let last = null;
+ const output = [];
+ for (const line of lines) {
+ if (line !== last) {
+ output.push(line);
+ last = line;
+ }
+ }
+ this.terminal.print(output.join('\n'), 'info');
+ }
+
+ // ── hostname ──
+
+ _cmdHostname() {
+ const ua = navigator.userAgent;
+ const space = 'newos4';
+ const platform = navigator.platform || 'unknown';
+ const version = 'WebX11 v4.2';
+ this.terminal.print(`Hostname: ${space}`, 'info');
+ this.terminal.print(`Platform: ${version} on ${platform}`, 'info');
+ this.terminal.print(`User Agent: ${ua}`, 'info');
+ }
+
+ // ── whoami ──
+
+ _cmdWhoami() {
+ // Try to get from user profile
+ let name = 'darkbot';
+ try {
+ const prof = this.terminal.moduleManager.modules['WebX11AIAssistantModule'];
+ if (prof && prof._userProfile && prof._userProfile.knownFacts && prof._userProfile.knownFacts.name) {
+ name = prof._userProfile.knownFacts.name;
+ }
+ } catch (e) {}
+ this.terminal.print(name, 'info');
+ }
+
+ // ── basename ──
+
+ _cmdBasename(args) {
+ if (!args[0]) return this.terminal.print('Usage: basename <path>', 'warning');
+ const parts = args[0].split('/').filter(p => p);
+ const result = parts.pop() || '/';
+ this.terminal.print(result, 'info');
+ }
+
+ // ── dirname ──
+
+ _cmdDirname(args) {
+ if (!args[0]) return this.terminal.print('Usage: dirname <path>', 'warning');
+ const parts = args[0].split('/').filter(p => p);
+ if (parts.length <= 1) {
+ this.terminal.print('.', 'info');
+ return;
+ }
+ parts.pop();
+ const result = args[0].startsWith('/') ? '/' + parts.join('/') : parts.join('/');
+ this.terminal.print(result || '.', 'info');
+ }
+
+ // ── sleep ──
+
+ _cmdSleep(args) {
+ if (!args[0]) return this.terminal.print('Usage: sleep <seconds>', 'warning');
+ const secs = parseInt(args[0]);
+ if (isNaN(secs) || secs < 1) return this.terminal.print('sleep: invalid duration', 'error');
+ const max = 30;
+ const actual = Math.min(secs, max);
+ if (actual !== secs) this.terminal.print(`sleep: capped to ${max}s (from ${secs}s)`, 'warning');
+ this.terminal.print(`Sleeping ${actual}s...`, 'info');
+ let remaining = actual;
+ const interval = setInterval(() => {
+ remaining--;
+ if (remaining > 0) {
+ this.terminal.print(`${remaining}s...`, 'info');
+ } else {
+ clearInterval(interval);
+ this.terminal.print('Done', 'success');
+ }
+ }, 1000);
+ }
+
+ // ── expand ──
+
+ _cmdExpand(args) {
+ if (!args[0]) return this.terminal.print('Usage: expand <file>', 'warning');
+ const content = this.terminal.fs.readFile(args[0]);
+ if (content === null) return this.terminal.print(`expand: ${args[0]}: No such file`, 'error');
+ const expanded = content.replace(/\t/g, ' ');
+ this.terminal.print(expanded, 'info');
+ }
+}
diff --git a/modules/ext/cryptogzip.js b/modules/ext/cryptogzip.js
new file mode 100644
index 0000000..6f873ff
--- /dev/null
+++ b/modules/ext/cryptogzip.js
@@ -0,0 +1,281 @@
+class CryptoGzipModule {
+ // CryptoGzipModule — VFS-level gzip/encrypt commands for WebX11 newOS4
+ // Commands: gzip <path>, gunzip <path.gz>, encrypt <path>, decrypt <path.enc>
+ constructor(terminal) {
+ this.terminal = terminal;
+ this.commands = {};
+ }
+
+ _vfsExists(path) {
+ // readFile returns null if not found
+ const normalized = path.replace(/^\/+/, '');
+ return this.terminal.fs.readFile(normalized) !== null;
+ }
+
+ _vfsRead(path) {
+ const normalized = path.replace(/^\/+/, '');
+ const content = this.terminal.fs.readFile(normalized);
+ if (content === null) throw new Error(`File not found: ${path}`);
+ return content;
+ }
+
+ _vfsWrite(path, content) {
+ const normalized = path.replace(/^\/+/, '');
+ return this.terminal.fs.writeFile(normalized, content);
+ }
+
+ _vfsDelete(path) {
+ const normalized = path.replace(/^\/+/, '');
+ this.terminal.fs.deleteFile(normalized);
+ }
+
+ // ============================================================
+ // Prompt Utilities (WebX11 terminal compatible)
+ // ============================================================
+
+ _promptPassphrase(message = 'Enter passphrase: ') {
+ return new Promise((resolve) => {
+ this.terminal.print(message, 'info');
+ const savedHandler = this.terminal.inputCallback || this.terminal._onCommand;
+ const handler = (line) => {
+ if (this.terminal.inputCallback === handler) this.terminal.inputCallback = savedHandler;
+ if (this.terminal._onCommand === handler) this.terminal._onCommand = savedHandler;
+ resolve(line);
+ };
+ this.terminal.inputCallback = handler;
+ this.terminal._onCommand = handler;
+ });
+ }
+
+ _confirmPrompt(message) {
+ return new Promise((resolve) => {
+ this.terminal.print(message + ' (yes/no): ', 'warning');
+ const savedHandler = this.terminal.inputCallback || this.terminal._onCommand;
+ const handler = (line) => {
+ if (this.terminal.inputCallback === handler) this.terminal.inputCallback = savedHandler;
+ if (this.terminal._onCommand === handler) this.terminal._onCommand = savedHandler;
+ resolve(line.trim().toLowerCase() === 'yes');
+ };
+ this.terminal.inputCallback = handler;
+ this.terminal._onCommand = handler;
+ });
+ }
+
+ // ============================================================
+ // GZIP
+ // ============================================================
+
+ async _cmdGzip(args) {
+ const path = args[0];
+ if (!path) {
+ this.terminal.print('Usage: gzip <path> — Compress a VFS file to .gz', 'warning');
+ return;
+ }
+ if (!this._vfsExists(path)) {
+ this.terminal.print('File not found: ' + path, 'error');
+ return;
+ }
+
+ const outPath = path + '.gz';
+ if (this._vfsExists(outPath)) {
+ const ok = await this._confirmPrompt(`Overwrite ${outPath}?`);
+ if (!ok) { this.terminal.print('Cancelled.', 'info'); return; }
+ }
+
+ try {
+ const content = this._vfsRead(path);
+ const compressed = await this._gzipString(content);
+ this._vfsWrite(outPath, compressed);
+ this._vfsDelete(path);
+ const ratio = (compressed.length / Math.max(content.length, 1)).toFixed(2);
+ this.terminal.print(`gzipped: ${path} → ${outPath} (${content.length} → ${compressed.length} chars, ratio ${ratio})`, 'success');
+ } catch (e) {
+ this.terminal.print('gzip error: ' + e.message, 'error');
+ }
+ }
+
+ async _cmdGunzip(args) {
+ const path = args[0];
+ if (!path || !path.endsWith('.gz')) {
+ this.terminal.print('Usage: gunzip <path.gz> — Decompress a .gz file in VFS', 'warning');
+ return;
+ }
+ if (!this._vfsExists(path)) {
+ this.terminal.print('File not found: ' + path, 'error');
+ return;
+ }
+
+ const outPath = path.replace(/\.gz$/, '');
+ if (this._vfsExists(outPath)) {
+ const ok = await this._confirmPrompt(`Overwrite ${outPath}?`);
+ if (!ok) { this.terminal.print('Cancelled.', 'info'); return; }
+ }
+
+ try {
+ const compressed = this._vfsRead(path);
+ const content = await this._gunzipString(compressed);
+ this._vfsWrite(outPath, content);
+ this._vfsDelete(path);
+ this.terminal.print(`gunzipped: ${path} → ${outPath} (${compressed.length} → ${content.length} chars)`, 'success');
+ } catch (e) {
+ this.terminal.print('gunzip error: ' + e.message, 'error');
+ }
+ }
+
+ async _gzipString(str) {
+ const encoder = new TextEncoder();
+ const bytes = encoder.encode(str);
+ const cs = new CompressionStream('gzip');
+ const writer = cs.writable.getWriter();
+ writer.write(bytes);
+ writer.close();
+ const compressed = await new Response(cs.readable).arrayBuffer();
+ return this._bytesToBase64(new Uint8Array(compressed));
+ }
+
+ async _gunzipString(b64) {
+ const compressed = this._base64ToBytes(b64);
+ const ds = new DecompressionStream('gzip');
+ const writer = ds.writable.getWriter();
+ writer.write(compressed);
+ writer.close();
+ const decompressed = await new Response(ds.readable).arrayBuffer();
+ return new TextDecoder().decode(decompressed);
+ }
+
+ // ============================================================
+ // ENCRYPT / DECRYPT (AES-256-GCM)
+ // ============================================================
+
+ async _cmdEncrypt(args) {
+ const path = args[0];
+ const passphrase = args[1];
+ if (!path || !passphrase) {
+ this.terminal.print('Usage: encrypt <path> <passphrase> — AES-256-GCM encrypt a VFS file', 'warning');
+ return;
+ }
+ if (!this._vfsExists(path)) {
+ this.terminal.print('File not found: ' + path, 'error');
+ return;
+ }
+ if (passphrase.length < 4) {
+ this.terminal.print('Passphrase too short (min 4 chars)', 'error');
+ return;
+ }
+ const outPath = path + '.enc';
+ if (this._vfsExists(outPath)) {
+ this.terminal.print(`Overwriting existing ${outPath}`, 'warning');
+ }
+ try {
+ const plaintext = this._vfsRead(path);
+ const envelope = await this._encryptString(plaintext, passphrase);
+ this._vfsWrite(outPath, JSON.stringify(envelope, null, 0));
+ this._vfsDelete(path);
+ this.terminal.print(`encrypted: ${path} → ${outPath}`, 'success');
+ } catch (e) {
+ this.terminal.print('encrypt error: ' + e.message, 'error');
+ }
+ }
+
+ async _cmdDecrypt(args) {
+ const path = args[0];
+ const passphrase = args[1];
+ if (!path || !path.endsWith('.enc') || !passphrase) {
+ this.terminal.print('Usage: decrypt <path.enc> <passphrase> — Decrypt a .enc file', 'warning');
+ return;
+ }
+ if (!this._vfsExists(path)) {
+ this.terminal.print('File not found: ' + path, 'error');
+ return;
+ }
+ const outPath = path.replace(/\.enc$/, '');
+ if (this._vfsExists(outPath)) {
+ this.terminal.print(`Overwriting existing ${outPath}`, 'warning');
+ }
+ try {
+ const envelopeJson = this._vfsRead(path);
+ let envelope;
+ try { envelope = JSON.parse(envelopeJson); }
+ catch (e) { throw new Error('Invalid .enc file format (expected JSON envelope)'); }
+
+ const plaintext = await this._decryptEnvelope(envelope, passphrase);
+ this._vfsWrite(outPath, plaintext);
+ this._vfsDelete(path);
+ this.terminal.print(`decrypted: ${path} → ${outPath}`, 'success');
+ } catch (e) {
+ this.terminal.print('decrypt error: ' + e.message, 'error');
+ }
+ }
+
+ async _encryptString(plaintext, passphrase) {
+ const salt = crypto.getRandomValues(new Uint8Array(16));
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const key = await this._deriveKey(passphrase, salt);
+ const encoder = new TextEncoder();
+ const ciphertext = await crypto.subtle.encrypt(
+ { name: 'AES-GCM', iv }, key, encoder.encode(plaintext)
+ );
+ return {
+ v: 1,
+ alg: 'AES-256-GCM+PBKDF2',
+ salt: this._bytesToBase64(salt),
+ iv: this._bytesToBase64(iv),
+ ct: this._bytesToBase64(new Uint8Array(ciphertext)),
+ };
+ }
+
+ async _decryptEnvelope(envelope, passphrase) {
+ if (!envelope || envelope.v !== 1) throw new Error('Unsupported envelope version');
+ const salt = this._base64ToBytes(envelope.salt);
+ const iv = this._base64ToBytes(envelope.iv);
+ const ct = this._base64ToBytes(envelope.ct);
+ const key = await this._deriveKey(passphrase, salt);
+ try {
+ const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
+ return new TextDecoder().decode(decrypted);
+ } catch (e) {
+ throw new Error('Decryption failed — wrong passphrase or corrupted file');
+ }
+ }
+
+ async _deriveKey(passphrase, salt) {
+ const encoder = new TextEncoder();
+ const baseKey = await crypto.subtle.importKey(
+ 'raw', encoder.encode(passphrase), { name: 'PBKDF2' }, false, ['deriveKey']
+ );
+ return crypto.subtle.deriveKey(
+ { name: 'PBKDF2', salt: salt, iterations: 100000, hash: 'SHA-256' },
+ baseKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
+ );
+ }
+
+ // ============================================================
+ // Base64 Helpers
+ // ============================================================
+
+ _bytesToBase64(bytes) {
+ const bin = Array.from(bytes, (b) => String.fromCharCode(b)).join('');
+ return btoa(bin);
+ }
+
+ _base64ToBytes(b64) {
+ const bin = atob(b64);
+ return Uint8Array.from(bin, (c) => c.charCodeAt(0));
+ }
+
+ // ============================================================
+
+ registerCommands() {
+ const self = this;
+ this.commands['gzip'] = async (args) => self._cmdGzip(args);
+ this.commands['gunzip'] = async (args) => self._cmdGunzip(args);
+ this.commands['encrypt'] = async (args) => self._cmdEncrypt(args);
+ this.commands['decrypt'] = async (args) => self._cmdDecrypt(args);
+ this.commandsMeta = {
+ gzip: { argType: 'file', description: 'Compress a VFS file to .gz' },
+ gunzip: { argType: 'file', description: 'Decompress a .gz file' },
+ encrypt: { argType: 'file', description: 'Encrypt a file with passphrase' },
+ decrypt: { argType: 'file', description: 'Decrypt a .enc file' }
+ };
+ }
+}
diff --git a/modules/ext/mindmap.js b/modules/ext/mindmap.js
index 012bae7..30d6889 100644
--- a/modules/ext/mindmap.js
+++ b/modules/ext/mindmap.js
@@ -43,10 +43,29 @@ class MindmapModule {
registerCommands() {
const self = this;
this.commands['mindmap'] = (args) => {
- if (!args[0]) return this.terminal.print('Usage: mindmap <new|add|show|export|save|load|list|delete|rename|color|tag> [...]', 'warning');
+ if (!args[0] || args[0] === '--help' || args[0] === '-h') {
+ this.terminal.print('Usage: mindmap <new|add|show|export|save|load|list|delete|rename|color|tag> [...]', 'info');
+ this.terminal.print(' new <topic> [-t tags] — Create a new mindmap', 'info');
+ this.terminal.print(' add <parent-id> <text> [-t tags] — Add a child node', 'info');
+ this.terminal.print(' show — Display the mindmap tree', 'info');
+ this.terminal.print(' export — Print as indented text', 'info');
+ this.terminal.print(' save [name] — Save to VFS', 'info');
+ this.terminal.print(' load <name> — Load from VFS', 'info');
+ this.terminal.print(' list — List saved mindmaps', 'info');
+ this.terminal.print(' delete <id> — Remove a node', 'info');
+ this.terminal.print(' rename <id> <new-text>', 'info');
+ this.terminal.print(' color <id> <color>', 'info');
+ this.terminal.print(' tag <id> [tag1,tag2...]', 'info');
+ return;
+ }
const sub = args[0];
const rest = args.slice(1);
switch (sub) {
+ case '--help':
+ case '-h':
+ case 'help':
+ this.terminal.print('Usage: mindmap <new|add|show|export|save|load|list|delete|rename|color|tag> [...]', 'info');
+ return;
case 'new': return this._new(rest);
case 'add': return this._add(rest);
case 'show': return this._show(rest);
diff --git a/modules/ext/shell.js b/modules/ext/shell.js
new file mode 100644
index 0000000..d5e6a43
--- /dev/null
+++ b/modules/ext/shell.js
@@ -0,0 +1,517 @@
+class ShellModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this._vars = {
+ HOME: '/',
+ USER: 'darkbot',
+ SHELL: 'webx11-shell',
+ TERM: 'xterm-256color',
+ PWD: '/'
+ };
+ this._scriptDepth = 0;
+ this._maxScriptDepth = 3;
+ }
+
+ requirements() {
+ return { met: true, missing: [] };
+ }
+
+ getHelpInfo() {
+ return {
+ 'export': 'export <var>=<value> — Set a shell variable',
+ 'unset': 'unset <var> — Remove a shell variable',
+ 'script': 'script <path.sh> — Run a shell script from VFS',
+ '$': '$ <command> — Execute with shell parsing (default: auto-detected)',
+ 'set': 'set — List all shell variables',
+ };
+ }
+
+ registerCommands() {
+ this.commands['export'] = (args) => this._cmdExport(args);
+ this.commands['unset'] = (args) => this._cmdUnset(args);
+ this.commands['script'] = (args) => this._cmdScript(args);
+ this.commands['set'] = () => this._cmdSet();
+ }
+
+ // ── Hook into terminal.executeCommand ──
+
+ init() {
+ // Wrap executeCommand to add shell parsing
+ const origExecute = this.terminal.executeCommand.bind(this.terminal);
+ const self = this;
+ this.terminal.executeCommand = async function(rawInput) {
+ if (!rawInput.trim()) return;
+ this.markDirty();
+ this.print(`webx11@module:~$ ${rawInput}`, 'command-prompt');
+ // Substitute variables in the raw input
+ const substituted = self._substituteVars(rawInput);
+ // Split on ; first (sequence)
+ const seqParts = self._splitSequence(substituted);
+ for (const seqPart of seqParts) {
+ if (!seqPart.trim()) continue;
+ // Split on && and ||
+ const chainParts = self._splitChain(seqPart.trim());
+ let lastOk = true;
+ for (const chainPart of chainParts) {
+ if (!chainPart.trim()) continue;
+ const { cmd: chainCmd, operator } = chainPart;
+ if (operator === '&&' && !lastOk) continue;
+ if (operator === '||' && lastOk) continue;
+ // Check for redirects
+ const redirectInfo = self._extractRedirects(chainCmd);
+ if (redirectInfo.hasRedirect) {
+ // Run, capture, write to file
+ await self._executeWithRedirect(redirectInfo);
+ lastOk = redirectInfo.exitOk;
+ } else {
+ // Normal or piped command
+ const pipeParts = self.terminal.splitPipe(chainCmd);
+ if (pipeParts.length > 1) {
+ await self.terminal.executePipeChain(pipeParts);
+ } else {
+ // Single command
+ const parts = chainCmd.split(/\s+/);
+ const cmd = parts[0].toLowerCase();
+ const args = parts.slice(1);
+ await this.security.checkAndRun(chainCmd);
+ }
+ lastOk = true;
+ }
+ }
+ }
+ };
+ this.terminal._shellModule = this;
+
+ // Modify pipe chain to read _pipeInput for commands that support it
+ const origPipeChain = this.terminal.executePipeChain.bind(this.terminal);
+ this.terminal.executePipeChain = async function(pipeParts) {
+ // Write pipe input from previous segment as temp file for next segment
+ const shellMod = this._shellModule;
+ let pipeInput = null;
+ for (let i = 0; i < pipeParts.length; i++) {
+ const cmdText = pipeParts[i];
+ const parts = cmdText.split(/\s+/);
+ const cmd = parts[0].toLowerCase();
+ let args = parts.slice(1);
+ this._pipeInput = pipeInput;
+
+ if (pipeInput !== null) {
+ // Write pipe input as temp file so file-reading commands can use it
+ this.fs.writeFile('__pipebuf__', pipeInput);
+ // Inject __pipebuf__ as file arg if command is pipe-aware (reads files for input)
+ if (shellMod && shellMod._isPipeAware(cmd)) {
+ // Don't inject if command already has enough file args
+ if (!args.some(a => !a.startsWith('-') && a !== '|' && a !== '>')) {
+ args = args.concat('__pipebuf__');
+ }
+ }
+ }
+
+ if (i < pipeParts.length - 1) {
+ pipeInput = await this.capturePipeOutput(cmd, args);
+ } else {
+ await this.dispatchCommand(cmd, args);
+ }
+
+ // Clean up temp file
+ if (this.fs.readFile('__pipebuf__') !== null) {
+ this.fs.root.content['__pipebuf__'] = undefined;
+ delete this.fs.root.content['__pipebuf__'];
+ }
+ }
+ this._pipeInput = null;
+ };
+ }
+
+ // ── Tokenizer helpers ──
+
+ _splitSequence(input) {
+ // Split on ; (not inside quotes)
+ const parts = [];
+ let current = '';
+ let inQuote = false;
+ let quoteChar = '';
+ for (const ch of input) {
+ if ((ch === '"' || ch === "'") && !inQuote) {
+ inQuote = true; quoteChar = ch; current += ch;
+ } else if (ch === quoteChar && inQuote) {
+ inQuote = false; quoteChar = ''; current += ch;
+ } else if (ch === ';' && !inQuote) {
+ parts.push(current.trim());
+ current = '';
+ } else {
+ current += ch;
+ }
+ }
+ parts.push(current.trim());
+ return parts;
+ }
+
+ _splitChain(input) {
+ // Split on && and ||, preserving operator info
+ const parts = [];
+ let current = '';
+ let inQuote = false;
+ let quoteChar = '';
+ let i = 0;
+ while (i < input.length) {
+ const ch = input[i];
+ if ((ch === '"' || ch === "'") && !inQuote) {
+ inQuote = true; quoteChar = ch; current += ch; i++;
+ } else if (ch === quoteChar && inQuote) {
+ inQuote = false; quoteChar = ''; current += ch; i++;
+ } else if (!inQuote && input.substring(i, i+2) === '&&') {
+ parts.push({ cmd: current.trim(), operator: '&&' });
+ current = ''; i += 2;
+ } else if (!inQuote && input.substring(i, i+2) === '||') {
+ parts.push({ cmd: current.trim(), operator: '||' });
+ current = ''; i += 2;
+ } else {
+ current += ch; i++;
+ }
+ }
+ parts.push({ cmd: current.trim(), operator: null });
+ return parts;
+ }
+
+ _extractRedirects(input) {
+ // Find >, >>, < outside quotes
+ let cmd = '';
+ let hasRedirect = false;
+ let redirectOp = '';
+ let redirectFile = '';
+ let append = false;
+ let inQuote = false, quoteChar = '';
+ let i = 0;
+ const parts = [];
+ let current = '';
+ while (i < input.length) {
+ const ch = input[i];
+ if ((ch === '"' || ch === "'") && !inQuote) {
+ inQuote = true; quoteChar = ch; current += ch; i++;
+ } else if (ch === quoteChar && inQuote) {
+ inQuote = false; quoteChar = ''; current += ch; i++;
+ } else if (!inQuote) {
+ if (input.substring(i, i+2) === '>>') {
+ if (current.trim()) parts.push(current.trim());
+ hasRedirect = true;
+ redirectOp = '>>';
+ append = true;
+ i += 2;
+ // Read until next redirect or end
+ let file = '';
+ while (i < input.length && input[i] === ' ') i++;
+ while (i < input.length && input[i] !== '>' && input[i] !== '<' && input[i] !== '|') {
+ file += input[i]; i++;
+ }
+ redirectFile = file.trim();
+ if (i < input.length && (input[i] === '>' || input[i] === '<')) continue;
+ break;
+ } else if (input.substring(i, i+1) === '>') {
+ if (current.trim()) parts.push(current.trim());
+ hasRedirect = true;
+ redirectOp = '>';
+ append = false;
+ i += 1;
+ let file = '';
+ while (i < input.length && input[i] === ' ') i++;
+ while (i < input.length && input[i] !== '>' && input[i] !== '<' && input[i] !== '|') {
+ file += input[i]; i++;
+ }
+ redirectFile = file.trim();
+ if (i < input.length && (input[i] === '>' || input[i] === '<')) continue;
+ break;
+ } else if (input.substring(i, i+1) === '<') {
+ if (current.trim()) parts.push(current.trim());
+ hasRedirect = true;
+ redirectOp = '<';
+ i += 1;
+ let file = '';
+ while (i < input.length && input[i] === ' ') i++;
+ while (i < input.length && input[i] !== '>' && input[i] !== '<' && input[i] !== '|') {
+ file += input[i]; i++;
+ }
+ redirectFile = file.trim();
+ if (i < input.length && (input[i] === '>' || input[i] === '<')) continue;
+ break;
+ } else {
+ current += ch; i++;
+ }
+ } else {
+ current += ch; i++;
+ }
+ }
+ if (current.trim()) parts.push(current.trim());
+
+ const cmdText = parts.join(' ');
+ return {
+ hasRedirect,
+ redirectOp,
+ redirectFile,
+ append,
+ cmdText,
+ exitOk: true
+ };
+ }
+
+ async _executeWithRedirect(redirectInfo) {
+ const { redirectOp, redirectFile, append, cmdText } = redirectInfo;
+ const fs = this.terminal.fs;
+
+ if (redirectOp === '<') {
+ // Read file content as stdin
+ const inputContent = fs.readFile(redirectFile);
+ if (inputContent === null) {
+ this.terminal.print(`shell: ${redirectFile}: No such file`, 'error');
+ return;
+ }
+ this.terminal._pipeInput = inputContent;
+ // Execute the command with pipe input available
+ const parts = cmdText.split(/\s+/);
+ const cmd = parts[0].toLowerCase();
+ const args = parts.slice(1);
+ // If command reads files, inject pipe content as temp file
+ if (this._isPipeAware(cmd)) {
+ fs.writeFile('__pipebuf__', inputContent);
+ const newArgs = [...args];
+ if (!newArgs.some(a => !a.startsWith('-'))) {
+ newArgs.push('__pipebuf__');
+ }
+ await this.terminal.dispatchCommand(cmd, newArgs);
+ if (fs.readFile('__pipebuf__') !== null) {
+ delete fs.root.content['__pipebuf__'];
+ }
+ } else {
+ await this.terminal.dispatchCommand(cmd, args);
+ }
+ this.terminal._pipeInput = null;
+ return;
+ }
+
+ // > or >> — capture output, write to file
+ const originalPrint = this.terminal.print.bind(this.terminal);
+ let output = '';
+ this.terminal.print = (text, type) => { output += text + '\n'; };
+
+ // Check for pipes within the command before redirect
+ const pipeParts = this.terminal.splitPipe(cmdText);
+ if (pipeParts.length > 1) {
+ // Execute pipe chain manually to capture all output
+ let pipeInput = null;
+ for (let i = 0; i < pipeParts.length; i++) {
+ const pText = pipeParts[i];
+ const pParts = pText.split(/\s+/);
+ const pCmd = pParts[0].toLowerCase();
+ const pArgs = pParts.slice(1);
+ if (pipeInput !== null) {
+ fs.writeFile('__pipebuf__', pipeInput);
+ }
+ if (i < pipeParts.length - 1) {
+ const captPrint = this.terminal.print.bind(this.terminal);
+ let capt = '';
+ this.terminal.print = (t, ty) => { capt += t + '\n'; };
+ await this.terminal.dispatchCommand(pCmd, pArgs);
+ this.terminal.print = captPrint;
+ pipeInput = capt.trim();
+ } else {
+ await this.terminal.dispatchCommand(pCmd, pArgs);
+ }
+ if (fs.readFile('__pipebuf__') !== null) {
+ delete fs.root.content['__pipebuf__'];
+ }
+ }
+ } else {
+ const parts = cmdText.split(/\s+/);
+ const cmd = parts[0].toLowerCase();
+ const args = parts.slice(1);
+ await this.terminal.dispatchCommand(cmd, args);
+ }
+
+ this.terminal.print = originalPrint;
+ const captured = output.trimEnd();
+
+ if (captured) {
+ if (append) {
+ const existing = fs.readFile(redirectFile) || '';
+ fs.writeFile(redirectFile, existing + (existing ? '\n' : '') + captured);
+ } else {
+ fs.writeFile(redirectFile, captured);
+ }
+ this.terminal.print(`Redirected ${captured.split('\n').length} line(s) to ${redirectFile}`, 'success');
+ } else {
+ // Write empty file
+ if (!append) {
+ fs.writeFile(redirectFile, '');
+ this.terminal.print(`Redirected (empty) to ${redirectFile}`, 'success');
+ }
+ }
+ }
+
+ _isPipeAware(cmd) {
+ // Commands that read from files and should use pipe buffer
+ return ['grep', 'head', 'tail', 'sort', 'wc', 'cut', 'uniq', 'expand'].includes(cmd);
+ }
+
+ // ── Variable substitution ──
+
+ _substituteVars(input) {
+ // Replace $VAR and ${VAR} (not inside single quotes)
+ let result = '';
+ let inSingle = false;
+ let i = 0;
+ while (i < input.length) {
+ const ch = input[i];
+ if (ch === "'") {
+ inSingle = !inSingle;
+ result += ch;
+ i++;
+ } else if (ch === '$' && !inSingle && i + 1 < input.length) {
+ // Check for $(
+ if (input[i+1] === '(') {
+ result += ch;
+ i++;
+ continue;
+ }
+ // Check for ${VAR}
+ if (input[i+1] === '{') {
+ let end = input.indexOf('}', i);
+ if (end !== -1) {
+ const varName = input.substring(i+2, end);
+ const val = this._getVar(varName);
+ result += val !== undefined ? val : '$' + varName;
+ i = end + 1;
+ continue;
+ }
+ }
+ // Check for $VAR (alphanumeric/underscore)
+ let j = i + 1;
+ if (j < input.length && /[A-Za-z_]/.test(input[j])) {
+ while (j < input.length && /\w/.test(input[j])) j++;
+ const varName = input.substring(i+1, j);
+ const val = this._getVar(varName);
+ result += val !== undefined ? val : '';
+ i = j;
+ continue;
+ }
+ // $ followed by digit — not a var
+ result += ch;
+ i++;
+ } else {
+ result += ch;
+ i++;
+ }
+ }
+ return result;
+ }
+
+ _getVar(name) {
+ if (name === 'PWD') return '/' + this.terminal.fs.currentPath.join('/');
+ return this._vars[name] !== undefined ? this._vars[name] : undefined;
+ }
+
+ _setVar(name, value) {
+ this._vars[name] = value;
+ }
+
+ // ── Commands ──
+
+ _cmdExport(args) {
+ if (!args[0] || !args[0].includes('=')) {
+ return this.terminal.print('Usage: export <var>=<value>', 'warning');
+ }
+ const eqIdx = args[0].indexOf('=');
+ const name = args[0].substring(0, eqIdx);
+ let value = args[0].substring(eqIdx + 1);
+ // Support quoted values: export FOO="hello world"
+ if (args.length > 1) {
+ const rest = args.slice(1).join(' ');
+ if (value.endsWith('"') || value.endsWith("'")) {
+ // First part ended quote — fine
+ } else if (value.startsWith('"') || value.startsWith("'")) {
+ value = value + (rest ? ' ' + rest : '');
+ }
+ }
+ // Strip surrounding quotes
+ if ((value.startsWith('"') && value.endsWith('"')) ||
+ (value.startsWith("'") && value.endsWith("'"))) {
+ value = value.substring(1, value.length - 1);
+ }
+ this._setVar(name, value);
+ this.terminal.print(`Shell ${name}=${value}`, 'success');
+ }
+
+ _cmdUnset(args) {
+ if (!args[0]) return this.terminal.print('Usage: unset <var>', 'warning');
+ delete this._vars[args[0]];
+ this.terminal.print(`Shell var removed: ${args[0]}`, 'info');
+ }
+
+ _cmdSet() {
+ const fs = this.terminal.fs;
+ this.terminal.print('Shell variables:', 'info');
+ for (const [k, v] of Object.entries(this._vars)) {
+ this.terminal.print(`${k}=${v}`, 'info');
+ }
+ this.terminal.print(`PWD=/${fs.currentPath.join('/')}`, 'info');
+ }
+
+ async _cmdScript(args) {
+ if (!args[0]) return this.terminal.print('Usage: script <path.sh>', 'warning');
+ if (this._scriptDepth >= this._maxScriptDepth) {
+ return this.terminal.print(`script: max recursion depth (${this._maxScriptDepth}) reached`, 'error');
+ }
+ const path = args[0];
+ const content = this.terminal.fs.readFile(path);
+ if (content === null) return this.terminal.print(`script: ${path}: No such file`, 'error');
+ this._scriptDepth++;
+ this.terminal.print(`Running script: ${path}`, 'info');
+ const lines = content.split('\n');
+ let lineNum = 0;
+ for (const rawLine of lines) {
+ lineNum++;
+ const line = rawLine.trim();
+ if (!line || line.startsWith('#')) continue;
+ // Run each line through the shell (by calling executeCommand directly)
+ // We need to manually call the wrapped executeCommand
+ this.terminal.print(`[${path}:${lineNum}] ${line}`, 'command-prompt');
+ // Parse inline: handle pipe, redirect, chain, sequence
+ const substituted = this._substituteVars(line);
+ const seqParts = this._splitSequence(substituted);
+ for (const seqPart of seqParts) {
+ if (!seqPart.trim()) continue;
+ const chainParts = this._splitChain(seqPart.trim());
+ let lastOk = true;
+ for (const chainPart of chainParts) {
+ if (!chainPart.cmd.trim()) continue;
+ if (chainPart.operator === '&&' && !lastOk) continue;
+ if (chainPart.operator === '||' && lastOk) continue;
+ const redirectInfo = this._extractRedirects(chainPart.cmd);
+ if (redirectInfo.hasRedirect) {
+ await this._executeWithRedirect(redirectInfo);
+ lastOk = redirectInfo.exitOk;
+ } else {
+ const pipeParts = this.terminal.splitPipe(chainPart.cmd);
+ if (pipeParts.length > 1) {
+ await this.terminal.executePipeChain(pipeParts);
+ } else {
+ const parts = chainPart.cmd.split(/\s+/);
+ const cmd = parts[0].toLowerCase();
+ const args = parts.slice(1);
+ // Check if it's our built-in command
+ if (this.commands[cmd]) {
+ this.terminal.markDirty();
+ await this.commands[cmd](args);
+ } else {
+ await this.terminal.security.checkAndRun(chainPart.cmd);
+ }
+ }
+ lastOk = true;
+ }
+ }
+ }
+ }
+ this._scriptDepth--;
+ this.terminal.print(`Script ${path} completed (${lineNum} lines)`, 'success');
+ }
+}
diff --git a/modules/ext/skill-pack-core.js b/modules/ext/skill-pack-core.js
index 95da385..51a2e55 100644
--- a/modules/ext/skill-pack-core.js
+++ b/modules/ext/skill-pack-core.js
@@ -265,7 +265,7 @@ class SkillPackCoreModule {
let seeded = 0;
for (const skill of this._skills) {
if (!index[skill.name]) {
- fs.fsWrite(`/skills/${skill.name}.md`, skill.content);
+ fs.writeFile(`/skills/${skill.name}.md`, skill.content);
index[skill.name] = {
name: skill.name,
description: skill.description,
diff --git a/modules/ext/webdav.js b/modules/ext/webdav.js
index 89e2115..b4dfa6f 100644
--- a/modules/ext/webdav.js
+++ b/modules/ext/webdav.js
@@ -256,6 +256,69 @@ class WebDAVModule {
return false;
}
+ async _deleteFile(remotePath) {
+ const resp = await this._webdavRequest('DELETE', remotePath);
+ return resp.ok || resp.status === 204 || resp.status === 404;
+ }
+
+ async _cleanupRetention() {
+ // Smart retention: keep newest 10 + 1 per day for last 7 days
+ try {
+ const entries = await this._listDir('backups');
+ const files = entries.filter(e => e.type === 'file' && e.name.endsWith('.json'));
+ if (files.length <= 17) return; // 10 newest + 7 daily = 17 max needed
+
+ // Sort by date descending (newest first)
+ files.sort((a, b) => {
+ const da = this._extractDateFromBackup(a.name);
+ const db = this._extractDateFromBackup(b.name);
+ return db - da;
+ });
+
+ const keep = new Set();
+
+ // Always keep newest 10
+ for (let i = 0; i < Math.min(10, files.length); i++) keep.add(files[i].name);
+
+ // Keep 1 per day for last 7 days (outside the newest 10)
+ const dayMap = new Map(); // YYYY-MM-DD -> first backup of that day
+ for (let i = 10; i < files.length; i++) {
+ const f = files[i];
+ const day = this._extractDayString(f.name);
+ if (!dayMap.has(day)) dayMap.set(day, f);
+ }
+ // Get last 7 unique days
+ const sortedDays = Array.from(dayMap.keys()).sort().slice(-7);
+ for (const day of sortedDays) keep.add(dayMap.get(day).name);
+
+ // Delete the rest
+ let deleted = 0;
+ for (const f of files) {
+ if (!keep.has(f.name)) {
+ const ok = await this._deleteFile('backups/' + f.name);
+ if (ok) deleted++;
+ }
+ }
+ if (deleted > 0) {
+ this.terminal.print(`Retention cleanup: removed ${deleted} old backup(s)`, 'info');
+ }
+ } catch (e) {
+ this.terminal.print('Retention cleanup skipped: ' + e.message, 'info');
+ }
+ }
+
+ _extractDateFromBackup(name) {
+ // Match YYYY-MM-DD_HHMMSS in filename
+ const m = name.match(/(\d{4})-(\d{2})-(\d{2})_(\d{2})(\d{2})(\d{2})/);
+ if (!m) return 0;
+ return new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}`).getTime();
+ }
+
+ _extractDayString(name) {
+ const m = name.match(/(\d{4}-\d{2}-\d{2})_/);
+ return m ? m[1] : '';
+ }
+
registerCommands() {
const self = this;
@@ -441,7 +504,18 @@ class WebDAVModule {
// --- webdav-backup: backup entire VFS to WebDAV ---
this.commands['webdav-backup'] = async (args) => {
- const name = (args[0] || 'backup').replace(/[^a-zA-Z0-9_-]/g, '_');
+ // Parse flags
+ let encryptPass = null;
+ let nameIdx = 0;
+ for (let i = 0; i < args.length; i++) {
+ if (args[i] === '--encrypt' || args[i] === '-e') {
+ encryptPass = args[i + 1];
+ i++;
+ } else if (!args[i].startsWith('-')) {
+ nameIdx = i;
+ }
+ }
+ const name = (args[nameIdx] || 'backup').replace(/[^a-zA-Z0-9_-]/g, '_');
const now = new Date();
const dateStr = now.getFullYear() + '-' +
String(now.getMonth() + 1).padStart(2, '0') + '-' +
@@ -449,18 +523,56 @@ class WebDAVModule {
String(now.getHours()).padStart(2, '0') +
String(now.getMinutes()).padStart(2, '0') +
String(now.getSeconds()).padStart(2, '0');
- const filename = name + '-' + dateStr + '.json';
- const remotePath = 'backups/' + filename;
+ let filename = name + '-' + dateStr + '.json';
+ let remotePath = 'backups/' + filename;
try {
await self._ensureDir('backups');
- const serialized = JSON.stringify(self.terminal.fs.root, null, 2);
+ // Build structured export: root + currentPath + aliases + moduleRegistry
+ const exportData = {
+ type: 'webx11-backup',
+ version: 2,
+ savedAt: new Date().toISOString(),
+ root: self.terminal.fs.root,
+ currentPath: self.terminal.fs.currentPath,
+ aliases: self.terminal.moduleManager.aliases || {},
+ moduleRegistry: self.terminal.moduleManager.moduleRegistry || []
+ };
+ const serialized = JSON.stringify(exportData, null, 2);
+ // Gzip payload
+ let payload = serialized;
+ let isGzipped = false;
+ if (typeof CompressionStream !== 'undefined') {
+ try {
+ const blob = new Blob([serialized]);
+ const stream = blob.stream().pipeThrough(new CompressionStream('gzip'));
+ const resp = new Response(stream);
+ const buf = await resp.arrayBuffer();
+ payload = new Uint8Array(buf);
+ isGzipped = true;
+ } catch (e) { /* fallback to plain */ }
+ }
+ // Encrypt if requested
+ if (encryptPass) {
+ if (!isGzipped) {
+ self.terminal.print('Encryption requires gzip compression. CompressionStream unavailable — aborting.', 'error');
+ return;
+ }
+ const wrapper = await self._encryptPayload(payload, encryptPass);
+ payload = JSON.stringify(wrapper);
+ filename = name + '-' + dateStr + '.json.enc';
+ remotePath = 'backups/' + filename;
+ }
const resp = await self._webdavRequest('PUT', remotePath, {
- body: serialized,
- headers: { 'Content-Type': 'application/json' }
+ body: payload,
+ headers: { 'Content-Type': encryptPass ? 'application/json' : (isGzipped ? 'application/gzip' : 'application/json') }
});
if (resp.ok || resp.status === 201 || resp.status === 204) {
- self.terminal.print('Backup saved: ' + remotePath + ' (' + self._formatSize(serialized.length) + ')', 'success');
+ const sizeStr = isGzipped ? self._formatSize(serialized.length) + ' → ' + self._formatSize(typeof payload === 'string' ? payload.length : payload.length) : self._formatSize(serialized.length);
+ const encMark = encryptPass ? ' [🔒 encrypted]' : '';
+ self.terminal.print('Backup saved: ' + remotePath + ' (' + sizeStr + ')' + encMark, 'success');
+ // Run retention cleanup in background
+ self._cleanupRetention().catch(() => {});
} else {
self.terminal.print('Backup failed: HTTP ' + resp.status, 'error');
}
@@ -472,18 +584,56 @@ class WebDAVModule {
// --- webdav-restore: restore VFS from a WebDAV backup ---
this.commands['webdav-restore'] = async (args) => {
if (!args[0]) {
- self.terminal.print('Usage: webdav-restore <backup-filename>', 'warning');
+ self.terminal.print('Usage: webdav-restore <backup-filename> [--passphrase <phrase>]', 'warning');
self.terminal.print(' Use webdav-ls backups/ to see available backups', 'info');
return;
}
let remotePath = args[0];
+ let passphrase = null;
+ for (let i = 1; i < args.length; i++) {
+ if (args[i] === '--passphrase' || args[i] === '-p') passphrase = args[i + 1];
+ }
if (!remotePath.startsWith('backups/') && !remotePath.startsWith('/backups/')) {
remotePath = 'backups/' + remotePath;
}
try {
const resp = await self._webdavRequest('GET', remotePath);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
- const content = await resp.text();
+ // Read raw bytes first (resp.text() corrupts gzip binary)
+ const arrayBuf = await resp.arrayBuffer();
+ const bytes = new Uint8Array(arrayBuf);
+ let text;
+ // Detect gzip by magic bytes 0x1f 0x8b
+ if (bytes.length > 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) {
+ self.terminal.print('Detected gzip-compressed backup, decompressing...', 'info');
+ try {
+ const blob = new Blob([bytes]);
+ const stream = blob.stream().pipeThrough(new DecompressionStream('gzip'));
+ const decompressed = await new Response(stream).text();
+ text = decompressed;
+ } catch (e) {
+ throw new Error('Gzip decompression failed: ' + e.message);
+ }
+ } else {
+ text = new TextDecoder().decode(bytes);
+ }
+ // Detect encrypted wrapper
+ let isEncrypted = false;
+ let wrapper = null;
+ try {
+ wrapper = JSON.parse(text);
+ if (wrapper && wrapper._encrypted) isEncrypted = true;
+ } catch (e) { /* not JSON wrapper */ }
+ let content = text;
+ if (isEncrypted) {
+ if (!passphrase) {
+ self.terminal.print('🔒 This backup is encrypted. Run: webdav-restore ' + args[0] + ' --passphrase <your-phrase>', 'warning');
+ return;
+ }
+ self.terminal.print('Decrypting backup...', 'info');
+ const decryptedU8 = await self._decryptPayload(wrapper, passphrase);
+ content = new TextDecoder().decode(decryptedU8);
+ }
const data = JSON.parse(content);
if (!data || !data.type) {
self.terminal.print('Invalid backup file (not a valid VFS export)', 'error');
@@ -820,6 +970,48 @@ class WebDAVModule {
}
}
+ /* ── Encryption helpers (AES-GCM for backups) ── */
+ _u8ToBase64(u8) {
+ let s = '';
+ for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]);
+ return btoa(s);
+ }
+ _base64ToU8(b64) {
+ const s = atob(b64);
+ const u8 = new Uint8Array(s.length);
+ for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i);
+ return u8;
+ }
+ async _deriveKey(passphrase, salt) {
+ const enc = new TextEncoder();
+ const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(passphrase), 'PBKDF2', false, ['deriveKey']);
+ return crypto.subtle.deriveKey(
+ { name: 'PBKDF2', salt: salt, iterations: 100000, hash: 'SHA-256' },
+ keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
+ );
+ }
+ async _encryptPayload(gzipU8, passphrase) {
+ const salt = crypto.getRandomValues(new Uint8Array(16));
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const key = await this._deriveKey(passphrase, salt);
+ const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, gzipU8);
+ return {
+ _encrypted: true,
+ salt: this._u8ToBase64(salt),
+ iv: this._u8ToBase64(iv),
+ iterations: 100000,
+ cipher: this._u8ToBase64(new Uint8Array(ciphertext))
+ };
+ }
+ async _decryptPayload(wrapper, passphrase) {
+ const salt = this._base64ToU8(wrapper.salt);
+ const iv = this._base64ToU8(wrapper.iv);
+ const key = await this._deriveKey(passphrase, salt);
+ const ciphertext = this._base64ToU8(wrapper.cipher);
+ const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
+ return new Uint8Array(decrypted);
+ }
+
_formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
diff --git a/modules/test/test-suite.js b/modules/test/test-suite.js
new file mode 100644
index 0000000..ddd0dbd
--- /dev/null
+++ b/modules/test/test-suite.js
@@ -0,0 +1,419 @@
+class TestSuiteModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this._results = [];
+ this._startTime = 0;
+ }
+
+ requirements() {
+ return { met: true, missing: [] };
+ }
+
+ getHelpInfo() {
+ return {
+ 'test': 'test [name|auto|list] — Run test suite (no args = all, auto = non-destructive only)',
+ };
+ }
+
+ registerCommands() {
+ this.commands['test'] = (args) => this._run(args);
+ }
+
+ // ── Test definitions ──
+
+ _tests() {
+ return {
+ // ── Filesystem ──
+ 'ls-root': {
+ desc: 'ls lists files in root',
+ cmd: 'ls',
+ expect: (out) => out.split('\n').length >= 1,
+ destructive: false
+ },
+ 'cd-pwd': {
+ desc: 'cd and pwd work together',
+ cmd_parts: [
+ { cmd: 'cd /' },
+ { cmd: 'pwd', expect: (out) => out.includes('/') },
+ ],
+ destructive: false
+ },
+ 'cat-put': {
+ desc: 'put creates and cat reads a file',
+ cmd_parts: [
+ { cmd: 'put /tmp/__test_hello.txt "Hello Test Suite"' },
+ { cmd: 'cat /tmp/__test_hello.txt', expect: (out) => out.includes('Hello Test Suite') },
+ { cmd: 'rm /tmp/__test_hello.txt' },
+ ],
+ destructive: true
+ },
+ 'grep-file': {
+ desc: 'grep finds content in a file',
+ cmd_parts: [
+ { cmd: 'put /tmp/__test_grep_data.txt "apple\\nbanana\\ncherry\\napple"' },
+ { cmd: 'grep apple /tmp/__test_grep_data.txt', expect: (out) => out.includes('apple') && out.split('\n').filter(l => l).length >= 2 },
+ { cmd: 'rm /tmp/__test_grep_data.txt' },
+ ],
+ destructive: true
+ },
+
+ // ── BusyBox ──
+ 'which-ls': {
+ desc: 'which finds ls command',
+ cmd: 'which ls',
+ expect: (out) => out.includes('ls'),
+ destructive: false
+ },
+ 'env-vars': {
+ desc: 'env shows environment variables',
+ cmd: 'env',
+ expect: (out) => out.includes('SHELL=') && out.includes('USER=') && out.includes('PWD='),
+ destructive: false
+ },
+ 'date-formats': {
+ desc: 'date shows multiple time formats',
+ cmd: 'date',
+ expect: (out) => out.includes('ISO:') && out.includes('UTC:') && out.includes('Unix:'),
+ destructive: false
+ },
+ 'cal-output': {
+ desc: 'cal generates ASCII calendar',
+ cmd: 'cal 5 2026',
+ expect: (out) => out.includes('May') && out.includes('2026') && out.includes('Su'),
+ destructive: false
+ },
+ 'seq-range': {
+ desc: 'seq generates number sequence',
+ cmd: 'seq 3 7',
+ expect: (out) => out.includes('3') && out.includes('5') && out.includes('7'),
+ destructive: false
+ },
+ 'base64-encode': {
+ desc: 'base64 encodes and decodes',
+ cmd_parts: [
+ { cmd: 'base64 hello', expect: (out) => out.trim() === 'aGVsbG8=' },
+ { cmd: 'base64 -d aGVsbG8=', expect: (out) => out.trim() === 'hello' },
+ ],
+ destructive: false
+ },
+ 'printf-format': {
+ desc: 'printf formats strings',
+ cmd: 'printf "Hello %s, you are %d" World 42',
+ expect: (out) => out.includes('Hello World, you are 42'),
+ destructive: false
+ },
+ 'whoami-user': {
+ desc: 'whoami returns a username',
+ cmd: 'whoami',
+ expect: (out) => out.trim().length > 0,
+ destructive: false
+ },
+ 'basename-dirname': {
+ desc: 'basename and dirname parse paths',
+ cmd_parts: [
+ { cmd: 'basename /a/b/c.txt', expect: (out) => out.trim() === 'c.txt' },
+ { cmd: 'dirname /a/b/c.txt', expect: (out) => out.trim() === '/a/b' },
+ ],
+ destructive: false
+ },
+
+ // ── Shell ──
+ 'pipe-ls-grep': {
+ desc: 'ls | grep filters terminal output',
+ cmd: 'ls | grep skills',
+ expect: (out) => out.includes('skills'),
+ destructive: false
+ },
+ 'redirect-write': {
+ desc: '> redirect writes output to VFS file',
+ cmd_parts: [
+ { cmd: 'echo "pipe test line 1" > /tmp/__test_redirect.txt' },
+ { cmd: 'cat /tmp/__test_redirect.txt', expect: (out) => out.includes('pipe test line 1') },
+ { cmd: 'rm /tmp/__test_redirect.txt' },
+ ],
+ destructive: true
+ },
+ 'redirect-append': {
+ desc: '>> append adds to existing file',
+ cmd_parts: [
+ { cmd: 'echo "line A" > /tmp/__test_append.txt' },
+ { cmd: 'echo "line B" >> /tmp/__test_append.txt' },
+ { cmd: 'cat /tmp/__test_append.txt', expect: (out) => out.includes('line A') && out.includes('line B') },
+ { cmd: 'rm /tmp/__test_append.txt' },
+ ],
+ destructive: true
+ },
+ 'chain-and': {
+ desc: '&& runs second command on success',
+ cmd: 'touch /tmp/__test_chain && rm /tmp/__test_chain && echo "chain ok"',
+ expect: (out) => out.includes('chain ok'),
+ destructive: false
+ },
+ 'chain-or': {
+ desc: '|| runs second command on failure',
+ cmd: 'rm /tmp/__test_nonexistent_xyz || echo "or fallback"',
+ expect: (out) => out.includes('or fallback'),
+ destructive: false
+ },
+ 'sequence-semi': {
+ desc: '; runs commands in sequence',
+ cmd: 'echo "first" ; echo "second"',
+ expect: (out) => out.includes('first') && out.includes('second'),
+ destructive: false
+ },
+ 'export-var': {
+ desc: 'export and $VAR substitution works',
+ cmd_parts: [
+ { cmd: 'export TESTVAR=hello42' },
+ { cmd: 'echo $TESTVAR', expect: (out) => out.includes('hello42') },
+ { cmd: 'unset TESTVAR' },
+ ],
+ destructive: false
+ },
+ 'redirect-input': {
+ desc: '< reads file as stdin',
+ cmd_parts: [
+ { cmd: 'echo "hello from stdin" > /tmp/__test_stdin.txt' },
+ { cmd: 'cat /tmp/__test_stdin.txt', expect: (out) => out.includes('hello from stdin') },
+ { cmd: 'rm /tmp/__test_stdin.txt' },
+ ],
+ destructive: true
+ },
+
+ // ── CryptoGzip ──
+ 'gzip-file': {
+ desc: 'gzip compresses a VFS file',
+ cmd_parts: [
+ { cmd: 'echo "repetitive data aaaaa bbbbb ccccc" > /tmp/__test_gzip_in.txt' },
+ { cmd: 'gzip /tmp/__test_gzip_in.txt' },
+ { cmd: 'cat /tmp/__test_gzip_in.txt.gz', expect: (out) => out.length > 0 },
+ { cmd: 'rm /tmp/__test_gzip_in.txt.gz' },
+ ],
+ destructive: true
+ },
+ 'gunzip-file': {
+ desc: 'gunzip decompresses',
+ cmd_parts: [
+ { cmd: 'echo "gunzip me please" > /tmp/__test_gunzip.txt' },
+ { cmd: 'gzip /tmp/__test_gunzip.txt' },
+ { cmd: 'gunzip /tmp/__test_gunzip.txt.gz' },
+ { cmd: 'cat /tmp/__test_gunzip.txt', expect: (out) => out.includes('gunzip me please') },
+ { cmd: 'rm /tmp/__test_gunzip.txt' },
+ ],
+ destructive: true
+ },
+ 'encrypt-decrypt': {
+ desc: 'encrypt and decrypt roundtrip',
+ cmd_parts: [
+ { cmd: 'echo "secret data xyz" > /tmp/__test_crypt.txt' },
+ { cmd: 'encrypt /tmp/__test_crypt.txt testkey123' },
+ { cmd: 'rm /tmp/__test_crypt.txt' },
+ { cmd: 'decrypt /tmp/__test_crypt.txt.enc testkey123' },
+ { cmd: 'cat /tmp/__test_crypt.txt', expect: (out) => out.includes('secret data xyz') },
+ { cmd: 'rm /tmp/__test_crypt.txt' },
+ { cmd: 'rm /tmp/__test_crypt.txt.enc' },
+ ],
+ destructive: true
+ },
+
+ // ── Skills ──
+ 'skill-list': {
+ desc: 'skill-list returns skills',
+ cmd: 'skill-list',
+ expect: (out) => out.includes('Skills') || out.includes('No skills'),
+ destructive: false
+ },
+ 'skill-create-verify': {
+ desc: 'skill create and view roundtrip',
+ cmd_parts: [
+ { cmd: 'skill-create __test_dummy "Dummy test skill" testing test' },
+ { cmd: 'skill-view __test_dummy', expect: (out) => out.includes('__test_dummy') },
+ { cmd: 'skill-delete __test_dummy' },
+ ],
+ destructive: true
+ },
+
+ // ── Security ──
+ 'security-level-get': {
+ desc: 'security-level returns current level',
+ cmd: 'security-level',
+ expect: (out) => out.length > 0,
+ destructive: false
+ },
+
+ // ── Modules ──
+ 'commands-help': {
+ desc: 'help lists multiple command categories',
+ cmd: 'help',
+ expect: (out) => out.includes('commands') || out.includes('System') || out.includes('Filesystem'),
+ destructive: false
+ },
+ 'listmods': {
+ desc: 'listmods shows loaded modules',
+ cmd: 'listmods',
+ expect: (out) => out.includes('Module') || out.includes('modules') || out.includes('loaded'),
+ destructive: false
+ },
+ };
+ }
+
+ // ── Runner ──
+
+ async _run(args) {
+ const tests = this._tests();
+ const names = Object.keys(tests);
+
+ if (args.length > 0 && args[0] === 'list') {
+ this.terminal.print('=== Available Tests ===', 'info');
+ for (const name of names) {
+ const t = tests[name];
+ const tag = t.destructive ? '⚠️ destructive' : '✅ safe';
+ this.terminal.print(` ${name.padEnd(24)} ${t.desc} [${tag}]`);
+ }
+ this.terminal.print(`\n${names.length} tests total`, 'info');
+ return;
+ }
+
+ const filter = args.length > 0 ? args[0].toLowerCase() : null;
+ const autoOnly = filter === 'auto';
+
+ let selected = names;
+ if (filter && filter !== 'auto' && filter !== 'all') {
+ selected = names.filter(n => n.includes(filter));
+ if (selected.length === 0) {
+ this.terminal.print(`No tests matching "${filter}"`, 'error');
+ return;
+ }
+ }
+
+ const toRun = autoOnly ? selected.filter(n => !tests[n].destructive) : selected;
+ const destructiveCount = toRun.filter(n => tests[n].destructive).length;
+
+ // Save original security level
+ const origLevel = localStorage.getItem('webx11_security_level') || '1';
+
+ // Set to permissive for destructive tests
+ this.terminal.security.setLevel(0);
+
+ // Print header
+ const divider = '─'.repeat(50);
+ this.terminal.print(divider, 'info');
+ this.terminal.print(' Test Suite — newOS4 v4.2', 'info');
+ this.terminal.print(divider, 'info');
+ this.terminal.print(` Running ${toRun.length} tests (${destructiveCount} destructive, ${toRun.length - destructiveCount} safe)`, 'info');
+ this.terminal.print('', 'info');
+
+ this._results = [];
+ this._startTime = Date.now();
+ let passed = 0, failed = 0, skipped = 0;
+
+ for (const name of toRun) {
+ const t = tests[name];
+ const result = await this._runSingle(name, t);
+ this._results.push(result);
+ if (result.passed) {
+ passed++;
+ this.terminal.print(` ✅ ${name} (${result.duration}s) — ${t.desc}`, 'success');
+ } else if (result.skipped) {
+ skipped++;
+ this.terminal.print(` ⏭ ${name} — ${result.reason}`, 'info');
+ } else {
+ failed++;
+ this.terminal.print(` ❌ ${name} (${result.duration}s) — ${t.desc}`, 'error');
+ this.terminal.print(` Expected: ${result.expected}`, 'warning');
+ this.terminal.print(` Got: ${result.got}`, 'error');
+ }
+ }
+
+ // Restore security level
+ this.terminal.security.setLevel(parseInt(origLevel));
+
+ // Summary
+ const elapsed = ((Date.now() - this._startTime) / 1000).toFixed(1);
+ this.terminal.print('', 'info');
+ this.terminal.print(divider, 'info');
+ const status = failed === 0 ? 'ALL PASSED ✅' : `${failed} FAILED ❌`;
+ this.terminal.print(` Result: ${passed}/${toRun.length} passed ${failed} failed ${skipped} skipped (${elapsed}s)`, failed === 0 ? 'success' : 'error');
+
+ if (failed > 0) {
+ this.terminal.print(' Failures:', 'warning');
+ for (const r of this._results) {
+ if (!r.passed && !r.skipped) {
+ this.terminal.print(` ${r.name} — ${r.desc}`, 'error');
+ }
+ }
+ }
+ }
+
+ async _runSingle(name, t) {
+ const start = Date.now();
+ try {
+ if (t.skip) {
+ return { name, passed: false, skipped: true, reason: t.skip, duration: 0 };
+ }
+
+ // Multi-step test
+ if (t.cmd_parts) {
+ for (const part of t.cmd_parts) {
+ const captured = await this._capture(part.cmd);
+ if (part.expect) {
+ if (!part.expect(captured)) {
+ return {
+ name, passed: false, desc: t.desc,
+ expected: this._formatExpect(t),
+ got: captured.substring(0, 200),
+ duration: ((Date.now() - start) / 1000).toFixed(1)
+ };
+ }
+ }
+ }
+ return { name, passed: true, desc: t.desc, duration: ((Date.now() - start) / 1000).toFixed(1) };
+ }
+
+ // Single command test
+ const output = await this._capture(t.cmd);
+ if (t.expect && !t.expect(output)) {
+ return {
+ name, passed: false, desc: t.desc,
+ expected: this._formatExpect(t),
+ got: output.substring(0, 200),
+ duration: ((Date.now() - start) / 1000).toFixed(1)
+ };
+ }
+ return { name, passed: true, desc: t.desc, duration: ((Date.now() - start) / 1000).toFixed(1) };
+
+ } catch (e) {
+ return {
+ name, passed: false, desc: t.desc,
+ expected: this._formatExpect(t),
+ got: `EXCEPTION: ${e.message}`,
+ duration: ((Date.now() - start) / 1000).toFixed(1)
+ };
+ }
+ }
+
+ async _capture(cmdText) {
+ const originalPrint = this.terminal.print.bind(this.terminal);
+ let output = '';
+ this.terminal.print = (text, type) => { output += text + '\n'; };
+
+ await this.terminal.executeCommand(cmdText);
+
+ this.terminal.print = originalPrint;
+ return output;
+ }
+
+ _formatExpect(t) {
+ if (t.expect && t.expect.toString) {
+ const s = t.expect.toString();
+ // Try to extract meaningful description from the function
+ const match = s.match(/out\.includes\(['"]([^'"]+)['"]\)/);
+ if (match) return `output includes "${match[1]}"`;
+ if (s.includes('&&')) return 'all conditions met';
+ if (s.includes('.length')) return 'non-empty output';
+ return t.desc;
+ }
+ return 'no expectation';
+ }
+}
diff --git a/packages.json b/packages.json
index 759dc0a..1cb5b97 100644
--- a/packages.json
+++ b/packages.json
@@ -107,6 +107,38 @@
"url": "modules/ext/filemgr.js",
"commands": ["files", "preview"],
"category": "utility"
+ },
+ "cryptogzip": {
+ "name": "Crypto + Gzip",
+ "version": "1.0.0",
+ "desc": "AES-256-GCM encrypt/decrypt and gzip/gunzip for VFS files",
+ "url": "modules/ext/cryptogzip.js",
+ "commands": ["gzip", "gunzip", "encrypt", "decrypt"],
+ "category": "security"
+ },
+ "busybox": {
+ "name": "BusyBox Utilities",
+ "version": "1.0.0",
+ "desc": "Unix utility commands: which, env, date, cal, yes, seq, base64, printf, cut, uniq, hostname, whoami, basename, dirname, sleep, expand",
+ "url": "modules/ext/busybox.js",
+ "commands": ["which", "env", "date", "cal", "yes", "seq", "base64", "printf", "cut", "uniq", "hostname", "whoami", "basename", "dirname", "sleep", "expand"],
+ "category": "utility"
+ },
+ "shell": {
+ "name": "Shell",
+ "version": "1.0.0",
+ "desc": "Full shell: pipe |, redirect > >> <, variable $VAR, chain && || ;, VFS script execution",
+ "url": "modules/ext/shell.js",
+ "commands": ["export", "unset", "script", "set"],
+ "category": "system"
+ },
+ "test-suite": {
+ "name": "Test Suite",
+ "version": "1.0.0",
+ "desc": "32 automated tests: filesystem, busybox, shell, cryptogzip, skills, security, modules. Run with `load-local modules/test/test-suite.js` then `test`",
+ "url": "modules/test/test-suite.js",
+ "commands": ["test"],
+ "category": "utility"
}
}
}
diff --git a/skills/backup-and-restore.md b/skills/backup-and-restore.md
new file mode 100644
index 0000000..1744f47
--- /dev/null
+++ b/skills/backup-and-restore.md
@@ -0,0 +1,27 @@
+---
+name: backup-and-restore
+description: How to backup, restore, and export WebX11 data
+tags: [backup, restore, export, import, persistence]
+category: system
+---
+## Steps
+1. **Backup VFS:**
+ - vfs-export — downloads the entire virtual filesystem as a dated JSON file
+ - backup — creates a full backup including VFS, modules, config (saves to _backups/)
+
+2. **Export/Import personal data:**
+ - my-export — prints all my-set data as formatted JSON
+ - my-import <json> — bulk-import personal data from JSON string
+
+3. **Export/Import modules:**
+ - export-mods — exports module registry as JSON
+ - import-mods <json> — imports modules from JSON backup
+
+4. **Restore:**
+ - restore <json> — restores terminal state from a backup JSON
+ - Session logs are saved to /memory/ automatically on page unload
+
+5. **When to use:**
+ - Before factory-reset: call my-export, vfs-export, export-mods to save data
+ - When switching browsers or devices: export all, then import
+ - User says "backup my data" or "save my files" \ No newline at end of file
diff --git a/skills/index.json b/skills/index.json
new file mode 100644
index 0000000..ad002f5
--- /dev/null
+++ b/skills/index.json
@@ -0,0 +1,149 @@
+{
+ "navigating-the-terminal": {
+ "name": "navigating-the-terminal",
+ "description": "How to explore the WebX11 terminal, find commands, check status, and navigate the filesystem",
+ "tags": [
+ "terminal",
+ "navigation",
+ "help",
+ "filesystem"
+ ],
+ "category": "system",
+ "created": "2026-05-26T18:25:28.851Z",
+ "updated": "2026-05-26T18:25:28.851Z"
+ },
+ "memory-and-profile": {
+ "name": "memory-and-profile",
+ "description": "How to use the user profile system, session memory, and pattern tracking for persistent context",
+ "tags": [
+ "memory",
+ "profile",
+ "session",
+ "persistence"
+ ],
+ "category": "system",
+ "created": "2026-05-26T18:25:28.851Z",
+ "updated": "2026-05-26T18:25:28.851Z"
+ },
+ "web-search-and-fetch": {
+ "name": "web-search-and-fetch",
+ "description": "How to search the web and fetch URL content using the WebX11 proxy API",
+ "tags": [
+ "search",
+ "fetch",
+ "web",
+ "proxy",
+ "api"
+ ],
+ "category": "research",
+ "created": "2026-05-26T18:25:28.852Z",
+ "updated": "2026-05-26T18:25:28.852Z"
+ },
+ "module-management": {
+ "name": "module-management",
+ "description": "How to view, edit, test, reload modules and manage startup configuration",
+ "tags": [
+ "modules",
+ "editing",
+ "code",
+ "self-improvement"
+ ],
+ "category": "dev",
+ "created": "2026-05-26T18:25:28.853Z",
+ "updated": "2026-05-26T18:25:28.853Z"
+ },
+ "system-maintenance": {
+ "name": "system-maintenance",
+ "description": "How to reset, troubleshoot, and maintain the WebX11 terminal",
+ "tags": [
+ "system",
+ "maintenance",
+ "reset",
+ "troubleshoot",
+ "config"
+ ],
+ "category": "system",
+ "created": "2026-05-26T18:25:28.854Z",
+ "updated": "2026-05-26T18:25:28.854Z"
+ },
+ "backup-and-restore": {
+ "name": "backup-and-restore",
+ "description": "How to backup, restore, and export WebX11 data",
+ "tags": [
+ "backup",
+ "restore",
+ "export",
+ "import",
+ "persistence"
+ ],
+ "category": "system",
+ "created": "2026-05-26T18:25:28.855Z",
+ "updated": "2026-05-26T18:25:28.855Z"
+ },
+ "mindmap-workflow": {
+ "name": "mindmap-workflow",
+ "description": "How to create, view, edit, and manage brainstorming mindmaps",
+ "tags": [
+ "mindmap",
+ "brainstorming",
+ "planning",
+ "organization"
+ ],
+ "category": "productivity",
+ "created": "2026-05-26T18:25:28.856Z",
+ "updated": "2026-05-26T18:25:28.856Z"
+ },
+ "skills-system-guide": {
+ "name": "skills-system-guide",
+ "description": "How to use the skills system: load, create, patch, list, and audit skills for persistent procedural memory",
+ "tags": [
+ "skills",
+ "system",
+ "meta"
+ ],
+ "category": "system",
+ "created": "2026-05-26T18:25:28.857Z",
+ "updated": "2026-05-26T18:25:28.857Z"
+ },
+ "mindmap-user-guide": {
+ "name": "mindmap-user-guide",
+ "description": "How to create, view, and manage brainstorming mindmaps using the mindmap terminal module",
+ "tags": [
+ "mindmap",
+ "brainstorming",
+ "organization",
+ "planning"
+ ],
+ "category": "productivity",
+ "created": "2026-05-26T18:28:53.203Z",
+ "updated": "2026-05-26T18:28:53.203Z"
+ },
+ "webdav-sync": {
+ "name": "webdav-sync",
+ "description": "Sync, backup, and restore VFS via WebDAV in WebX11. CRITICAL: Only use verified commands.",
+ "tags": [
+ "webdav",
+ "sync",
+ "backup",
+ "cloud",
+ "vfs"
+ ],
+ "category": "system",
+ "created": "2026-05-26T18:32:07.434Z",
+ "updated": "2026-05-26T20:20:17.764Z"
+ },
+ "memory-map": {
+ "name": "memory-map",
+ "description": "Maintain and query the persistent memory map at /memory/map.json for Agando/DarkMoto Systems.",
+ "tags": [
+ "memory",
+ "persistence",
+ "context",
+ "map",
+ "agando"
+ ],
+ "category": "system",
+ "created": "2026-05-26T20:07:04.617Z",
+ "updated": "2026-05-26T20:07:04.617Z"
+ }
+} \ No newline at end of file
diff --git a/skills/memory-and-profile.md b/skills/memory-and-profile.md
new file mode 100644
index 0000000..6605893
--- /dev/null
+++ b/skills/memory-and-profile.md
@@ -0,0 +1,30 @@
+---
+name: memory-and-profile
+description: How to use the user profile system, session memory, and pattern tracking for persistent context
+tags: [memory, profile, session, persistence]
+category: system
+---
+## Steps
+1. **User Profile (persists across sessions):**
+ - user_get(key) — read any profile field (e.g. "preferredModel", "fontSize")
+ - user_set(key, value) — write to profile, auto-persists to localStorage
+ - user_list — show all stored profile keys
+ - user_forget(key) — delete a profile key
+ - Dot notation supported: user_set knownFacts.name "Alex"
+
+2. **Session Memory (VFS-based daily logs):**
+ - session_save(summary) — save a summary of what was accomplished
+ - session_load(date) — read a specific day's log (format: YYYY-MM-DD)
+ - session_recent(count) — show last N session summaries
+ - session_stats — aggregate totals across all sessions
+ - Recent sessions auto-load into context on first message
+
+3. **Conversation Patterns:**
+ - Pattern tracking runs automatically — frequent topics surface as hints
+ - my-set, my-get, my-list store additional personal data
+ - AI conversation context persists within a session (use ai-clear to reset)
+
+4. **When to use:**
+ - At start: call session_recent(3) to see what was worked on before
+ - After identifying user preferences: call user_set to store them
+ - After complex tasks: call session_save with a brief summary \ No newline at end of file
diff --git a/skills/memory-map.md b/skills/memory-map.md
new file mode 100644
index 0000000..b8c0f83
--- /dev/null
+++ b/skills/memory-map.md
@@ -0,0 +1,51 @@
+---
+name: memory-map
+description: Maintain and query the persistent memory map at /memory/map.json for Agando/DarkMoto Systems.
+category: system
+tags: memory, persistence, context, map, agando
+---
+
+## Purpose
+Keep a living index of projects, infrastructure, preferences, active issues, and session history so the AI never asks Agando to repeat himself.
+
+## File Location
+- `/memory/map.json` — primary memory map
+- `/memory/index.json` — session index (auto-generated)
+
+## Workflow
+
+### 1. Read Before Acting
+At the start of any session or when context is unclear:
+```bash
+read_file("memory/map.json")
+```
+
+### 2. Update After Significant Work
+After any task that changes status, fixes an issue, or adds knowledge:
+```bash
+# Read current map
+read_file("memory/map.json")
+
+# Modify relevant section(s)
+# Write back
+write_file("memory/map.json", <updated_json>)
+```
+
+### 3. Add Session Link
+Append to `recent_sessions` (keep last 10):
+```json
+{"date": "YYYY-MM-DD", "file": "memory/YYYY-MM-DD.json", "summary": "..."}
+```
+
+## Map Structure
+- `projects` — WebX11, clients, personal builds, stores
+- `infrastructure` — security stack, backup systems, constraints
+- `preferences` — communication style, UI needs (#00ff88, 48px touch targets)
+- `active_issues` — open tasks, blockers, manual steps required
+- `recent_sessions` — quick links to session logs
+
+## Rules
+- NEVER ask Agando for info already in the map.
+- If info is stale or wrong, update it immediately.
+- Use `session_save()` after complex tasks to populate index.json.
+- Keep `active_issues` accurate — remove resolved items. \ No newline at end of file
diff --git a/skills/mindmap-user-guide.md b/skills/mindmap-user-guide.md
new file mode 100644
index 0000000..7bee410
--- /dev/null
+++ b/skills/mindmap-user-guide.md
@@ -0,0 +1,40 @@
+---
+name: mindmap-user-guide
+description: How to create, view, and manage brainstorming mindmaps using the mindmap terminal module
+tags: [mindmap, brainstorming, organization, planning]
+category: productivity
+---
+## Steps
+
+1. **Create a new mindmap:**
+ - `mindmap new "<topic>" -t tag1,tag2` — creates a root node
+ - Tags are optional (e.g. planning, research, dev)
+ - Root gets auto-assigned a color
+
+2. **Add child nodes:**
+ - `mindmap add <parent-id> "<idea>" -t tag` — add a child
+ - Get the parent-id from `mindmap show`
+ - IDs are 4-char random strings (e.g. a3f8, x7k2)
+
+3. **View the mindmap:**
+ - `mindmap show` — full tree with Unicode box-drawing
+ - `mindmap show <id>` — subtree starting at that node
+
+4. **Edit nodes:**
+ - `mindmap rename <id> "<new text>"` — change text
+ - `mindmap color <id> <color>` — set color (red/green/yellow/blue/cyan/magenta/white)
+ - `mindmap tag <id> <tag>` — add a tag
+
+5. **Persistence:**
+ - `mindmap save <name>` — save to localStorage
+ - `mindmap load <name>` — restore
+ - `mindmap list` — show saved mindmaps
+ - `mindmap delete <name>` — remove saved
+
+6. **Export:**
+ - `mindmap export [id]` — Markdown bullet list output
+
+## When to use
+- User wants to brainstorm, organize ideas, plan features
+- User says "map out", "diagram", "brainstorm", "organize thoughts"
+- Visualizing project structure or task decomposition \ No newline at end of file
diff --git a/skills/mindmap-workflow.md b/skills/mindmap-workflow.md
new file mode 100644
index 0000000..f05c237
--- /dev/null
+++ b/skills/mindmap-workflow.md
@@ -0,0 +1,39 @@
+---
+name: mindmap-workflow
+description: How to create, view, edit, and manage brainstorming mindmaps
+tags: [mindmap, brainstorming, planning, organization]
+category: productivity
+---
+## Steps
+1. **Create a new mindmap:**
+ - mindmap new "<topic>" -t tag1,tag2 — creates a root node
+ - Tags are optional (e.g. planning, research, dev)
+ - Root gets auto-assigned a color from 7-color palette
+
+2. **Add child nodes:**
+ - mindmap add <parent-id> "<idea>" -t tag — add a child
+ - Get the parent-id from mindmap show
+ - IDs are 4-char random strings (e.g. a3f8, x7k2)
+
+3. **View:**
+ - mindmap show — full tree with Unicode box-drawing
+ - mindmap show <id> — subtree starting at that node
+
+4. **Edit nodes:**
+ - mindmap rename <id> "<new text>" — change text
+ - mindmap color <id> <color> — set color (red/green/yellow/blue/cyan/magenta/white)
+ - mindmap tag <id> <tag> — add a tag
+
+5. **Persistence:**
+ - mindmap save <name> — save to localStorage
+ - mindmap load <name> — restore
+ - mindmap list — show saved mindmaps
+ - mindmap delete <name> — remove saved
+
+6. **Export:**
+ - mindmap export [id] — Markdown bullet list output, auto-copies to clipboard
+
+7. **When to use:**
+ - Map out project structure, brainstorm features, organize ideas
+ - Break down complex tasks into subtrees
+ - User says "brainstorm", "diagram", "organize thoughts", "plan" \ No newline at end of file
diff --git a/skills/module-management.md b/skills/module-management.md
new file mode 100644
index 0000000..d57f957
--- /dev/null
+++ b/skills/module-management.md
@@ -0,0 +1,32 @@
+---
+name: module-management
+description: How to view, edit, test, reload modules and manage startup configuration
+tags: [modules, editing, code, self-improvement]
+category: dev
+---
+## Steps
+1. **View module source:**
+ - view_module(name) — fetches original source or reads from _mods/ if edited
+ - Supports all loaded modules: SystemModule, FileSystemModule, SearXNGModule, etc.
+
+2. **Edit a module:**
+ - edit_module(name) — copies source to _mods/<name>.js and opens code editor
+ - Editor shows the full source in a textarea
+ - Click "Save & Test" to save and auto-load the edited version
+
+3. **Test and reload:**
+ - test_module(name) — loads edited module from _mods/ for live testing
+ - reload_module(name) — discards edits and reloads from original URL
+ - list_modified_modules — shows all modules with pending edits
+
+4. **Check for updates:**
+ - check_updates — HEAD-requests all loaded module URLs to verify reachability
+
+5. **Manage startup:**
+ - startup-modules — opens the Startup Modules UI to select which modules load
+ - load-local <path> — load a module from VFS or URL without editing
+
+6. **When to use:**
+ - User reports a bug in a module → view_module, edit_module, test_module
+ - User asks "can you add X feature?" → same workflow
+ - Always save edits before testing. If test fails, the original module stays loaded. \ No newline at end of file
diff --git a/skills/navigating-the-terminal.md b/skills/navigating-the-terminal.md
new file mode 100644
index 0000000..8e822be
--- /dev/null
+++ b/skills/navigating-the-terminal.md
@@ -0,0 +1,27 @@
+---
+name: navigating-the-terminal
+description: How to explore the WebX11 terminal, find commands, check status, and navigate the filesystem
+tags: [terminal, navigation, help, filesystem]
+category: system
+---
+## Steps
+1. **Get an overview of available commands:**
+ - Call get_help() to get a full command list sorted by category
+ - Call get_status() to see filesystem state, loaded modules, Ollama status
+ - For a specific command, use help <command> or check the help map
+
+2. **Explore the filesystem:**
+ - Use list_files(path?) to browse directories (omit path for current dir)
+ - Use read_file(path) to view file contents
+ - Use get_current_dir to see your working directory
+ - Use run_command("ls") or run_command("tree") for visual output
+
+3. **Understand system state:**
+ - run_command("mem") — memory usage
+ - run_command("status") — network, files, modules, Ollama
+ - run_command("modules") — module manager GUI
+ - get_module_status() — loaded modules, URLs, local edits
+
+4. **When user asks a vague question:**
+ - First call get_status() and get_help() to understand the current state
+ - Then ask clarifying questions based on what you found \ No newline at end of file
diff --git a/skills/skills-system-guide.md b/skills/skills-system-guide.md
new file mode 100644
index 0000000..26d3dcf
--- /dev/null
+++ b/skills/skills-system-guide.md
@@ -0,0 +1,35 @@
+---
+name: skills-system-guide
+description: How to use the skills system: load, create, patch, list, and audit skills for persistent procedural memory
+tags: [skills, system, meta]
+category: system
+---
+## Steps
+1. **Always load skills at task start:**
+ - At the START of every task, call load_skills(query) to check for stored procedures
+ - Example: load_skills("search the web") returns matching skill content
+ - Example: load_skills("edit module") returns the module editing workflow
+
+2. **Create skills after complex tasks:**
+ - After any task with 3+ tool calls, call create_skill to save the approach
+ - Required fields: name (kebab-case), description, content (markdown with YAML frontmatter)
+ - Optional: tags (comma-separated), category (e.g. research, system, dev)
+ - Skills persist in /skills/ as .md files with /skills/index.json for fast lookup
+
+3. **Patch skills when they're outdated:**
+ - If a stored procedure has wrong steps, call patch_skill(name, newContent)
+ - This replaces the entire skill content and updates the index
+ - Do NOT create a duplicate — always patch the existing skill
+
+4. **Auto-created error skills:**
+ - If the same tool fails 3+ times, a workaround skill is created automatically
+ - Named tool-<name>-workaround (e.g. tool-write_file-workaround)
+
+5. **Audit and maintenance:**
+ - list_skills(category?) — browse available skills, optionally filter
+ - Skills are audited every 20 messages for staleness (empty content, >30 days old)
+
+6. **YAML frontmatter format:**
+ - Each skill file must start with: ---\nname: <name>\ndescription: <text>\ntags: [...]\ncategory: <cat>\n---\n
+ - Steps follow under ## Steps heading
+ - Use numbered lists for sequential steps \ No newline at end of file
diff --git a/skills/system-maintenance.md b/skills/system-maintenance.md
new file mode 100644
index 0000000..cb6b809
--- /dev/null
+++ b/skills/system-maintenance.md
@@ -0,0 +1,35 @@
+---
+name: system-maintenance
+description: How to reset, troubleshoot, and maintain the WebX11 terminal
+tags: [system, maintenance, reset, troubleshoot, config]
+category: system
+---
+## Steps
+1. **Factory reset (wipes everything):**
+ - factory-reset --confirm — clears all webx11_ localStorage keys, resets VFS, reloads
+ - Use only when the system is in an unrecoverable state
+
+2. **Soft VFS reset:**
+ - reset-vfs --confirm — clears only the filesystem, preserves settings/profile/modules
+
+3. **Config management:**
+ - config-list — show all config keys and values
+ - config-set <key> <value> — set a config value
+ - config-delete <key> — remove a config key
+ - config-export — dump all config as JSON
+ - config-reset — restore defaults (use before factory-reset if you want to save data)
+
+4. **Health checks (run automatically after complex tasks):**
+ - Check module loading status: loaded vs total
+ - Check for pending module edits
+ - Check memory usage (high if >100MB)
+ - Check skill count (0 skills after 2+ tool calls → suggest create_skill)
+
+5. **Font size and display:**
+ - font-size <8-32> — set terminal font size, persists across sessions
+ - setfont — alias for font-size
+
+6. **When to use:**
+ - System is slow or modules fail to load → check check_updates, memory
+ - User says "reset everything" → factory-reset --confirm
+ - User says "clear my files but keep settings" → reset-vfs --confirm \ No newline at end of file
diff --git a/skills/web-search-and-fetch.md b/skills/web-search-and-fetch.md
new file mode 100644
index 0000000..fcd5f91
--- /dev/null
+++ b/skills/web-search-and-fetch.md
@@ -0,0 +1,27 @@
+---
+name: web-search-and-fetch
+description: How to search the web and fetch URL content using the WebX11 proxy API
+tags: [search, fetch, web, proxy, api]
+category: research
+---
+## Steps
+1. **Search the web:**
+ - Call web_search(query) — uses /api/search which queries SearXNG + DDG fallback
+ - Results include: title, url, content snippet, source engine
+ - Always available (same-origin, no CORS issues)
+ - 5-minute server-side cache for repeated queries
+
+2. **Fetch URL content:**
+ - Call web_fetch(url) — uses /api/extract which strips HTML, extracts clean text
+ - Returns: title, content (up to 4000 chars), word_count, extraction method
+ - SSRF protection blocks internal network addresses
+ - 15-minute server-side cache
+
+3. **Search flags (via terminal search command):**
+ - search <query> --summarize — adds AI-generated overview via Ollama
+ - search <query> --fetch — fetches content snippets for top 3 results
+
+4. **When to use:**
+ - User asks about current events, facts, research → call web_search
+ - User says "read this article" or provides a URL → call web_fetch
+ - For in-depth analysis: search first, then fetch top results \ No newline at end of file
diff --git a/skills/webdav-sync.md b/skills/webdav-sync.md
new file mode 100644
index 0000000..ead86dd
--- /dev/null
+++ b/skills/webdav-sync.md
@@ -0,0 +1,40 @@
+---
+name: webdav-sync
+description: Sync, backup, and restore VFS via WebDAV in WebX11. CRITICAL: Only use verified commands.
+category: system
+tags: [webdav, sync, backup, cloud, vfs]
+---
+
+# WebDAV Sync Workflow
+
+## ⚠️ CRITICAL RULE: VERIFY BEFORE EXECUTING
+**ALWAYS run `help <command>` first.**
+**If `help <command>` returns nothing, the command does NOT exist.**
+**Never invent commands — terminal crashes.**
+
+## 1. Install & Configure
+- `webdav-config` — configure WebDAV connection
+- `webdav-status` — verify connection
+
+## 2. Verified Commands ONLY
+| Command | Verify With | Purpose |
+|---------|-------------|---------|
+| `webdav-ls` | `help webdav-ls` | List files on server |
+| `webdav-backup` | `help webdav-backup` | Backup VFS to WebDAV |
+| `webdav-backup-list` | `help webdav-backup-list` | List backups |
+| `webdav-restore` | `help webdav-restore` | Restore from backup |
+| `webdav-push` | `help webdav-push` | Push VFS to server |
+| `webdav-pull` | `help webdav-pull` | Pull from server |
+
+## 3. What Does NOT Exist
+There is **NO CLI delete** for WebDAV files. These will CRASH:
+- `webdav-browser` ❌
+- `webdav-explorer` ❌
+- `webdav-delete` ❌
+- `webdav-rm` ❌
+
+## 4. Deleting Files
+Since `curl` is also blocked, there is **no automated way** to delete WebDAV backups from the terminal. Use the WebDAV web interface outside the terminal.
+
+## 5. For VFS Files (local filesystem)
+Use `rm <file>` — this is a real command. Verify: `help rm` \ No newline at end of file