Spaces:
Running
Running
v0.4: trash audit, terminal text, settings remove to bin
Browse files- live_apps.js +0 -0
- settings.js +1 -1
- 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.
|
| 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 |
+
})();
|