@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
918 lines • 43.5 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 { VersionHistory } from "../../cache/versions.js";
import { releaseInputs } from "../../io/async_line_iterator.js";
import { commandLimitsToJSON, parseCommandLimits } from "../../policy/builtin/output_cap.js";
import { DEFAULT_PROCESS_PERMISSIONS, parseProcessPermissions, } from "../../process/config.js";
import { EnvVar } from "../../secrets/config.js";
import { Terminal } from "../../shell/console/index.js";
import { BIN_PREFIX, IFS_DEFAULT, RANDOM, RANDOM_UNSET, SHELL_ARGV0, } from "../../shell/constants.js";
import { attrsFromLetters, copyVar, makeVar, storedAttrs, VarAttr } from "../../shell/variable.js";
import { DEFAULT_VISIBILITY } from "../../types.js";
import { SystemIdSource } from "../../utils/ids.js";
import { compareCodePoints } from "../../utils/sort.js";
import { STARTUP_VALUES } from "./constants.js";
import { functionSources } from "./functions.js";
import { commandsFromDict, commandsToDict, decisionFromDict, decisionToDict, scriptsFromDict, scriptsToDict, } from "./serialize.js";
/**
* Read one entry of a session record, ignoring anything inherited from
* `Object.prototype`. Shell names are script-controlled, so a plain
* `record[name]` lookup would hand back `Object.prototype` (or one of
* its methods) for a name like `__proto__` or `toString`; Python's dicts
* have no such shadow, so this guard is the TypeScript side only.
*/
export function sessionEntry(record, name) {
return Object.hasOwn(record, name) ? record[name] : undefined;
}
/**
* Write one entry of a session record as an own property. A plain
* `record[name] = value` on the name `__proto__` runs the inherited
* setter instead: it silently drops a string value and rewrites the
* record's prototype for an array one.
*/
export function setSessionEntry(record, name, value) {
Object.defineProperty(record, name, {
value,
writable: true,
enumerable: true,
configurable: true,
});
}
/**
* A copy of `record` with a null prototype. Session records (env,
* functions, arrays) hold script-controlled names, so they must not
* inherit from `Object.prototype`: on a plain object, reading a name
* like `toString` hands back an inherited function and assigning
* `__proto__` runs the inherited setter instead of storing the value.
* With no prototype, every name is an ordinary key. Python's dicts
* need no equivalent.
*/
export function ownRecord(record) {
return Object.assign(Object.create(null), record);
}
/**
* Variable records for a plain name/value map. The one conversion from
* the shape an embedder speaks (a process environment) to the shape the
* session stores.
*
* Every seeded name is exported, because a process environment is by
* definition the exported set: these are the names the embedder means a
* child runtime to inherit, and `envSnapshot` hands on only what carries
* the attribute. Seeding them plain would leave them visible to `$X` and
* invisible to every runtime, which is not what an embedder passing an
* env record is asking for.
*/
export function varsFromEnv(env) {
const out = ownRecord();
const exported = new Set([VarAttr.Export]);
for (const [name, value] of Object.entries(env))
out[name] = makeVar(value, exported);
return out;
}
/**
* Variable records for a stored session's two halves, the restore side
* of `toJSON`. `env` carries every scalar and `attrs` the letter cluster
* for the names that have one, so a name in `attrs` alone is bash's
* declared-but-unset third state (`export Z`) and restores with no value.
*
* Not `varsFromEnv`: that one reads a bare record as a *process*
* environment and exports all of it, which is right for an embedder
* handing over an env record and wrong here, where the attributes were
* recorded. Restoring through it promoted every plain `X=hello` to an
* exported one on the first reload.
*/
export function varsFromDict(env, attrs) {
const out = ownRecord();
for (const [name, value] of Object.entries(env)) {
out[name] = makeVar(value, attrsFromLetters(attrs[name] ?? ''));
}
for (const [name, letters] of Object.entries(attrs)) {
if (!(name in out))
out[name] = makeVar(null, attrsFromLetters(letters));
}
return out;
}
/**
* Variable records for a workspace env block.
*
* The declaration side of the env plane: a bare string is the literal
* short form (exported, like `varsFromEnv`), a mapping is coerced
* through `EnvVar`, and a managed entry becomes bash's third
* state -- exported, unset -- carrying the pointer as `ManagedRef`.
* After this translation the session vars are the only truth the fill
* step reads.
*/
export function varsFromEntries(entries) {
const out = ownRecord();
for (const [name, rawEntry] of Object.entries(entries)) {
const entry = EnvVar.parse(typeof rawEntry === 'string' ? { value: rawEntry } : rawEntry);
const attrs = new Set();
if (entry.from !== undefined) {
const managed = {
source: entry.from,
ref: entry.ref,
key: entry.key ?? name,
eager: entry.fetch === 'eager',
};
attrs.add(VarAttr.Export);
if (entry.readonly)
attrs.add(VarAttr.Readonly);
out[name] = { value: null, attrs, managed };
continue;
}
if (entry.export)
attrs.add(VarAttr.Export);
if (entry.readonly)
attrs.add(VarAttr.Readonly);
out[name] = makeVar(entry.value ?? null, attrs);
}
return out;
}
/**
* The stored shape of a bare variable table.
*
* The three keys a stored session writes (`toJSON`): `env` holds the
* plain scalars, `var_attrs` the letter clusters, `managed` the
* pointers -- and a managed name serializes as its pointer, never its
* value, the same rule the session codec states. This exists for the
* workspace env template, a variable table with no session around it,
* so a snapshot or copy can carry the declaration.
*/
export function varsToFields(table) {
const managed = ownRecord();
const env = ownRecord();
const letters = ownRecord();
for (const [name, v] of Object.entries(table)) {
if (v.managed !== undefined)
managed[name] = v.managed;
if (v.attrs.size > 0)
letters[name] = storedAttrs(v);
}
for (const [name, v] of Object.entries(table)) {
if (typeof v.value === 'string' && !Object.hasOwn(managed, name))
env[name] = v.value;
}
const fields = { env, var_attrs: letters };
if (Object.keys(managed).length > 0) {
const refs = ownRecord();
for (const [name, ref] of Object.entries(managed)) {
const entry = {
from: ref.source,
ref: ref.ref,
key: ref.key,
};
if (ref.eager)
entry.fetch = 'eager';
refs[name] = entry;
}
fields.managed = refs;
}
return fields;
}
/**
* The variable table a `varsToFields` payload restores.
*
* `varsFromDict` reads the two plain halves; each managed name then
* restores declared-but-unfetched, its value forced back to null so a
* payload that smuggles one in is discarded rather than trusted --
* exactly how `fromJSON` restores a stored session's vars.
*/
export function varsFromFields(data) {
return restoredVars(data.env ?? {}, data.var_attrs ?? {}, data.managed);
}
/**
* The restore side of `toJSON`'s three env keys. A managed name
* restores declared-but-unfetched: the value is forced back to null,
* because a stored session must never carry the plaintext, so one a
* tampered payload smuggles into `env` is discarded rather than
* trusted.
*/
function restoredVars(env, attrs, managed) {
const out = attrs === undefined ? varsFromEnv(env) : varsFromDict(env, attrs);
for (const [name, m] of Object.entries(managed ?? {})) {
const base = sessionEntry(out, name) ?? makeVar(null, new Set([VarAttr.Export]));
setSessionEntry(out, name, {
value: null,
attrs: base.attrs,
managed: { source: m.from, ref: m.ref, key: m.key, eager: m.fetch === 'eager' },
});
}
return out;
}
/** Copy a variable store deeply enough that a child cannot write back. */
/** Copy locals; temporary call environments become ordinary saved scopes in the child. */
function copyLocals(frame) {
const copied = new Map();
for (const [name, variable] of frame)
copied.set(name, variable === null ? null : copyVar(variable));
return copied;
}
function copyVars(vars) {
const out = ownRecord();
for (const [name, v] of Object.entries(vars)) {
// The record is frozen, but an indexed or associative value is a
// live container, so the copy has to reach inside it.
out[name] = copyVar(v);
}
return out;
}
export class SessionState {
versions = new VersionHistory();
sessionId;
cwd;
// The spelling `cd` arrived at: `..` simplified textually, symlinks
// left alone. bash reports it as $PWD and `pwd -L`, and applies the
// next `cd`'s `..` to it. Undefined whenever it would equal `cwd`,
// which is every session that has not walked through a symlink. `cwd`
// stays physical because it is what every operand resolves against.
logicalCwd;
// One record per variable: value plus attributes. This is the whole
// variable store -- `env`, `arrays` and `readonlyVars` are read-only
// projections of it, so a name cannot be a scalar in one container and
// an array in another, and an attribute cannot drift from the value it
// describes.
vars;
createdAt;
functions;
// The functions `readonly -f` has frozen. A set beside `functions`
// rather than a flag on the body, because the readonly fact is the
// session's, not the definition's. Kept apart from the readonly
// *variable* set: `readonly -f f` and `readonly f` are two different
// frozen things in bash, and each refuses in its own voice.
readonlyFunctions;
// The functions `export -f` marked, which a nested shell inherits.
exportedFunctions;
lastExitCode;
// `${PIPESTATUS[@]}`: the exit status of every segment of the last
// pipeline, where a simple command is a one-segment pipeline. Written
// only through `recordStatus` (`executor/statement.ts`), the one function
// `$?` goes through as well, so the two can never disagree.
// Empty in a fresh shell, as bash's is: the first `${PIPESTATUS[*]}`
// expands to nothing until a statement records one.
pipeStatus = [];
// `${FUNCNAME[@]}`: the function frames on the call stack, innermost
// first, a sourced file as `source` (`CallStack.functionNames`). Written
// where a frame is pushed and popped, and answered by the arrays view
// before the store, so an assignment to it is ignored. Null once
// `unset FUNCNAME` has made it an ordinary name, as bash's unset does for
// the rest of the shell.
functionNames = [];
// A pipeline's per-segment statuses, parked by `handlePipe` for the
// statement boundary that closes it to claim. Null between them.
pipeStatusPending = null;
// Which line stamped the three fields above, so an aborted line only
// puts back what it overwrote. Two `execute()` calls can share one
// session, and an abort restoring its snapshot over a concurrent
// line's finished status would resurrect a value that line already
// superseded. Runtime identity, never serialized: a restored snapshot
// has no line running on it.
statusWriter = null;
// Where `$RANDOM` draws its first seed: the workspace's id source, shared
// by every fork and never serialized.
ids = new SystemIdSource();
// `$RANDOM`'s generator state and the seed word it last consumed
// (`session/rng.ts`). A child shell reseeds, as bash's does, and the
// parent retains its own state while the child runs.
randomState = null;
randomSeed = null;
randomLast = 0;
positionalArgs;
// What `$0` expands to. Null is the shell itself; a nested `bash`/`sh`
// sets it to the script file it is running, or to the name given after
// `-c`, and restores it afterwards.
scriptName;
exitTrap = null;
exitTrapInherited = false;
// The `trap ... ERR` and `trap ... RETURN` actions, '' for an ignored
// one, and whether the running scope hides each: a child shell lists but
// runs neither unless `set -E` / `set -T`, and a scope that sets one sees
// it. Live shell state, like the EXIT action.
errTrap = null;
returnTrap = null;
errTrapHidden = false;
returnTrapHidden = false;
// Whether each action is running in this shell: bash runs neither again
// until it finishes, whatever the action registers meanwhile. A child
// shell starts with neither running.
errTrapRunning = false;
returnTrapRunning = false;
trapStatus = null;
tty = new Terminal();
jobOutput = null;
jobWaits = null;
// A per-call line's jobs, which a callback in it reaches; null otherwise.
jobTable = null;
shellOptions;
// Transient `set -e` marker: true when the failure just returned
// came from a short-circuited &&/|| branch or a `!`-negated command,
// which bash exempts from errexit. Reset on every node execution.
errexitImmune;
// Whether the running command is in a context where bash ignores `set -e`:
// an `if`/`while`/`until` test, the left of `&&`/`||`, or after `!`.
// Everything run there, a function or a subshell included, ignores it
// (`ignoringErrexit`); a child shell keeps the context.
errexitIgnored = false;
// Whether `set -e` is ending the shell: its statements unwind without
// running ERR or RETURN again, as bash's exit leaves at once.
errexitExiting = false;
// Variables shadowed by `local` / `declare` in the running function; a
// null value means the caller had no variable of that name. One stack,
// not one per container: a local shadows the whole record, so its
// value and its attributes are saved and restored together.
localVars = null;
// Hidden `getopts` state: the char offset within the word being
// scanned (0 = positioned at the word's leading dash), plus the OPTIND
// that offset belongs to. A caller resetting OPTIND makes the seen
// value stale, restarting the scan at the word's first letter, as bash
// 5.2 does: after `getopts ab o` reads `a` from `-ab`, `OPTIND=1` makes
// the next one read `a` again, not `b`.
getoptsPos = 0;
getoptsOptind = null;
// `shopt` options, kept apart from `set -o` ones (bash keeps two
// vocabularies). Only names set away from their default are stored.
shopts = {};
// `alias NAME=VALUE` definitions. Per read (the parse and the row a
// command began on, joined) that changed aliases, their values as it
// began (null: not defined), so the commands of that read keep them; per
// read that ran `shopt` on `expand_aliases`, the option as it began
// (both released when the typed line ends); and ownership of alias text,
// so a self-referential value stops.
aliases = {};
aliasMarks = new Map();
expandAliasesMarks = new Map();
aliasExpansion = null;
// The aliases a running function's body expands, as its definition saw
// them (`FunctionSite.aliases`); null reads the live table.
aliasView = null;
parseSeq = 0;
parseCurrent = 0;
// The row the running parse starts on in the text that spelled it: 0
// for a line, a function's definition row for its body, which is parsed
// again from its own source but reads aliases where it was written.
parseRow = 0;
// Where each function was defined (`FunctionSite`), so its body expands
// the aliases of that place and its approvals stand under it; a function
// loaded from a stored session has none and runs as a parse of its own.
functionSites = new Map();
// The owner of this session's terminal streams, which an `exec` copy of
// one names (`exec 3>&1`), and whether a line of the session is running,
// whose outermost program routes what was written to them. Each fork gets
// its own: a child shell writing to its parent's terminal is writing to a
// stream it did not open. Mirrors Python's terminal and _line_open.
terminal = Symbol('terminal');
lineOpen = false;
// File-creation mask. bash's default for a fresh shell.
umask = 0o022;
// `exec` redirect-only state: where the shell's own stdout, stderr and
// stdin point after a bare `exec > file`. Null is the terminal; `""`
// is a closed descriptor whose writes drop. `execStdin` is the one
// descriptor an `exec <` opened: every statement after it reads on
// from where the one before stopped, across lines and into a child
// shell, which shares it as bash's fork shares fd 0. `execStdoutInput`
// and `execStderrInput` are the read end a stream holds after `exec
// 1<f` or `exec 1<&0`, which a dup shares the offset of.
descriptors = new Map();
execStdout = null;
execStdoutAppend = false;
execStdoutInput = null;
execStderr = null;
execStderrAppend = false;
execStderrInput = null;
execStdin = null;
execStdinUnreadable = false;
// What fd 0 holds when it is not its own read end: CLOSED after `exec
// <&-`, a writing stream's identity after `exec 0<&1`, so a later dup
// from fd 0 copies that (`exec 2<&0` then writes to stdout) or is
// refused (`0: Bad file descriptor`); null for the read end itself.
execStdinIdentity = null;
// The readers a fork inherited: its parent still holds them, so the fork
// closing one never closes the parent's. Mirrors Python's _borrowed.
borrowed = [];
localFrames = [];
// The names a running `declare -g` has put at global scope
// (`reachGlobal`), each with its frame and the function's local it set
// aside: arithmetic in the declaration still reads that local.
reached = [];
// The caller's `RANDOM` marker for every frame that shadows the name,
// innermost last: a local `RANDOM` is an ordinary variable for the
// function's extent, and the generator resumes when it returns.
localRandom = [];
mountModes;
visibility;
hideReasons;
commands;
scripts;
profile;
commandLimits;
network;
terminalOutput = true;
processes;
processId;
shellPid;
processDepth;
// How deep the shell this session runs is nested: the levels the shells
// that started it had re-entered, which its call stack counts on from
// (`CallStack.nesting`).
shellNesting;
decisions;
generation;
lastBgJobId;
constructor(init) {
this.sessionId = init.sessionId;
this.errexitImmune = false;
this.cwd = init.cwd ?? '/';
this.logicalCwd = init.logicalCwd;
this.vars = ownRecord(init.vars);
this.createdAt = init.createdAt ?? Date.now() / 1000;
this.functions = functionSources(init.functions);
this.readonlyFunctions = new Set(init.readonlyFunctions ?? []);
this.exportedFunctions = new Set(init.exportedFunctions ?? []);
this.lastExitCode = init.lastExitCode ?? 0;
this.positionalArgs = init.positionalArgs ?? [];
this.scriptName = init.scriptName ?? null;
this.shellOptions = init.shellOptions ?? {};
this.mountModes = init.mountModes ?? null;
this.visibility = { ...DEFAULT_VISIBILITY, ...init.visibility };
this.hideReasons = init.hideReasons ?? [];
this.commands = init.commands ?? null;
this.scripts = init.scripts ?? {};
this.processId = init.processId ?? null;
this.shellPid = init.shellPid ?? null;
this.processDepth = init.processDepth ?? 0;
this.shellNesting = init.shellNesting ?? 0;
this.profile = init.profile ?? null;
this.commandLimits = { ...init.commandLimits };
this.network = init.network ?? null;
this.processes = parseProcessPermissions(init.processes ?? DEFAULT_PROCESS_PERMISSIONS);
this.decisions = init.decisions ?? [];
this.generation = init.generation ?? 0;
this.lastBgJobId = init.lastBgJobId ?? null;
// bash exports $PWD from startup, so a session that has never run
// `cd` still has one. Seeding here rather than at lookup time is what
// makes it an ordinary variable: assignable, unsettable, and listed
// by `env`. "Exports" is literal -- it carries the attribute, which is
// what keeps it in `env` now that the process view is the exported set
// rather than every string.
if (!Object.hasOwn(this.vars, 'PWD'))
this.vars.PWD = makeVar(this.cwd, new Set([VarAttr.Export]));
// bash starts with a PATH when the environment gives it none, and does
// not export it: `env` does not list it and a child process, such as a
// host interpreter, keeps its own. The one directory here is where
// every program's file is.
if (!Object.hasOwn(this.vars, 'PATH'))
this.vars.PATH = makeVar(BIN_PREFIX, new Set());
// bash sets IFS at startup and never exports it, so a fresh shell reads
// `${#IFS}` as 3 and `OLDIFS=$IFS ... IFS=$OLDIFS` puts the default
// back rather than an empty IFS that splits nothing.
if (!Object.hasOwn(this.vars, 'IFS'))
this.vars.IFS = makeVar(IFS_DEFAULT, new Set());
// bash starts OPTIND (an integer) and OPTERR at 1 and never exports them,
// so `shift $((OPTIND-1))` works before any `getopts`.
for (const [name, start] of Object.entries(STARTUP_VALUES)) {
if (!Object.hasOwn(this.vars, name))
this.vars[name] = copyVar(start);
}
}
/**
* Return a copy of this session with `overrides` applied. Mutable
* containers (env, functions, readonlyVars, arrays, positionalArgs)
* are shallow-copied so mutations on the fork do not leak back into
* the source. Every field — including capability fields like
* `mountModes` — is propagated, so callers cannot accidentally
* forget one when adding new fields.
*
* A caller that moves the fork with `cwd` supplies a physical path
* with no typed spelling behind it, so the source's logical name is
* dropped rather than left describing where the fork is not — the same
* reasoning as `shell_dirs.setCwd`. Deciding it here rather than at
* each call site is what keeps `execute({cwd})` from reporting the
* persistent session's old directory from `pwd`. `??` cannot express
* this, since the value being chosen is `undefined`.
*/
fork(overrides = {}) {
const movedTo = 'logicalCwd' in overrides ? undefined : overrides.cwd;
const vars = overrides.vars ?? copyVars(this.vars);
// $PWD names where the session is, so it follows the move even when
// the caller also supplied variables to layer on.
if (movedTo !== undefined)
vars.PWD = makeVar(movedTo, new Set([VarAttr.Export]));
const forked = new SessionState({
sessionId: overrides.sessionId ?? this.sessionId,
cwd: overrides.cwd ?? this.cwd,
logicalCwd: movedTo !== undefined ? undefined : (overrides.logicalCwd ?? this.logicalCwd),
vars,
createdAt: overrides.createdAt ?? this.createdAt,
functions: overrides.functions ?? { ...this.functions },
readonlyFunctions: overrides.readonlyFunctions ?? new Set(this.readonlyFunctions),
exportedFunctions: overrides.exportedFunctions ?? new Set(this.exportedFunctions),
lastExitCode: overrides.lastExitCode ?? this.lastExitCode,
positionalArgs: overrides.positionalArgs ?? [...this.positionalArgs],
scriptName: overrides.scriptName ?? this.scriptName,
shellOptions: overrides.shellOptions ?? { ...this.shellOptions },
mountModes: overrides.mountModes ?? this.mountModes,
visibility: overrides.visibility ?? this.visibility,
hideReasons: overrides.hideReasons ?? this.hideReasons,
commands: overrides.commands ?? this.commands,
scripts: overrides.scripts ?? this.scripts,
profile: overrides.profile ?? this.profile,
commandLimits: overrides.commandLimits ?? this.commandLimits,
network: overrides.network ?? this.network,
processes: overrides.processes ?? this.processes,
processId: overrides.processId ?? this.processId,
shellPid: overrides.shellPid ?? this.shellPid,
processDepth: overrides.processDepth ?? this.processDepth,
shellNesting: overrides.shellNesting ?? this.shellNesting,
decisions: overrides.decisions ?? this.decisions,
generation: overrides.generation ?? this.generation,
lastBgJobId: overrides.lastBgJobId ?? this.lastBgJobId,
});
// A fork is the same shell going on, so a startup variable the source
// unset stays unset (`unset IFS; ( ... )`) rather than being seeded
// again; a fork given new variables starts them.
if (overrides.vars === undefined)
forked.vars = vars;
if (this.randomSeed === RANDOM_UNSET)
forked.randomSeed = RANDOM_UNSET;
forked.versions = this.versions;
forked.ids = this.ids;
forked.terminalOutput = this.terminalOutput;
forked.pipeStatus = [...this.pipeStatus];
forked.functionNames = this.functionNames;
forked.exitTrap = this.exitTrap;
forked.exitTrapInherited = this.exitTrapInherited;
forked.errTrap = this.errTrap;
forked.returnTrap = this.returnTrap;
forked.errTrapHidden = this.errTrapHidden;
forked.returnTrapHidden = this.returnTrapHidden;
forked.tty = this.tty;
forked.jobOutput = this.jobOutput;
forked.jobWaits = this.jobWaits;
forked.jobTable = this.jobTable;
forked.getoptsPos = this.getoptsPos;
forked.errexitIgnored = this.errexitIgnored;
forked.getoptsOptind = this.getoptsOptind;
forked.shopts = { ...this.shopts };
forked.aliases = { ...this.aliases };
forked.parseSeq = this.parseSeq;
forked.aliasMarks = new Map([...this.aliasMarks].map(([read, began]) => [read, new Map(began)]));
forked.expandAliasesMarks = new Map(this.expandAliasesMarks);
forked.aliasView = this.aliasView;
forked.functionSites = new Map(this.functionSites);
forked.umask = this.umask;
forked.descriptors = new Map(this.descriptors);
forked.execStdout = this.execStdout;
forked.execStdoutAppend = this.execStdoutAppend;
forked.execStdoutInput = this.execStdoutInput;
forked.execStderr = this.execStderr;
forked.execStderrAppend = this.execStderrAppend;
forked.execStderrInput = this.execStderrInput;
forked.execStdin = this.execStdin;
forked.execStdinUnreadable = this.execStdinUnreadable;
forked.execStdinIdentity = this.execStdinIdentity;
forked.borrowed = [...this.borrowed, ...this.openInputs()];
return forked;
}
/**
* A child shell of this session: a fork that reads on from here. `fork`
* copies what a session keeps; a child shell (a command substitution, a
* subshell) also inherits the reader's position, the aliases being
* expanded and the local frames, and reseeds `$RANDOM` on its first draw
* instead of replaying this session's seed. Mirrors Python.
*/
subshell() {
const child = this.fork();
child.parseCurrent = this.parseCurrent;
child.parseRow = this.parseRow;
child.aliasExpansion = this.aliasExpansion;
child.localVars = this.localVars === null ? null : copyLocals(this.localVars);
child.localFrames = this.localFrames.map((frame) => frame === this.localVars && child.localVars !== null ? child.localVars : copyLocals(frame));
child.localRandom = [...this.localRandom];
if (child.randomSeed !== RANDOM_UNSET)
child.drawAfresh();
return child;
}
/**
* Start `$RANDOM` on a new sequence at its next draw, rather than seed it
* from the value the variable holds now.
*/
drawAfresh() {
const word = this.vars[RANDOM]?.value;
this.randomSeed = typeof word === 'string' ? word : null;
}
/**
* A new shell started from this session, as a nested `bash` is. bash runs
* a nested shell as a program of its own, which inherits the working
* directory, the umask, the open files and the environment: the exported
* variables, as plain exported strings (no array, no other attribute), and
* the functions `export -f` marked. The rest starts as a fresh shell's
* does: the other variables and functions, the aliases, the `set` and
* `shopt` options, `$?`, `$!`, `$RANDOM`'s sequence, `getopts`'s place,
* the call stack, any test its caller is in (`if bash -ec 'false; ...'`
* still ends at `false`) and the startup variables, which bash never reads
* from its environment: IFS is dropped and `STARTUP_VALUES` restart. A
* managed variable not yet fetched crosses as its pointer, which the
* nested shell fetches through. Mirrors Python.
*/
newShell() {
const vars = ownRecord();
for (const [name, v] of Object.entries(this.vars)) {
if (!v.attrs.has(VarAttr.Export) || name === 'IFS')
continue;
if (typeof v.value !== 'string' && v.managed === undefined)
continue;
const start = sessionEntry(STARTUP_VALUES, name);
vars[name] =
start === undefined
? {
value: v.value,
attrs: new Set([VarAttr.Export]),
...(v.managed === undefined ? {} : { managed: v.managed }),
}
: makeVar(start.value, new Set([...start.attrs, VarAttr.Export]));
}
const functions = Object.fromEntries(Object.entries(this.functions).filter(([name]) => this.exportedFunctions.has(name)));
const child = this.fork({
vars,
functions,
exportedFunctions: new Set(Object.keys(functions)),
readonlyFunctions: new Set(),
shellOptions: {},
lastExitCode: 0,
});
child.functionSites = new Map([...this.functionSites].filter(([name]) => name in child.functions));
child.aliases = {};
child.aliasMarks = new Map();
child.expandAliasesMarks = new Map();
child.shopts = {};
child.pipeStatus = [];
child.lastBgJobId = null;
child.functionNames = [];
child.getoptsPos = 0;
child.getoptsOptind = null;
child.errexitIgnored = false;
child.drawAfresh();
return child;
}
/** Remove a function with its definition site and export mark. */
removeFunction(name) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete this.functions[name];
this.functionSites.delete(name);
this.exportedFunctions.delete(name);
}
/**
* What `$0` expands to. Null is the shell itself; a nested `bash`/`sh`
* sets it to the script it is running, or to the name given after
* `-c`. An empty name is a name, so it is not folded into the default:
* GNU `bash -c 'echo "[$0]"' ""` prints `[]`.
*/
get argv0() {
return this.scriptName ?? SHELL_ARGV0;
}
/**
* The scalar variables, by name.
*
* A frozen read-only projection of `vars`, not a container: a writer
* goes through `SessionView.set` (or `seedVar` when seeding a session
* before it is narrowed), so a `pre_session` policy sees every write.
* `state.ts` has always documented that rule; freezing the projection
* is what stops it being walked around by assigning into storage, and
* an assignment into a plain object would land in a throwaway and
* vanish -- silent loss is the exact failure this store removes.
*/
get env() {
const out = ownRecord();
for (const [name, v] of Object.entries(this.vars)) {
if (typeof v.value === 'string')
out[name] = v.value;
}
return Object.freeze(out);
}
/** The indexed arrays, by name. Read-only, like `env`. */
get arrays() {
const out = ownRecord();
for (const [name, v] of Object.entries(this.vars)) {
if (Array.isArray(v.value))
out[name] = v.value;
}
return Object.freeze(out);
}
/** The associative arrays, by name. Read-only, like `env`. */
get assocs() {
const out = ownRecord();
for (const [name, v] of Object.entries(this.vars)) {
if (v.value !== null && typeof v.value === 'object' && !Array.isArray(v.value)) {
out[name] = v.value;
}
}
return Object.freeze(out);
}
/** The names `readonly` has marked. Read-only, like `env`. */
get readonlyVars() {
const out = new Set();
for (const [name, v] of Object.entries(this.vars)) {
if (v.attrs.has(VarAttr.Readonly))
out.add(name);
}
return out;
}
openInputs() {
return [
this.execStdin,
this.execStdoutInput,
this.execStderrInput,
...[...this.descriptors.values()].map((descriptor) => descriptor.source),
].filter((source) => source !== null);
}
/** Close the readers among `inputs` that this shell no longer names and did not inherit. */
releaseInputs(inputs) {
return releaseInputs(inputs, [...this.openInputs(), ...this.borrowed]);
}
/**
* Close the readers this child shell opened, as it ends; the ones it
* inherited stay with the shell that opened them. Mirrors Python's
* close_inputs.
*/
closeInputs() {
return releaseInputs(this.openInputs(), this.borrowed);
}
/**
* The durable-field payload the session table and snapshots persist.
* Keys are snake_case, byte-identical to Python's `SessionState.to_dict`,
* so both languages can share one store (a py daemon creates the
* session, a node kernel tier binds it).
*/
toJSON() {
return this.toDict();
}
toDict() {
// A managed name serializes as its pointer, never its value: a
// stored session may leak only where a secret lives. `env` skips
// the name (the fetched plaintext must not land in the record)
// while `var_attrs` keeps its letters, so a payload with the
// `managed` key stripped still restores the name as
// attributed-unset rather than dropping it.
const managed = ownRecord();
for (const [name, v] of Object.entries(this.vars)) {
if (v.managed !== undefined)
managed[name] = v.managed;
}
// `env` is every scalar and `var_attrs` the letters set on the names
// that carry any, rather than one key holding both: `env` is the
// shape an embedder writes and the other language reads, so it stays
// a plain name/value map, and the attributes ride beside it. Without
// the second key a reload could only guess, and guessing "exported"
// turned every plain `X=hello` into an exported one on the first
// round trip.
const scalars = ownRecord();
for (const [name, value] of Object.entries(this.env)) {
if (!Object.hasOwn(managed, name))
scalars[name] = value;
}
const data = {
session_id: this.sessionId,
cwd: this.cwd,
env: scalars,
created_at: this.createdAt,
generation: this.generation,
};
const letters = ownRecord();
for (const [name, v] of Object.entries(this.vars)) {
if (v.attrs.size > 0)
letters[name] = storedAttrs(v);
}
// Always written, even empty, because its *presence* is the
// discriminator: a payload without it is read as a bare process
// environment and every name in it comes back exported. Writing it
// only when non-empty made `export -n X` (or any session whose last
// attribute was cleared) serialize as a process environment, so the
// reload re-exported everything it held.
data.var_attrs = letters;
if (Object.keys(managed).length > 0) {
const refs = ownRecord();
for (const [name, ref] of Object.entries(managed)) {
const entry = { from: ref.source, ref: ref.ref, key: ref.key };
if (ref.eager)
entry.fetch = 'eager';
refs[name] = entry;
}
data.managed = refs;
}
if (Object.keys(this.functions).length > 0)
data.functions = { ...this.functions };
if (this.readonlyFunctions.size > 0)
data.readonly_functions = [...this.readonlyFunctions].sort(compareCodePoints);
if (this.exportedFunctions.size > 0)
data.exported_functions = [...this.exportedFunctions].sort(compareCodePoints);
if (this.mountModes !== null) {
data.mount_modes = Object.fromEntries(this.mountModes);
}
const vis = this.visibility;
if (vis.paths !== null) {
data.hidden_paths = {
paths: [...(vis.paths.paths ?? [])],
patterns: [...(vis.paths.patterns ?? [])],
};
}
if (vis.shown !== null) {
data.shown_paths = {
entries: vis.shown.entries.map((e) => e.mode == null ? { path: e.path } : { path: e.path, mode: e.mode }),
};
}
if (this.hideReasons.length > 0) {
data.hide_reasons = this.hideReasons.map((g) => ({
patterns: [...g.patterns],
reason: g.reason,
}));
}
if (vis.vars !== null) {
data.hidden_vars = {
names: [...(vis.vars.names ?? [])],
patterns: [...(vis.vars.patterns ?? [])],
};
}
if (this.commands !== null)
data.commands = commandsToDict(this.commands);
if (Object.keys(this.scripts).length > 0)
data.scripts = scriptsToDict(this.scripts);
if (Object.keys(this.commandLimits).length > 0)
data.command_limits = commandLimitsToJSON(this.commandLimits);
if (this.network !== null)
data.network = this.network;
if (JSON.stringify(this.processes) !== JSON.stringify(DEFAULT_PROCESS_PERMISSIONS))
data.processes = this.processes;
if (this.profile !== null)
data.profile = this.profile;
if (this.decisions.length > 0)
data.decisions = this.decisions.map(decisionToDict);
return data;
}
static fromDict(data) {
const commands = data.commands != null ? commandsFromDict(data.commands) : null;
const processes = parseProcessPermissions(data.processes ?? DEFAULT_PROCESS_PERMISSIONS);
const vars = data.env !== undefined || data.var_attrs !== undefined || data.managed != null
? restoredVars(data.env ?? {}, data.var_attrs, data.managed)
: undefined;
const session = new SessionState({
sessionId: data.session_id,
...(data.functions === undefined ? {} : { functions: data.functions }),
readonlyFunctions: new Set(data.readonly_functions ?? []),
exportedFunctions: new Set(data.exported_functions ?? []),
...(data.cwd !== undefined ? { cwd: data.cwd } : {}),
// No `var_attrs` at all means the payload is a bare process
// environment -- an embedder's record, or one another writer
// hand-built -- so every name in it is exported, which is what a
// process environment means. With the key present the attributes
// were recorded and are restored as they were written.
...(vars === undefined ? {} : { vars }),
...(data.created_at !== undefined ? { createdAt: data.created_at } : {}),
...(data.generation !== undefined ? { generation: data.generation } : {}),
mountModes: data.mount_modes != null ? new Map(Object.entries(data.mount_modes)) : null,
visibility: {
paths: data.hidden_paths != null
? { paths: data.hidden_paths.paths ?? [], patterns: data.hidden_paths.patterns ?? [] }
: null,
shown: data.shown_paths != null
? {
entries: (data.shown_paths.entries ?? []).map((e) => ({
path: e.path,
mode: e.mode ?? null,
})),
}
: null,
vars: data.hidden_vars != null
? { names: data.hidden_vars.names ?? [], patterns: data.hidden_vars.patterns ?? [] }
: null,
processes: processes.list,
},
hideReasons: data.hide_reasons != null
? data.hide_reasons.map((g) => ({ patterns: g.patterns ?? [], reason: g.reason ?? '' }))
: [],
commands,
scripts: scriptsFromDict(data.scripts ?? {}),
profile: data.profile ?? null,
commandLimits: parseCommandLimits(data.command_limits),
network: data.network ?? null,
processes,
decisions: data.decisions != null ? data.decisions.map(decisionFromDict) : [],
});
// A recorded session comes back as it was written, so a startup
// variable it had unset stays unset rather than being seeded again; a
// bare environment starts a new shell. The constructor seeded a copy.
if (data.var_attrs !== undefined && vars !== undefined)
session.vars = vars;
return session;
}
}
//# sourceMappingURL=session.js.map