@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
1,160 lines • 49.8 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 { sessionAccess } from "../../context/session_context.js";
import { PolicyDenied, postSessionGate, preSessionGate, } from "../../policy/index.js";
import { evaluateArith, plainDecimal } from "../../shell/arith.js";
import { arrayExtent, arrayGet, arrayHas, arrayValues, arrayWith, makeArray, } from "../../shell/array.js";
import { FUNCNAME, PIPESTATUS, RANDOM, RANDOM_MODULUS, RANDOM_UNSET, } from "../../shell/constants.js";
import { encodeText, joinBytes } from "../../shell/bytes.js";
import { ArithError, ExitSignal, ReadonlyVariableError } from "../../shell/errors.js";
import { varHidden } from "../../utils/hidden.js";
import { asyncContextIsolatesTasks } from "../../utils/async_context.js";
import { compareCodePoints } from "../../utils/sort.js";
import { draw, initialSeed } from "./rng.js";
import { ownRecord, sessionEntry, setSessionEntry } from "./session.js";
import { coerceValue, detach, makeVar, TempEnv, VarAttr, withAttr, withValue, } from "../../shell/variable.js";
/**
* The one copy-out of a session's environment.
*
* Every tier that hands the env onward as a process view (a command's or
* CLI's or a runtime's `ctx.env`, the `env` builtin) copies
* through here, so the hidden-vars filter lands on all of them by
* construction rather than on however many hand-rolled copies someone
* remembers. The copy keeps the null prototype session records carry.
*
* *Exported* names only, which is what makes this the process view
* rather than a second spelling of `visibleEnv`. bash puts a variable
* in a child's environment when it carries the export attribute, not
* when it happens to hold a string: `X=hello` is absent from `env` and
* `export Y=world` is present. An unset name carrying the attribute
* (`export Z`) is absent too, which falls out of the value check
* rather than needing its own arm.
*
* Diverges from bash on one point: bash also carries each function
* `export -f` marked, as a `BASH_FUNC_NAME%%` entry. mirage hands those to
* a nested shell directly (`SessionState.newShell`), so neither `env` nor a
* runtime lists them.
*/
export function envSnapshot(session) {
const out = ownRecord();
for (const [name, v] of Object.entries(session.vars)) {
if (typeof v.value === 'string' &&
v.attrs.has(VarAttr.Export) &&
!varHidden(session.visibility, name)) {
out[name] = v.value;
}
}
return out;
}
/**
* The name a `declare -n` reference points at, null otherwise. Null
* also for a reference declared but not yet aimed (`declare -n r`
* before `r=v`): bash treats the first assignment as naming the target,
* so until then it stands for nothing.
*/
export function namerefTarget(session, name) {
const v = sessionEntry(session.vars, name);
if (!v?.attrs.has(VarAttr.Nameref))
return null;
return typeof v.value === 'string' && v.value ? v.value : null;
}
/**
* The variable a name stands for, following `declare -n` chains. A name
* that is not a reference is its own answer. A chain that comes back to
* itself (`declare -n a=b; declare -n b=a`) is bash's circular name
* reference, read as unset: it resolves to the empty name, which no
* record has, so a reader sees unset and a writer falls back to the
* reference's own record. The warning line is the one part not
* reproduced.
*/
export function deref(session, name) {
let current = name;
const seen = new Set();
for (;;) {
const target = namerefTarget(session, current);
if (target === null)
return current;
if (seen.has(current))
return '';
seen.add(current);
current = target;
}
}
/** The variable's value, null when unset or hidden. Sync on purpose:
* `$X` expansion is the hot path, so a read stays a record lookup plus
* the hidden check. A name reference reads its target. */
export function envGet(session, name) {
const resolved = deref(session, name);
if (varHidden(session.visibility, resolved))
return null;
const v = sessionEntry(session.vars, resolved);
return v !== undefined && typeof v.value === 'string' ? v.value : null;
}
/**
* Whether `readonly` has marked the name.
*
* A hidden name answers false: isReadonly speaks about the session's
* visible world, and calling a name that reads as unset "readonly"
* would leak it. `followRef` asks about what a `declare -n` reference
* points at; a write to the reference itself (`declare -n r=w`,
* `unset -n r`) asks about the reference.
*/
export function envIsReadonly(session, name, followRef = true) {
const resolved = followRef ? deref(session, name) : name;
if (varHidden(session.visibility, resolved))
return false;
const v = sessionEntry(session.vars, resolved);
return v?.attrs.has(VarAttr.Readonly) ?? false;
}
/**
* The env mapping a reader tier should resolve names against.
*
* Always a filtered copy, never `session.env`: that getter is itself a
* projection built fresh per access, so handing it out would copy the
* store anyway and freeze the answer at that moment. TS diverges from
* python's lazy mapping view deliberately: expansion sites read records
* with plain property access, so a copy is the shape they already
* consume, and env sizes make the copy cost noise.
*
* The *shell* view, and no longer a synonym for `envSnapshot`: this is
* what `$X`, arithmetic, `[[ ]]`, `IFS` and the bare `set` listing
* resolve against, and they see every variable, exported or not. The
* two were the same function while the process view was also "every
* string", and reusing it once the process view narrowed would have
* stopped `$X` resolving a plain assignment. python kept them separate
* all along (`_VisibleEnv` beside `env_snapshot`); this is TS catching
* up to it.
*/
export function visibleEnv(session) {
const out = ownRecord();
for (const [name, v] of Object.entries(session.vars)) {
if (typeof v.value === 'string' && !varHidden(session.visibility, name)) {
out[name] = v.value;
}
}
return out;
}
/**
* The arrays mapping a reader tier should resolve names against.
*
* The arrays twin of `visibleEnv`: the embedder can seed
* `session.arrays` before narrowing, so a hidden name can hold an
* array and array reads need the same filter env reads get.
*/
export function visibleArrays(session) {
const out = ownRecord();
for (const [name, v] of Object.entries(session.vars)) {
if (Array.isArray(v.value) && !varHidden(session.visibility, name)) {
out[name] = v.value;
}
}
// PIPESTATUS and FUNCNAME are the session's records, never the store's:
// an assignment to either is ignored, as bash ignores one, because the
// record answers before the store.
if (!varHidden(session.visibility, PIPESTATUS)) {
out[PIPESTATUS] = session.pipeStatus.map((code) => String(code));
}
if (session.functionNames !== null && !varHidden(session.visibility, FUNCNAME)) {
out[FUNCNAME] = [...session.functionNames];
}
return out;
}
/**
* The associative arrays a reader tier should resolve names against.
*
* The third sibling beside `visibleEnv` and `visibleArrays`, for the
* same reason both exist: the embedder can seed a hidden name with any
* value shape, so every reader tier filters the same way.
*/
export function visibleAssocs(session) {
const out = ownRecord();
for (const [name, v] of Object.entries(session.vars)) {
if (v.value !== null &&
typeof v.value === 'object' &&
!Array.isArray(v.value) &&
!varHidden(session.visibility, name)) {
out[name] = v.value;
}
}
return out;
}
/**
* Write one variable through the session plane's gate.
*
* General over variable shapes: a string stores a scalar, a ShellArray
* stores a whole array, and the two storages stay exclusive. Semantics
* live here once — the hidden refusal, readonly refusal, the
* `preSession` policy gate (whose context value renders an array as
* its present elements joined by spaces), then the store — so every
* writer states them the same way whichever tier or spelling asked.
* Writers with richer mechanics (subscripts, appends, holes) compute
* the resulting value on a copy and hand it here, so a denial never
* leaves a half-applied write. Null policies gate nothing (a writer
* outside a workspace). Throws PolicyDenied when the name is hidden
* for this session (a landed write would clobber the real value the
* host's wiring still reads; a swallowed one would gaslight the
* writer — the vars twin of EACCES on a create into hidden path
* space), ReadonlyVariableError when the name is readonly, and
* PolicyDenied when a preSession policy refuses the write.
*/
/**
* Refuse a write that names a hidden variable.
*
* The sync half of `setVar`'s hidden gate, shared with the
* expansion-time writers that land on the raw env (`${X:=d}`,
* `$((X=5))`, `printf -v`): a landed write would clobber the real
* value the host's wiring still reads, and a swallowed one would
* gaslight the writer; refuse loudly instead, the vars twin of EACCES
* on a create into hidden path space.
*/
/**
* Remove one surrounding quote pair from an associative subscript.
*
* An arithmetic reference carries its subscript verbatim, so `m["x"]`
* arrives with the quotes bash would have removed; one layer comes off
* and anything else is the key itself.
*/
export function stripKeyQuotes(text) {
const first = text.charAt(0);
if (text.length >= 2 &&
first === text.charAt(text.length - 1) &&
(first === '"' || first === "'")) {
return text.slice(1, -1);
}
return text;
}
/**
* The `ElementOps` implementation bound to one session.
*
* It lives beside the other reader projections because the session view
* needs it too: the `-i` coercion evaluates `n=a[1]+1` at the write, and a
* resolver that imported the session view would close a cycle.
*/
class SessionElements {
session;
constructor(session) {
this.session = session;
}
isAssoc(name) {
return visibleAssocs(this.session)[name] !== undefined;
}
holdsArray(name) {
return this.isAssoc(name) || visibleArrays(this.session)[name] !== undefined;
}
/** `subscript` is an associative array's raw subscript text, or an indexed
* one's index, which the evaluator has already read as arithmetic. */
resolve(name, subscript) {
if (visibleAssocs(this.session)[name] !== undefined) {
return stripKeyQuotes(subscript);
}
let idx = Number(subscript);
if (idx < 0) {
const arr = visibleArrays(this.session)[name];
if (arr !== undefined)
idx += arrayExtent(arr);
else if (envGet(this.session, name) !== null)
idx += 1;
if (idx < 0)
throw new ArithError('bad array subscript', `${name}[${subscript}]`);
}
return String(idx);
}
read(name, key) {
const amap = visibleAssocs(this.session)[name];
if (amap !== undefined)
return amap[key] ?? null;
const arr = visibleArrays(this.session)[name];
const idx = Number(key);
if (arr === undefined) {
const scalar = envGet(this.session, name);
if (scalar === null)
return null;
return idx === 0 ? scalar : null;
}
return arrayHas(arr, idx) ? arrayGet(arr, idx) : null;
}
}
/** Element callbacks bound to one session, for `evaluateArith`. */
export function sessionElements(session) {
return new SessionElements(session);
}
/**
* Land arithmetic assignments in order, each as the whole variable it
* produces, so a refusal never leaves one half-applied. Each lands the way
* `assignElement` lands one: through a reference on its target, a bare name
* over an array at element 0 (`A=(old keep); n='A=9'` keeps `keep`), naming
* the element it assigns so an `-i` array never runs its other elements
* again (`A=(0 'x++'); declare -i A; (( A[0]=9 ))` leaves `x++`).
* Both of its contexts, a subscript and an `-i` value, end the shell on a
* readonly name (`declare -i n; ( n='R=3'; echo no )` ends only the
* subshell): ExitSignal.
*/
async function landWrites(session, store, writes) {
for (const write of writes) {
const name = deref(session, write.name) || write.name;
const assoc = visibleAssocs(session)[name];
const arr = visibleArrays(session)[name];
let value = write.value;
let assigned = null;
if (assoc !== undefined) {
const key = write.key ?? '0';
value = { ...assoc, [key]: write.value };
assigned = new Set([key]);
}
else if (write.key !== null || arr !== undefined) {
const index = write.key === null ? 0 : Number(write.key);
// A scalar becomes element 0, as `assignElement` turns it
// (`x=7; n='x[1]=5'` keeps the 7).
const scalar = arr === undefined ? conversionScalar(session, name) : undefined;
value = arrayWith(arr ?? makeArray(scalar === undefined ? [] : [scalar]), index, write.value);
assigned = new Set([index]);
}
try {
await store(name, value, true, assigned);
}
catch (err) {
if (err instanceof ReadonlyVariableError)
throw err.signal(true);
throw err;
}
}
}
/**
* An indexed subscript resolved outside an arithmetic expression:
* `${a[i]}`, `a[i]=v`, `unset 'a[i]'`, `[[ -v a[i] ]]`.
*
* The subscript is arithmetic, so it may assign (`a[x=3]`) and seed
* (`a[RANDOM=42]`), and bash binds those as it evaluates them. Each
* lands through the session view once the index is known, then the `RANDOM`
* reader replays the draws made after the seed. A subscript that fails
* to evaluate lands what it assigned before failing and then throws, the
* subscript text leading the message, since bash aborts the line on it
* (`${a[1/0]}` is `1/0: division by 0`) rather than reading element 0.
* `view` is the gated session view; null lands the writes ungated, outside a
* workspace. Throws what the session view throws too: a PolicyDenied, or an
* ArithError from a `-i` name refusing the value; a readonly name ends the
* shell wherever a subscript is (`${a[R=3]}`): ExitSignal.
*/
export async function subscriptIndex(session, subscript, view = null) {
const plain = plainDecimal(subscript);
if (plain !== null)
return Number(plain);
const reader = randomReader(session);
let idx = 0;
let writes;
let error = null;
try {
const result = sessionArith(session, subscript, reader, session.shellOptions.nounset === true);
idx = Number(result.value);
writes = result.writes;
}
catch (err) {
if (!(err instanceof ArithError || err instanceof ReadonlyVariableError))
throw err;
error = err;
writes = err.writes;
}
await landWrites(session, (name, value, followRef, assigned) => view !== null
? view.set(name, value, followRef, assigned)
: setVar(session, null, name, value, followRef, undefined, assigned), writes);
reader.settle();
if (error instanceof ReadonlyVariableError)
throw error.signal(true);
if (error !== null)
throw error;
return idx;
}
/**
* The `-i` coercion: evaluate the incoming text as arithmetic.
*
* Reads resolve against the visible env, so `n=x+1` sees `x`, and
* element references resolve through the session's resolver, so
* `n=a[1]+1` and `n=m[k]+1` see the element; an unresolvable name is 0
* (`n=abc` stores `0`), which is the arithmetic rule, not a refusal. A
* malformed expression throws ArithError with the offending text led,
* which is how every caller voices it (`bash: 1+: syntax error: operand
* expected`), so it is spelled once here rather than at each of the
* sites that catch it.
*/
/** Evaluate a host-supplied seed; invalid arithmetic propagates. Read
* without the generator on offer: a host word naming `RANDOM` would
* otherwise draw, and the draw reseed, without end. */
export function seedFrom(word, session) {
const value = evaluateArith(word, visibleEnv(session), sessionElements(session)).value;
const modulus = BigInt(RANDOM_MODULUS);
return Number(((value % modulus) + modulus) % modulus);
}
/** Draw from the session generator, or null after RANDOM is unset.
* Shell assignments validate and seed at the session view. A host-seeded
* variable is consumed here on its first read. Reseeding resets repeat
* suppression to zero independently of the stored word. */
export function nextRandom(session, stored) {
if (session.randomSeed === RANDOM_UNSET ||
(stored === undefined && session.randomSeed !== null)) {
return null;
}
let state;
let last;
const seed = stored !== undefined && stored !== session.randomSeed ? seedFrom(stored, session) : null;
if (seed !== null) {
state = seed;
last = 0;
}
else if (session.randomState === null) {
state = initialSeed(session.ids);
last = 0;
}
else {
state = session.randomState;
last = session.randomLast;
}
const [nextState, value] = draw(state, last);
state = nextState;
session.randomState = state;
session.randomLast = value;
const word = String(value);
const existing = session.vars[RANDOM];
session.vars[RANDOM] = existing !== undefined ? withValue(existing, word) : makeVar(word);
session.randomSeed = word;
return value;
}
/**
* Arithmetic's reads of `$RANDOM`, bound to one session.
*
* A read before the expression assigns `RANDOM` draws from the session
* generator. bash seeds at the instant of an assignment and every later
* read draws from the new seed (`$((RANDOM=42, RANDOM))` is the first
* draw after seeding with 42). Here the assignment is still pending at
* the session view, which lands it gated after evaluation, so the
* evaluator tells the reader of each assignment as it is made (`wrote`),
* the reader seeds a scratch generator the way the session view will and draws
* from that, and `settle` replays the draws on the session once the
* session view has seeded it: the session ends where bash's does, seeded and
* advanced by every read since the last assignment, and the write still
* reaches the gate as the assignment it is. Each assignment restarts
* the scratch generator and the count, since the session view lands only the
* last value written, and the draws are replayed only if the session view did
* land it: an assignment the caller never applied leaves the session as
* it was.
*
* Lives beside the session view rather than with the generator because the
* session view needs it too: `RANDOM=RANDOM` draws once while the seed is
* evaluated, then seeds with the draw, as bash does: it reads an
* assigned seed as an arithmetic expression.
*/
export class RandomReader {
session;
seeded = null;
state = 0;
last = 0;
draws = 0;
constructor(session) {
this.session = session;
}
special(name) {
const session = this.session;
return (name === RANDOM && !varHidden(session.visibility, name) && session.randomSeed !== RANDOM_UNSET);
}
/** The dynamic value of a name, null for a name that has none. */
read = (name) => {
if (!this.special(name))
return null;
if (this.seeded === null) {
const value = nextRandom(this.session, visibleEnv(this.session)[name]);
return value === null ? null : String(value);
}
const [state, value] = draw(this.state, this.last);
this.state = state;
this.last = value;
this.draws += 1;
return String(value);
};
/** Note an assignment the expression made: the name and its value, an
* integer's text. */
wrote = (name, value) => {
if (!this.special(name))
return;
this.seeded = value;
const modulus = BigInt(RANDOM_MODULUS);
this.state = Number(((BigInt(value) % modulus) + modulus) % modulus);
this.last = 0;
this.draws = 0;
};
/** Replay the scratch draws on the session generator, once the session view
* has seeded it with the value the expression assigned. */
settle() {
if (this.seeded === null || this.session.randomSeed !== this.seeded)
return;
for (let i = 0; i < this.draws; i++) {
nextRandom(this.session, visibleEnv(this.session)[RANDOM]);
}
this.draws = 0;
}
}
/**
* End `RANDOM`'s special meaning when a non-string lands on it.
*
* Once bash turns `RANDOM` into an array it neither draws nor seeds,
* so `RANDOM=(1 2)`, `declare -a RANDOM`, `RANDOM[1]=5` and
* `RANDOM+=(3)` all leave an ordinary array that `$RANDOM` reads element
* 0 of, for good, as `unset RANDOM` does. Every store entry point calls this,
* gated or not, since a host seeding an array onto the name means the
* same thing.
*/
export function noteRandomKind(session, name, value) {
if (name === RANDOM && typeof value !== 'string')
session.randomSeed = RANDOM_UNSET;
}
/**
* The scalar an array conversion keeps as element 0.
*
* When bash turns a variable into an array, its current value becomes
* element 0, and for a live `RANDOM` looking the name up is what draws:
* `RANDOM[1]=5` leaves `[0]` holding one draw and `declare -a RANDOM` one
* alone, after which the array is ordinary.
*/
export function conversionScalar(session, name) {
if (name === RANDOM) {
const drawn = nextRandom(session, visibleEnv(session)[RANDOM]);
if (drawn !== null)
return String(drawn);
}
return session.env[name];
}
/** Bind arithmetic `$RANDOM` reads to a session. */
export function randomReader(session) {
return new RandomReader(session);
}
/**
* Evaluate `text` as every arithmetic context of the shell does: against
* the visible env and the session's elements, drawing through `reader`, and
* stopping at a write to a readonly name, as bash's evaluation does
* (`(( X=5, R=3 ))` binds X and refuses R). Throws ArithError when the text
* does not evaluate, and ReadonlyVariableError, carrying the writes made before it,
* for a readonly name. `added` is an integer `+=`'s added text, read after
* `text` in the same evaluation and added to it.
*/
export function sessionArith(session, text, reader, nounset = false, added = null) {
return evaluateArith(text, visibleEnv(session), sessionElements(session), reader.read, reader.wrote, nounset, (name) => readonlyTarget(session, name), added);
}
/**
* The readonly variable a write to `name` reaches, through a `declare -n`
* reference, which the refusal names; null when the write lands. Mirrors
* Python's _readonly_target.
*/
function readonlyTarget(session, name) {
const target = deref(session, name);
return envIsReadonly(session, target, false) ? target : null;
}
/**
* The `-i` coercion and the `RANDOM` seed, as one evaluation. The
* incoming text evaluates as arithmetic against the visible env, element
* references resolving through the session's resolver, so `n=x+1` sees
* `x` and `n=a[1]+1` the element; an unresolvable name is 0 (`n=abc`
* stores `0`), the arithmetic rule, not a refusal. `RANDOM` draws, as in
* every other arithmetic context, so `n=RANDOM` and a `RANDOM=RANDOM`
* seed both advance the generator. The assignments the expression makes
* are kept for the session view to land (`landCoercion`): bash binds `x` in
* `n='x=5'` and in `RANDOM='x=5'`, before the error too if the expression
* then fails. A malformed expression throws ArithError with the
* offending text leading, the way every caller voices it; a write to a
* readonly name ends the shell (ExitSignal), as bash's coercion does, where
* a seed (`evaluate`) reports it the way it reports a malformed one.
*/
class IntegerCoercion {
session;
reader;
writes = [];
constructor(session) {
this.session = session;
this.reader = randomReader(session);
}
run = (text, added = null) => {
try {
return this.evaluate(text, added);
}
catch (err) {
if (err instanceof ReadonlyVariableError)
throw err.signal(true);
throw err;
}
};
/**
* The value `text` evaluates to, keeping the writes it made before an
* ArithError or a ReadonlyVariableError. With `added`, the two sides of an
* integer `+=` (`appended`) evaluate in turn in one evaluation and add:
* the second sees what the first assigned, and an error names the side
* that made it, as bash's does (`N+=1+` is `1+: syntax error`).
*/
evaluate(text, added = null) {
const session = this.session;
// Inside a `declare -g` the expression still reads the function's
// scope, as bash's does (`local H=2; declare -gi G=H` stores 2), while
// the value lands on the global.
const reachAgain = stepBack(session);
let result;
try {
result = sessionArith(session, text, this.reader, false, added);
}
catch (err) {
if (err instanceof ArithError || err instanceof ReadonlyVariableError) {
for (const item of err.writes)
this.writes.push(item);
}
throw err;
}
finally {
reachAgain();
}
for (const item of result.writes)
this.writes.push(item);
return result.value.toString();
}
}
/**
* Land the assignments a coercion made, each through the session view, in the scope
* it read, then settle its `RANDOM` draws. Inside a `declare -g` that is the
* function's: `local G=3; declare -gi G='G=G+10'` leaves the local at 13 and
* stores 13 globally, as bash's does (`stepBack`).
*/
async function landCoercion(session, store, coercion) {
const reachAgain = stepBack(session);
try {
await landWrites(session, store, coercion.writes);
}
finally {
reachAgain();
}
coercion.reader.settle();
}
/**
* Evaluate `text` as an `-i` write coerces it, land what it assigns through
* `view`, and give back the value: a `declare -ni r=M` value, which bash
* evaluates before refusing the reference (`M='X=5'` sets X), and its
* `r+=M` form, whose `added` text evaluates after `text` in the same
* evaluation and adds to it. Inside a `declare -g` it reads the
* function's scope, as the coercion does. A hidden name throws
* PolicyDenied and a readonly one ExitSignal, which ends the shell as
* bash's does, the assignments before it landed; a malformed text throws
* ArithError once the ones before the error land.
*/
export async function evaluateInteger(session, view, text, added = null) {
const coercion = new IntegerCoercion(session);
try {
return coercion.run(text, added);
}
finally {
await landCoercion(session, (name, value, followRef, assigned) => view.set(name, value, followRef, assigned), coercion);
}
}
/**
* What a `+=` hands `setVar`: the held text then the added one, or on an
* integer the held text with the added one as `added`, the two evaluating
* there in turn and summing behind the store's refusals. The held value
* evaluates too, so `n='x=5'; declare -i n; n+=x` stores 10, and an empty
* side counts as 0. An array extends element 0 and a map key `"0"`
* (`S=x; declare -a S+=y` gives `([0]="xy")`), and `held` is null when unset.
*/
export function appended(held, added, integer) {
const text = typeof held === 'string' ? held : Array.isArray(held) ? arrayGet(held, 0) : (held?.['0'] ?? '');
return integer ? [text, added] : [joinBytes(text + added), null];
}
export function ensureVarVisible(session, name) {
if (varHidden(session.visibility, name)) {
throw new PolicyDenied(`${name}: permission denied`, name);
}
}
async function setVar(session, policies, name, value, followRef = true, diagnostics, assigned = null, added = null) {
if (followRef)
name = deref(session, name) || name;
ensureVarVisible(session, name);
if (envIsReadonly(session, name, false)) {
throw new ReadonlyVariableError(name);
}
// Attributes belong to the name, not to the value, so a plain
// assignment keeps them: `declare -i n; n=3` stays an integer. The old
// two-container store had to remember to evict the name from whichever
// container it was not landing in; one record cannot disagree with
// itself that way.
const existing = sessionEntry(session.vars, name);
// The value-shaping attributes (`-i -l -u`) apply here, at the write,
// which is where bash applies them: `declare -l s; s=ABC` stores `abc`,
// so every reader agrees without per-read work. `-i` evaluates against
// the visible env, and a bad expression throws the arithmetic error
// as bash does. Coercion runs before the gate so a rule judges the
// value that will land: `declare -l profile; profile=ADMIN` stores `admin`,
// and a rule refusing `admin` must see that, not the raw text.
const coercion = new IntegerCoercion(session);
const store = (name, value, followRef, assigned) => setVar(session, policies, name, value, followRef, diagnostics, assigned);
let shaped = value;
if (existing !== undefined && existing.attrs.size > 0) {
try {
shaped = coerceValue(value, existing.attrs, (text) => coercion.run(text, added), assigned);
}
catch (err) {
// bash bound what the expression assigned before it failed
// (`declare -i n; x='y=5,1/0'; n=x` leaves y at 5, and a RANDOM
// seed in it seeds); they land, gated, before the refusal reports.
if (err instanceof ArithError || err instanceof ExitSignal) {
await landCoercion(session, store, coercion);
}
throw err;
}
}
const written = {
verb: 'set',
key: name,
value: gateRendering(shaped),
sessionId: session.sessionId,
};
await preSessionGate(policies, written);
const generator = [session.randomState, session.randomSeed, session.randomLast];
if (name === RANDOM && session.randomSeed !== RANDOM_UNSET && typeof shaped === 'string') {
// A seed that fails or writes a readonly name seeds nothing: its
// earlier writes land and the error is reported, but the line goes on,
// unless the write was in a subscript.
try {
const value = BigInt(coercion.evaluate(shaped));
const modulus = BigInt(RANDOM_MODULUS);
session.randomState = Number(((value % modulus) + modulus) % modulus);
}
catch (err) {
if (err instanceof ExitSignal)
await landCoercion(session, store, coercion);
if (!(err instanceof ArithError || err instanceof ReadonlyVariableError))
throw err;
await landCoercion(session, store, coercion);
if (err instanceof ReadonlyVariableError && (err.inSubscript || diagnostics === undefined)) {
throw err.signal();
}
if (diagnostics === undefined)
throw err;
diagnostics.push(err.message);
return;
}
session.randomSeed = shaped;
session.randomLast = 0;
}
noteRandomKind(session, name, shaped);
// The assignments the coercion or the seed made land now, gated each,
// before the name they were made for.
await landCoercion(session, store, coercion);
// A reference cannot hold an array: one landing on an unaimed
// `declare -n` record drops the mark (`withValue`) and bash says so
// (`declare -n r; r=(x)`). A declaration that named the kind (`-a`,
// `-A`) took the mark off before writing, silently, as bash does.
if (diagnostics !== undefined &&
existing?.attrs.has(VarAttr.Nameref) === true &&
typeof shaped === 'object') {
diagnostics.push(encodeText(`bash: warning: ${name}: removing nameref attribute\n`));
}
let stored = existing === undefined ? makeVar(shaped) : withValue(existing, shaped);
// An agent write to a managed name shadows session-locally: the
// pointer drops and the record becomes a plain variable for this
// session only. Only the host-tier fill step writes pointer-keeping
// records, and it goes directly into `session.vars`, not here.
if (stored.managed !== undefined)
stored = detach(stored);
// `set -a` marks every name assigned *while it is on*, which is why
// it is read here at write time rather than applied to the session in
// bulk when the option flips: `B=1; set -a; C=2; set +a; D=3` exports
// only C.
if (session.shellOptions.allexport === true) {
stored = withAttr(stored, VarAttr.Export);
}
setSessionEntry(session.vars, name, stored);
try {
await postSessionGate(policies, written, existing == null ? null : gateRendering(existing.value));
}
catch (err) {
// Whatever stops the check, a refusal or a host that failed to
// answer, the write does not stand, and neither does a seed.
place(session, name, existing ?? null);
[session.randomState, session.randomSeed, session.randomLast] = generator;
throw err;
}
}
/**
* Drop one variable through the session plane's gate; a missing name
* is quiet. A hidden name is a quiet no-op that writes nothing:
* hidden reads as unset, bash's unset of a missing name is quiet, and
* popping the real value would let a session mutate state it cannot
* see. Throws ReadonlyVariableError when the name is readonly,
* PolicyDenied when a preSession policy refuses the write.
*/
async function unsetVar(session, policies, name, followRef = true) {
if (followRef)
name = deref(session, name) || name;
if (varHidden(session.visibility, name))
return;
if (envIsReadonly(session, name, false)) {
throw new ReadonlyVariableError(name);
}
const written = {
verb: 'unset',
key: name,
value: null,
sessionId: session.sessionId,
};
await preSessionGate(policies, written);
const old = sessionEntry(session.vars, name) ?? null;
const frame = shadowingFrame(session, name);
drop(session, name);
try {
await postSessionGate(policies, written, old === null ? null : gateRendering(old.value));
}
catch (err) {
// The unset revealed what an enclosing scope saved; that value goes
// back to the scope before the name's own record returns.
if (frame !== undefined && !frame.has(name)) {
frame.set(name, sessionEntry(session.vars, name) ?? null);
}
place(session, name, old);
throw err;
}
// bash: unsetting RANDOM strips its special meaning for good.
if (name === RANDOM)
session.randomSeed = RANDOM_UNSET;
}
function place(session, name, v) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
if (v === null)
delete session.vars[name];
else
setSessionEntry(session.vars, name, v);
}
/**
* Put each name's global record in place for a `declare -g`, and return the
* call that puts the running locals back. Outside a function, or for a name
* no frame on the call path shadows, the global record is already in place.
* Otherwise the running local lives in `session.vars` and the global is
* what the *outermost* shadowing frame saved, so the two swap for the
* declaration: its writes, marks and kind checks reach the global, and the
* local comes back untouched, which is what GNU shows (`local G=5; declare
* -gr G=1` leaves `$G` at 5 and writable in the function, 1 and frozen
* outside, and a nested `declare -g` reaches past the caller's local too).
* Arithmetic it runs still reads the locals (`stepBack`).
*/
export function reachGlobal(session, names) {
const swapped = [];
for (const name of new Set(names)) {
const outer = session.localFrames.find((frame) => frame.has(name));
if (outer === undefined)
continue;
swapped.push([name, outer, sessionEntry(session.vars, name) ?? null]);
place(session, name, outer.get(name) ?? null);
}
session.reached = swapped;
return () => {
for (const [name, outer, running] of session.reached) {
outer.set(name, sessionEntry(session.vars, name) ?? null);
place(session, name, running);
}
session.reached = [];
};
}
/**
* Put the locals a running `declare -g` set aside back in place for one
* arithmetic evaluation or the writes it made, and return the call that
* reaches the globals again, keeping what those writes gave the locals.
*/
function stepBack(session) {
const reached = session.reached.map(([name]) => [name, sessionEntry(session.vars, name) ?? null]);
for (const [name, , running] of session.reached)
place(session, name, running);
return () => {
session.reached = session.reached.map(([name, outer]) => [
name,
outer,
sessionEntry(session.vars, name) ?? null,
]);
for (const [name, v] of reached)
place(session, name, v);
};
}
/** The innermost scope on the call path that saved `name`. */
function shadowingFrame(session, name) {
for (let i = session.localFrames.length - 1; i >= 0; i--) {
const frame = session.localFrames[i];
if (frame?.has(name) === true)
return frame;
}
return undefined;
}
/**
* Remove a variable as bash's `unset` does.
*
* A name the running function made local stays unset until it returns.
* A name an enclosing scope shadows, a caller's `local` or the temporary
* environment of `x=1 f`, is that scope's to lose: the unset reveals the
* value it saved, and the name holds that value from then on (GNU:
* `x=old; x=pre f` where f runs `unset x` reads `old` inside f and after
* it).
*/
function drop(session, name) {
const frame = shadowingFrame(session, name);
const saved = frame?.get(name) ?? null;
if (frame !== undefined && frame !== session.localVars && name !== RANDOM) {
frame.delete(name);
if (saved !== null) {
setSessionEntry(session.vars, name, saved);
return;
}
}
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete session.vars[name];
}
/**
* Let a temporary-environment variable outlive its call.
*
* bash keeps a name that `x=1 f` put in front of a function once
* something inside runs `export x` or `readonly x`: x still holds its
* value after f returns, where otherwise the caller's comes back.
*/
export function outliveCall(session, name) {
const frame = shadowingFrame(session, name);
if (frame instanceof TempEnv)
frame.delete(name);
}
/**
* Whether the running function's call assigned `name` in front.
*
* `x=1 f` puts `x` in f's temporary environment, which sits right under
* f's own frame of locals.
*/
export function inCallEnv(session, name) {
const below = session.localFrames[session.localFrames.length - 2];
return below instanceof TempEnv && below.has(name);
}
/**
* The positional parameters in scope.
*
* Inside a function they are the function's own, even when it was called
* with none: bash's `f` run bare sees `$#` as 0, never its caller's
* count. Outside every function they are the shell's.
*/
export function positionalParams(session, callStack) {
if (callStack !== null && callStack.depth > 1)
return callStack.getAllPositional();
return session.positionalArgs;
}
/**
* Replace the positional parameters in scope.
*
* `set --` and `shift` inside a function change the function's own and
* leave the caller's alone, as bash's do.
*/
export function setPositionalParams(session, callStack, values) {
if (callStack !== null && callStack.depth > 1)
callStack.setPositional(values);
else
session.positionalArgs = values;
}
/**
* Record the caller's record before a `local` shadows it, once per frame.
*
* `RANDOM` parks its generator marker too: a local `RANDOM` is an ordinary
* variable for the function's extent (`local RANDOM=5; echo $RANDOM`
* prints 5, and `local RANDOM=(7)` leaves the caller's generator alone),
* and `restoreLocals` hands the marker back.
*/
export function shadowLocal(session, locals, name) {
if (locals.has(name))
return;
locals.set(name, sessionEntry(session.vars, name) ?? null);
if (name === RANDOM) {
session.localRandom.push(session.randomSeed);
session.randomSeed = RANDOM_UNSET;
}
}
/**
* Put a returning function's shadowed records back.
*
* Deliberate divergence: bash reseeds the global generator when a local
* `RANDOM` is popped (`RANDOM=42; f(){ local RANDOM; }; f; echo $RANDOM`
* prints 11074 where 17772 was next); mirage resumes the caller's
* sequence where it left off.
*/
export function restoreLocals(session, locals) {
for (const [key, old] of locals) {
if (old === null) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete session.vars[key];
}
else {
setSessionEntry(session.vars, key, old);
}
}
if (locals.has(RANDOM))
session.randomSeed = session.localRandom.pop() ?? null;
}
/**
* The session plane's view: five facts bound to one session.
*
* The one constructor every tier uses — builtins, the command
* dispatcher, a bare unit test — so the gate cannot be skipped by
* picking a different entry point. The view is the whole capability: it
* carries no handle back to the raw session.
*/
/**
* Write a variable without consulting the gate.
*
* For seeding a session before it is handed out -- the embedder
* populating an environment, a test arranging state. `visibleArrays`
* already names this case ("the embedder can seed session.arrays before
* narrowing"). Anything reached from a command line goes through
* `SessionView.set` instead, which is the whole point of the store being
* read-only from outside.
*/
export function seedVar(session, name, value) {
const existing = sessionEntry(session.vars, name);
setSessionEntry(session.vars, name, existing === undefined ? makeVar(value) : withValue(existing, value));
noteRandomKind(session, name, value);
}
/**
* Turn one attribute on or off, creating the name if needed.
*
* bash's `readonly NAME` / `export NAME` on a name that does not exist
* yet marks it anyway, and the name stays *unset*: GNU prints
* `declare -r ONLY` with no value and `${ONLY-d}` still expands to `d`.
* So the record is created with no value, not with an empty string.
*
* A null attribute changes no attribute and only ensures the name
* exists, which is what a bare `local L` / `declare D` does: GNU answers
* `declare -- L` and `${L-d}` still expands to `d`, so those two cannot
* route through a value writer either.
*/
export function setAttr(session, name, attr, on = true) {
const existing = sessionEntry(session.vars, name) ?? makeVar();
setSessionEntry(session.vars, name, attr === null ? existing : withAttr(existing, attr, on));
}
/**
* Turn one attribute on or off through the session plane's gate.
*
* The no-value writer beside `setVar`. `export NAME`, `readonly NAME`
* and a bare `local NAME` on a fresh name write no value at all -- the
* name stays unset and merely declared -- so routing them through
* `setVar` would have to invent one, and inventing `''` is exactly the
* divergence that made `export Z` show up in `env` and `${L-d}` stop
* expanding to `d`. A null attribute declares the name and changes no
* attribute.
*
* Gated all the same, because a mark is still a session write: a
* hidden name refuses, and `preSession` sees it with a null value,
* which is how a rule tells a mark from an assignment if it cares.
* Skipping the gate here would let a line the agent types put an
* attribute on a name the deployment refused it.
*/
async function markVar(session, policies, name, attr, on, followRef = true) {
// `readonly r` and `export r` on a reference mark what it points at,
// and `declare -rn r` the reference itself (`followRef` false); the
// nameref attribute always belongs to the reference's own record.
if (followRef && attr !== VarAttr.Nameref)
name = deref(session, name) || name;
ensureVarVisible(session, name);
const written = {
verb: 'set',
key: name,
value: null,
sessionId: session.sessionId,
};
await preSessionGate(policies, written);
const old = sessionEntry(session.vars, name) ?? null;
const seed = session.randomSeed;
setAttr(session, name, attr, on);
try {
await postSessionGate(policies, written, old === null ? null : gateRendering(old.value));
}
catch (err) {
place(session, name, old);
session.randomSeed = seed;
throw err;
}
}
/**
* A session write as one call, so a ONCE answer to its preSession question
* stands until its postSession one is answered too. Without task isolation
* a call cannot be told from another task's, so there the write is its
* own. Mirrors Python's `_one_call`.
*/
function oneCall(session, policies, write) {
return policies === null || !asyncContextIsolatesTasks
? write()
: policies.decisions.withinCall(session.sessionId, write);
}
export function sessionView(session, policies = null, diagnostics) {
return {
get: (name) => envGet(session, name),
snapshot: () => envSnapshot(session),
set: (name, value, followRef = true, assigned = null, added = null) => oneCall(session, policies, () => setVar(session, policies, name, value, followRef, diagnostics, assigned, added)),
unset: (name, followRef = true) => oneCall(session, policies, () => unsetVar(session, policies, name, followRef)),
mark: (name, attr, on, followRef = true) => oneCall(session, policies, () => markVar(session, policies, name, attr, on, followRef)),
isReadonly: (name, followRef = true) => envIsReadonly(session, name, followRef),
profile: () => session.profile,
cwd: () => session.cwd,
sessionId: session.sessionId,
access: () => sessionAccess(session),
};
}
// The names the shell maintains itself (`seedVar`'s second caller): a `cd`
// writes the first two and `[[ =~ ]]` the third, ungated, because they are
// the shell's to keep current rather than the session's to admit. Mirrors
// Python `SHELL_BOOKKEEPING`.
export const SHELL_BOOKKEEPING = new Set(['PWD', 'OLDPWD', 'BASH_REMATCH']);
/**
* The value a `preSession` method is shown for one variable: a scalar as
* itself, an indexed array as its present elements joined by spaces, an
* associative one in sorted-key order, and null for a variable that is
* declared but unset. One rendering, so a rule reads the same text whether
* the write came from a typed line or a restore. Mirrors Python
* `gate_rendering`.
*/
export function gateRendering(value) {
if (value === null)
return null;
if (typeof value === 'string')
return value;
if (Array.isArray(value))
return arrayValues(value).join(' ');
return Object.keys(value)
.sort(compareCodePoints)
.map((k) => value[k])
.join(' ');
}
/**
* Vet a variable table a snapshot restores through the session gate.
*
* A snapshot is the one env input the deployment did not author, so the
* `preSession` rule that refuses a name on a typed line has to see the
* restore too. Every restored variable fires the gate as a `set` of its
* rendered value before any of them lands, and a refusal aborts the load
* with the `PolicyDenied` a live `export` of that name reports, rather
* than dropping the one variable: a partial restore is a workspace whose
* state matches no snapshot. The shell's own bookkeeping
* (`SHELL_BOOKKEEPING`) is exempt here as it is live. Null policies gate
* nothing. Mirrors Python `gate_restored_vars`.
*/
export async function gateRestoredVars(policies, sessionId, table) {
for (const [name, variable] of Object.entries(table)) {
if (SHELL_BOOKKEEPING.has(name))
continue;
await preSessionGate(policies, {
verb: 'set',
key: name,
value: gateRendering(variable.value),
sessionId,
});
}
}
//# sourceMappingURL=state.js.map