UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

918 lines • 43.5 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { 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