class ShellModule { constructor() { this.commands = {}; this.terminal = null; this._vars = { HOME: '/', USER: 'darkbot', SHELL: 'webx11-shell', TERM: 'xterm-256color', PWD: '/' }; this._scriptDepth = 0; this._maxScriptDepth = 3; } requirements() { return { met: true, missing: [] }; } getHelpInfo() { return { 'export': 'export = — Set a shell variable', 'unset': 'unset — Remove a shell variable', 'script': 'script — Run a shell script from VFS', '$': '$ — Execute with shell parsing (default: auto-detected)', 'sh': 'sh — Run a shell script from VFS (alias for script)', 'set': 'set — List all shell variables', }; } registerCommands() { this.commands['export'] = (args) => this._cmdExport(args); this.commands['unset'] = (args) => this._cmdUnset(args); this.commands['script'] = (args) => this._cmdScript(args); this.commands['sh'] = (args) => this._cmdScript(args); this.commands['set'] = () => this._cmdSet(); } // ── Hook into terminal.executeCommand ── init() { // Guard: prevent double-wrapping if init is called multiple times, and adopt the wrapper // if another instance already installed one (shell.js used to be loaded twice — once in // critical-ext-bundle.js, once from main.html's extension list — and the two copies could // disagree about their version). if (this._initialized) return; if (this.terminal && this.terminal._shellModule && this.terminal._shellModule !== this) { this._initialized = true; return; } this._initialized = true; // Wrap executeCommand to add shell parsing — only for compound commands const origExecute = this.terminal.executeCommand.bind(this.terminal); const self = this; this.terminal.executeCommand = async function(rawInput) { if (!rawInput.trim()) return; this.markDirty(); this.printUserPrompt(rawInput); // Save to command history (preserved from original executeCommand) const cmdRaw = rawInput.trim(); if (this.history.length === 0 || this.history[this.history.length - 1] !== cmdRaw) { this.history.push(cmdRaw); } if (this.history.length > this._historyCap) { this.history = this.history.slice(-this._historyCap); } this.historyIndex = this.history.length; this._saveCommandHistory(); // For simple single-word commands without special chars, skip shell parsing if (/^[a-zA-Z0-9._\/-]+$/.test(rawInput.trim()) && !rawInput.includes(' ') && !rawInput.includes('$')) { try { return await this.security.checkAndRun(rawInput); } catch(e) { this.print('Error: ' + e.message, 'error'); return; } } // Substitute variables in the raw input const substituted = self._substituteVars(rawInput); // Split on ; first (sequence) const seqParts = self._splitSequence(substituted); for (const seqPart of seqParts) { if (!seqPart.trim()) continue; // Split on && and || — operator now attached to the FOLLOWING segment const chainParts = self._splitChain(seqPart.trim()); let lastOk = true; for (const chainPart of chainParts) { if (!chainPart.cmd.trim()) continue; const { cmd: chainCmd, operator } = chainPart; // Check the operator that PRECEDES this command if (operator === '&&' && !lastOk) continue; if (operator === '||' && lastOk) continue; // Check for redirects const redirectInfo = self._extractRedirects(chainCmd); if (redirectInfo.hasRedirect) { // Same capture-and-write path the script runner uses. _executeWithRedirect // handed `>` to builtins, so `echo hi > /f` printed the operator instead of // writing the file. lastOk = await self._runCommand(chainCmd, { $: 0 }, { inScript: false }); } else { // Normal or piped command const pipeParts = this.splitPipe(chainCmd); if (pipeParts.length > 1) { await this.executePipeChain(pipeParts); } else { // Single command — check for shell built-ins first // v4.6.132: quote-aware (split(/\s+/) broke `grep "a b" f; ls`) const argv = self._tokenize(chainCmd); if (!argv.length) continue; const cmd = argv[0].toLowerCase(); // Globs expand at the prompt too — the script runner did it and the // interactive path did not, so `ls *.txt` printed (empty) but the same // line inside a script worked. const args = self._expandGlobs(argv.slice(1), (this.fs && this.fs.currentPath) || []); const expandedCmd = [cmd].concat(args).join(' '); if (self.commands[cmd]) { try { this.markDirty(); await self.commands[cmd](args); lastOk = true; } catch(e) { this.print('Error: ' + e.message, 'error'); lastOk = false; } } else { try { await this.security.checkAndRun(expandedCmd); lastOk = true; } catch(e) { this.print('Error: ' + e.message, 'error'); lastOk = false; } } } } } } }; this.terminal._shellModule = this; // Modify pipe chain to add pipe-aware injection and security checks const origPipeChain = this.terminal.executePipeChain.bind(this.terminal); this.terminal.executePipeChain = async function(pipeParts) { // Write pipe input from previous segment as temp file for next segment const shellMod = this._shellModule; let pipeInput = null; for (let i = 0; i < pipeParts.length; i++) { const cmdText = pipeParts[i]; // v4.6.132: quote-aware tokenisation. split(/\s+/) turned `grep "a b" f | wc -l` // into args ['"a','b"','f'], and `cmd | | cmd2` into a stage with cmd '' that was // still dispatched. The security tokeniser keeps a quoted argument whole and // returns [] for an empty stage, so the guard below can skip it. const argv = this.security._parseArgs(String(cmdText || '').trim()); if (argv.length === 0) continue; const cmd = argv[0].toLowerCase(); let args = argv.slice(1); // Security gate — replicate the original executePipeChain checks const cls = this.security._classify(cmdText); if (cls.destructive && this.security.level > 0) { this.print(`⚠️ Security: Destructive command "${cmd}" blocked in pipe at level ${this.security.level}`, 'error'); return; } if (cls.system && this.security.level > 0) { this.print(`⚠️ Security: System command "${cmd}" blocked in pipe at level ${this.security.level}`, 'error'); return; } if (!cls.known && this.security.level >= 2) { this.print(`⚠️ Security: Unknown command "${cmd}" blocked in pipe (paranoid mode)`, 'error'); return; } this._pipeInput = pipeInput; if (pipeInput !== null) { // Write pipe input as temp file so file-reading commands can use it this.fs.writeFile('__pipebuf__', pipeInput); // Inject __pipebuf__ as file arg if command is pipe-aware (reads files for input) if (shellMod && shellMod._isPipeAware(cmd)) { // Don't inject if command already has enough file args if (!args.some(a => !a.startsWith('-') && a !== '|' && a !== '>')) { args = args.concat('__pipebuf__'); } } } try { if (i < pipeParts.length - 1) { // v4.6.131: hand the stage's own text to the command (this override is the // one that actually runs — it replaces terminal.executePipeChain), so // `js "a b".length | wc -c` keeps its quotes like a bare command does. pipeInput = await this.capturePipeOutput(cmd, args, cmdText); } else { await this.dispatchCommand(cmd, args, cmdText); } } finally { // Clean up temp file if (this.fs.readFile('__pipebuf__') !== null) { delete this.fs.root.content['__pipebuf__']; } } } this._pipeInput = null; }; } // ── Tokenizer helpers ── /** * Split a command line into argv, honouring quotes — the same parser the dispatcher uses * (`security._parseArgs`), so the shell paths and the bare-command path cannot drift. * v4.6.132: every `.split(/\s+/)` call site in this file turned a quoted argument into several, * and dispatched an empty stage as a command named ''. */ _tokenize(cmdText) { const src = String(cmdText || '').trim(); if (!src) return []; const sec = (this.terminal && this.terminal.security) || this.security; if (sec && typeof sec._parseArgs === 'function') return sec._parseArgs(src); return src.split(/\s+/); // defensive: the terminal always exposes security } _splitSequence(input) { // Split on ; (not inside quotes) const parts = []; let current = ''; let inQuote = false; let quoteChar = ''; for (const ch of input) { if ((ch === '"' || ch === "'") && !inQuote) { inQuote = true; quoteChar = ch; current += ch; } else if (ch === quoteChar && inQuote) { inQuote = false; quoteChar = ''; current += ch; } else if (ch === ';' && !inQuote) { parts.push(current.trim()); current = ''; } else { current += ch; } } parts.push(current.trim()); return parts; } _splitChain(input) { // Split on && and ||, preserving operator info // Operator is attached to the FOLLOWING segment (the one it guards) const parts = []; let current = ''; let inQuote = false; let quoteChar = ''; let pendingOp = null; // operator from previous split, applies to next segment let i = 0; while (i < input.length) { const ch = input[i]; if ((ch === '"' || ch === "'") && !inQuote) { inQuote = true; quoteChar = ch; current += ch; i++; } else if (ch === quoteChar && inQuote) { inQuote = false; quoteChar = ''; current += ch; i++; } else if (!inQuote && input.substring(i, i+2) === '&&') { parts.push({ cmd: current.trim(), operator: pendingOp }); pendingOp = '&&'; current = ''; i += 2; } else if (!inQuote && input.substring(i, i+2) === '||') { parts.push({ cmd: current.trim(), operator: pendingOp }); pendingOp = '||'; current = ''; i += 2; } else { current += ch; i++; } } parts.push({ cmd: current.trim(), operator: pendingOp }); return parts; } _extractRedirects(input) { // Find >, >>, < outside quotes let cmd = ''; let hasRedirect = false; let redirectOp = ''; let redirectFile = ''; let append = false; let inQuote = false, quoteChar = ''; let i = 0; const parts = []; let current = ''; while (i < input.length) { const ch = input[i]; if ((ch === '"' || ch === "'") && !inQuote) { inQuote = true; quoteChar = ch; current += ch; i++; } else if (ch === quoteChar && inQuote) { inQuote = false; quoteChar = ''; current += ch; i++; } else if (!inQuote) { if (input.substring(i, i+2) === '>>') { if (current.trim()) { parts.push(current.trim()); current = ''; } hasRedirect = true; redirectOp = '>>'; append = true; i += 2; // Read until next redirect or end let file = ''; while (i < input.length && input[i] === ' ') i++; while (i < input.length && input[i] !== '>' && input[i] !== '<' && input[i] !== '|') { file += input[i]; i++; } redirectFile = file.trim(); if (i < input.length && (input[i] === '>' || input[i] === '<')) continue; break; } else if (input.substring(i, i+1) === '>') { if (current.trim()) { parts.push(current.trim()); current = ''; } hasRedirect = true; redirectOp = '>'; append = false; i += 1; let file = ''; while (i < input.length && input[i] === ' ') i++; while (i < input.length && input[i] !== '>' && input[i] !== '<' && input[i] !== '|') { file += input[i]; i++; } redirectFile = file.trim(); if (i < input.length && (input[i] === '>' || input[i] === '<')) continue; break; } else if (input.substring(i, i+1) === '<') { if (current.trim()) { parts.push(current.trim()); current = ''; } hasRedirect = true; redirectOp = '<'; i += 1; let file = ''; while (i < input.length && input[i] === ' ') i++; while (i < input.length && input[i] !== '>' && input[i] !== '<' && input[i] !== '|') { file += input[i]; i++; } redirectFile = file.trim(); if (i < input.length && (input[i] === '>' || input[i] === '<')) continue; break; } else { current += ch; i++; } } else { current += ch; i++; } } if (current.trim()) parts.push(current.trim()); const cmdText = parts.join(' '); return { hasRedirect, redirectOp, redirectFile, append, cmdText, exitOk: true }; } async _executeWithRedirect(redirectInfo) { const { redirectOp, redirectFile, append, cmdText } = redirectInfo; const fs = this.terminal.fs; if (redirectOp === '<') { // Read file content as stdin const inputContent = fs.readFile(redirectFile); if (inputContent === null) { this.terminal.print(`shell: ${redirectFile}: No such file`, 'error'); redirectInfo.exitOk = false; return; } this.terminal._pipeInput = inputContent; try { // v4.6.132: quote-aware — `cat < "/my file.txt"` and `grep "a b" f < x` lost the // quoted argument to split(/\s+/). const argv = this._tokenize(cmdText); const cmd = (argv[0] || '').toLowerCase(); const args = argv.slice(1); // If command reads files, inject pipe content as temp file if (this._isPipeAware(cmd)) { fs.writeFile('__pipebuf__', inputContent); const newArgs = [...args]; if (!newArgs.some(a => !a.startsWith('-'))) { newArgs.push('__pipebuf__'); } try { await this.terminal.dispatchCommand(cmd, newArgs); } finally { if (fs.readFile('__pipebuf__') !== null) { delete fs.root.content['__pipebuf__']; } } } else { await this.terminal.dispatchCommand(cmd, args); } redirectInfo.exitOk = true; } catch(e) { this.terminal.print('Error: ' + e.message, 'error'); redirectInfo.exitOk = false; } finally { this.terminal._pipeInput = null; } return; } // > or >> — capture output, write to file let output = ''; this.terminal._pushPrintCapture(buf => { output += buf; }); let cmdOk = true; try { // Check for pipes within the command before redirect const pipeParts = this.terminal.splitPipe(cmdText); if (pipeParts.length > 1) { // Execute pipe chain manually to capture all output let pipeInput = null; for (let i = 0; i < pipeParts.length; i++) { const pText = pipeParts[i]; // v4.6.132: tokenise like the interactive path (and keep the raw text, so a // `js` stage in a script keeps its string literals too). const pArgv = this._tokenize(pText); if (!pArgv.length) continue; const pCmd = pArgv[0].toLowerCase(); const pArgs = pArgv.slice(1); if (pipeInput !== null) { fs.writeFile('__pipebuf__', pipeInput); } if (i < pipeParts.length - 1) { let capt = ''; this.terminal._pushPrintCapture(buf => { capt += buf; }); try { await this.terminal.dispatchCommand(pCmd, pArgs, pText); } finally { this.terminal._popPrintCapture(); } pipeInput = capt.trim(); } else { await this.terminal.dispatchCommand(pCmd, pArgs, pText); } if (fs.readFile('__pipebuf__') !== null) { delete fs.root.content['__pipebuf__']; } } } else { // v4.6.132: quote-aware, and pass the raw text so `js`/`node -e` keep literals here too const argv = this._tokenize(cmdText); const cmd = (argv[0] || '').toLowerCase(); const args = argv.slice(1); await this.terminal.dispatchCommand(cmd, args, cmdText); } } catch(e) { cmdOk = false; // Pop capture before showing the error this.terminal._popPrintCapture(); this.terminal.print('Error: ' + e.message, 'error'); redirectInfo.exitOk = false; return; } finally { // Always pop capture — critical: without this, terminal output stays captured // (empty stack = normal DOM printing, so pop is idempotent-safe when already popped) if (this.terminal._printCaptureStack && this.terminal._printCaptureStack.length > 0) { // popped in catch path for error case; success path pops here this.terminal._popPrintCapture(); } } const captured = output.trimEnd(); if (captured) { if (append) { const existing = fs.readFile(redirectFile) || ''; fs.writeFile(redirectFile, existing + (existing ? '\n' : '') + captured); } else { fs.writeFile(redirectFile, captured); } this.terminal.print(`Redirected ${captured.split('\n').length} line(s) to ${redirectFile}`, 'success'); } else { // Write empty file if (!append) { fs.writeFile(redirectFile, ''); this.terminal.print(`Redirected (empty) to ${redirectFile}`, 'success'); } } redirectInfo.exitOk = cmdOk; } _isPipeAware(cmd) { // Commands that read from files and should use the pipe buffer file. wc is NOT here any // more: it reads terminal._pipeInput natively (v4.6.111), and injecting __pipebuf__ gave // `3 __pipebuf__` instead of a bare count. // grep/head/tail/wc read terminal._pipeInput themselves (v4.6.111-116). Injecting the // __pipebuf__ file for them made `cat f | grep x` match twice (the pipe buffer AND the cwd // file of the same name) and printed "3 __pipebuf__" from wc. return ['sort', 'cut', 'uniq', 'expand'].includes(cmd); } // ── Variable substitution ── _substituteVars(input) { // Replace $VAR and ${VAR} (not inside single quotes) let result = ''; let inSingle = false; let i = 0; while (i < input.length) { const ch = input[i]; if (ch === "'") { inSingle = !inSingle; result += ch; i++; } else if (ch === '$' && !inSingle && i + 1 < input.length) { // Check for $( if (input[i+1] === '(') { result += ch; i++; continue; } // Check for ${VAR} if (input[i+1] === '{') { let end = input.indexOf('}', i); if (end !== -1) { const varName = input.substring(i+2, end); const val = this._getVar(varName); result += val !== undefined ? val : '$' + varName; i = end + 1; continue; } } // Check for $VAR (alphanumeric/underscore) let j = i + 1; if (j < input.length && /[A-Za-z_]/.test(input[j])) { while (j < input.length && /\w/.test(input[j])) j++; const varName = input.substring(i+1, j); const val = this._getVar(varName); result += val !== undefined ? val : ''; i = j; continue; } // $ followed by digit — not a var result += ch; i++; } else { result += ch; i++; } } return result; } _getVar(name) { if (name === 'PWD') return '/' + this.terminal.fs.currentPath.join('/'); return this._vars[name] !== undefined ? this._vars[name] : undefined; } _setVar(name, value) { this._vars[name] = value; } // ── Commands ── _cmdExport(args) { if (!args[0] || !args[0].includes('=')) { return this.terminal.print('Usage: export =', 'warning'); } const eqIdx = args[0].indexOf('='); const name = args[0].substring(0, eqIdx); let value = args[0].substring(eqIdx + 1); // Support quoted values: export FOO="hello world" if (args.length > 1) { const rest = args.slice(1).join(' '); if (value.endsWith('"') || value.endsWith("'")) { // First part ended quote — fine } else if (value.startsWith('"') || value.startsWith("'")) { value = value + (rest ? ' ' + rest : ''); } } // Strip surrounding quotes if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.substring(1, value.length - 1); } this._setVar(name, value); this.terminal.print(`Shell ${name}=${value}`, 'success'); } _cmdUnset(args) { if (!args[0]) return this.terminal.print('Usage: unset ', 'warning'); delete this._vars[args[0]]; this.terminal.print(`Shell var removed: ${args[0]}`, 'info'); } _cmdSet() { const fs = this.terminal.fs; this.terminal.print('Shell variables:', 'info'); for (const [k, v] of Object.entries(this._vars)) { this.terminal.print(`${k}=${v}`, 'info'); } this.terminal.print(`PWD=/${fs.currentPath.join('/')}`, 'info'); } async _cmdScript(args) { if (!args[0]) return this.terminal.print('Usage: script ', 'warning'); if (this._scriptDepth >= this._maxScriptDepth) { return this.terminal.print(`script: max recursion depth (${this._maxScriptDepth}) reached`, 'error'); } const path = args[0]; const content = this.terminal.fs.readFile(path); if (content === null) return this.terminal.print(`script: ${path}: No such file`, 'error'); this._scriptDepth++; try { this.terminal.print(`Running script: ${path}`, 'info'); const env = Object.assign({ $: 0 }, this._scriptEnv || {}); this._inScript = true; let result = { ran: 0 }; try { const tree = this._parseBlocks(content.split('\n')); result = await this._runInstructions(tree, env, { path, inScript: true }); } catch (e) { // A parse error (unclosed block, bad for-header) must read as a script error, not // as a crashed terminal. this.terminal.print(`Script ${path} failed: ${e.message}`, 'error'); result = { ran: 0 }; } finally { this._inScript = false; } this._scriptEnv = env; this.terminal.print(`Script ${path} completed (${result.ran} statements, exit ${env.$})`, 'success'); } finally { // Always decrement — even on error or thrown exception this._scriptDepth--; } } // Parse a script into an instruction tree. Line-by-line execution cannot express a loop body, // which is why `for … done` used to be fed to the AI one line at a time. _parseBlocks(lines) { const root = { body: [] }; const stack = [root]; const cur = () => stack[stack.length - 1]; const push = (node) => { const top = cur(); if (top.op === 'if') { (top.inElse ? top.else : top.then).push(node); } else { top.body.push(node); } }; // `if [ -f /x ]; then echo y; fi` on one line is common enough to support: split atoms on // ';' when the line carries a block keyword, and keep quoted text intact otherwise. const atoms = []; lines.forEach((raw) => { const l = String(raw).replace(/\r$/, ''); if (l.includes(';') && /\b(then|do|done|fi|else)\b/.test(l)) l.split(';').forEach(p => atoms.push(p)); else atoms.push(l); }); atoms.forEach((raw, idx) => { const line = String(raw).replace(/\r$/, '').trim(); const lineNo = idx + 1; if (!line || line.startsWith('#')) return; const head = line.split(/\s+/)[0]; if (head === 'for') { const m = line.match(/^for\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+(.*)$/); if (!m) throw new Error('line ' + lineNo + ": expected 'for in '"); const node = { op: 'for', line: lineNo, var: m[1], items: m[2].trim().split(/\s+/).filter(Boolean), body: [] }; push(node); stack.push(node); return; } if (head === 'while') { const node = { op: 'while', line: lineNo, test: line.slice(5).trim(), body: [] }; push(node); stack.push(node); return; } if (head === 'if') { const node = { op: 'if', line: lineNo, test: line.slice(2).trim(), then: [], else: [], inElse: false }; push(node); stack.push(node); return; } if (head === 'then' || head === 'do') { // `then echo y` on the same atom still means "run echo y" const rest = line.slice(head.length).trim(); if (rest) push({ op: 'simple', line: lineNo, text: rest }); return; } if (head === 'else') { const top = cur(); if (top.op !== 'if') throw new Error('line ' + lineNo + ": 'else' without 'if'"); top.inElse = true; const rest = line.slice(4).trim(); if (rest) push({ op: 'simple', line: lineNo, text: rest }); return; } if (head === 'done' || head === 'fi') { const top = stack.pop(); const want = !top ? null : (top.op === 'if' ? 'fi' : 'done'); if (!want) throw new Error('line ' + lineNo + ": '" + head + "' without an open block"); if (head !== want) throw new Error('line ' + lineNo + ": expected '" + want + "' to close '" + top.op + "' from line " + top.line); return; } push({ op: 'simple', line: lineNo, text: line }); }); if (stack.length !== 1) { const open = stack[stack.length - 1]; throw new Error("unclosed '" + open.op + "' from line " + open.line + " — expected '" + (open.op === 'if' ? 'fi' : 'done') + "'"); } const fix = (nodes) => nodes.map(n => { if (n.op === 'if') { n.body = n.then; n.elseBody = n.else; } if (n.body && (n.op === 'for' || n.op === 'while')) fix(n.body); if (n.body && n.op === 'if') fix(n.body); if (n.elseBody) fix(n.elseBody); return n; }); return fix(root.body); } // Same rules as _substituteVars, but against an explicit env so a loop body sees `$i`, and // `$?` carries the previous statement's exit status. _expandVars(input, env) { const src = String(input); let out = '', inSingle = false, inDouble = false; for (let i = 0; i < src.length; i++) { const ch = src[i]; if (ch === "'" && !inDouble) { inSingle = !inSingle; out += ch; continue; } if (ch === '"' && !inSingle) { inDouble = !inDouble; out += ch; continue; } if (ch !== '$' || inSingle) { out += ch; continue; } const next = src[i + 1]; if (next === '?') { out += String(env.$ == null ? 0 : env.$); i++; continue; } let name = ''; if (next === '{') { const end = src.indexOf('}', i + 2); if (end === -1) { out += ch; continue; } name = src.slice(i + 2, end); i = end; } else { const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i + 1)); if (!m) { out += ch; continue; } name = m[0]; i += name.length; } out += (env[name] == null ? '' : String(env[name])); } return out; } // Wildcards only expand for unquoted arguments, and only against the VFS — `ls *.txt` used to // return an empty list because the literal pattern was handed to ls. _expandGlobs(args, cwdSegments) { const globToRe = (pat) => new RegExp('^' + pat.replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]') + '$'); const dirOf = (p) => { const segs = String(p).split('/').filter(Boolean); segs.pop(); return segs; }; return args.flatMap((arg) => { const a = String(arg); if (/^['"]/.test(a) || !/[*?]/.test(a)) return [a]; const segs = a.split('/').filter(Boolean); const namePat = segs.pop() || '*'; const rawDir = a.startsWith('/') ? segs : cwdSegments.concat(segs); // cwd segments may arrive as ['work'] or ['/work'] — normalise both const dirPath = '/' + rawDir.map(s => String(s).split('/').filter(Boolean).join('/')) .filter(Boolean).join('/'); let entries = []; try { const fsv = this.terminal.fs; // ls() is the canonical lister; readDir() is the fallback so the helper also works // against a bare fake fs (and against any future module that only implements one). entries = (fsv && (fsv.ls ? fsv.ls(dirPath) : null)) || (fsv && fsv.readDir ? fsv.readDir(dirPath) : null) || []; } catch (e) { entries = []; } const re = globToRe(namePat); const hits = entries.filter(e => re.test(e.name)).map(e => (dirPath === '/' ? '' : dirPath) + '/' + e.name); return hits.length ? hits : [a]; }); } // Evaluate the only condition syntaxes the runner supports: test -f/-d/-e/-z/-n, `a = b`, // and the bracket spelling of the same. Anything else is false, never a crash. _evalTest(expr, env) { const t = this._expandVars(String(expr == null ? '' : expr), env || {}) .trim() .replace(/^\[\s*/, '') // `[ -f x ]` .replace(/\s*\]$/, '') .replace(/^test\s+/, '') // `test -f x` — without this every `if test` was false .trim(); if (!t) return false; const fs = this.terminal.fs; // v4.6.132: quote-aware — `[ -f "/my file.txt" ]` was false because the path was split. const parts = this._tokenize(t); const p = parts[1]; switch (parts[0]) { case '-f': return typeof fs.readFile(p) === 'string'; case '-d': return !!fs.readDir(p); case '-e': return typeof fs.readFile(p) === 'string' || !!fs.readDir(p); case '-z': return !p; case '-n': return !!p; case '!': return !this._evalTest(parts.slice(1).join(' '), env); } if (parts.length === 3 && (parts[1] === '=' || parts[1] === '==')) return parts[0] === parts[2]; if (parts.length === 3 && parts[1] === '!=') return parts[0] !== parts[2]; return false; } async _runInstructions(nodes, env, ctx) { let ran = 0; for (const node of nodes) { if (node.op === 'for') { for (const rawItem of node.items) { const expanded = this._expandVars(rawItem, env); // `for f in /work/*.txt` used to run ONCE with $f set to the literal pattern, // because only command arguments went through glob expansion. const cwd = (this.terminal.fs && this.terminal.fs.currentPath) || []; const items = this._expandGlobs([expanded], cwd); for (const item of items) { env[node.var] = item; const r = await this._runInstructions(node.body, env, ctx); ran += r.ran + 1; } } continue; } if (node.op === 'while') { let guard = 0; const cap = this._maxLoopIterations || 1000; while (guard++ < cap && this._evalTest(node.test, env)) { const r = await this._runInstructions(node.body, env, ctx); ran += r.ran + 1; } if (guard > cap) this.terminal.print('script: while loop hit the iteration limit (' + cap + ')', 'warning'); continue; } if (node.op === 'if') { const take = this._evalTest(node.test, env); env.$ = take ? 0 : 1; const r = await this._runInstructions(take ? node.body : (node.elseBody || []), env, ctx); ran += r.ran + 1; continue; } const r = await this._runSimple(this._expandVars(node.text, env), env, Object.assign({}, ctx, { line: node.line })); ran += r.ran; } return { ran }; } async _runSimple(text, env, ctx) { const seqParts = this._splitSequence(text); let lastOk = true; for (const seqPart of seqParts) { if (!seqPart.trim()) continue; lastOk = true; for (const chainPart of this._splitChain(seqPart.trim())) { if (!chainPart.cmd.trim()) continue; if (chainPart.operator === '&&' && !lastOk) continue; if (chainPart.operator === '||' && lastOk) continue; lastOk = await this._runCommand(chainPart.cmd, env, ctx); } } env.$ = lastOk ? 0 : 1; return { ran: 1, ok: lastOk }; } async _runCommand(cmdText, env, ctx) { const redirectInfo = this._extractRedirects(cmdText); if (redirectInfo.hasRedirect) { const cap = { output: '', onChunk: null }; const stack = this.terminal._printCaptureStack || (this.terminal._printCaptureStack = []); stack.push(cap); let ok = true; try { ok = await this._runCommand(redirectInfo.cmdText, env, ctx); } catch (e) { this.terminal.print('Error: ' + e.message, 'error'); ok = false; } finally { stack.pop(); } const target = redirectInfo.redirectFile; const prev = redirectInfo.append ? (this.terminal.fs.readFile(target) || '') : ''; const wrote = this.terminal.fs.writeFile(target, prev + cap.output); if (!wrote) { this.terminal.print('script: cannot write ' + target, 'error'); return false; } return ok; } const pipeParts = this.terminal.splitPipe ? this.terminal.splitPipe(cmdText) : [cmdText]; if (pipeParts.length > 1) { await this.terminal.executePipeChain(pipeParts); return true; } return this._dispatchOne(cmdText, env, ctx); } async _dispatchOne(cmdText, env, ctx) { // v4.6.132: quote-aware tokenisation (split(/\s+/) split `grep "a b" f` into three args) const argv = this._tokenize(cmdText); const cmd = (argv[0] || '').toLowerCase(); const cwd = (this.terminal.fs && this.terminal.fs.currentPath) || []; const args = this._expandGlobs(argv.slice(1), cwd); if (this.commands[cmd]) { try { this.terminal.markDirty(); await this.commands[cmd](args); return true; } catch (e) { this.terminal.print('Error: ' + e.message, 'error'); return false; } } const known = (this.terminal.moduleManager && this.terminal.moduleManager.getAllCommands ? this.terminal.moduleManager.getAllCommands() : {})[cmd]; if (ctx && ctx.inScript && !known) { // A script must never answer an unknown command by calling the AI. this.terminal.print('script:' + ctx.path + ':' + (ctx.line || '?') + ': ' + cmd + ': command not found', 'error'); return false; } // Dispatch the EXPANDED argument list — passing cmdText here silently dropped the glob // results, so `ls /g*.txt` still reached the fs as a literal pattern. const rebuilt = [cmd].concat(args).join(' '); try { await this.terminal.security.checkAndRun(rebuilt); return true; } catch (e) { this.terminal.print('Error: ' + e.message, 'error'); return false; } } } if (typeof window.webx11 !== 'undefined' && window.webx11) { // Register with a cache-busting query: an unversioned URL let the browser keep serving a // pre-v4.6.111 shell.js after the bundle had already shipped the new runner. const _mm = window.webx11.moduleManager; const _ver = (_mm && (_mm.LATEST_VER || _mm.latestVer)) || ('t' + Date.now()); const _inst = new ShellModule(); webx11.moduleManager.registerModule(_inst, 'https://webx11.duckdns.org/spaces/newos4/modules/ext/shell.js?v=' + _ver); // registerModule() does not call init(); only loadModule() does. A module registered from the // boot bundle therefore never installed its executeCommand wrapper, so prompt-level globbing // and redirects silently did nothing. Init it here, once. if (typeof _inst.init === 'function' && !_inst._initialized) { try { _inst.init(); } catch (e) { console.warn('ShellModule init failed:', e && e.message); } } }