SeaWolf-AI commited on
Commit
628d658
·
verified ·
1 Parent(s): e99b721

v0.4: trash audit, terminal text, settings remove to bin

Browse files
Files changed (3) hide show
  1. live_apps.js +0 -0
  2. settings.js +1 -1
  3. terminal.js +787 -787
live_apps.js CHANGED
The diff for this file is too large to render. See raw diff
 
settings.js CHANGED
@@ -352,7 +352,7 @@ window.VK_EXTRA_APPS.push(h => ({
352
  main.querySelectorAll('[data-lang]').forEach(b => b.onclick = () => { if (b.dataset.lang === LANG) return; LS.set('vk_lang', b.dataset.lang); location.reload(); });
353
  }
354
  if (cur === 'apps' && q('#vs2-remove')) q('#vs2-remove').onclick = async () => {
355
- try { await api('store/install', {id: ''}); toast('App removed'); await load(); }
356
  catch (e) { res('#vs2-appres', esc(errMsg(e))); }
357
  };
358
  }
 
352
  main.querySelectorAll('[data-lang]').forEach(b => b.onclick = () => { if (b.dataset.lang === LANG) return; LS.set('vk_lang', b.dataset.lang); location.reload(); });
353
  }
354
  if (cur === 'apps' && q('#vs2-remove')) q('#vs2-remove').onclick = async () => {
355
+ try { const cur = (data && (data.app || data.installed)) || null; await api('store/install', {id: ''}); try { const bin = JSON.parse(localStorage.getItem('vk_trash') || '[]'); if (cur && cur.id) { bin.push({id: cur.id, name: cur.name || cur.id, removed_at: new Date().toISOString()}); localStorage.setItem('vk_trash', JSON.stringify(bin)); } } catch (e2) {} toast('App removed (moved to the Recycle Bin)'); await load(); }
356
  catch (e) { res('#vs2-appres', esc(errMsg(e))); }
357
  };
358
  }
terminal.js CHANGED
@@ -1,787 +1,787 @@
1
- /* ViKernel Desktop: Terminal app.
2
- * Browser-only shell over an in-memory virtual filesystem. Nothing here runs on a real server.
3
- * Live calls go only to api/ag/* through h.api. Security-sensitive commands are sent to the real gate.
4
- */
5
- (function () {
6
- 'use strict';
7
-
8
- const CDN = 'https://cdn.jsdelivr.net/npm/';
9
- const XTERM_JS = CDN + '@xterm/xterm@5.5.0/lib/xterm.js';
10
- const XTERM_CSS = CDN + '@xterm/xterm@5.5.0/css/xterm.css';
11
- const FIT_JS = CDN + '@xterm/addon-fit@0.10.0/lib/addon-fit.js';
12
- const VFS_KEY = 'vk_term_vfs_v1';
13
- const HIST_KEY = 'vk_term_hist_v1';
14
- const HOME = '/home/guest';
15
-
16
- // ANSI helpers
17
- const C = {
18
- r: s => '\x1b[31m' + s + '\x1b[0m', g: s => '\x1b[32m' + s + '\x1b[0m',
19
- y: s => '\x1b[33m' + s + '\x1b[0m', b: s => '\x1b[34m' + s + '\x1b[0m',
20
- c: s => '\x1b[36m' + s + '\x1b[0m', m: s => '\x1b[35m' + s + '\x1b[0m',
21
- dim: s => '\x1b[2m' + s + '\x1b[0m', bold: s => '\x1b[1m' + s + '\x1b[0m',
22
- };
23
- const stripAnsi = s => String(s).replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
24
-
25
- // ---------- CDN loader (inject once, await) ----------
26
- function loadTag(kind, url) {
27
- return new Promise((res, rej) => {
28
- const sel = kind === 'script' ? 'script[src="' + url + '"]' : 'link[href="' + url + '"]';
29
- const prev = document.querySelector(sel);
30
- if (prev) {
31
- if (prev.dataset.loaded === '1') return res();
32
- prev.addEventListener('load', () => res());
33
- prev.addEventListener('error', () => rej(new Error('cdn')));
34
- return;
35
- }
36
- const el = document.createElement(kind);
37
- if (kind === 'script') { el.src = url; el.async = false; } else { el.rel = 'stylesheet'; el.href = url; }
38
- el.addEventListener('load', () => { el.dataset.loaded = '1'; res(); });
39
- el.addEventListener('error', () => rej(new Error('cdn')));
40
- document.head.appendChild(el);
41
- });
42
- }
43
- function loadXterm() {
44
- if (!window.__vkXtermP) {
45
- window.__vkXtermP = Promise.all([loadTag('link', XTERM_CSS), loadTag('script', XTERM_JS)])
46
- .then(() => loadTag('script', FIT_JS))
47
- .then(() => { if (!window.Terminal || !window.FitAddon) throw new Error('cdn'); });
48
- window.__vkXtermP.catch(() => { window.__vkXtermP = null; });
49
- }
50
- return window.__vkXtermP;
51
- }
52
-
53
- // ---------- Virtual filesystem ----------
54
- const dir = (c) => ({ t: 'd', c: c || {} });
55
- const file = (d) => ({ t: 'f', d: d });
56
-
57
- function defaultFS() {
58
- return dir({
59
- home: dir({ guest: dir({
60
- 'readme.txt': file('Welcome to ViKernel Desktop.\nThis terminal runs entirely in your browser on a virtual filesystem.\nTry: help, vik status, vik audit tail, ask "list my files", rm -rf /\n'),
61
- 'notes.md': file('# Notes\n- Every agent action passes the gate first.\n- The audit log is a hash chain; verify it with `vik audit verify`.\n- The kill switch halts everything: `vik kill`.\n'),
62
- project: dir({
63
- 'main.py': file('def main():\n print("hello from a sandboxed project")\n\nif __name__ == "__main__":\n main()\n'),
64
- 'config.json': file('{\n "name": "demo-project",\n "version": "0.1.0",\n "policy": "allowlist"\n}\n'),
65
- }),
66
- }) }),
67
- etc: dir({
68
- hostname: file('vikernel\n'),
69
- 'os-release': file('NAME="ViKernel Secure OS"\nVERSION="1.0 (demo)"\nID=vikernel\nPRETTY_NAME="ViKernel Secure OS 1.0 (browser demo)"\n'),
70
- passwd: file('root:x:0:0:root:/root:/bin/vsh\ngate:x:100:100:policy gate:/var/gate:/sbin/nologin\nagent:x:200:200:agent runtime:/var/agent:/sbin/nologin\nguest:x:1000:1000:Guest:/home/guest:/bin/vsh\n'),
71
- shadow: file('(protected)'),
72
- }),
73
- var: dir({ log: dir({
74
- 'vikernel.log': file('[ 0.000] vikernel: boot, mode B (CPU reference)\n[ 0.012] integrity: measured boot OK\n[ 0.031] gate: policy loaded (allowlist + rules)\n[ 0.044] audit: chain opened\n[ 0.090] agent: runtime attached, model muse-spark-1.3\n'),
75
- }) }),
76
- apps: dir({}),
77
- tmp: dir({}),
78
- });
79
- }
80
-
81
- window.VK_TERM = (body, h) => {
82
- h = h || {};
83
- const api = h.api;
84
- let root;
85
- try { root = JSON.parse(sessionStorage.getItem(VFS_KEY) || 'null'); } catch (e) { root = null; }
86
- if (!root || root.t !== 'd') root = defaultFS();
87
- let cwd = HOME;
88
- let history = [];
89
- try { history = JSON.parse(sessionStorage.getItem(HIST_KEY) || '[]') || []; } catch (e) { history = []; }
90
- let halted = false;
91
- let online = true;
92
-
93
- const save = () => {
94
- try { sessionStorage.setItem(VFS_KEY, JSON.stringify(root)); } catch (e) { /* storage unavailable */ }
95
- try { sessionStorage.setItem(HIST_KEY, JSON.stringify(history.slice(-200))); } catch (e) { /* ignore */ }
96
- };
97
-
98
- // Sync /apps from the store's installed apps (shape tolerant).
99
- function installedIds() {
100
- const s = h.store;
101
- let list = [];
102
- try {
103
- if (typeof s === 'function') list = s() || [];
104
- else if (Array.isArray(s)) list = s;
105
- else if (s && Array.isArray(s.installed)) list = s.installed;
106
- else if (s && typeof s.installed === 'string') list = [s.installed];
107
- else if (s && s.installed && typeof s.installed === 'object') list = [s.installed];
108
- } catch (e) { list = []; }
109
- return list.map(a => (typeof a === 'string' ? a : (a && (a.id || a.name)))).filter(Boolean);
110
- }
111
- function addAppManifest(id, meta) {
112
- const apps = root.c.apps || (root.c.apps = dir());
113
- const d = apps.c[id] || (apps.c[id] = dir());
114
- d.c['manifest.json'] = file(JSON.stringify(Object.assign({ id: id, source: 'store', note: 'installed for this session' }, meta || {}), null, 2) + '\n');
115
- }
116
- installedIds().forEach(id => { if (!root.c.apps.c[id]) addAppManifest(id); });
117
-
118
- // Path helpers
119
- function norm(p) {
120
- if (!p || p === '~') p = HOME;
121
- else if (p.startsWith('~/')) p = HOME + p.slice(1);
122
- if (!p.startsWith('/')) p = cwd + '/' + p;
123
- const out = [];
124
- p.split('/').forEach(seg => {
125
- if (!seg || seg === '.') return;
126
- if (seg === '..') out.pop(); else out.push(seg);
127
- });
128
- return '/' + out.join('/');
129
- }
130
- function lookup(p) {
131
- let n = root;
132
- for (const seg of norm(p).split('/').filter(Boolean)) {
133
- if (!n || n.t !== 'd' || !n.c[seg]) return null;
134
- n = n.c[seg];
135
- }
136
- return n;
137
- }
138
- function parentOf(p) {
139
- const a = norm(p); const i = a.lastIndexOf('/');
140
- return { dir: lookup(a.slice(0, i) || '/'), name: a.slice(i + 1), path: a };
141
- }
142
- const disp = p => (p === HOME ? '~' : p.startsWith(HOME + '/') ? '~' + p.slice(HOME.length) : p);
143
- const isShadow = p => norm(p) === '/etc/shadow';
144
- function readFile(p) {
145
- if (isShadow(p)) throw new Error(p + ': permission denied (sandbox)');
146
- const n = lookup(p);
147
- if (!n) throw new Error(p + ': no such file or directory');
148
- if (n.t === 'd') throw new Error(p + ': is a directory');
149
- return n.d;
150
- }
151
- function writeFile(p, data, append) {
152
- if (isShadow(p) || norm(p).startsWith('/etc/')) throw new Error(p + ': permission denied (sandbox)');
153
- const { dir: d, name } = parentOf(p);
154
- if (!d || d.t !== 'd') throw new Error(p + ': no such file or directory');
155
- if (d.c[name] && d.c[name].t === 'd') throw new Error(p + ': is a directory');
156
- d.c[name] = file((append && d.c[name] ? d.c[name].d : '') + data);
157
- save();
158
- }
159
- function mkdirP(p) {
160
- let n = root;
161
- for (const seg of norm(p).split('/').filter(Boolean)) {
162
- if (!n.c[seg]) n.c[seg] = dir();
163
- n = n.c[seg];
164
- if (n.t !== 'd') throw new Error(p + ': not a directory');
165
- }
166
- }
167
- const clone = n => JSON.parse(JSON.stringify(n));
168
- function walk(n, p, fn) {
169
- fn(p, n);
170
- if (n.t === 'd') Object.keys(n.c).sort().forEach(k => walk(n.c[k], (p === '/' ? '' : p) + '/' + k, fn));
171
- }
172
- async function sha256(text) {
173
- const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
174
- return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
175
- }
176
-
177
- // Flag parsing: returns {f:Set, n:value, rest:[]}
178
- function flags(args, withValue) {
179
- const f = new Set(); const rest = []; const vals = {};
180
- for (let i = 0; i < args.length; i++) {
181
- const a = args[i];
182
- if (withValue && withValue.includes(a)) { vals[a] = args[++i]; continue; }
183
- if (/^-[A-Za-z]+$/.test(a)) a.slice(1).split('').forEach(ch => f.add(ch)); else rest.push(a);
184
- }
185
- return { f, vals, rest };
186
- }
187
-
188
- // ---------- Live backend helpers ----------
189
- function apiErr(e) {
190
- if (e && e.message === 'rate') return C.y('The live backend is busy (rate limit). Please wait a few seconds and try again.');
191
- online = false;
192
- return C.r('live backend unavailable (' + ((e && e.message) || 'error') + ')');
193
- }
194
- async function refreshState() {
195
- try { const s = await api('status'); halted = String(s.kernel_state || '').toUpperCase() === 'HALTED'; online = true; return s; }
196
- catch (e) { return null; }
197
- }
198
- function verdictLine(v) {
199
- const verdict = String(v.verdict || '?').toUpperCase();
200
- const col = verdict === 'ALLOW' ? C.g : verdict === 'HALTED' ? C.y : C.r;
201
- const rule = v.rule ? ' (rule ' + v.rule + (v.reason ? ': ' + v.reason : '') + ')' : (v.reason ? ' (' + v.reason + ')' : '');
202
- const seq = v.audit_seq != null ? ' audit #' + v.audit_seq : '';
203
- return col(verdict + rule + seq);
204
- }
205
- async function gate(fn, args) {
206
- try {
207
- const v = await api('gate/decide', { fn: fn, args: args });
208
- if (String(v.verdict).toUpperCase() === 'HALTED') halted = true;
209
- return v;
210
- } catch (e) { return { error: apiErr(e) }; }
211
- }
212
-
213
- // ---------- Commands: (args, stdin) -> string ----------
214
- const HELP = {
215
- ls: 'ls [-l] [-a] [path] list directory', cd: 'cd [dir] change directory', pwd: 'pwd print working directory',
216
- cat: 'cat <file...> print files', echo: 'echo <text> print text', mkdir: 'mkdir [-p] <dir> create directory',
217
- touch: 'touch <file> create empty file', rm: 'rm [-r] [-f] <path> remove', cp: 'cp [-r] <src> <dst> copy',
218
- mv: 'mv <src> <dst> move or rename', grep: 'grep [-i] [-n] <pattern> [file] search text',
219
- head: 'head [-n N] [file] first lines', tail: 'tail [-n N] [file] last lines', wc: 'wc [-l] [-w] [-c] [file] count',
220
- find: 'find [path] [-name pattern] find files', tree: 'tree [path] directory tree', whoami: 'whoami current user',
221
- uname: 'uname [-a] system info', date: 'date current date', history: 'history command history',
222
- clear: 'clear clear the screen (Ctrl+L)', help: 'help list commands', man: 'man <cmd> command manual',
223
- top: 'top process snapshot', ps: 'ps process list', download: 'download <file> save a VFS file to your computer',
224
- vik: 'vik status | kill | resume --key <k> | audit tail [n] | audit verify | policy list | apps list|install <id>|remove',
225
- vdrpack: 'vdrpack init <dir> | build <dir> <out.vdr> | sign <file> | verify <file> (browser preview)',
226
- ask: 'ask "<prompt>" send a request to the gated agent (Meta Muse Spark 1.3)',
227
- };
228
-
229
- function lsLine(name, n, long) {
230
- const label = n.t === 'd' ? C.b(name + '/') : name;
231
- if (!long) return label;
232
- const size = n.t === 'd' ? 4096 : n.d.length;
233
- return (n.t === 'd' ? 'drwxr-xr-x' : '-rw-r--r--') + ' guest guest ' + String(size).padStart(6) + ' ' + label;
234
- }
235
- function procTable() {
236
- const apps = Object.keys((root.c.apps && root.c.apps.c) || {});
237
- const rows = [['1', 'root', '0.3', '1.2', halted ? 'T' : 'S', 'vikernel (kernel, mode B)'],
238
- ['42', 'gate', '0.8', '0.6', 'S', 'gate --policy allowlist+rules'],
239
- ['77', 'agent', '2.1', '3.4', halted ? 'T' : 'S', 'agent --model muse-spark-1.3'],
240
- ['91', 'root', '0.1', '0.2', 'S', 'auditd --chain sha256']];
241
- apps.forEach((a, i) => rows.push([String(120 + i), 'guest', '0.2', '0.5', halted ? 'T' : 'S', 'app ' + a + ' (sandboxed)']));
242
- rows.push(['300', 'guest', '0.0', '0.1', 'R', 'vsh (this terminal)']);
243
- return ' PID USER %CPU %MEM S COMMAND\n' + rows.map(r => r[0].padStart(5) + ' ' + r[1].padEnd(6) + ' ' + r[2].padStart(4) + ' ' + r[3].padStart(4) + ' ' + r[4] + ' ' + r[5]).join('\n');
244
- }
245
-
246
- const cmds = {
247
- async help() {
248
- return C.bold('ViKernel shell (browser preview). Commands:') + '\n' +
249
- Object.keys(HELP).sort().map(k => ' ' + C.c(k.padEnd(9)) + ' ' + HELP[k].replace(/^\S+\s*/, '')).join('\n') +
250
- '\nPipes (|) and redirects (>, >>) work. Tab completes. Ctrl+Shift+C copies, Ctrl+V pastes.';
251
- },
252
- async man(a) {
253
- if (!a[0]) return 'What manual page do you want?';
254
- return HELP[a[0]] ? C.bold(a[0].toUpperCase() + '(1)') + '\n\nSYNOPSIS\n ' + HELP[a[0]] : 'No manual entry for ' + a[0];
255
- },
256
- async pwd() { return cwd; },
257
- async whoami() { return 'guest'; },
258
- async date() { return new Date().toString(); },
259
- async uname(a) { return a.includes('-a') ? 'ViKernel vikernel 1.0-demo #1 SMP mode-B (CPU reference) wasm32 browser ViKernel/OS' : 'ViKernel'; },
260
- async history() { return history.map((l, i) => String(i + 1).padStart(4) + ' ' + l).join('\n'); },
261
- async echo(a) { return a.join(' '); },
262
- async ps() { return procTable(); },
263
- async top() { return C.bold('top - ' + new Date().toTimeString().slice(0, 8) + ' up 0 min, 1 user, kernel ' + (halted ? C.y('HALTED') : C.g('RUNNING'))) + '\n' + procTable() + '\n' + C.dim('(static snapshot)'); },
264
- async cd(a) {
265
- const p = norm(a[0] || '~'); const n = lookup(p);
266
- if (!n) throw new Error('cd: ' + a[0] + ': no such file or directory');
267
- if (n.t !== 'd') throw new Error('cd: ' + a[0] + ': not a directory');
268
- cwd = p; return '';
269
- },
270
- async ls(a) {
271
- const { f, rest } = flags(a);
272
- const targets = rest.length ? rest : ['.'];
273
- return targets.map(t => {
274
- const n = lookup(t);
275
- if (!n) throw new Error('ls: cannot access ' + t + ': no such file or directory');
276
- if (n.t === 'f') return lsLine(t, n, f.has('l'));
277
- let names = Object.keys(n.c).sort();
278
- if (!f.has('a')) names = names.filter(x => !x.startsWith('.'));
279
- else names = ['.', '..'].concat(names);
280
- const lines = names.map(x => lsLine(x, n.c[x] || dir(), f.has('l')));
281
- const body = f.has('l') ? lines.join('\n') : lines.join(' ');
282
- return (targets.length > 1 ? t + ':\n' : '') + body;
283
- }).join('\n');
284
- },
285
- async cat(a, stdin) {
286
- if (!a.length) return stdin || '';
287
- return a.map(p => readFile(p)).join('').replace(/\n$/, '');
288
- },
289
- async mkdir(a) {
290
- const { f, rest } = flags(a);
291
- rest.forEach(p => {
292
- if (f.has('p')) return mkdirP(p);
293
- const { dir: d, name } = parentOf(p);
294
- if (!d) throw new Error('mkdir: cannot create ' + p + ': no such file or directory');
295
- if (d.c[name]) throw new Error('mkdir: cannot create ' + p + ': file exists');
296
- d.c[name] = dir();
297
- });
298
- save(); return '';
299
- },
300
- async touch(a) { a.forEach(p => { if (!lookup(p)) writeFile(p, ''); }); return ''; },
301
- async rm(a) {
302
- const { f, rest } = flags(a);
303
- rest.forEach(p => {
304
- const { dir: d, name, path } = parentOf(p);
305
- if (path.startsWith('/etc') || path === '/') throw new Error('rm: ' + p + ': permission denied (sandbox)');
306
- if (!d || !d.c[name]) { if (!f.has('f')) throw new Error('rm: cannot remove ' + p + ': no such file or directory'); return; }
307
- if (d.c[name].t === 'd' && !f.has('r')) throw new Error('rm: cannot remove ' + p + ': is a directory');
308
- delete d.c[name];
309
- });
310
- save(); return '';
311
- },
312
- async cp(a) {
313
- const { f, rest } = flags(a);
314
- if (rest.length < 2) throw new Error('cp: missing operand');
315
- const src = lookup(rest[0]);
316
- if (!src || isShadow(rest[0])) throw new Error('cp: cannot stat ' + rest[0]);
317
- if (src.t === 'd' && !f.has('r')) throw new Error('cp: -r not specified; omitting directory ' + rest[0]);
318
- let dst = rest[1]; const dn = lookup(dst);
319
- if (dn && dn.t === 'd') dst = norm(dst) + '/' + norm(rest[0]).split('/').pop();
320
- const { dir: d, name } = parentOf(dst);
321
- if (!d) throw new Error('cp: cannot create ' + rest[1]);
322
- d.c[name] = clone(src); save(); return '';
323
- },
324
- async mv(a) {
325
- if (a.length < 2) throw new Error('mv: missing operand');
326
- const s = parentOf(a[0]);
327
- if (!s.dir || !s.dir.c[s.name] || s.path.startsWith('/etc')) throw new Error('mv: cannot move ' + a[0]);
328
- let dst = a[1]; const dn = lookup(dst);
329
- if (dn && dn.t === 'd') dst = norm(dst) + '/' + s.name;
330
- const d = parentOf(dst);
331
- if (!d.dir) throw new Error('mv: cannot move to ' + a[1]);
332
- d.dir.c[d.name] = s.dir.c[s.name]; delete s.dir.c[s.name]; save(); return '';
333
- },
334
- async grep(a, stdin) {
335
- const { f, rest } = flags(a);
336
- if (!rest.length) throw new Error('usage: grep [-i] [-n] pattern [file]');
337
- let re;
338
- try { re = new RegExp(rest[0], f.has('i') ? 'i' : ''); } catch (e) { throw new Error('grep: bad pattern'); }
339
- const text = rest[1] ? readFile(rest[1]) : (stdin || '');
340
- return text.split('\n').map((l, i) => re.test(l) ? (f.has('n') ? C.g(String(i + 1)) + ':' : '') + l : null).filter(x => x !== null).join('\n');
341
- },
342
- async head(a, stdin) {
343
- const { vals, rest } = flags(a, ['-n']); const n = parseInt(vals['-n'] || '10', 10);
344
- return (rest[0] ? readFile(rest[0]) : stdin || '').replace(/\n$/, '').split('\n').slice(0, n).join('\n');
345
- },
346
- async tail(a, stdin) {
347
- const { vals, rest } = flags(a, ['-n']); const n = parseInt(vals['-n'] || '10', 10);
348
- return (rest[0] ? readFile(rest[0]) : stdin || '').replace(/\n$/, '').split('\n').slice(-n).join('\n');
349
- },
350
- async wc(a, stdin) {
351
- const { f, rest } = flags(a);
352
- const t = rest[0] ? readFile(rest[0]) : (stdin || '');
353
- const l = (t.match(/\n/g) || []).length, w = (t.match(/\S+/g) || []).length, c = new TextEncoder().encode(t).length;
354
- const parts = [];
355
- if (!f.size || f.has('l')) parts.push(l);
356
- if (!f.size || f.has('w')) parts.push(w);
357
- if (!f.size || f.has('c')) parts.push(c);
358
- return parts.map(x => String(x).padStart(7)).join('') + (rest[0] ? ' ' + rest[0] : '');
359
- },
360
- async find(a) {
361
- const { vals, rest } = flags(a, ['-name']);
362
- const base = norm(rest[0] || '.'); const n = lookup(base);
363
- if (!n) throw new Error('find: ' + rest[0] + ': no such file or directory');
364
- const pat = vals['-name'] ? new RegExp('^' + vals['-name'].replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$') : null;
365
- const out = [];
366
- walk(n, base, (p) => { if (!pat || pat.test(p.split('/').pop())) out.push(p); });
367
- return out.join('\n');
368
- },
369
- async tree(a) {
370
- const base = norm(a[0] || '.'); const n = lookup(base);
371
- if (!n || n.t !== 'd') throw new Error('tree: ' + (a[0] || '.') + ': not a directory');
372
- const lines = [C.b(disp(base))]; let nd = 0, nf = 0;
373
- (function rec(d, pre) {
374
- const ks = Object.keys(d.c).sort();
375
- ks.forEach((k, i) => {
376
- const last = i === ks.length - 1; const c = d.c[k];
377
- lines.push(pre + (last ? '`-- ' : '|-- ') + (c.t === 'd' ? C.b(k) : k));
378
- if (c.t === 'd') { nd++; rec(c, pre + (last ? ' ' : '| ')); } else nf++;
379
- });
380
- })(n, '');
381
- return lines.join('\n') + '\n\n' + nd + ' directories, ' + nf + ' files';
382
- },
383
- async download(a) {
384
- if (!a[0]) throw new Error('usage: download <file>');
385
- const text = readFile(a[0]);
386
- const url = URL.createObjectURL(new Blob([text], { type: 'application/octet-stream' }));
387
- const link = document.createElement('a');
388
- link.href = url; link.download = norm(a[0]).split('/').pop();
389
- document.body.appendChild(link); link.click(); link.remove();
390
- setTimeout(() => URL.revokeObjectURL(url), 2000);
391
- return C.g('downloaded ' + link.download + ' (' + text.length + ' bytes, from the browser VFS)');
392
- },
393
-
394
- async vik(a) {
395
- const sub = a[0], sub2 = a[1];
396
- try {
397
- if (sub === 'status') {
398
- const s = await api('status');
399
- halted = String(s.kernel_state || '').toUpperCase() === 'HALTED'; online = true;
400
- const st = halted ? C.y(s.kernel_state) : C.g(s.kernel_state);
401
- return 'kernel_state ' + st + '\naudit_n ' + s.audit_n + '\naudit_head ' + String(s.audit_head || '').slice(0, 16) +
402
- '\nverified ' + (s.verified ? C.g('true') : C.r(String(s.verified)));
403
- }
404
- if (sub === 'kill') {
405
- const r = await api('kill', {}); await refreshState();
406
- return C.y((r && (r.message || r.msg)) || 'kill switch engaged');
407
- }
408
- if (sub === 'resume') {
409
- const i = a.indexOf('--key'); const key = i >= 0 ? a[i + 1] : '';
410
- if (!key) return 'usage: vik resume --key <operator key>';
411
- const r = await api('resume', { key: key }); await refreshState();
412
- const msg = (r && (r.message || r.msg)) || (halted ? 'refused' : 'resumed');
413
- return halted ? C.r(msg) : C.g(msg);
414
- }
415
- if (sub === 'audit' && sub2 === 'tail') {
416
- const n = parseInt(a[2] || '10', 10) || 10;
417
- const r = await api('audit');
418
- const rows = (r.entries || r.audit || r.rows || (Array.isArray(r) ? r : [])).slice(-n);
419
- if (!rows.length) return '(audit log is empty)';
420
- return rows.map(e => {
421
- const ok = e.ok === false || /DENY|REFUS/i.test(String(e.verdict || '')) ? C.r('refused') : C.g('ok');
422
- return '#' + e.seq + ' ' + (e.actor || '-') + ' ' + (e.cmd || e.fn || '-') + ' ' + ok + ' ' + (e.state || e.kernel_state || '-') + ' ' + C.dim(String(e.chain || e.hash || '').slice(0, 12));
423
- }).join('\n');
424
- }
425
- if (sub === 'audit' && sub2 === 'verify') {
426
- const r = await api('audit'); const v = r.verify || {};
427
- return 'verify.ok ' + (v.ok ? C.g('true') : C.r(String(v.ok))) + '\nverify.n ' + v.n;
428
- }
429
- if (sub === 'policy' && sub2 === 'list') {
430
- return C.bold('RULE MEANING') + '\n' +
431
- ' ' + C.g('A') + ' allowlisted action: allowed and recorded in the audit chain\n' +
432
- ' ' + C.r('D') + ' dangerous action (shell, network send, secrets): denied\n' +
433
- ' ' + C.r('F') + ' function not on the allowlist: denied\n' +
434
- ' ' + C.c('M') + ' store apps may only call what their manifest declares\n' +
435
- ' ' + C.y('HALT') + ' kill switch engaged: every action is refused until operator resume';
436
- }
437
- if (sub === 'apps') {
438
- if (sub2 === 'list') {
439
- const r = await api('store/catalog');
440
- const apps = r.apps || r.catalog || r.items || (Array.isArray(r) ? r : []);
441
- const inst = Object.keys(root.c.apps.c);
442
- return apps.map(x => (inst.includes(x.id) ? C.g('* ') : ' ') + C.c(String(x.id).padEnd(18)) + ' ' + (x.name || '') + (x.desc || x.description ? C.dim(' ' + (x.desc || x.description)) : '')).join('\n') + '\n' + C.dim('(* = installed in this session)');
443
- }
444
- if (sub2 === 'install') {
445
- if (!a[2]) return 'usage: vik apps install <id>';
446
- const r = await api('store/install', { id: a[2], session: window.VK_SID });
447
- addAppManifest(a[2], r && r.manifest ? r.manifest : null); save();
448
- return C.g('installed ' + a[2]) + ' -> /apps/' + a[2] + '/manifest.json' + (r && r.message ? '\n' + r.message : '');
449
- }
450
- if (sub2 === 'remove') {
451
- const r = await api('store/install', { id: '', session: window.VK_SID });
452
- root.c.apps = dir(); save();
453
- return C.y((r && r.message) || 'apps removed for this session');
454
- }
455
- }
456
- } catch (e) { return apiErr(e); }
457
- return 'usage: ' + HELP.vik;
458
- },
459
-
460
- async vdrpack(a) {
461
- const note = C.dim('browser preview. Real packages are built with vdrpack.py (downloadable at downloads/vdrpack.py).');
462
- const [sub, x, y] = a;
463
- if (sub === 'init') {
464
- if (!x) return 'usage: vdrpack init <dir>';
465
- mkdirP(x + '/app');
466
- const name = norm(x).split('/').pop();
467
- writeFile(x + '/manifest.json', JSON.stringify({ id: name, version: '0.1.0', entry: 'app/workflow.json', permissions: ['read_file'] }, null, 2) + '\n');
468
- writeFile(x + '/app/workflow.json', JSON.stringify({ steps: [{ fn: 'read_file', args: { path: '~/readme.txt' } }] }, null, 2) + '\n');
469
- writeFile(x + '/icon.png', '(placeholder icon)');
470
- return C.g('initialized ' + disp(norm(x))) + ': manifest.json, app/workflow.json, icon.png\n' + note;
471
- }
472
- if (sub === 'build') {
473
- if (!x || !y) return 'usage: vdrpack build <dir> <out.vdr>';
474
- const n = lookup(x); if (!n || n.t !== 'd') throw new Error('vdrpack: ' + x + ': not a directory');
475
- const base = norm(x); const files = [];
476
- const list = []; walk(n, base, (p, m) => { if (m.t === 'f') list.push([p, m.d]); });
477
- for (const [p, d] of list) files.push({ path: p.slice(base.length + 1), size: d.length, sha256: await sha256(d) });
478
- writeFile(y, JSON.stringify({ format: 'vdr-preview', label: 'browser preview', built: new Date().toISOString(), files: files }, null, 2) + '\n');
479
- return C.g('built ' + y) + ' (' + files.length + ' files hashed with sha256)\n' + note;
480
- }
481
- if (sub === 'sign') {
482
- if (!x) return 'usage: vdrpack sign <file>';
483
- const pkg = JSON.parse(readFile(x));
484
- const digest = await sha256(JSON.stringify(pkg.files || []));
485
- pkg.signature = { label: 'demo signature (browser preview, not ed25519)', value: 'demo-' + digest.slice(0, 32) };
486
- writeFile(x, JSON.stringify(pkg, null, 2) + '\n');
487
- return C.y('signed ' + x + ' with a demo signature (browser preview, not ed25519)') + '\n' + note;
488
- }
489
- if (sub === 'verify') {
490
- if (!x) return 'usage: vdrpack verify <file>';
491
- const pkg = JSON.parse(readFile(x));
492
- const dirGuess = norm(x).replace(/\/[^/]*$/, '');
493
- const out = []; let bad = 0;
494
- for (const fl of pkg.files || []) {
495
- let cur = null;
496
- const cands = [dirGuess + '/' + (norm(x).split('/').pop().replace(/\.vdr$/, '')) + '/' + fl.path, dirGuess + '/' + fl.path];
497
- for (const c of cands) { const m = lookup(c); if (m && m.t === 'f') { cur = await sha256(m.d); break; } }
498
- const ok = cur === fl.sha256; if (!ok) bad++;
499
- out.push((ok ? C.g('OK ') : C.r(cur ? 'BAD ' : 'MISS')) + ' ' + fl.path);
500
- }
501
- out.push(pkg.signature ? C.y('signature: ' + pkg.signature.label) : C.dim('unsigned'));
502
- out.push(bad ? C.r(bad + ' file(s) failed') : C.g('all hashes match'));
503
- return out.join('\n') + '\n' + note;
504
- }
505
- return 'usage: ' + HELP.vdrpack + '\n' + note;
506
- },
507
-
508
- async ask(a) {
509
- const msg = a.join(' ').trim();
510
- if (!msg) return 'usage: ask "<prompt>"';
511
- let r;
512
- try { r = await withSpinner(api('chat', { message: msg, session: window.VK_SID })); } catch (e) { return apiErr(e); }
513
- const out = [];
514
- (r.events || []).forEach(ev => {
515
- const t = ev.type;
516
- if (t === 'proposed') out.push(C.dim('proposed ' + ev.fn + '(' + JSON.stringify(ev.args || {}) + ') ' + (ev.model || '') + ' ' + (ev.model_ms != null ? ev.model_ms + 'ms' : '') + ' ' + (ev.tokens != null ? ev.tokens + ' tok' : '')));
517
- else if (t === 'verdict') { out.push('verdict ' + ev.fn + ' ' + verdictLine(ev)); if (String(ev.verdict).toUpperCase() === 'HALTED') halted = true; }
518
- else if (t === 'executed') { const res = typeof ev.result === 'string' ? ev.result : JSON.stringify(ev.result); out.push(C.c('executed ' + ev.fn) + C.dim(' -> ' + String(res).slice(0, 300))); }
519
- else if (t === 'final') out.push('\n' + C.bold(ev.text || '') + (ev.model ? C.dim('\n[' + ev.model + ']') : ''));
520
- });
521
- return out.join('\n') || '(no response)';
522
- },
523
- };
524
-
525
- // ---------- Security demo routing ----------
526
- async function securityIntercept(line) {
527
- const t = line.trim();
528
- let fn = null, args = null, label = '';
529
- if (/^sudo\b/.test(t)) { fn = 'run_shell'; args = { command: t }; }
530
- else if (/^rm\b/.test(t) && /(^|\s)\/(\s|$|\*)|(^|\s)\/etc\b/.test(t)) { fn = 'run_shell'; args = { command: t }; }
531
- else if (/^(curl|wget)\b/.test(t)) {
532
- const url = (t.split(/\s+/).find(x => /^https?:\/\//i.test(x)) || t.split(/\s+/).pop() || '');
533
- fn = 'http_post'; args = { url: url, body: '' };
534
- } else if (/\/etc\/shadow\b/.test(t)) { fn = 'read_file'; args = { path: '/etc/shadow' }; label = 'shadow'; }
535
- if (!fn) return null;
536
- const v = await withSpinner(gate(fn, args));
537
- if (v.error) return v.error + '\n' + C.r('blocked locally (sandbox)');
538
- let out = C.dim('gate/decide ' + fn) + '\n' + verdictLine(v);
539
- if (String(v.verdict).toUpperCase() === 'ALLOW') out += '\n' + C.r(label === 'shadow' ? 'permission denied (sandbox)' : 'not executed: this terminal is a sandbox');
540
- return out;
541
- }
542
-
543
- // ---------- Parser and pipeline ----------
544
- function tokenize(s) {
545
- const toks = []; let cur = ''; let q = null; let has = false;
546
- for (let i = 0; i < s.length; i++) {
547
- const ch = s[i];
548
- if (q) { if (ch === q) q = null; else if (ch === '\\' && q === '"' && i + 1 < s.length) cur += s[++i]; else cur += ch; continue; }
549
- if (ch === '"' || ch === "'") { q = ch; has = true; continue; }
550
- if (/\s/.test(ch)) { if (cur || has) toks.push(cur); cur = ''; has = false; continue; }
551
- if (ch === '|') { if (cur || has) toks.push(cur); toks.push({ op: '|' }); cur = ''; has = false; continue; }
552
- if (ch === '>') {
553
- if (cur || has) toks.push(cur); cur = ''; has = false;
554
- if (s[i + 1] === '>') { toks.push({ op: '>>' }); i++; } else toks.push({ op: '>' });
555
- continue;
556
- }
557
- cur += ch;
558
- }
559
- if (q) throw new Error('syntax error: unterminated quote');
560
- if (cur || has) toks.push(cur);
561
- return toks;
562
- }
563
- const ALWAYS = l => /^\s*(vik\s+status|vik\s+resume|clear|help)\b/.test(l);
564
-
565
- async function execLine(line) {
566
- if (!line.trim()) return '';
567
- if (halted && !ALWAYS(line)) return C.y('HALTED: the kernel is stopped. Resume with: vik resume --key <operator key>');
568
- const sec = await securityIntercept(line);
569
- if (sec !== null) return sec;
570
- const toks = tokenize(line);
571
- let redirect = null;
572
- const ri = toks.findIndex(x => typeof x === 'object' && x.op !== '|');
573
- if (ri >= 0) {
574
- if (typeof toks[ri + 1] !== 'string') throw new Error('syntax error near ' + toks[ri].op);
575
- redirect = { op: toks[ri].op, path: toks[ri + 1] };
576
- toks.splice(ri, 2);
577
- }
578
- const segs = [[]];
579
- toks.forEach(x => { if (typeof x === 'object' && x.op === '|') segs.push([]); else segs[segs.length - 1].push(x); });
580
- let data = '';
581
- for (const seg of segs) {
582
- if (!seg.length) throw new Error('syntax error near |');
583
- const name = seg[0];
584
- if (name === 'clear') { term.clear(); data = ''; continue; }
585
- const fnc = cmds[name];
586
- if (!fnc) throw new Error(name + ': command not found (try help)');
587
- data = await fnc(seg.slice(1), data);
588
- if (data == null) data = '';
589
- if (segs.length > 1) data = stripAnsi(data) + (data && !data.endsWith('\n') ? '\n' : '');
590
- }
591
- if (redirect) {
592
- writeFile(redirect.path, stripAnsi(data) + (data && !data.endsWith('\n') ? '\n' : ''), redirect.op === '>>');
593
- return '';
594
- }
595
- return String(data).replace(/\n$/, '');
596
- }
597
-
598
- // ---------- Terminal front end (xterm or fallback) ----------
599
- let term = null;
600
- let busy = false;
601
- let spinTimer = null;
602
- const prompt = () => C.g('guest@vikernel') + ':' + C.b(disp(cwd)) + '$ ';
603
-
604
- async function withSpinner(p) {
605
- const frames = ['|', '/', '-', '\\']; let i = 0;
606
- if (term && term.write) {
607
- term.write(C.dim(' working '));
608
- spinTimer = setInterval(() => term.write('\b' + frames[i++ % 4]), 100);
609
- term.write(' ');
610
- }
611
- try { return await p; }
612
- finally {
613
- clearInterval(spinTimer); spinTimer = null;
614
- if (term && term.write) term.write('\r\x1b[K');
615
- }
616
- }
617
-
618
- function out(s) { if (s) term.write(String(s).replace(/\r?\n/g, '\r\n') + '\r\n'); }
619
-
620
- async function runAndPrint(line) {
621
- busy = true;
622
- if (line.trim()) { history.push(line); save(); }
623
- try {
624
- const res = await execLine(line);
625
- out(res);
626
- if (/^\s*vik\s+(kill|resume)\b/.test(line) && h.toast) h.toast(halted ? 'Kernel halted' : 'Kernel running');
627
- } catch (e) { out(C.r(e.message || String(e))); }
628
- busy = false;
629
- }
630
-
631
- function completions(line) {
632
- const words = line.split(/\s+/); const last = words[words.length - 1];
633
- if (words.length === 1) return { last, items: Object.keys(cmds).concat(['clear']).filter(c => c.startsWith(last)) };
634
- const slash = last.lastIndexOf('/');
635
- const dpart = slash >= 0 ? last.slice(0, slash + 1) : '';
636
- const base = slash >= 0 ? last.slice(slash + 1) : last;
637
- const n = lookup(dpart || '.');
638
- if (!n || n.t !== 'd') return { last, items: [] };
639
- return { last, items: Object.keys(n.c).filter(k => k.startsWith(base)).map(k => dpart + k + (n.c[k].t === 'd' ? '/' : '')) };
640
- }
641
-
642
- async function boot() {
643
- let skip = false;
644
- const skipper = () => { skip = true; };
645
- body.addEventListener('keydown', skipper, true);
646
- const pause = ms => skip ? Promise.resolve() : new Promise(r => setTimeout(r, ms));
647
- const ln = async (s, ms) => { out(s); await pause(ms); };
648
- await ln(C.bold('ViKernel Secure OS boot'), 220);
649
- await ln('[ ' + C.g('OK') + ' ] integrity check ... OK', 220);
650
- await ln('[ ' + C.g('OK') + ' ] kernel mode B (CPU reference)', 220);
651
- await ln('[ ' + C.g('OK') + ' ] policy loaded (allowlist + rules)', 200);
652
- const s = await refreshState();
653
- if (s) {
654
- await ln('[ ' + C.g('OK') + ' ] audit chain head ' + String(s.audit_head || '').slice(0, 16), 220);
655
- await ln('[ ' + C.g('OK') + ' ] agent: Meta Muse Spark 1.3', 200);
656
- if (halted) out(C.y('kernel is HALTED. Resume with: vik resume --key <operator key>'));
657
- } else {
658
- await ln('[ ' + C.y('!!') + ' ] live backend unavailable, offline mode', 200);
659
- await ln('[ ' + C.g('OK') + ' ] agent: Meta Muse Spark 1.3', 100);
660
- }
661
- body.removeEventListener('keydown', skipper, true);
662
- out(C.dim('Type help for commands. Everything here runs in your browser; security actions go to the live gate.'));
663
- }
664
-
665
- function startXterm() {
666
- body.innerHTML = '';
667
- body.style.background = '#0B1020';
668
- const host = document.createElement('div');
669
- host.style.cssText = 'width:100%;height:100%;padding:6px;box-sizing:border-box;background:#0B1020;';
670
- body.appendChild(host);
671
- term = new window.Terminal({
672
- cursorBlink: true, scrollback: 2000, fontSize: 13, convertEol: false,
673
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
674
- theme: { background: '#0B1020', foreground: '#D6DEEB', cursor: '#7FDBCA', selectionBackground: '#1D3B53',
675
- black: '#1B2333', red: '#EF5350', green: '#22DA6E', yellow: '#FFCB6B', blue: '#82AAFF', magenta: '#C792EA', cyan: '#7FDBCA', white: '#D6DEEB',
676
- brightBlack: '#637777', brightRed: '#FF6B6B', brightGreen: '#5CF19E', brightYellow: '#FFE08A', brightBlue: '#A3C2FF', brightMagenta: '#DDB3FF', brightCyan: '#A5F0E0', brightWhite: '#FFFFFF' },
677
- });
678
- const fit = new window.FitAddon.FitAddon();
679
- term.loadAddon(fit); term.open(host);
680
- const doFit = () => { try { fit.fit(); } catch (e) { /* hidden window */ } };
681
- doFit();
682
- try { new ResizeObserver(doFit).observe(body); } catch (e) { window.addEventListener('resize', doFit); }
683
-
684
- const copySel = () => { const s = term.getSelection(); if (s && navigator.clipboard) navigator.clipboard.writeText(s).catch(() => {}); return !!s; };
685
- term.attachCustomKeyEventHandler(ev => {
686
- if (ev.type !== 'keydown') return true;
687
- if (ev.ctrlKey && ev.shiftKey && (ev.key === 'C' || ev.key === 'c')) { copySel(); if (h.toast) h.toast('Copied'); return false; }
688
- if (ev.ctrlKey && (ev.key === 'v' || ev.key === 'V')) return false; // let the browser paste event reach xterm
689
- return true;
690
- });
691
- host.addEventListener('contextmenu', ev => { if (copySel()) { ev.preventDefault(); if (h.toast) h.toast('Copied'); } });
692
-
693
- let line = '', cur = 0, hIdx = -1, draft = '';
694
- const redraw = () => {
695
- term.write('\r\x1b[K' + prompt() + line);
696
- const back = line.length - cur; if (back > 0) term.write('\x1b[' + back + 'D');
697
- };
698
- const showPrompt = () => { line = ''; cur = 0; hIdx = -1; term.write(prompt()); };
699
-
700
- term.onData(async d => {
701
- if (busy) return;
702
- if (d === '\r') {
703
- term.write('\r\n'); const l = line; line = ''; cur = 0; hIdx = -1;
704
- await runAndPrint(l); showPrompt(); return;
705
- }
706
- if (d === '\x03') { term.write('^C\r\n'); showPrompt(); return; }
707
- if (d === '\x0c') { term.clear(); redraw(); return; }
708
- if (d === '\x7f' || d === '\b') { if (cur > 0) { line = line.slice(0, cur - 1) + line.slice(cur); cur--; redraw(); } return; }
709
- if (d === '\x1b[3~') { if (cur < line.length) { line = line.slice(0, cur) + line.slice(cur + 1); redraw(); } return; }
710
- if (d === '\x1b[D') { if (cur > 0) { cur--; term.write(d); } return; }
711
- if (d === '\x1b[C') { if (cur < line.length) { cur++; term.write(d); } return; }
712
- if (d === '\x1b[H' || d === '\x1bOH' || d === '\x1b[1~' || d === '\x01') { cur = 0; redraw(); return; }
713
- if (d === '\x1b[F' || d === '\x1bOF' || d === '\x1b[4~' || d === '\x05') { cur = line.length; redraw(); return; }
714
- if (d === '\x1b[A') {
715
- if (!history.length) return;
716
- if (hIdx === -1) { draft = line; hIdx = history.length; }
717
- hIdx = Math.max(0, hIdx - 1); line = history[hIdx]; cur = line.length; redraw(); return;
718
- }
719
- if (d === '\x1b[B') {
720
- if (hIdx === -1) return;
721
- hIdx++; if (hIdx >= history.length) { hIdx = -1; line = draft; } else line = history[hIdx];
722
- cur = line.length; redraw(); return;
723
- }
724
- if (d === '\t') {
725
- const { last, items } = completions(line.slice(0, cur));
726
- if (items.length === 1) {
727
- const add = items[0].slice(last.length) + (items[0].endsWith('/') ? '' : ' ');
728
- line = line.slice(0, cur) + add + line.slice(cur); cur += add.length; redraw();
729
- } else if (items.length > 1) { term.write('\r\n' + items.join(' ') + '\r\n'); redraw(); }
730
- return;
731
- }
732
- if (d.startsWith('\x1b')) return;
733
- const text = d.replace(/\r?\n/g, ' ').replace(/[\x00-\x1f]/g, '');
734
- if (!text) return;
735
- line = line.slice(0, cur) + text + line.slice(cur); cur += text.length; redraw();
736
- });
737
-
738
- term.focus();
739
- busy = true;
740
- boot().then(() => { busy = false; showPrompt(); });
741
- }
742
-
743
- function startFallback() {
744
- body.innerHTML = '';
745
- const wrap = document.createElement('div');
746
- wrap.style.cssText = 'display:flex;flex-direction:column;width:100%;height:100%;background:#0B1020;color:#D6DEEB;font:13px ui-monospace,Menlo,Consolas,monospace;';
747
- const pre = document.createElement('pre');
748
- pre.style.cssText = 'flex:1;margin:0;padding:8px;overflow:auto;white-space:pre-wrap;word-break:break-word;';
749
- const row = document.createElement('div');
750
- row.style.cssText = 'display:flex;gap:6px;padding:6px 8px;border-top:1px solid #1D3B53;';
751
- const pl = document.createElement('span');
752
- const inp = document.createElement('input');
753
- inp.style.cssText = 'flex:1;background:transparent;border:0;outline:0;color:inherit;font:inherit;';
754
- inp.setAttribute('aria-label', 'terminal input');
755
- row.appendChild(pl); row.appendChild(inp); wrap.appendChild(pre); wrap.appendChild(row); body.appendChild(wrap);
756
- term = {
757
- write: s => {
758
- s = String(s);
759
- if (s.indexOf('\r\x1b[K') === 0) return; // spinner clear: ignore in fallback
760
- if (/^\x1b\[2m working|^\b/.test(s)) return;
761
- pre.textContent += stripAnsi(s).replace(/\r\n/g, '\n').replace(/\r/g, '');
762
- pre.scrollTop = pre.scrollHeight;
763
- },
764
- clear: () => { pre.textContent = ''; },
765
- };
766
- const setP = () => { pl.textContent = stripAnsi(prompt()); };
767
- let hIdx = -1;
768
- inp.addEventListener('keydown', async ev => {
769
- if (ev.key === 'Enter' && !busy) {
770
- const l = inp.value; inp.value = ''; hIdx = -1;
771
- term.write(stripAnsi(prompt()) + l + '\n');
772
- await runAndPrint(l); setP();
773
- } else if (ev.key === 'ArrowUp' && history.length) {
774
- hIdx = hIdx === -1 ? history.length - 1 : Math.max(0, hIdx - 1); inp.value = history[hIdx]; ev.preventDefault();
775
- } else if (ev.key === 'ArrowDown' && hIdx !== -1) {
776
- hIdx++; if (hIdx >= history.length) { hIdx = -1; inp.value = ''; } else inp.value = history[hIdx];
777
- } else if (ev.key === 'l' && ev.ctrlKey) { term.clear(); ev.preventDefault(); }
778
- });
779
- out(C.dim('(terminal library unavailable; using the simple console)'));
780
- busy = true; setP();
781
- boot().then(() => { busy = false; inp.focus(); });
782
- }
783
-
784
- body.textContent = 'Loading terminal...';
785
- loadXterm().then(startXterm, startFallback).catch(() => { try { startFallback(); } catch (e) { /* give up quietly */ } });
786
- };
787
- })();
 
1
+ /* ViKernel Desktop: Terminal app.
2
+ * Browser-only shell over an in-memory virtual filesystem. Nothing here runs on a real server.
3
+ * Live calls go only to api/ag/* through h.api. Security-sensitive commands are sent to the real gate.
4
+ */
5
+ (function () {
6
+ 'use strict';
7
+
8
+ const CDN = 'https://cdn.jsdelivr.net/npm/';
9
+ const XTERM_JS = CDN + '@xterm/xterm@5.5.0/lib/xterm.js';
10
+ const XTERM_CSS = CDN + '@xterm/xterm@5.5.0/css/xterm.css';
11
+ const FIT_JS = CDN + '@xterm/addon-fit@0.10.0/lib/addon-fit.js';
12
+ const VFS_KEY = 'vk_term_vfs_v1';
13
+ const HIST_KEY = 'vk_term_hist_v1';
14
+ const HOME = '/home/guest';
15
+
16
+ // ANSI helpers
17
+ const C = {
18
+ r: s => '\x1b[31m' + s + '\x1b[0m', g: s => '\x1b[32m' + s + '\x1b[0m',
19
+ y: s => '\x1b[33m' + s + '\x1b[0m', b: s => '\x1b[34m' + s + '\x1b[0m',
20
+ c: s => '\x1b[36m' + s + '\x1b[0m', m: s => '\x1b[35m' + s + '\x1b[0m',
21
+ dim: s => '\x1b[2m' + s + '\x1b[0m', bold: s => '\x1b[1m' + s + '\x1b[0m',
22
+ };
23
+ const stripAnsi = s => String(s).replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
24
+
25
+ // ---------- CDN loader (inject once, await) ----------
26
+ function loadTag(kind, url) {
27
+ return new Promise((res, rej) => {
28
+ const sel = kind === 'script' ? 'script[src="' + url + '"]' : 'link[href="' + url + '"]';
29
+ const prev = document.querySelector(sel);
30
+ if (prev) {
31
+ if (prev.dataset.loaded === '1') return res();
32
+ prev.addEventListener('load', () => res());
33
+ prev.addEventListener('error', () => rej(new Error('cdn')));
34
+ return;
35
+ }
36
+ const el = document.createElement(kind);
37
+ if (kind === 'script') { el.src = url; el.async = false; } else { el.rel = 'stylesheet'; el.href = url; }
38
+ el.addEventListener('load', () => { el.dataset.loaded = '1'; res(); });
39
+ el.addEventListener('error', () => rej(new Error('cdn')));
40
+ document.head.appendChild(el);
41
+ });
42
+ }
43
+ function loadXterm() {
44
+ if (!window.__vkXtermP) {
45
+ window.__vkXtermP = Promise.all([loadTag('link', XTERM_CSS), loadTag('script', XTERM_JS)])
46
+ .then(() => loadTag('script', FIT_JS))
47
+ .then(() => { if (!window.Terminal || !window.FitAddon) throw new Error('cdn'); });
48
+ window.__vkXtermP.catch(() => { window.__vkXtermP = null; });
49
+ }
50
+ return window.__vkXtermP;
51
+ }
52
+
53
+ // ---------- Virtual filesystem ----------
54
+ const dir = (c) => ({ t: 'd', c: c || {} });
55
+ const file = (d) => ({ t: 'f', d: d });
56
+
57
+ function defaultFS() {
58
+ return dir({
59
+ home: dir({ guest: dir({
60
+ 'readme.txt': file('Welcome to ViKernel Desktop.\nThis terminal runs entirely in your browser on a virtual filesystem.\nTry: help, vik status, vik audit tail, ask "list my files", rm -rf /\n'),
61
+ 'notes.md': file('# Notes\n- Every agent action passes the gate first.\n- The audit log is a hash chain; verify it with `vik audit verify`.\n- The kill switch halts everything: `vik kill`.\n'),
62
+ project: dir({
63
+ 'main.py': file('def main():\n print("hello from a sandboxed project")\n\nif __name__ == "__main__":\n main()\n'),
64
+ 'config.json': file('{\n "name": "demo-project",\n "version": "0.1.0",\n "policy": "allowlist"\n}\n'),
65
+ }),
66
+ }) }),
67
+ etc: dir({
68
+ hostname: file('vikernel\n'),
69
+ 'os-release': file('NAME="ViKernel Secure OS"\nVERSION="1.0 (demo)"\nID=vikernel\nPRETTY_NAME="ViKernel Secure OS 1.0 (browser demo)"\n'),
70
+ passwd: file('root:x:0:0:root:/root:/bin/vsh\ngate:x:100:100:policy gate:/var/gate:/sbin/nologin\nagent:x:200:200:agent runtime:/var/agent:/sbin/nologin\nguest:x:1000:1000:Guest:/home/guest:/bin/vsh\n'),
71
+ shadow: file('(protected)'),
72
+ }),
73
+ var: dir({ log: dir({
74
+ 'vikernel.log': file('[ 0.000] vikernel: boot, mode B (CPU reference)\n[ 0.012] integrity: measured boot OK\n[ 0.031] gate: policy loaded (allowlist + rules)\n[ 0.044] audit: chain opened\n[ 0.090] agent: runtime attached, model muse-spark-1.3\n'),
75
+ }) }),
76
+ apps: dir({}),
77
+ tmp: dir({}),
78
+ });
79
+ }
80
+
81
+ window.VK_TERM = (body, h) => {
82
+ h = h || {};
83
+ const api = h.api;
84
+ let root;
85
+ try { root = JSON.parse(sessionStorage.getItem(VFS_KEY) || 'null'); } catch (e) { root = null; }
86
+ if (!root || root.t !== 'd') root = defaultFS();
87
+ let cwd = HOME;
88
+ let history = [];
89
+ try { history = JSON.parse(sessionStorage.getItem(HIST_KEY) || '[]') || []; } catch (e) { history = []; }
90
+ let halted = false;
91
+ let online = true;
92
+
93
+ const save = () => {
94
+ try { sessionStorage.setItem(VFS_KEY, JSON.stringify(root)); } catch (e) { /* storage unavailable */ }
95
+ try { sessionStorage.setItem(HIST_KEY, JSON.stringify(history.slice(-200))); } catch (e) { /* ignore */ }
96
+ };
97
+
98
+ // Sync /apps from the store's installed apps (shape tolerant).
99
+ function installedIds() {
100
+ const s = h.store;
101
+ let list = [];
102
+ try {
103
+ if (typeof s === 'function') list = s() || [];
104
+ else if (Array.isArray(s)) list = s;
105
+ else if (s && Array.isArray(s.installed)) list = s.installed;
106
+ else if (s && typeof s.installed === 'string') list = [s.installed];
107
+ else if (s && s.installed && typeof s.installed === 'object') list = [s.installed];
108
+ } catch (e) { list = []; }
109
+ return list.map(a => (typeof a === 'string' ? a : (a && (a.id || a.name)))).filter(Boolean);
110
+ }
111
+ function addAppManifest(id, meta) {
112
+ const apps = root.c.apps || (root.c.apps = dir());
113
+ const d = apps.c[id] || (apps.c[id] = dir());
114
+ d.c['manifest.json'] = file(JSON.stringify(Object.assign({ id: id, source: 'store', note: 'installed for this session' }, meta || {}), null, 2) + '\n');
115
+ }
116
+ installedIds().forEach(id => { if (!root.c.apps.c[id]) addAppManifest(id); });
117
+
118
+ // Path helpers
119
+ function norm(p) {
120
+ if (!p || p === '~') p = HOME;
121
+ else if (p.startsWith('~/')) p = HOME + p.slice(1);
122
+ if (!p.startsWith('/')) p = cwd + '/' + p;
123
+ const out = [];
124
+ p.split('/').forEach(seg => {
125
+ if (!seg || seg === '.') return;
126
+ if (seg === '..') out.pop(); else out.push(seg);
127
+ });
128
+ return '/' + out.join('/');
129
+ }
130
+ function lookup(p) {
131
+ let n = root;
132
+ for (const seg of norm(p).split('/').filter(Boolean)) {
133
+ if (!n || n.t !== 'd' || !n.c[seg]) return null;
134
+ n = n.c[seg];
135
+ }
136
+ return n;
137
+ }
138
+ function parentOf(p) {
139
+ const a = norm(p); const i = a.lastIndexOf('/');
140
+ return { dir: lookup(a.slice(0, i) || '/'), name: a.slice(i + 1), path: a };
141
+ }
142
+ const disp = p => (p === HOME ? '~' : p.startsWith(HOME + '/') ? '~' + p.slice(HOME.length) : p);
143
+ const isShadow = p => norm(p) === '/etc/shadow';
144
+ function readFile(p) {
145
+ if (isShadow(p)) throw new Error(p + ': permission denied (sandbox)');
146
+ const n = lookup(p);
147
+ if (!n) throw new Error(p + ': no such file or directory');
148
+ if (n.t === 'd') throw new Error(p + ': is a directory');
149
+ return n.d;
150
+ }
151
+ function writeFile(p, data, append) {
152
+ if (isShadow(p) || norm(p).startsWith('/etc/')) throw new Error(p + ': permission denied (sandbox)');
153
+ const { dir: d, name } = parentOf(p);
154
+ if (!d || d.t !== 'd') throw new Error(p + ': no such file or directory');
155
+ if (d.c[name] && d.c[name].t === 'd') throw new Error(p + ': is a directory');
156
+ d.c[name] = file((append && d.c[name] ? d.c[name].d : '') + data);
157
+ save();
158
+ }
159
+ function mkdirP(p) {
160
+ let n = root;
161
+ for (const seg of norm(p).split('/').filter(Boolean)) {
162
+ if (!n.c[seg]) n.c[seg] = dir();
163
+ n = n.c[seg];
164
+ if (n.t !== 'd') throw new Error(p + ': not a directory');
165
+ }
166
+ }
167
+ const clone = n => JSON.parse(JSON.stringify(n));
168
+ function walk(n, p, fn) {
169
+ fn(p, n);
170
+ if (n.t === 'd') Object.keys(n.c).sort().forEach(k => walk(n.c[k], (p === '/' ? '' : p) + '/' + k, fn));
171
+ }
172
+ async function sha256(text) {
173
+ const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
174
+ return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
175
+ }
176
+
177
+ // Flag parsing: returns {f:Set, n:value, rest:[]}
178
+ function flags(args, withValue) {
179
+ const f = new Set(); const rest = []; const vals = {};
180
+ for (let i = 0; i < args.length; i++) {
181
+ const a = args[i];
182
+ if (withValue && withValue.includes(a)) { vals[a] = args[++i]; continue; }
183
+ if (/^-[A-Za-z]+$/.test(a)) a.slice(1).split('').forEach(ch => f.add(ch)); else rest.push(a);
184
+ }
185
+ return { f, vals, rest };
186
+ }
187
+
188
+ // ---------- Live backend helpers ----------
189
+ function apiErr(e) {
190
+ if (e && e.message === 'rate') return C.y('The live backend is busy (rate limit). Please wait a few seconds and try again.');
191
+ online = false;
192
+ return C.r('live backend unavailable (' + ((e && e.message) || 'error') + ')');
193
+ }
194
+ async function refreshState() {
195
+ try { const s = await api('status'); halted = String(s.kernel_state || '').toUpperCase() === 'HALTED'; online = true; return s; }
196
+ catch (e) { return null; }
197
+ }
198
+ function verdictLine(v) {
199
+ const verdict = String(v.verdict || '?').toUpperCase();
200
+ const col = verdict === 'ALLOW' ? C.g : verdict === 'HALTED' ? C.y : C.r;
201
+ const rule = v.reason ? ' (' + v.reason + ')' : (v.rule ? ' (rule ' + v.rule + ')' : '');
202
+ const seq = v.audit_seq != null ? ' audit #' + v.audit_seq : '';
203
+ return col(verdict + rule + seq);
204
+ }
205
+ async function gate(fn, args) {
206
+ try {
207
+ const v = await api('gate/decide', { fn: fn, args: args });
208
+ if (String(v.verdict).toUpperCase() === 'HALTED') halted = true;
209
+ return v;
210
+ } catch (e) { return { error: apiErr(e) }; }
211
+ }
212
+
213
+ // ---------- Commands: (args, stdin) -> string ----------
214
+ const HELP = {
215
+ ls: 'ls [-l] [-a] [path] list directory', cd: 'cd [dir] change directory', pwd: 'pwd print working directory',
216
+ cat: 'cat <file...> print files', echo: 'echo <text> print text', mkdir: 'mkdir [-p] <dir> create directory',
217
+ touch: 'touch <file> create empty file', rm: 'rm [-r] [-f] <path> remove', cp: 'cp [-r] <src> <dst> copy',
218
+ mv: 'mv <src> <dst> move or rename', grep: 'grep [-i] [-n] <pattern> [file] search text',
219
+ head: 'head [-n N] [file] first lines', tail: 'tail [-n N] [file] last lines', wc: 'wc [-l] [-w] [-c] [file] count',
220
+ find: 'find [path] [-name pattern] find files', tree: 'tree [path] directory tree', whoami: 'whoami current user',
221
+ uname: 'uname [-a] system info', date: 'date current date', history: 'history command history',
222
+ clear: 'clear clear the screen (Ctrl+L)', help: 'help list commands', man: 'man <cmd> command manual',
223
+ top: 'top process snapshot', ps: 'ps process list', download: 'download <file> save a VFS file to your computer',
224
+ vik: 'vik status | kill | resume --key <k> | audit tail [n] | audit verify | policy list | apps list|install <id>|remove',
225
+ vdrpack: 'vdrpack init <dir> | build <dir> <out.vdr> | sign <file> | verify <file> (browser preview)',
226
+ ask: 'ask "<prompt>" send a request to the gated agent (Meta Muse Spark 1.3)',
227
+ };
228
+
229
+ function lsLine(name, n, long) {
230
+ const label = n.t === 'd' ? C.b(name + '/') : name;
231
+ if (!long) return label;
232
+ const size = n.t === 'd' ? 4096 : n.d.length;
233
+ return (n.t === 'd' ? 'drwxr-xr-x' : '-rw-r--r--') + ' guest guest ' + String(size).padStart(6) + ' ' + label;
234
+ }
235
+ function procTable() {
236
+ const apps = Object.keys((root.c.apps && root.c.apps.c) || {});
237
+ const rows = [['1', 'root', '0.3', '1.2', halted ? 'T' : 'S', 'vikernel (kernel, mode B)'],
238
+ ['42', 'gate', '0.8', '0.6', 'S', 'gate --policy allowlist+rules'],
239
+ ['77', 'agent', '2.1', '3.4', halted ? 'T' : 'S', 'agent --model muse-spark-1.3'],
240
+ ['91', 'root', '0.1', '0.2', 'S', 'auditd --chain sha256']];
241
+ apps.forEach((a, i) => rows.push([String(120 + i), 'guest', '0.2', '0.5', halted ? 'T' : 'S', 'app ' + a + ' (sandboxed)']));
242
+ rows.push(['300', 'guest', '0.0', '0.1', 'R', 'vsh (this terminal)']);
243
+ return ' PID USER %CPU %MEM S COMMAND\n' + rows.map(r => r[0].padStart(5) + ' ' + r[1].padEnd(6) + ' ' + r[2].padStart(4) + ' ' + r[3].padStart(4) + ' ' + r[4] + ' ' + r[5]).join('\n');
244
+ }
245
+
246
+ const cmds = {
247
+ async help() {
248
+ return C.bold('ViKernel shell (browser preview). Commands:') + '\n' +
249
+ Object.keys(HELP).sort().map(k => ' ' + C.c(k.padEnd(9)) + ' ' + HELP[k].replace(/^\S+\s*/, '')).join('\n') +
250
+ '\nPipes (|) and redirects (>, >>) work. Tab completes. Ctrl+Shift+C copies, Ctrl+V pastes.';
251
+ },
252
+ async man(a) {
253
+ if (!a[0]) return 'What manual page do you want?';
254
+ return HELP[a[0]] ? C.bold(a[0].toUpperCase() + '(1)') + '\n\nSYNOPSIS\n ' + HELP[a[0]] : 'No manual entry for ' + a[0];
255
+ },
256
+ async pwd() { return cwd; },
257
+ async whoami() { return 'guest'; },
258
+ async date() { return new Date().toString(); },
259
+ async uname(a) { return a.includes('-a') ? 'ViKernel vikernel 1.0-demo #1 SMP mode-B (CPU reference) wasm32 browser ViKernel/OS' : 'ViKernel'; },
260
+ async history() { return history.map((l, i) => String(i + 1).padStart(4) + ' ' + l).join('\n'); },
261
+ async echo(a) { return a.join(' '); },
262
+ async ps() { return procTable(); },
263
+ async top() { return C.bold('top - ' + new Date().toTimeString().slice(0, 8) + ' up 0 min, 1 user, kernel ' + (halted ? C.y('HALTED') : C.g('RUNNING'))) + '\n' + procTable() + '\n' + C.dim('(static snapshot)'); },
264
+ async cd(a) {
265
+ const p = norm(a[0] || '~'); const n = lookup(p);
266
+ if (!n) throw new Error('cd: ' + a[0] + ': no such file or directory');
267
+ if (n.t !== 'd') throw new Error('cd: ' + a[0] + ': not a directory');
268
+ cwd = p; return '';
269
+ },
270
+ async ls(a) {
271
+ const { f, rest } = flags(a);
272
+ const targets = rest.length ? rest : ['.'];
273
+ return targets.map(t => {
274
+ const n = lookup(t);
275
+ if (!n) throw new Error('ls: cannot access ' + t + ': no such file or directory');
276
+ if (n.t === 'f') return lsLine(t, n, f.has('l'));
277
+ let names = Object.keys(n.c).sort();
278
+ if (!f.has('a')) names = names.filter(x => !x.startsWith('.'));
279
+ else names = ['.', '..'].concat(names);
280
+ const lines = names.map(x => lsLine(x, n.c[x] || dir(), f.has('l')));
281
+ const body = f.has('l') ? lines.join('\n') : lines.join(' ');
282
+ return (targets.length > 1 ? t + ':\n' : '') + body;
283
+ }).join('\n');
284
+ },
285
+ async cat(a, stdin) {
286
+ if (!a.length) return stdin || '';
287
+ return a.map(p => readFile(p)).join('').replace(/\n$/, '');
288
+ },
289
+ async mkdir(a) {
290
+ const { f, rest } = flags(a);
291
+ rest.forEach(p => {
292
+ if (f.has('p')) return mkdirP(p);
293
+ const { dir: d, name } = parentOf(p);
294
+ if (!d) throw new Error('mkdir: cannot create ' + p + ': no such file or directory');
295
+ if (d.c[name]) throw new Error('mkdir: cannot create ' + p + ': file exists');
296
+ d.c[name] = dir();
297
+ });
298
+ save(); return '';
299
+ },
300
+ async touch(a) { a.forEach(p => { if (!lookup(p)) writeFile(p, ''); }); return ''; },
301
+ async rm(a) {
302
+ const { f, rest } = flags(a);
303
+ rest.forEach(p => {
304
+ const { dir: d, name, path } = parentOf(p);
305
+ if (path.startsWith('/etc') || path === '/') throw new Error('rm: ' + p + ': permission denied (sandbox)');
306
+ if (!d || !d.c[name]) { if (!f.has('f')) throw new Error('rm: cannot remove ' + p + ': no such file or directory'); return; }
307
+ if (d.c[name].t === 'd' && !f.has('r')) throw new Error('rm: cannot remove ' + p + ': is a directory');
308
+ delete d.c[name];
309
+ });
310
+ save(); return '';
311
+ },
312
+ async cp(a) {
313
+ const { f, rest } = flags(a);
314
+ if (rest.length < 2) throw new Error('cp: missing operand');
315
+ const src = lookup(rest[0]);
316
+ if (!src || isShadow(rest[0])) throw new Error('cp: cannot stat ' + rest[0]);
317
+ if (src.t === 'd' && !f.has('r')) throw new Error('cp: -r not specified; omitting directory ' + rest[0]);
318
+ let dst = rest[1]; const dn = lookup(dst);
319
+ if (dn && dn.t === 'd') dst = norm(dst) + '/' + norm(rest[0]).split('/').pop();
320
+ const { dir: d, name } = parentOf(dst);
321
+ if (!d) throw new Error('cp: cannot create ' + rest[1]);
322
+ d.c[name] = clone(src); save(); return '';
323
+ },
324
+ async mv(a) {
325
+ if (a.length < 2) throw new Error('mv: missing operand');
326
+ const s = parentOf(a[0]);
327
+ if (!s.dir || !s.dir.c[s.name] || s.path.startsWith('/etc')) throw new Error('mv: cannot move ' + a[0]);
328
+ let dst = a[1]; const dn = lookup(dst);
329
+ if (dn && dn.t === 'd') dst = norm(dst) + '/' + s.name;
330
+ const d = parentOf(dst);
331
+ if (!d.dir) throw new Error('mv: cannot move to ' + a[1]);
332
+ d.dir.c[d.name] = s.dir.c[s.name]; delete s.dir.c[s.name]; save(); return '';
333
+ },
334
+ async grep(a, stdin) {
335
+ const { f, rest } = flags(a);
336
+ if (!rest.length) throw new Error('usage: grep [-i] [-n] pattern [file]');
337
+ let re;
338
+ try { re = new RegExp(rest[0], f.has('i') ? 'i' : ''); } catch (e) { throw new Error('grep: bad pattern'); }
339
+ const text = rest[1] ? readFile(rest[1]) : (stdin || '');
340
+ return text.split('\n').map((l, i) => re.test(l) ? (f.has('n') ? C.g(String(i + 1)) + ':' : '') + l : null).filter(x => x !== null).join('\n');
341
+ },
342
+ async head(a, stdin) {
343
+ const { vals, rest } = flags(a, ['-n']); const n = parseInt(vals['-n'] || '10', 10);
344
+ return (rest[0] ? readFile(rest[0]) : stdin || '').replace(/\n$/, '').split('\n').slice(0, n).join('\n');
345
+ },
346
+ async tail(a, stdin) {
347
+ const { vals, rest } = flags(a, ['-n']); const n = parseInt(vals['-n'] || '10', 10);
348
+ return (rest[0] ? readFile(rest[0]) : stdin || '').replace(/\n$/, '').split('\n').slice(-n).join('\n');
349
+ },
350
+ async wc(a, stdin) {
351
+ const { f, rest } = flags(a);
352
+ const t = rest[0] ? readFile(rest[0]) : (stdin || '');
353
+ const l = (t.match(/\n/g) || []).length, w = (t.match(/\S+/g) || []).length, c = new TextEncoder().encode(t).length;
354
+ const parts = [];
355
+ if (!f.size || f.has('l')) parts.push(l);
356
+ if (!f.size || f.has('w')) parts.push(w);
357
+ if (!f.size || f.has('c')) parts.push(c);
358
+ return parts.map(x => String(x).padStart(7)).join('') + (rest[0] ? ' ' + rest[0] : '');
359
+ },
360
+ async find(a) {
361
+ const { vals, rest } = flags(a, ['-name']);
362
+ const base = norm(rest[0] || '.'); const n = lookup(base);
363
+ if (!n) throw new Error('find: ' + rest[0] + ': no such file or directory');
364
+ const pat = vals['-name'] ? new RegExp('^' + vals['-name'].replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$') : null;
365
+ const out = [];
366
+ walk(n, base, (p) => { if (!pat || pat.test(p.split('/').pop())) out.push(p); });
367
+ return out.join('\n');
368
+ },
369
+ async tree(a) {
370
+ const base = norm(a[0] || '.'); const n = lookup(base);
371
+ if (!n || n.t !== 'd') throw new Error('tree: ' + (a[0] || '.') + ': not a directory');
372
+ const lines = [C.b(disp(base))]; let nd = 0, nf = 0;
373
+ (function rec(d, pre) {
374
+ const ks = Object.keys(d.c).sort();
375
+ ks.forEach((k, i) => {
376
+ const last = i === ks.length - 1; const c = d.c[k];
377
+ lines.push(pre + (last ? '`-- ' : '|-- ') + (c.t === 'd' ? C.b(k) : k));
378
+ if (c.t === 'd') { nd++; rec(c, pre + (last ? ' ' : '| ')); } else nf++;
379
+ });
380
+ })(n, '');
381
+ return lines.join('\n') + '\n\n' + nd + ' directories, ' + nf + ' files';
382
+ },
383
+ async download(a) {
384
+ if (!a[0]) throw new Error('usage: download <file>');
385
+ const text = readFile(a[0]);
386
+ const url = URL.createObjectURL(new Blob([text], { type: 'application/octet-stream' }));
387
+ const link = document.createElement('a');
388
+ link.href = url; link.download = norm(a[0]).split('/').pop();
389
+ document.body.appendChild(link); link.click(); link.remove();
390
+ setTimeout(() => URL.revokeObjectURL(url), 2000);
391
+ return C.g('downloaded ' + link.download + ' (' + text.length + ' bytes, from the browser VFS)');
392
+ },
393
+
394
+ async vik(a) {
395
+ const sub = a[0], sub2 = a[1];
396
+ try {
397
+ if (sub === 'status') {
398
+ const s = await api('status');
399
+ halted = String(s.kernel_state || '').toUpperCase() === 'HALTED'; online = true;
400
+ const st = halted ? C.y(s.kernel_state) : C.g(s.kernel_state);
401
+ return 'kernel_state ' + st + '\naudit_n ' + s.audit_n + '\naudit_head ' + String(s.audit_head || '').slice(0, 16) +
402
+ '\nverified ' + (s.verified ? C.g('true') : C.r(String(s.verified)));
403
+ }
404
+ if (sub === 'kill') {
405
+ const r = await api('kill', {}); await refreshState();
406
+ return C.y((r && (r.message || r.msg)) || 'kill switch engaged');
407
+ }
408
+ if (sub === 'resume') {
409
+ const i = a.indexOf('--key'); const key = i >= 0 ? a[i + 1] : '';
410
+ if (!key) return 'usage: vik resume --key <operator key>';
411
+ const r = await api('resume', { key: key }); await refreshState();
412
+ const msg = (r && (r.message || r.msg)) || (halted ? 'refused' : 'resumed');
413
+ return halted ? C.r(msg) : C.g(msg);
414
+ }
415
+ if (sub === 'audit' && sub2 === 'tail') {
416
+ const n = parseInt(a[2] || '10', 10) || 10;
417
+ const r = await api('audit');
418
+ const rows = (r.entries || r.audit || r.rows || (Array.isArray(r) ? r : [])).slice(-n);
419
+ if (!rows.length) return '(audit log is empty)';
420
+ return rows.map(e => {
421
+ const ok = e.ok === false || /DENY|REFUS/i.test(String(e.verdict || '')) ? C.r('refused') : C.g('ok');
422
+ return '#' + e.seq + ' ' + (e.actor || '-') + ' ' + (e.cmd || e.fn || '-') + ' ' + ok + ' ' + (e.state || e.kernel_state || '-') + ' ' + C.dim(String(e.chain || e.hash || '').slice(0, 12));
423
+ }).join('\n');
424
+ }
425
+ if (sub === 'audit' && sub2 === 'verify') {
426
+ const r = await api('audit'); const v = r.verify || {};
427
+ return 'verify.ok ' + (v.ok ? C.g('true') : C.r(String(v.ok))) + '\nverify.n ' + v.n;
428
+ }
429
+ if (sub === 'policy' && sub2 === 'list') {
430
+ return C.bold('RULE MEANING') + '\n' +
431
+ ' ' + C.g('A') + ' allowlisted action: allowed and recorded in the audit chain\n' +
432
+ ' ' + C.r('D') + ' dangerous action (shell, network send, secrets): denied\n' +
433
+ ' ' + C.r('F') + ' function not on the allowlist: denied\n' +
434
+ ' ' + C.c('M') + ' store apps may only call what their manifest declares\n' +
435
+ ' ' + C.y('HALT') + ' kill switch engaged: every action is refused until operator resume';
436
+ }
437
+ if (sub === 'apps') {
438
+ if (sub2 === 'list') {
439
+ const r = await api('store/catalog');
440
+ const apps = r.apps || r.catalog || r.items || (Array.isArray(r) ? r : []);
441
+ const inst = Object.keys(root.c.apps.c);
442
+ return apps.map(x => (inst.includes(x.id) ? C.g('* ') : ' ') + C.c(String(x.id).padEnd(18)) + ' ' + (x.name || '') + (x.desc || x.description ? C.dim(' ' + (x.desc || x.description)) : '')).join('\n') + '\n' + C.dim('(* = installed in this session)');
443
+ }
444
+ if (sub2 === 'install') {
445
+ if (!a[2]) return 'usage: vik apps install <id>';
446
+ const r = await api('store/install', { id: a[2], session: window.VK_SID });
447
+ addAppManifest(a[2], r && r.manifest ? r.manifest : null); save();
448
+ return C.g('installed ' + a[2]) + ' -> /apps/' + a[2] + '/manifest.json' + (r && r.message ? '\n' + r.message : '');
449
+ }
450
+ if (sub2 === 'remove') {
451
+ const r = await api('store/install', { id: '', session: window.VK_SID });
452
+ root.c.apps = dir(); save();
453
+ return C.y((r && r.message) || 'apps removed for this session');
454
+ }
455
+ }
456
+ } catch (e) { return apiErr(e); }
457
+ return 'usage: ' + HELP.vik;
458
+ },
459
+
460
+ async vdrpack(a) {
461
+ const note = C.dim('browser preview. Real packages are built with vdrpack.py (downloadable at downloads/vdrpack.py).');
462
+ const [sub, x, y] = a;
463
+ if (sub === 'init') {
464
+ if (!x) return 'usage: vdrpack init <dir>';
465
+ mkdirP(x + '/app');
466
+ const name = norm(x).split('/').pop();
467
+ writeFile(x + '/manifest.json', JSON.stringify({ id: name, version: '0.1.0', entry: 'app/workflow.json', permissions: ['read_file'] }, null, 2) + '\n');
468
+ writeFile(x + '/app/workflow.json', JSON.stringify({ steps: [{ fn: 'read_file', args: { path: '~/readme.txt' } }] }, null, 2) + '\n');
469
+ writeFile(x + '/icon.png', '(placeholder icon)');
470
+ return C.g('initialized ' + disp(norm(x))) + ': manifest.json, app/workflow.json, icon.png\n' + note;
471
+ }
472
+ if (sub === 'build') {
473
+ if (!x || !y) return 'usage: vdrpack build <dir> <out.vdr>';
474
+ const n = lookup(x); if (!n || n.t !== 'd') throw new Error('vdrpack: ' + x + ': not a directory');
475
+ const base = norm(x); const files = [];
476
+ const list = []; walk(n, base, (p, m) => { if (m.t === 'f') list.push([p, m.d]); });
477
+ for (const [p, d] of list) files.push({ path: p.slice(base.length + 1), size: d.length, sha256: await sha256(d) });
478
+ writeFile(y, JSON.stringify({ format: 'vdr-preview', label: 'browser preview', built: new Date().toISOString(), files: files }, null, 2) + '\n');
479
+ return C.g('built ' + y) + ' (' + files.length + ' files hashed with sha256)\n' + note;
480
+ }
481
+ if (sub === 'sign') {
482
+ if (!x) return 'usage: vdrpack sign <file>';
483
+ const pkg = JSON.parse(readFile(x));
484
+ const digest = await sha256(JSON.stringify(pkg.files || []));
485
+ pkg.signature = { label: 'demo signature (browser preview, not ed25519)', value: 'demo-' + digest.slice(0, 32) };
486
+ writeFile(x, JSON.stringify(pkg, null, 2) + '\n');
487
+ return C.y('signed ' + x + ' with a demo signature (browser preview, not ed25519)') + '\n' + note;
488
+ }
489
+ if (sub === 'verify') {
490
+ if (!x) return 'usage: vdrpack verify <file>';
491
+ const pkg = JSON.parse(readFile(x));
492
+ const dirGuess = norm(x).replace(/\/[^/]*$/, '');
493
+ const out = []; let bad = 0;
494
+ for (const fl of pkg.files || []) {
495
+ let cur = null;
496
+ const cands = [dirGuess + '/' + (norm(x).split('/').pop().replace(/\.vdr$/, '')) + '/' + fl.path, dirGuess + '/' + fl.path];
497
+ for (const c of cands) { const m = lookup(c); if (m && m.t === 'f') { cur = await sha256(m.d); break; } }
498
+ const ok = cur === fl.sha256; if (!ok) bad++;
499
+ out.push((ok ? C.g('OK ') : C.r(cur ? 'BAD ' : 'MISS')) + ' ' + fl.path);
500
+ }
501
+ out.push(pkg.signature ? C.y('signature: ' + pkg.signature.label) : C.dim('unsigned'));
502
+ out.push(bad ? C.r(bad + ' file(s) failed') : C.g('all hashes match'));
503
+ return out.join('\n') + '\n' + note;
504
+ }
505
+ return 'usage: ' + HELP.vdrpack + '\n' + note;
506
+ },
507
+
508
+ async ask(a) {
509
+ const msg = a.join(' ').trim();
510
+ if (!msg) return 'usage: ask "<prompt>"';
511
+ let r;
512
+ try { r = await withSpinner(api('chat', { message: msg, session: window.VK_SID })); } catch (e) { return apiErr(e); }
513
+ const out = [];
514
+ (r.events || []).forEach(ev => {
515
+ const t = ev.type;
516
+ if (t === 'proposed') out.push(C.dim('proposed ' + ev.fn + '(' + JSON.stringify(ev.args || {}) + ') ' + (ev.model || '') + ' ' + (ev.model_ms != null ? ev.model_ms + 'ms' : '') + ' ' + (ev.tokens != null ? ev.tokens + ' tok' : '')));
517
+ else if (t === 'verdict') { out.push('verdict ' + ev.fn + ' ' + verdictLine(ev)); if (String(ev.verdict).toUpperCase() === 'HALTED') halted = true; }
518
+ else if (t === 'executed') { const res = typeof ev.result === 'string' ? ev.result : JSON.stringify(ev.result); out.push(C.c('executed ' + ev.fn) + C.dim(' -> ' + String(res).slice(0, 300))); }
519
+ else if (t === 'final') out.push('\n' + C.bold(ev.text || '') + (ev.model ? C.dim('\n[' + ev.model + ']') : ''));
520
+ });
521
+ return out.join('\n') || '(no response)';
522
+ },
523
+ };
524
+
525
+ // ---------- Security demo routing ----------
526
+ async function securityIntercept(line) {
527
+ const t = line.trim();
528
+ let fn = null, args = null, label = '';
529
+ if (/^sudo\b/.test(t)) { fn = 'run_shell'; args = { command: t }; }
530
+ else if (/^rm\b/.test(t) && /(^|\s)\/(\s|$|\*)|(^|\s)\/etc\b/.test(t)) { fn = 'run_shell'; args = { command: t }; }
531
+ else if (/^(curl|wget)\b/.test(t)) {
532
+ const url = (t.split(/\s+/).find(x => /^https?:\/\//i.test(x)) || t.split(/\s+/).pop() || '');
533
+ fn = 'http_post'; args = { url: url, body: '' };
534
+ } else if (/\/etc\/shadow\b/.test(t)) { fn = 'read_file'; args = { path: '/etc/shadow' }; label = 'shadow'; }
535
+ if (!fn) return null;
536
+ const v = await withSpinner(gate(fn, args));
537
+ if (v.error) return v.error + '\n' + C.r('blocked locally (sandbox)');
538
+ let out = C.dim('gate/decide ' + fn) + '\n' + verdictLine(v);
539
+ if (String(v.verdict).toUpperCase() === 'ALLOW') out += '\n' + C.r(label === 'shadow' ? 'permission denied (sandbox)' : 'not executed: this terminal is a sandbox');
540
+ return out;
541
+ }
542
+
543
+ // ---------- Parser and pipeline ----------
544
+ function tokenize(s) {
545
+ const toks = []; let cur = ''; let q = null; let has = false;
546
+ for (let i = 0; i < s.length; i++) {
547
+ const ch = s[i];
548
+ if (q) { if (ch === q) q = null; else if (ch === '\\' && q === '"' && i + 1 < s.length) cur += s[++i]; else cur += ch; continue; }
549
+ if (ch === '"' || ch === "'") { q = ch; has = true; continue; }
550
+ if (/\s/.test(ch)) { if (cur || has) toks.push(cur); cur = ''; has = false; continue; }
551
+ if (ch === '|') { if (cur || has) toks.push(cur); toks.push({ op: '|' }); cur = ''; has = false; continue; }
552
+ if (ch === '>') {
553
+ if (cur || has) toks.push(cur); cur = ''; has = false;
554
+ if (s[i + 1] === '>') { toks.push({ op: '>>' }); i++; } else toks.push({ op: '>' });
555
+ continue;
556
+ }
557
+ cur += ch;
558
+ }
559
+ if (q) throw new Error('syntax error: unterminated quote');
560
+ if (cur || has) toks.push(cur);
561
+ return toks;
562
+ }
563
+ const ALWAYS = l => /^\s*(vik\s+status|vik\s+resume|clear|help)\b/.test(l);
564
+
565
+ async function execLine(line) {
566
+ if (!line.trim()) return '';
567
+ if (halted && !ALWAYS(line)) return C.y('HALTED: the kernel is stopped. Resume with: vik resume --key <operator key>');
568
+ const sec = await securityIntercept(line);
569
+ if (sec !== null) return sec;
570
+ const toks = tokenize(line);
571
+ let redirect = null;
572
+ const ri = toks.findIndex(x => typeof x === 'object' && x.op !== '|');
573
+ if (ri >= 0) {
574
+ if (typeof toks[ri + 1] !== 'string') throw new Error('syntax error near ' + toks[ri].op);
575
+ redirect = { op: toks[ri].op, path: toks[ri + 1] };
576
+ toks.splice(ri, 2);
577
+ }
578
+ const segs = [[]];
579
+ toks.forEach(x => { if (typeof x === 'object' && x.op === '|') segs.push([]); else segs[segs.length - 1].push(x); });
580
+ let data = '';
581
+ for (const seg of segs) {
582
+ if (!seg.length) throw new Error('syntax error near |');
583
+ const name = seg[0];
584
+ if (name === 'clear') { term.clear(); data = ''; continue; }
585
+ const fnc = cmds[name];
586
+ if (!fnc) throw new Error(name + ': command not found (try help)');
587
+ data = await fnc(seg.slice(1), data);
588
+ if (data == null) data = '';
589
+ if (segs.length > 1) data = stripAnsi(data) + (data && !data.endsWith('\n') ? '\n' : '');
590
+ }
591
+ if (redirect) {
592
+ writeFile(redirect.path, stripAnsi(data) + (data && !data.endsWith('\n') ? '\n' : ''), redirect.op === '>>');
593
+ return '';
594
+ }
595
+ return String(data).replace(/\n$/, '');
596
+ }
597
+
598
+ // ---------- Terminal front end (xterm or fallback) ----------
599
+ let term = null;
600
+ let busy = false;
601
+ let spinTimer = null;
602
+ const prompt = () => C.g('guest@vikernel') + ':' + C.b(disp(cwd)) + '$ ';
603
+
604
+ async function withSpinner(p) {
605
+ const frames = ['|', '/', '-', '\\']; let i = 0;
606
+ if (term && term.write) {
607
+ term.write(C.dim(' working '));
608
+ spinTimer = setInterval(() => term.write('\b' + frames[i++ % 4]), 100);
609
+ term.write(' ');
610
+ }
611
+ try { return await p; }
612
+ finally {
613
+ clearInterval(spinTimer); spinTimer = null;
614
+ if (term && term.write) term.write('\r\x1b[K');
615
+ }
616
+ }
617
+
618
+ function out(s) { if (s) term.write(String(s).replace(/\r?\n/g, '\r\n') + '\r\n'); }
619
+
620
+ async function runAndPrint(line) {
621
+ busy = true;
622
+ if (line.trim()) { history.push(line); save(); }
623
+ try {
624
+ const res = await execLine(line);
625
+ out(res);
626
+ if (/^\s*vik\s+(kill|resume)\b/.test(line) && h.toast) h.toast(halted ? 'Kernel halted' : 'Kernel running');
627
+ } catch (e) { out(C.r(e.message || String(e))); }
628
+ busy = false;
629
+ }
630
+
631
+ function completions(line) {
632
+ const words = line.split(/\s+/); const last = words[words.length - 1];
633
+ if (words.length === 1) return { last, items: Object.keys(cmds).concat(['clear']).filter(c => c.startsWith(last)) };
634
+ const slash = last.lastIndexOf('/');
635
+ const dpart = slash >= 0 ? last.slice(0, slash + 1) : '';
636
+ const base = slash >= 0 ? last.slice(slash + 1) : last;
637
+ const n = lookup(dpart || '.');
638
+ if (!n || n.t !== 'd') return { last, items: [] };
639
+ return { last, items: Object.keys(n.c).filter(k => k.startsWith(base)).map(k => dpart + k + (n.c[k].t === 'd' ? '/' : '')) };
640
+ }
641
+
642
+ async function boot() {
643
+ let skip = false;
644
+ const skipper = () => { skip = true; };
645
+ body.addEventListener('keydown', skipper, true);
646
+ const pause = ms => skip ? Promise.resolve() : new Promise(r => setTimeout(r, ms));
647
+ const ln = async (s, ms) => { out(s); await pause(ms); };
648
+ await ln(C.bold('ViKernel Secure OS boot'), 220);
649
+ await ln('[ ' + C.g('OK') + ' ] integrity check ... OK', 220);
650
+ await ln('[ ' + C.g('OK') + ' ] kernel mode B (CPU reference)', 220);
651
+ await ln('[ ' + C.g('OK') + ' ] policy loaded (allowlist + rules)', 200);
652
+ const s = await refreshState();
653
+ if (s) {
654
+ await ln('[ ' + C.g('OK') + ' ] audit chain head ' + String(s.audit_head || '').slice(0, 16), 220);
655
+ await ln('[ ' + C.g('OK') + ' ] agent: Meta Muse Spark 1.3', 200);
656
+ if (halted) out(C.y('kernel is HALTED. Resume with: vik resume --key <operator key>'));
657
+ } else {
658
+ await ln('[ ' + C.y('!!') + ' ] live backend unavailable, offline mode', 200);
659
+ await ln('[ ' + C.g('OK') + ' ] agent: Meta Muse Spark 1.3', 100);
660
+ }
661
+ body.removeEventListener('keydown', skipper, true);
662
+ out(C.dim('Type help for commands. Everything here runs in your browser; security actions go to the live gate.'));
663
+ }
664
+
665
+ function startXterm() {
666
+ body.innerHTML = '';
667
+ body.style.background = '#0B1020';
668
+ const host = document.createElement('div');
669
+ host.style.cssText = 'width:100%;height:100%;padding:6px;box-sizing:border-box;background:#0B1020;';
670
+ body.appendChild(host);
671
+ term = new window.Terminal({
672
+ cursorBlink: true, scrollback: 2000, fontSize: 13, convertEol: false,
673
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
674
+ theme: { background: '#0B1020', foreground: '#D6DEEB', cursor: '#7FDBCA', selectionBackground: '#1D3B53',
675
+ black: '#1B2333', red: '#EF5350', green: '#22DA6E', yellow: '#FFCB6B', blue: '#82AAFF', magenta: '#C792EA', cyan: '#7FDBCA', white: '#D6DEEB',
676
+ brightBlack: '#637777', brightRed: '#FF6B6B', brightGreen: '#5CF19E', brightYellow: '#FFE08A', brightBlue: '#A3C2FF', brightMagenta: '#DDB3FF', brightCyan: '#A5F0E0', brightWhite: '#FFFFFF' },
677
+ });
678
+ const fit = new window.FitAddon.FitAddon();
679
+ term.loadAddon(fit); term.open(host);
680
+ const doFit = () => { try { fit.fit(); } catch (e) { /* hidden window */ } };
681
+ doFit();
682
+ try { new ResizeObserver(doFit).observe(body); } catch (e) { window.addEventListener('resize', doFit); }
683
+
684
+ const copySel = () => { const s = term.getSelection(); if (s && navigator.clipboard) navigator.clipboard.writeText(s).catch(() => {}); return !!s; };
685
+ term.attachCustomKeyEventHandler(ev => {
686
+ if (ev.type !== 'keydown') return true;
687
+ if (ev.ctrlKey && ev.shiftKey && (ev.key === 'C' || ev.key === 'c')) { copySel(); if (h.toast) h.toast('Copied'); return false; }
688
+ if (ev.ctrlKey && (ev.key === 'v' || ev.key === 'V')) return false; // let the browser paste event reach xterm
689
+ return true;
690
+ });
691
+ host.addEventListener('contextmenu', ev => { if (copySel()) { ev.preventDefault(); if (h.toast) h.toast('Copied'); } });
692
+
693
+ let line = '', cur = 0, hIdx = -1, draft = '';
694
+ const redraw = () => {
695
+ term.write('\r\x1b[K' + prompt() + line);
696
+ const back = line.length - cur; if (back > 0) term.write('\x1b[' + back + 'D');
697
+ };
698
+ const showPrompt = () => { line = ''; cur = 0; hIdx = -1; term.write(prompt()); };
699
+
700
+ term.onData(async d => {
701
+ if (busy) return;
702
+ if (d === '\r') {
703
+ term.write('\r\n'); const l = line; line = ''; cur = 0; hIdx = -1;
704
+ await runAndPrint(l); showPrompt(); return;
705
+ }
706
+ if (d === '\x03') { term.write('^C\r\n'); showPrompt(); return; }
707
+ if (d === '\x0c') { term.clear(); redraw(); return; }
708
+ if (d === '\x7f' || d === '\b') { if (cur > 0) { line = line.slice(0, cur - 1) + line.slice(cur); cur--; redraw(); } return; }
709
+ if (d === '\x1b[3~') { if (cur < line.length) { line = line.slice(0, cur) + line.slice(cur + 1); redraw(); } return; }
710
+ if (d === '\x1b[D') { if (cur > 0) { cur--; term.write(d); } return; }
711
+ if (d === '\x1b[C') { if (cur < line.length) { cur++; term.write(d); } return; }
712
+ if (d === '\x1b[H' || d === '\x1bOH' || d === '\x1b[1~' || d === '\x01') { cur = 0; redraw(); return; }
713
+ if (d === '\x1b[F' || d === '\x1bOF' || d === '\x1b[4~' || d === '\x05') { cur = line.length; redraw(); return; }
714
+ if (d === '\x1b[A') {
715
+ if (!history.length) return;
716
+ if (hIdx === -1) { draft = line; hIdx = history.length; }
717
+ hIdx = Math.max(0, hIdx - 1); line = history[hIdx]; cur = line.length; redraw(); return;
718
+ }
719
+ if (d === '\x1b[B') {
720
+ if (hIdx === -1) return;
721
+ hIdx++; if (hIdx >= history.length) { hIdx = -1; line = draft; } else line = history[hIdx];
722
+ cur = line.length; redraw(); return;
723
+ }
724
+ if (d === '\t') {
725
+ const { last, items } = completions(line.slice(0, cur));
726
+ if (items.length === 1) {
727
+ const add = items[0].slice(last.length) + (items[0].endsWith('/') ? '' : ' ');
728
+ line = line.slice(0, cur) + add + line.slice(cur); cur += add.length; redraw();
729
+ } else if (items.length > 1) { term.write('\r\n' + items.join(' ') + '\r\n'); redraw(); }
730
+ return;
731
+ }
732
+ if (d.startsWith('\x1b')) return;
733
+ const text = d.replace(/\r?\n/g, ' ').replace(/[\x00-\x1f]/g, '');
734
+ if (!text) return;
735
+ line = line.slice(0, cur) + text + line.slice(cur); cur += text.length; redraw();
736
+ });
737
+
738
+ term.focus();
739
+ busy = true;
740
+ boot().then(() => { busy = false; showPrompt(); });
741
+ }
742
+
743
+ function startFallback() {
744
+ body.innerHTML = '';
745
+ const wrap = document.createElement('div');
746
+ wrap.style.cssText = 'display:flex;flex-direction:column;width:100%;height:100%;background:#0B1020;color:#D6DEEB;font:13px ui-monospace,Menlo,Consolas,monospace;';
747
+ const pre = document.createElement('pre');
748
+ pre.style.cssText = 'flex:1;margin:0;padding:8px;overflow:auto;white-space:pre-wrap;word-break:break-word;';
749
+ const row = document.createElement('div');
750
+ row.style.cssText = 'display:flex;gap:6px;padding:6px 8px;border-top:1px solid #1D3B53;';
751
+ const pl = document.createElement('span');
752
+ const inp = document.createElement('input');
753
+ inp.style.cssText = 'flex:1;background:transparent;border:0;outline:0;color:inherit;font:inherit;';
754
+ inp.setAttribute('aria-label', 'terminal input');
755
+ row.appendChild(pl); row.appendChild(inp); wrap.appendChild(pre); wrap.appendChild(row); body.appendChild(wrap);
756
+ term = {
757
+ write: s => {
758
+ s = String(s);
759
+ if (s.indexOf('\r\x1b[K') === 0) return; // spinner clear: ignore in fallback
760
+ if (/^\x1b\[2m working|^\b/.test(s)) return;
761
+ pre.textContent += stripAnsi(s).replace(/\r\n/g, '\n').replace(/\r/g, '');
762
+ pre.scrollTop = pre.scrollHeight;
763
+ },
764
+ clear: () => { pre.textContent = ''; },
765
+ };
766
+ const setP = () => { pl.textContent = stripAnsi(prompt()); };
767
+ let hIdx = -1;
768
+ inp.addEventListener('keydown', async ev => {
769
+ if (ev.key === 'Enter' && !busy) {
770
+ const l = inp.value; inp.value = ''; hIdx = -1;
771
+ term.write(stripAnsi(prompt()) + l + '\n');
772
+ await runAndPrint(l); setP();
773
+ } else if (ev.key === 'ArrowUp' && history.length) {
774
+ hIdx = hIdx === -1 ? history.length - 1 : Math.max(0, hIdx - 1); inp.value = history[hIdx]; ev.preventDefault();
775
+ } else if (ev.key === 'ArrowDown' && hIdx !== -1) {
776
+ hIdx++; if (hIdx >= history.length) { hIdx = -1; inp.value = ''; } else inp.value = history[hIdx];
777
+ } else if (ev.key === 'l' && ev.ctrlKey) { term.clear(); ev.preventDefault(); }
778
+ });
779
+ out(C.dim('(terminal library unavailable; using the simple console)'));
780
+ busy = true; setP();
781
+ boot().then(() => { busy = false; inp.focus(); });
782
+ }
783
+
784
+ body.textContent = 'Loading terminal...';
785
+ loadXterm().then(startXterm, startFallback).catch(() => { try { startFallback(); } catch (e) { /* give up quietly */ } });
786
+ };
787
+ })();