@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
436 lines • 15.1 kB
JavaScript
// ========= 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 { declaredFacts } from "../../commands/builtin/generic_bind/factory.js";
import { FlagView, SPECS, WordKind, parseCommand } from "../../commands/spec/index.js";
import { parseToKwargs } from "../../commands/spec/parser.js";
import { compileSpec } from "../../commands/spec/compile.js";
import { PARSES_KEPT } from "../../commands/spec/constants.js";
import { BUILTIN_GROUP } from "../../shell/constants.js";
import { BuiltinGroup, ShellBuiltin } from "../../shell/types.js";
import { Follow, LineFacts } from "../../types.js";
// Bash builtins the parser accepts but the executor cannot honor; they
// still lookup to the shell layer so the error names a capability gap.
export const UNSUPPORTED_BUILTINS = new Set([
'bg',
'complete',
'compgen',
'ulimit',
]);
export const NAMESPACE_COMMANDS = new Set([
'getfattr',
'ln',
'readlink',
'setfattr',
]);
// Programs the router answers by name from the mount table and the attr
// overlay (node/command_dispatch), with spec-classified words like a mount
// command's. No mount registers them, so lookup names them here for `type`,
// `command -v` and the /usr/bin listing.
export const ROUTED_COMMANDS = new Set([
'chgrp',
'chmod',
'chown',
'df',
'mount',
]);
// The namespace and routed programs that change what they name. No mount
// registers them, so no handler declares the write; policy reads it here.
export const WRITING_PROGRAMS = new Set([
'chgrp',
'chmod',
'chown',
'ln',
'setfattr',
]);
// The programs Debian ships as a script that runs another one with an option
// put first: egrep is `exec grep -E "$@"`, so grep is what parses the line and
// answers it, its refusals and --help included (grep 3.11, gzip 1.13). Each
// is a program wherever the one it runs is a mount command.
export const EXEC_WRAPPERS = new Map([
['egrep', ['grep', '-E']],
['fgrep', ['grep', '-F']],
['zegrep', ['zgrep', '-E']],
['zfgrep', ['zgrep', '-F']],
]);
// Interpreter names select runtime adapters; session builtins stay in Mirage.
export const INTERPRETER_NAMES = new Set([...BUILTIN_GROUP]
.filter(([, group]) => group === BuiltinGroup.INTERPRETERS)
.map(([name]) => name));
// Shell words a native runtime's captures can take from the session: the
// interpreters, and the process tools whose host programs see that
// runtime's own processes rather than mirage's managed runners.
export const CAPTURABLE_BUILTINS = new Set([
...INTERPRETER_NAMES,
'ps',
'kill',
]);
// bash reserved words that mirage's grammar implements. The parser, not
// the executor, consumes them, so they never reach lookup; `type` reports
// them and the CLI registry refuses them as head words. `coproc` is omitted
// because its construct is not implemented.
export const KEYWORDS = new Set([
'time',
'if',
'then',
'else',
'elif',
'fi',
'case',
'esac',
'for',
'select',
'while',
'until',
'do',
'done',
'in',
'function',
'{',
'}',
'!',
'[[',
']]',
]);
// ShellBuiltin subset handled through the job table in the executor.
export const JOB_BUILTINS = new Set([
'wait',
'fg',
'kill',
'jobs',
'disown',
'ps',
]);
// Commands the router must not resolve the last component for, even
// under a trailing slash, because none of them acts on the link's
// target and each says so in its own words. GNU tar strips the slash
// and archives the link (`tar -cf a.tar dlink/` == `tar -cf a.tar
// dlink`; only -h descends). The four destructive ones refuse outright:
// `rm dlink/` is "Is a directory", `rm -r dlink/` and `mv dlink/ d` and
// `unlink dlink/` are "Not a directory", and `rmdir dlink/` has a
// message of its own, "Symbolic link not followed". Probed on GNU
// coreutils 9.4 / tar 1.35. mkdir is here for the same reason it is in
// SELF_RESOLVING: it lstats the name it is creating, so `mkdir -p
// dangle/` collides with the link exactly as `mkdir -p dangle` does.
// Info-ZIP is the counter-example and is deliberately absent:
// `zip -y -r a.zip dlink/` descends where `zip -y -r a.zip dlink`
// stores the link.
export const SLASH_KEEPS_LAST = new Set([
'tar',
'rm',
'rmdir',
'mv',
'unlink',
'mkdir',
]);
// Commands that decide the last component for themselves, so the router
// resolves only the prefix for them. chmod/chown/chgrp read -h off their
// own line (GNU `chgrp -h ops link` writes the link, not its target),
// touch reads -h the same way, ln is creating the name in its second
// operand, readlink's whole subject is the link it was handed, and
// mkdir is naming something that must not exist yet -- resolving its
// last component would make `mkdir -p dangle` create the link's missing
// target where GNU answers "File exists". getfattr and setfattr read -h
// off their own line and hand the dispatcher the link or its target.
// A trailing slash still applies to most of them: these are
// lstat-by-default, not slash-proof (`touch dlink/` succeeds against the
// target directory, `touch flink/` is "Not a directory"), which is why
// this is separate from SLASH_KEEPS_LAST.
const SELF_RESOLVING = new Set([
'chmod',
'chown',
'chgrp',
'touch',
'ln',
'readlink',
'mkdir',
'getfattr',
'setfattr',
]);
/**
* What a line of a builtin does, read off its parsed flags by the command's
* own facts; a wrapper does what the program it runs does. Mirrors Python's
* `facts_of`.
*/
export function factsOf(name, bag, operands) {
const runs = EXEC_WRAPPERS.get(name)?.[0] ?? name;
const declared = declaredFacts(runs);
const facts = declared instanceof LineFacts ? declared : declared(new FlagView(bag, SPECS[runs]), operands);
if (SELF_RESOLVING.has(name))
return facts.with({ follows: Follow.NONE });
return facts;
}
/**
* What a command line does, read off its words by the command's own grammar
* and facts (`factsOf`), so an abbreviation, a long spelling and an option's
* attached value read as the handler will read them: `grep --recur` and
* `grep -d recurse` walk, `grep -ephrase` does not. A command that decides
* its operand's last component itself (`SELF_RESOLVING`) follows no link.
* An operand classified as a path carries whether it is a pattern still to
* expand; `cwd` is what a path option's value (`mv -t`) resolves against.
* Mirrors Python's `line_facts`.
*/
export function lineFacts(name, words, cwd) {
const [runs, first] = EXEC_WRAPPERS.get(name) ?? [name, undefined];
const spec = SPECS[runs];
if (spec === undefined || (runs !== 'ls' && declaredFacts(runs) instanceof LineFacts)) {
return factsOf(name, {}, []);
}
const typed = first === undefined ? words.slice(1) : [first, ...words.slice(1)];
const argv = typed.map((w) => (typeof w === 'string' ? w : w.rawPath));
const parsed = parseCommand(spec, argv, cwd, runs);
const operands = typed.filter((_, i) => parsed.wordRoles[i] === WordKind.OPERAND);
// The parse is keyed by the words, the cwd (which `mv -t` resolves in) and
// the grammar; a fact reads beyond it only the name it was typed as (a
// wrapper, a self-resolving command) and which operands are patterns,
// never a path's value. A word list typed with its patterns quoted every
// way keeps PARSES_KEPT readings, as a spec does.
const key = JSON.stringify([
name,
operands.map((w) => typeof w !== 'string' && w.pattern !== null),
]);
let kept = parsed.facts.get(key);
if (kept === undefined) {
kept = factsOf(name, parseToKwargs(parsed), operands);
if (parsed.facts.size >= PARSES_KEPT)
parsed.facts.clear();
parsed.facts.set(key, kept);
}
return kept;
}
// Options whose argument is a program, so the words after it are that
// program's argv rather than the command's own: python3's -c and -m
// (`python3 -c 'code' -u x` hands -u to the code). This is a table
// rather than a spec field on purpose. argparse cannot express it at
// all (`add_argument("-c")` beside `nargs=REMAINDER` answers
// `unrecognized arguments: -u`), and CPython parses its own command
// line in C for the same reason, so it is one command's rule and not
// part of the shared grammar.
const PROGRAM_OPTIONS = {
python: ['-c', '-m'],
python3: ['-c', '-m'],
};
/**
* Where a short cluster's program value ends, or null if it has none.
*
* Args:
* word: the option word, dash included.
* index: the word's position in the command's words.
* carriers: the spellings whose value is a program.
* valueSpellings: every short spelling that takes a value.
*/
function clusterHandoff(word, index, carriers, valueSpellings) {
for (let j = 1; j < word.length; j++) {
const letter = `-${word[j] ?? ''}`;
// Attached (`-cCODE`) carries its value in this word; the detached
// form takes the next one.
if (carriers.includes(letter))
return word.slice(j + 1) !== '' ? index + 1 : index + 2;
if (valueSpellings.includes(letter))
return null;
}
return null;
}
/**
* How many words a short cluster with no program value consumes.
*
* Args:
* word: the option word, dash included.
* valueSpellings: every short spelling that takes a value.
*/
function clusterWords(word, valueSpellings) {
for (let j = 1; j < word.length; j++) {
if (valueSpellings.includes(`-${word[j] ?? ''}`))
return word.slice(j + 1) !== '' ? 1 : 2;
}
return 1;
}
/**
* Insert POSIX's `--` after a program-carrying option's value.
*
* The handoff already has a spelling every parser honors, so the rule is
* applied by writing one down rather than by teaching the parser a new
* kind of option. The marker is inserted unconditionally, never skipped
* because the line already carries one: CPython stops parsing at -c and
* passes a later `--` through as data (`python3 -c p -- -u` gives the
* program `['-c', '--', '-u']`), so the parser must consume exactly the
* one added here and leave any typed one alone.
*
* Only the option run before the first operand is scanned. A -c after an
* operand belongs to the program (`python3 s.py -c x`), and the operand
* already ended option parsing by itself.
*
* The scan walks a short cluster letter by letter rather than matching
* the word's prefix, because the carrier need not be first: CPython
* reads `python3 -uc 'p' -v` and `python3 -uc'p' -v` the same way it
* reads the unclustered forms, handing `-v` to the program.
*/
export function endOptionsAfterProgram(name, words) {
const carriers = PROGRAM_OPTIONS[name];
if (carriers === undefined)
return words;
// Value spellings say which words carry a value, so `-W ignore -c p`
// steps over `ignore` instead of reading it as the first operand.
const spec = SPECS[name];
if (spec === undefined)
return words;
const compiled = compileSpec(spec);
let i = 0;
while (i < words.length) {
const word = words[i] ?? '';
if (word === '--')
return words;
// The first operand, which ended option parsing by itself.
if (!word.startsWith('-') || word === '-')
return words;
if (word.startsWith('--')) {
const attached = word.includes('=');
const longName = word.split('=', 1)[0] ?? word;
i += attached || !compiled.longValueSpellings.has(longName) ? 1 : 2;
continue;
}
const after = clusterHandoff(word, i, carriers, compiled.valueSpellings);
if (after === null) {
i += clusterWords(word, compiled.valueSpellings);
continue;
}
// Nothing to hand over, and a detached form with no value at all is
// the parser's refusal to report, not ours to turn into a program
// named `--`.
if (after >= words.length)
return words;
return [...words.slice(0, after), '--', ...words.slice(after)];
}
return words;
}
// bash 5.2's own builtins (`compgen -b` on debian:stable-slim). `type`
// calls one of these a shell builtin and `command -v` prints it bare,
// even where a program of the same name is on PATH too (echo, test,
// pwd); any other word mirage runs is a program, reported by its path.
export const BASH_BUILTINS = new Set([
'.',
':',
'[',
'alias',
'bg',
'bind',
'break',
'builtin',
'caller',
'cd',
'command',
'compgen',
'complete',
'compopt',
'continue',
'declare',
'dirs',
'disown',
'echo',
'enable',
'eval',
'exec',
'exit',
'export',
'false',
'fc',
'fg',
'getopts',
'hash',
'help',
'history',
'jobs',
'kill',
'let',
'local',
'logout',
'mapfile',
'popd',
'printf',
'pushd',
'pwd',
'read',
'readarray',
'readonly',
'return',
'set',
'shift',
'shopt',
'source',
'suspend',
'test',
'times',
'trap',
'true',
'type',
'typeset',
'ulimit',
'umask',
'unalias',
'unset',
'wait',
]);
// The builtins that are the shell's own, which no program loader can
// find: `find -exec` execs through execvp and sees nothing the shell
// defined, so these heads are `find: 'cd': No such file or directory`
// (findutils 4.10 on debian:stable-slim, where `kill` is bash's alone
// since procps is absent), while `echo`, `sh`, `xargs` or `python3` are
// programs there as well as builtins here.
export const SHELL_ONLY_BUILTINS = new Set([
'.',
':',
'alias',
'bg',
'break',
'cd',
'command',
'compgen',
'complete',
'continue',
'declare',
'disown',
'eval',
'exec',
'exit',
'export',
'fg',
'getopts',
'help',
'history',
'jobs',
'kill',
'let',
'local',
'mapfile',
'read',
'readarray',
'readonly',
'return',
'set',
'shift',
'shopt',
'source',
'trap',
'type',
'typeset',
'ulimit',
'umask',
'unalias',
'unset',
'wait',
]);
export const SHELL_NAMES = new Set([
...Object.values(ShellBuiltin),
...UNSUPPORTED_BUILTINS,
]);
//# sourceMappingURL=constants.js.map