UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

225 lines • 10.1 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { specOf } from "../../../../commands/spec/index.js"; import { helpPage, versionLine } from "../../../../commands/spec/standard.js"; import { runAsShell } from "../../../../context/session_context.js"; import { yieldBytes } from "../../../../io/stream.js"; import { IOResult, materialize } from "../../../../io/types.js"; import { decodeText, encodeText } from "../../../../shell/bytes.js"; import { CallStack } from "../../../../shell/call_stack.js"; import { JobOutput } from "../../../../shell/console/index.js"; import { SET_OPTION_NAMES } from "../../../../shell/constants.js"; import { parseOptionWord } from "../../../../shell/options.js"; import { EvaluationContext } from "../../../evaluation.js"; import { ExecutionNode } from "../../../types.js"; import { clearTraps, finishShell } from "../../traps.js"; import { BASH_LONG_OPTIONS, BASH_START_FLAGS, BASH_UNSUPPORTED_LONG_OPTIONS } from "./constants.js"; import { readScriptFile, scriptError } from "./script.js"; function bashArgs(partial) { return { script: null, path: null, argv: [], settings: [], invalid: null, needsValue: null, help: false, version: false, ...partial, }; } /** * Read Bash startup options, then select a program and its argv. * * GNU Bash 5.2 reads long options (one or two dashes) before short ones. * Help/version return after that pass: unknown long options still fail, * while help outranks version and unsupported modes. A long option after * a short one is refused as `--`. Unsupported short options name the * whole character here, rather than its first byte as GNU does. * * Options after a script file or `-c`'s program are positional; `-` and * `--` end option parsing. `-c` takes the next word, never the rest of its * cluster: `-cx 'echo hi'` traces and runs `echo hi`. */ export function parseBashArgs(args) { const settings = []; let wantHelp = false; let wantVersion = false; let unsupported = null; let i = 0; while (i < args.length && (args[i] ?? '').startsWith('-')) { const spelling = args[i] ?? ''; const spelledLong = spelling.startsWith('--') && spelling.length > 2; const name = spelledLong ? spelling.slice(2) : spelling.slice(1); const takesValue = BASH_LONG_OPTIONS.get(name); if (takesValue === undefined) { if (spelledLong) return bashArgs({ invalid: spelling }); break; } if (takesValue) { if (i + 1 >= args.length) return bashArgs({ needsValue: name }); i += 1; } else if (SET_OPTION_NAMES.has(name)) { settings.push([name, true]); } else if (BASH_UNSUPPORTED_LONG_OPTIONS.has(name)) { unsupported ??= spelling; } wantHelp = wantHelp || name === 'help'; wantVersion = wantVersion || name === 'version'; i += 1; } if (wantHelp || wantVersion) return bashArgs({ help: wantHelp, version: wantVersion }); if (unsupported !== null) return bashArgs({ invalid: unsupported }); let readStdin = false; while (i < args.length) { const tok = args[i] ?? ''; if (tok === '--' || tok === '-') { i += 1; break; } if (tok.startsWith('--')) return bashArgs({ invalid: '--' }); const word = parseOptionWord(tok, args[i + 1] ?? null); if (word === null) break; for (const ch of word.other) { if (!BASH_START_FLAGS.has(ch)) return bashArgs({ invalid: tok.charAt(0) + ch }); } for (const item of word.settings) settings.push(item); readStdin = readStdin || word.other.includes('s'); if (word.other.includes('c')) { const next = args[i + word.consumed]; if (next === undefined) return bashArgs({ needsValue: '-c' }); return bashArgs({ script: next, argv: args.slice(i + word.consumed + 1), settings }); } i += word.consumed; } // The program comes from stdin whenever no operand names one, which is // the rule `-s` states explicitly for the case where operands do follow: // `bash -s A B` reads stdin and makes A and B positional. if (i < args.length && !readStdin) { return bashArgs({ path: args[i] ?? '', argv: args.slice(i + 1), settings }); } return bashArgs({ argv: args.slice(i), settings }); } /** * Run a nested shell: inline text from `-c`, or a script file. * * `name` is the head word (`bash` or `sh`). bash reports itself by * `argv[0]`, so the diagnostics follow the spelling the caller used. * * A nested shell is a program of its own, so it runs on a new shell started * from the session's environment (`SessionState.newShell`) and leaves the * caller's state alone: `bash -c 'cd /x'` leaves the caller where it was, * and `x=1; bash -c 'echo $x'` prints an empty line, as in bash, where the * nested shell is a separate process. `handleSource` is the opposite case * and deliberately runs on the caller's session, because a sourced file is * the caller. */ export async function handleBash(dispatch, executeFn, args, context, stdin = null, name = 'bash', sink, jobTable, // The caller's frames, whose nesting the nested shell counts on from. callStack = null) { let session = context.session; const parsed = parseBashArgs(args); if (parsed.help || parsed.version) { // bash answers --help ahead of --version, whatever their order, and // runs nothing else. The page and the version line are the ones every // mirage command prints, under the name typed. `sh` is this same shell, // so `sh --version` answers as bash invoked as sh does; Debian's dash // refuses it ("Illegal option --", 2). const text = parsed.help ? helpPage(name, specOf('bash')) : versionLine(name); return [ yieldBytes(encodeText(text)), new IOResult(), new ExecutionNode({ command: name, exitCode: 0 }), ]; } if (parsed.invalid !== null) { // GNU words this "invalid option" and follows it with a usage block. // One word covers both cases here on purpose: some of what lands here // is an option bash has and mirage does not implement (`-r`, // `--restricted`), and calling those invalid would be a lie. The exit // status is GNU's 2 either way. return scriptError(name, `${parsed.invalid}: unsupported option`, 2); } if (parsed.needsValue !== null) { return scriptError(name, `${parsed.needsValue}: option requires an argument`, 2); } let script = parsed.script; let scriptName = script !== null && parsed.argv.length > 0 ? (parsed.argv[0] ?? name) : name; const positional = script !== null ? parsed.argv.slice(1) : parsed.argv; if (script === null && parsed.path !== null) { scriptName = parsed.path; const [text, failure] = await readScriptFile(dispatch, name, parsed.path, session); if (failure !== null) return failure; script = text; } if (script === null && stdin !== null) { const data = await materialize(stdin); if (data.length > 0) { script = decodeText(data); stdin = null; } } if (script === null) { return [null, new IOResult(), new ExecutionNode({ command: name, exitCode: 0 })]; } const cs = callStack ?? new CallStack(session.shellNesting); cs.admit(name); context = new EvaluationContext(session.newShell(), context.frame.fork(), context); session = context.session; session.shellNesting = cs.nesting + 1; clearTraps(session); session.jobOutput = new JobOutput(session.jobOutput ?? session.tty.jobs); session.positionalArgs = positional; session.scriptName = scriptName; for (const [option, enable] of parsed.settings) session.shellOptions[option] = enable; // A nested shell is its own process, with its own jobs: its `jobs` and // `wait` see only them, its EXIT action's included, and they are not its // caller's. const jobs = jobTable === undefined ? {} : { jobTable: jobTable.child() }; // A nested shell is a program of its own: the builtins it runs are its // builtins again, whatever `find -exec` marked the outer line. let io; try { io = await runAsShell(async () => finishShell((action, opts) => executeFn(action, { ...opts, ...jobs, context }), session, await executeFn(script, { context, sessionId: session.sessionId, stdin, ...(sink === undefined ? {} : { sink }), ...jobs, }), stdin)); } finally { await session.closeInputs(); } const label = parsed.path !== null ? `${name} ${parsed.path}` : `${name} -c ${script}`; return [io.stdout, io, new ExecutionNode({ command: label, exitCode: io.exitCode })]; } /** The `bash` / `sh` arm; the head word names the nested shell. */ export async function bashBuiltin(call) { return handleBash(call.dispatch, call.executeFn, [...call.argv.args], call.context, call.stdin, call.argv.name, call.sink, call.jobTable, call.callStack); } //# sourceMappingURL=bash.js.map