UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

359 lines • 13.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 { parseShellOptions, SHELL_SPECS } from "../../../../commands/spec/shell.js"; import { isFsError } from "../../../../errors/fs.js"; import { lineBuffer } from "../../../../io/async_line_iterator.js"; import { checkMemory } from "../../../../io/memory.js"; import { IOResult } from "../../../../io/types.js"; import { PolicyDenied } from "../../../../policy/errors.js"; import { decodeText, encodeText } from "../../../../shell/bytes.js"; import { endsEscaped } from "../../../../shell/escapes.js"; import { assignElement } from "../../../session/elements.js"; import { sessionView, visibleEnv } from "../../../session/state.js"; import { ExecutionNode } from "../../../types.js"; import { TARGET_RE } from "../constants.js"; import { builtinUsage, countOperand, isValidName, readonlyRefusal, recordDelimiter, refusal, requireView, } from "../shared.js"; import { READ_VALUE_LETTERS } from "./constants.js"; /** Split on whitespace runs with a maxsplit, like Python's split(None, n). */ function splitOnWhitespace(text, maxsplit) { const out = []; let i = 0; while (i < text.length) { while (i < text.length && /[ \t\n]/.test(text[i] ?? '')) i++; if (i >= text.length) break; if (out.length === maxsplit) { out.push(text.slice(i)); return out; } let j = i; while (j < text.length && !/[ \t\n]/.test(text[j] ?? '')) j++; out.push(text.slice(i, j)); i = j; } return out; } function readRefusal(msg) { const err = encodeText(msg); return [ null, new IOResult({ exitCode: 1, stderr: err }), new ExecutionNode({ command: 'read', exitCode: 1, stderr: err }), ]; } function readTimeout(text) { const value = Number(text); return Number.isFinite(value) && value >= 0 ? value : null; } /** Which of `-n`/`-N` was written last, since bash keeps only one. */ function lastCountFlag(args) { let which = null; let i = 0; while (i < args.length) { const tok = args[i] ?? ''; if (tok === '--' || !tok.startsWith('-') || tok === '-') break; let j = 1; while (j < tok.length) { const ch = tok[j] ?? ''; if (ch === 'n' || ch === 'N') which = ch; if (READ_VALUE_LETTERS.has(ch)) { if (j === tok.length - 1) i++; break; } j++; } i++; } return which; } function splitReadLine(line, ifs, slots) { if (ifs === ' \t\n') { const trimmed = line.replace(/^[ \t\n]+|[ \t\n]+$/g, ''); if (slots === 0) return trimmed === '' ? [] : trimmed.split(/[ \t\n]+/); return splitOnWhitespace(trimmed, slots - 1); } if (ifs === '') return [line]; const ifsWs = new Set(ifs.split('').filter((c) => c === ' ' || c === '\t' || c === '\n')); let start = 0; let end = line.length; while (start < end && ifsWs.has(line[start] ?? '')) start++; while (end > start && ifsWs.has(line[end - 1] ?? '')) end--; const work = line.slice(start, end); const nSplits = slots === 0 ? work.length : Math.max(0, slots - 1); const chars = new Set(ifs.split('')); const out = []; let cur = ''; for (const ch of work) { if (chars.has(ch) && out.length < nSplits) { out.push(cur); cur = ''; continue; } cur += ch; } out.push(cur); return out; } /** read's default backslash handling: a backslash quotes the next * character and a backslash-newline pair is a line continuation. */ function unescapeRead(text) { let out = ''; let i = 0; while (i < text.length) { const ch = text.charAt(i); if (ch === '\\' && i + 1 < text.length) { const nxt = text.charAt(i + 1); if (nxt !== '\n') out += nxt; i += 2; continue; } out += ch; i++; } return out; } async function readRaw(buffer, raw, delim, nchars, exact, signal) { if (exact !== null) { const [data, complete] = await buffer.readChars(exact, null, signal); return [decodeText(data), complete]; } if (nchars !== null) { let [data, complete] = await buffer.readChars(nchars, delim, signal); let held = data.byteLength; let text = decodeText(data); while (!raw && complete && endsEscaped(text) && text.length < nchars) { ; [data, complete] = await buffer.readChars(nchars - text.length, delim, signal); held += data.byteLength; checkMemory(held); text += decodeText(data); } return [text, complete]; } let [data, complete] = await buffer.readUntil(delim, signal); let held = data.byteLength; let text = decodeText(data); while (!raw && complete && delim === 10 && endsEscaped(text)) { ; [data, complete] = await buffer.readUntil(delim, signal); held += data.byteLength + 1; checkMemory(held); text += '\n' + decodeText(data); } return [text, complete]; } /** * One line as a bare `read` stores it in REPLY: backslashes processed, a * continuation joined, nothing split off; null at end of input. `select` * takes its choice this way (bash calls this builtin). */ export async function readReply(buffer, signal) { const [line, complete] = await readRaw(buffer, false, 10, null, null, signal); return complete ? unescapeRead(line) : null; } /** * Read one line (or delimited record, or character count) into * variables, with bash's option surface. `-r` turns off backslash * processing; `-d C` reads to `C`; `-n N`/`-N N` bound the read; `-a * NAME` stores fields in an array; `-t` accepts a timeout (0 answers * whether a source is present); `-p -s -e -i` are non-tty no-ops; `-u FD` * reads an open descriptor, sharing its cursor with aliases. The * status is 1 when end of input ended the read. */ export async function handleRead(args, session, stdin, state = null, signal) { const parse = parseShellOptions(SHELL_SPECS.read, args); if (parse.invalid !== null) { const token = parse.invalid.startsWith('--') ? parse.invalid : `-${parse.invalid}`; const err = encodeText(`bash: read: ${token}: invalid option\n${builtinUsage('read')}`); return [ null, new IOResult({ exitCode: 2, stderr: err }), new ExecutionNode({ command: 'read', exitCode: 2 }), ]; } if (parse.needsValue !== null) { const err = encodeText(`bash: read: -${parse.needsValue}: option requires an argument\n${builtinUsage('read')}`); return [ null, new IOResult({ exitCode: 2, stderr: err }), new ExecutionNode({ command: 'read', exitCode: 2 }), ]; } const flags = parse.flags; const raw = flags.r === true; const delim = recordDelimiter(typeof flags.d === 'string' ? flags.d : null); for (const key of ['n', 'N']) { if (typeof flags[key] === 'string' && countOperand(flags[key]) === null) { return readRefusal(`bash: read: ${flags[key]}: invalid number\n`); } } let nchars = null; let exact = null; const which = lastCountFlag(args); if (which === 'N') exact = countOperand(String(flags.N)); else if (which === 'n') nchars = countOperand(String(flags.n)); let timeout = null; if (typeof flags.t === 'string') { timeout = readTimeout(flags.t); if (timeout === null) { return readRefusal(`bash: read: ${flags.t}: invalid timeout specification\n`); } } if (typeof flags.u === 'string' && flags.u !== '0') { const descriptor = /^\d+$/.test(flags.u) ? session.descriptors.get(Number(flags.u)) : undefined; if (descriptor?.source == null) return readRefusal(`bash: read: ${flags.u}: invalid file descriptor: Bad file descriptor\n`); stdin = descriptor.source; } const arrayName = typeof flags.a === 'string' ? flags.a : null; if (arrayName !== null && !isValidName(arrayName)) { return readRefusal(`bash: read: \`${arrayName}': not a valid identifier\n`); } const variables = parse.operands.length > 0 ? parse.operands : ['REPLY']; const view = requireView(state); const buffer = stdin !== null ? lineBuffer(stdin) : null; if (timeout === 0) { const code = buffer !== null ? 0 : 1; return [ null, new IOResult({ exitCode: code }), new ExecutionNode({ command: 'read', exitCode: code }), ]; } let complete = false; let line = ''; if (buffer !== null) { try { ; [line, complete] = await readRaw(buffer, raw, delim, nchars, exact, signal); } catch (err) { if (!isFsError(err) || err.code !== 'EBADF') throw err; // stdin is closed or write-only (`read x <&-`, `read x 0<&1`). return readRefusal('bash: read: read error: 0: Bad file descriptor\n'); } } if (!raw) line = unescapeRead(line); const ifs = visibleEnv(session).IFS ?? ' \t\n'; if (arrayName !== null) { const parts = exact !== null ? (line !== '' ? [line] : []) : splitReadLine(line, ifs, 0); if (view.isReadonly(arrayName)) return readonlyRefusal('read', arrayName); try { await view.set(arrayName, [...parts]); } catch (err) { if (err instanceof PolicyDenied) return refusal('read', err); throw err; } const code = complete ? 0 : 1; return [ null, new IOResult({ exitCode: code }), new ExecutionNode({ command: 'read', exitCode: code }), ]; } // With no name the line goes to REPLY whole, its blanks kept. const parts = exact !== null || parse.operands.length === 0 ? [line] : splitReadLine(line, ifs, variables.length); for (let i = 0; i < variables.length; i++) { const name = variables[i]; if (name === undefined) continue; const refused = await readStore(session, view, name, parts[i] ?? ''); if (refused !== null) return refused; } const code = complete ? 0 : 1; return [ null, new IOResult({ exitCode: code }), new ExecutionNode({ command: 'read', exitCode: code }), ]; } /** * Store one `read` target, scalar or `name[sub]` element. * * bash accepts a subscripted target (`read "m[k]"`), which is an * element write, not a variable literally named `m[k]`; the readonly * guard resolves the base name first, since that is what `readonly` * records. Returns the refusal result, or null when the write landed. */ async function readStore(session, view, varName, value) { const match = TARGET_RE.exec(varName); const base = match?.[1] ?? varName; const subscript = match?.[2] ?? null; if (view.isReadonly(base)) return readonlyRefusal('read', base); if (subscript === null) { try { await view.set(varName, value); } catch (err) { if (err instanceof PolicyDenied) return refusal('read', err); throw err; } return null; } let status; try { status = await assignElement(session, view, base, subscript, value); } catch (err) { if (err instanceof PolicyDenied) return refusal('read', err); throw err; } if (status === 'readonly') return readonlyRefusal('read', base); if (status === 'denied') { const err = encodeText(`bash: ${base}: permission denied\n`); return [ null, new IOResult({ exitCode: 1, stderr: err }), new ExecutionNode({ command: 'read', exitCode: 1, stderr: err }), ]; } if (status !== 'ok') { const err = encodeText(`bash: read: ${varName}: bad array subscript\n`); return [ null, new IOResult({ exitCode: 1, stderr: err }), new ExecutionNode({ command: 'read', exitCode: 1, stderr: err }), ]; } return null; } /** The `read` arm. */ export async function readBuiltin(call) { return handleRead([...call.argv.args], call.context.session, call.stdin, sessionView(call.context.session, call.registry.policies, call.context.frame.diagnostics), call.signal); } //# sourceMappingURL=read.js.map