chore: import upstream snapshot with attribution
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:30:44 +08:00
commit bcbd1bdb22
5748 changed files with 562488 additions and 0 deletions
@@ -0,0 +1,13 @@
# ========= 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. =========
@@ -0,0 +1,78 @@
# ========= 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. =========
from mirage.workspace.executor.builtins.condition import handle_test
from mirage.workspace.executor.builtins.dirs import handle_cd
from mirage.workspace.executor.builtins.history import handle_history
from mirage.workspace.executor.builtins.links import (follow_paths, handle_ln,
handle_readlink,
link_flags, prepare_mv,
strip_link_operands)
from mirage.workspace.executor.builtins.man import (_collect_man_hits,
_render_man_entry,
_render_man_index,
handle_man)
from mirage.workspace.executor.builtins.scope import _scope_path, _to_scope
from mirage.workspace.executor.builtins.script import (handle_bash,
handle_eval,
handle_sleep,
handle_source)
from mirage.workspace.executor.builtins.text import (_interpret_escapes,
handle_echo,
handle_printf)
from mirage.workspace.executor.builtins.timeout import handle_timeout
from mirage.workspace.executor.builtins.xargs import handle_xargs
from mirage.workspace.executor.builtins.vars import ( # isort: skip
handle_export, handle_local, handle_printenv, handle_read, handle_readonly,
handle_return, handle_set, handle_shift, handle_trap, handle_unset,
handle_whoami)
__all__ = [
'_collect_man_hits',
'_interpret_escapes',
'_render_man_entry',
'_render_man_index',
'_scope_path',
'_to_scope',
'handle_bash',
'handle_cd',
'handle_echo',
'handle_eval',
'handle_export',
'handle_history',
'handle_ln',
'handle_local',
'handle_readlink',
'link_flags',
'follow_paths',
'prepare_mv',
'strip_link_operands',
'handle_man',
'handle_printenv',
'handle_printf',
'handle_read',
'handle_readonly',
'handle_return',
'handle_set',
'handle_shift',
'handle_sleep',
'handle_source',
'handle_test',
'handle_timeout',
'handle_trap',
'handle_unset',
'handle_whoami',
'handle_xargs',
]
@@ -0,0 +1,102 @@
# ========= 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. =========
from collections.abc import Callable
from mirage.io import IOResult
from mirage.io.types import ByteSource
from mirage.types import PathSpec
from mirage.utils.path import resolve_path
from mirage.workspace.executor.builtins.scope import _scope_path, _to_scope
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
async def _eval_test(dispatch: Callable, argv: list, cwd: str) -> bool:
if not argv:
return False
first = _scope_path(argv[0])
if first == "!" and len(argv) > 1:
return not await _eval_test(dispatch, argv[1:], cwd)
if len(argv) == 1:
return bool(first)
if len(argv) == 2:
op = _scope_path(argv[0])
val = argv[1]
if op == "-z":
return _scope_path(val) == ""
if op == "-n":
return _scope_path(val) != ""
if op == "-f":
# A relative string operand resolves against cwd, like bash:
# `cd /data && test -f plain.txt` checks /data/plain.txt.
path = _scope_path(val)
if not isinstance(val, PathSpec) and not path:
return False
scope = val if isinstance(val, PathSpec) else _to_scope(
resolve_path(path, cwd))
try:
await dispatch("stat", scope)
return True
except (FileNotFoundError, ValueError):
return False
if op == "-d":
path = _scope_path(val)
if not isinstance(val, PathSpec) and not path:
return False
if not isinstance(val, PathSpec):
path = resolve_path(path, cwd)
scope = val if isinstance(val, PathSpec) else PathSpec(
virtual=path, directory=path, resource_path="", resolved=False)
try:
await dispatch("readdir", scope)
return True
except (FileNotFoundError, ValueError, NotADirectoryError):
return False
if len(argv) == 3:
left = _scope_path(argv[0])
op = _scope_path(argv[1])
right = _scope_path(argv[2])
if op == "=" or op == "==":
return left == right
if op == "!=":
return left != right
try:
li, ri = int(left), int(right)
except (ValueError, TypeError):
return False
if op == "-eq":
return li == ri
if op == "-ne":
return li != ri
if op == "-lt":
return li < ri
if op == "-le":
return li <= ri
if op == "-gt":
return li > ri
if op == "-ge":
return li >= ri
return False
async def handle_test(
dispatch: Callable,
argv: list,
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
result = await _eval_test(dispatch, argv, session.cwd)
code = 0 if result else 1
return None, IOResult(exit_code=code), ExecutionNode(command="test",
exit_code=code)
@@ -0,0 +1,168 @@
# ========= 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 posixpath
from collections.abc import Callable
from mirage.io import IOResult
from mirage.io.types import ByteSource
from mirage.types import FileType, PathSpec
from mirage.utils.path import (MAX_SYMLINK_HOPS, CycleError, resolve_path,
resolve_symlinks)
from mirage.workspace.executor.builtins.scope import _scope_path, _to_scope
from mirage.workspace.session import Session
from mirage.workspace.session.shell_dirs import change_dir
from mirage.workspace.types import ExecutionNode
def _norm(path: str) -> str:
resolved = posixpath.normpath(path)
if resolved.startswith("//"):
resolved = "/" + resolved.lstrip("/")
return resolved
def _resolve_target(combined: str, links: dict[str, str],
physical: bool) -> str:
"""Resolve a combined ``cd`` path, following symlinks per mode.
Logical (``-L``, default) simplifies ``..`` textually first, then
follows links. Physical (``-P``) follows links first so ``..`` acts on
the link target. Both loop resolve<->normalize until stable.
Args:
combined (str): The absolute target (cwd joined to arg).
links (dict[str, str]): The symlink table (link -> target).
physical (bool): True for ``-P``, False for ``-L``.
Returns:
str: The final absolute path with links resolved.
Raises:
CycleError: On a symlink loop or unbounded expansion (ELOOP).
"""
p = combined if physical else _norm(combined)
for _ in range(MAX_SYMLINK_HOPS):
n = _norm(resolve_symlinks(p, links))
if n == p:
return n
p = n
raise CycleError(p)
def _cdpath_searchable(target: str) -> bool:
"""Return whether ``target`` triggers a ``$CDPATH`` search.
Args:
target: The as-typed ``cd`` operand.
Returns:
True when ``target`` is relative and does not begin with ``./``
or ``../`` (mirroring GNU bash's ``cd`` search rule).
"""
if target.startswith(("/", "./", "../")):
return False
return target not in (".", "..")
def _cd_candidates(
raw: str,
cdpath_target: str | None,
session: Session,
) -> list[tuple[str, bool]]:
"""Build the ordered list of directories ``cd`` should try.
Args:
raw: The resolved operand path string.
cdpath_target: The as-typed operand when a ``$CDPATH`` search
applies, else ``None``.
session: The shell session (provides cwd and env).
Returns:
``(resolved_path, announce)`` pairs in trial order; ``announce``
marks a non-empty ``$CDPATH`` hit whose absolute path GNU prints.
"""
cwd = session.cwd
fallback = resolve_path(raw, cwd)
cdpath = session.env.get("CDPATH")
if (not cdpath or not cdpath_target
or not _cdpath_searchable(cdpath_target)):
return [(fallback, False)]
out: list[tuple[str, bool]] = []
for entry in cdpath.split(":"):
base = resolve_path(entry, cwd) if entry else cwd
out.append((resolve_path(cdpath_target, base), entry != ""))
out.append((fallback, False))
return out
async def handle_cd(
dispatch: Callable,
is_mount_root: Callable[[str], bool],
path: str | PathSpec,
session: Session,
print_path: bool = False,
cdpath_target: str | None = None,
links: dict[str, str] | None = None,
physical: bool = False,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
raw = _scope_path(path)
table = links or {}
candidates = _cd_candidates(raw, cdpath_target, session)
error: str | None = None
for resolved, announce in candidates:
if table:
try:
resolved = _resolve_target(resolved, table, physical)
except CycleError:
error = f"cd: {raw}: Too many levels of symbolic links\n"
continue
if resolved == "/":
return _cd_success(session, "/", raw, print_path or announce)
scope = _to_scope(resolved)
s = None
not_found = False
try:
s, _ = await dispatch("stat", scope)
except FileNotFoundError:
not_found = True
except ValueError as exc:
error = f"cd: {raw}: {exc}\n"
continue
if s is None or not_found:
if is_mount_root(resolved):
return _cd_success(session, resolved, raw, print_path
or announce)
error = f"cd: {raw}: No such file or directory\n"
continue
if s.type != FileType.DIRECTORY:
error = f"cd: {raw}: Not a directory\n"
continue
return _cd_success(session, resolved, raw, print_path or announce)
err = (error or f"cd: {raw}: No such file or directory\n").encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=f"cd {raw}",
exit_code=1,
stderr=err)
def _cd_success(
session: Session,
resolved: str,
raw: str,
print_path: bool,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
change_dir(session, resolved)
out = (resolved + "\n").encode() if print_path else None
return out, IOResult(), ExecutionNode(command=f"cd {raw}", exit_code=0)
@@ -0,0 +1,121 @@
# ========= 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. =========
from mirage.io.types import ByteSource, IOResult
from mirage.resource.history import HISTORY_PREFIX
from mirage.workspace.session.session import Session
from mirage.workspace.types import ExecutionNode
USAGE = ("history: usage: history [-c] [-d offset] [n] or "
"history -awrn [filename] or history -ps arg [arg...]\n")
OPTION_CHARS = "cdanrwsp"
def _usage_error(message: str) -> tuple[None, IOResult, ExecutionNode]:
err = (message + USAGE).encode()
io = IOResult(exit_code=2, stderr=err)
return None, io, ExecutionNode(command="history", exit_code=2, stderr=err)
def _parse_args(
args: list[str]) -> tuple[dict[str, object], list[str], str | None]:
"""Parse history builtin args the way bash getopt does.
Args:
args (list[str]): Raw tokens after the command name.
Returns:
tuple[dict[str, object], list[str], str | None]: Flags dict,
operand texts, and an error message (None when parsing
succeeded). Any dash-leading token is option-parsed, digits
included (`history -1` is an invalid option in bash); `--` or
the first operand ends option parsing, so `history -s rm -rf`
stores "rm -rf" as text. `-d` takes the rest of its token as
the offset when attached (`-d3`, and `-dc` deletes entry "c"),
otherwise the next token.
"""
flags: dict[str, object] = {}
texts: list[str] = []
options_done = False
i = 0
while i < len(args):
token = args[i]
if options_done or token == "-" or not token.startswith("-"):
texts.append(token)
options_done = True
elif token == "--":
options_done = True
else:
j = 1
while j < len(token):
ch = token[j]
if ch not in OPTION_CHARS:
return {}, [], f"history: -{ch}: invalid option\n"
flags[ch] = True
if ch == "d":
rest = token[j + 1:]
if rest:
flags["d"] = rest
elif i + 1 < len(args):
i += 1
flags["d"] = args[i]
else:
return ({}, [],
"history: -d: option requires an argument\n")
break
j += 1
i += 1
return flags, texts, None
async def handle_history(
registry,
args: list[str],
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Dispatch the history shell builtin to the view mount.
GNU lookup order: builtins resolve before mount commands, so a
mount-local command named "history" can never shadow this one.
The actual semantics live on the /.bash_history view resource;
this handler only parses options and routes.
Args:
registry (MountRegistry): The workspace's mount registry.
args (list[str]): Raw builtin args (flags and counts).
session (Session): Calling session.
"""
flags, texts, error = _parse_args(args)
if error is not None:
return _usage_error(error)
try:
mount = registry.mount_for(HISTORY_PREFIX)
except ValueError:
err = b"history: not enabled for this workspace\n"
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="history",
exit_code=1,
stderr=err)
stream, io = await mount.execute_cmd("history", [],
texts,
flags,
cwd=session.cwd,
session_id=session.session_id)
# The view command always returns byte stderr, but io.stderr is typed
# as a ByteSource (a possible lazy stream); resolve it to bytes so the
# execution-tree node holds concrete stderr, never an unread stream.
stderr = await io.materialize_stderr()
return stream, io, ExecutionNode(command="history",
exit_code=io.exit_code,
stderr=stderr)
@@ -0,0 +1,260 @@
# ========= 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 dataclasses
import time
from collections.abc import Callable
from mirage.io import IOResult
from mirage.io.types import ByteSource
from mirage.types import FileType, PathSpec, word_text
from mirage.utils.path import CycleError, resolve_path
from mirage.workspace.mount.namespace import Namespace
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
def _split_flags(
args: list[str | PathSpec],
known: str,
) -> tuple[set[str], list[str | PathSpec]]:
flags: set[str] = set()
operands: list[str | PathSpec] = []
parsing = True
for arg in args:
s = arg.virtual if isinstance(arg, PathSpec) else str(arg)
if parsing and s == "--":
parsing = False
continue
if (parsing and s != "-" and len(s) >= 2 and s.startswith("-")
and all(c in known for c in s[1:])):
flags.update(s[1:])
continue
parsing = False
operands.append(arg)
return flags, operands
def link_flags(args: list[str | PathSpec], known: str) -> set[str]:
flags, _ = _split_flags(args, known)
return flags
def _abs(arg: str | PathSpec, cwd: str) -> str:
if isinstance(arg, PathSpec):
return arg.virtual
return resolve_path(arg, cwd)
def handle_ln(
namespace: Namespace,
session: Session,
args: list[str | PathSpec],
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
flags, operands = _split_flags(args, "sfnv")
if len(operands) < 2:
err = b"ln: missing file operand\n"
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="ln",
exit_code=1,
stderr=err)
# GNU: with more than two operands the last must be a directory;
# namespace links never name directories, so this is always an error
# (an expanded multi-match glob source lands here).
if len(operands) > 2:
err = (f"ln: target '{word_text(operands[-1])}' "
f"is not a directory\n").encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="ln",
exit_code=1,
stderr=err)
link_abs = _abs(operands[1], session.cwd)
target_typed = word_text(operands[0])
exists = namespace.is_link(link_abs) and "f" not in flags
if namespace.is_mount_root(link_abs) or exists:
err = (f"ln: failed to create symbolic link "
f"'{word_text(operands[1])}': File exists\n").encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="ln",
exit_code=1,
stderr=err)
namespace.symlink(link_abs, target_typed, time.time())
out = None
if "v" in flags:
out = (f"'{word_text(operands[1])}' -> '{target_typed}'\n").encode()
return out, IOResult(), ExecutionNode(command="ln", exit_code=0)
def follow_paths(
namespace: Namespace,
items: list[str | PathSpec],
) -> list[str | PathSpec]:
"""Rewrite path operands through the symlink table (open(2) semantics).
Non-path items and paths that resolve to themselves pass through
untouched. A rewritten spec keeps the user-typed form in ``raw_path``
so error messages still name the operand as typed; the mount re-stamps
``resource_path`` at dispatch.
Args:
namespace (Namespace): addressing authority holding the link table.
items (list[str | PathSpec]): classified command parts.
Raises:
CycleError: when a path loops past the hop limit (ELOOP).
"""
out: list[str | PathSpec] = []
for item in items:
if not isinstance(item, PathSpec):
out.append(item)
continue
try:
virtual = namespace.follow(item.virtual)
except CycleError:
raise CycleError(item.raw_path) from None
if virtual == item.virtual:
out.append(item)
continue
out.append(
dataclasses.replace(item,
virtual=virtual,
directory=virtual[:virtual.rfind("/") + 1]
or "/",
resource_path=""))
return out
def strip_link_operands(
namespace: Namespace,
items: list[str | PathSpec],
) -> tuple[list[str | PathSpec], int]:
"""Unlink and drop ``rm`` operands that are symlinks.
GNU ``rm`` removes the link itself and never follows it; a dangling
link removes fine. Remaining operands stay for backend dispatch.
Args:
namespace (Namespace): addressing authority holding the link table.
items (list[str | PathSpec]): classified command parts.
Returns:
tuple[list[str | PathSpec], int]: surviving parts and the number
of link entries removed.
"""
removed = 0
kept: list[str | PathSpec] = []
for item in items:
if isinstance(item, PathSpec) and namespace.is_link(item.virtual):
namespace.unlink(item.virtual)
removed += 1
continue
kept.append(item)
return kept, removed
async def prepare_mv(
namespace: Namespace,
dispatch: Callable,
items: list[str | PathSpec],
) -> tuple[list[str | PathSpec], str | None, tuple[ByteSource | None, IOResult,
ExecutionNode] | None]:
"""Adjust a two-operand ``mv`` for symlink operands.
A link source renames the link entry itself (into a destination
directory when one exists, mirroring rename(2) preceded by mv's dst
stat). A link destination whose target is a directory is followed
(mv moves into it); any other link destination is replaced, so its
entry must drop once the backend move succeeds.
Args:
namespace (Namespace): addressing authority holding the link table.
dispatch (Callable): op dispatcher used to stat the destination.
items (list[str | PathSpec]): classified command parts.
Returns:
tuple: (possibly rewritten parts, link path to unlink after a
successful backend move, early result when the mv completed as a
pure namespace rename).
"""
paths = [p for p in items if isinstance(p, PathSpec)]
if len(paths) != 2:
return items, None, None
src, dst = paths
if namespace.is_link(src.virtual):
target_dst = dst.virtual
stat = await _stat_or_none(dispatch, dst)
if stat is not None and stat.type == FileType.DIRECTORY:
target_dst = (dst.virtual.rstrip("/") + "/" +
src.virtual.rsplit("/", 1)[-1])
namespace.unlink(target_dst)
namespace.rename(src.virtual, target_dst)
return items, None, (None, IOResult(),
ExecutionNode(command="mv", exit_code=0))
if namespace.is_link(dst.virtual):
followed = namespace.follow(dst.virtual)
stat = await _stat_or_none(dispatch, PathSpec.from_str_path(followed))
if stat is not None and stat.type == FileType.DIRECTORY:
return follow_paths(namespace, items), None, None
return items, dst.virtual, None
return items, None, None
async def _stat_or_none(dispatch: Callable, path: PathSpec):
"""Stat a path via dispatch, mapping a missing file to ``None``.
Args:
dispatch (Callable): op dispatcher.
path (PathSpec): path to stat.
"""
# A missing destination is an expected mv case (plain rename), not an
# error to surface.
try:
stat, _ = await dispatch("stat", path)
except FileNotFoundError:
return None
return stat
def handle_readlink(
namespace: Namespace,
session: Session,
args: list[str | PathSpec],
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
flags, operands = _split_flags(args, "fenm")
if not operands:
err = b"readlink: missing operand\n"
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="readlink",
exit_code=1,
stderr=err)
lines: list[str] = []
exit_code = 0
for op in operands:
target = namespace.readlink(_abs(op, session.cwd))
if target is None:
exit_code = 1
continue
lines.append(target)
if not lines:
return None, IOResult(exit_code=exit_code), ExecutionNode(
command="readlink", exit_code=exit_code)
if "n" in flags:
text = "".join(lines)
else:
text = "".join(line + "\n" for line in lines)
return text.encode(), IOResult(exit_code=exit_code), ExecutionNode(
command="readlink", exit_code=exit_code)
@@ -0,0 +1,200 @@
# ========= 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. =========
from dataclasses import dataclass
from mirage.commands.config import RegisteredCommand
from mirage.commands.spec import SPECS, CommandSpec
from mirage.io import IOResult
from mirage.io.types import ByteSource
from mirage.workspace.mount.mount import MountEntry
from mirage.workspace.mount.registry import DEV_PREFIX, MountRegistry
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
@dataclass
class _ManHit:
mount: MountEntry
cmd: RegisteredCommand
is_general: bool
def _collect_man_hits(name: str, registry: MountRegistry) -> list[_ManHit]:
hits: list[_ManHit] = []
for mount in registry.mounts():
if mount.prefix == DEV_PREFIX:
continue
cmd = mount.resolve_command(name)
if cmd is None:
continue
hits.append(
_ManHit(mount=mount,
cmd=cmd,
is_general=mount.is_general_command(name)))
return hits
def _render_options_table(spec: CommandSpec) -> list[str]:
if not spec.options:
return []
lines: list[str] = []
lines.append("## OPTIONS")
lines.append("")
lines.append("| short | long | value | description |")
lines.append("| ----- | ---- | ----- | ----------- |")
for opt in spec.options:
short = opt.short if opt.short is not None else ""
long = opt.long if opt.long is not None else ""
desc = opt.description if opt.description is not None else ""
lines.append(f"| {short} | {long} | {opt.value_kind.value} | {desc} |")
lines.append("")
return lines
def _render_man_entry(name: str, hits: list[_ManHit]) -> str:
first = hits[0]
spec = first.cmd.spec
lines: list[str] = []
lines.append(f"# {name}")
lines.append("")
lines.append(spec.description if spec.
description is not None else "(no description)")
lines.append("")
lines.extend(_render_options_table(spec))
lines.append("## RESOURCES")
lines.append("")
seen: set[str] = set()
has_general = False
rows: list[str] = []
for h in hits:
if h.is_general:
has_general = True
continue
kind = h.mount.resource.name
filetype = h.cmd.filetype
key = f"{kind}\x00{filetype or ''}"
if key in seen:
continue
seen.add(key)
if filetype is not None:
rows.append(f"- {kind} (filetype: {filetype})")
else:
rows.append(f"- {kind}")
rows.sort()
if has_general:
lines.append("- general")
for r in rows:
lines.append(r)
return "\n".join(lines) + "\n"
_SHELL_BUILTIN_MAN: dict[str, str] = {
"bash": "bash",
"sh": "bash",
}
def _render_shell_builtin_man(name: str, spec: CommandSpec) -> str:
lines: list[str] = []
lines.append(f"# {name}")
lines.append("")
lines.append(spec.description if spec.
description is not None else "(no description)")
lines.append("")
lines.extend(_render_options_table(spec))
lines.append("## RESOURCES")
lines.append("")
lines.append("- shell builtin")
return "\n".join(lines) + "\n"
def _render_man_index(session: Session, registry: MountRegistry) -> str:
by_kind: dict[str, MountEntry] = {}
for m in registry.mounts():
if m.prefix == DEV_PREFIX:
continue
if m.resource.name not in by_kind:
by_kind[m.resource.name] = m
try:
cwd_mount: MountEntry | None = registry.mount_for(session.cwd)
except ValueError:
cwd_mount = None
cwd_kind: str | None = None
if cwd_mount is not None and cwd_mount.prefix != DEV_PREFIX:
cwd_kind = cwd_mount.resource.name
kinds = sorted(by_kind.keys())
ordered: list[str] = []
if cwd_kind is not None and cwd_kind in by_kind:
ordered.append(cwd_kind)
for k in kinds:
if k == cwd_kind:
continue
ordered.append(k)
lines: list[str] = []
general_seen: dict[str, RegisteredCommand] = {}
for kind in ordered:
m = by_kind[kind]
lines.append(f"# {kind}")
lines.append("")
all_cmds = m.all_commands()
resource_cmds = sorted(
(c for c in all_cmds if not m.is_general_command(c.name)),
key=lambda c: c.name,
)
for cmd in resource_cmds:
desc = (cmd.spec.description if cmd.spec.description is not None
else "(no description)")
lines.append(f"- {cmd.name} \u2014 {desc}")
for cmd in all_cmds:
if (m.is_general_command(cmd.name)
and cmd.name not in general_seen):
general_seen[cmd.name] = cmd
lines.append("")
lines.append("# general")
lines.append("")
for name in sorted(general_seen):
cmd = general_seen[name]
desc = (cmd.spec.description
if cmd.spec.description is not None else "(no description)")
lines.append(f"- {name} \u2014 {desc}")
return "\n".join(lines) + "\n"
async def handle_man(
args: list[str],
session: Session,
registry: MountRegistry,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
if not args:
out = _render_man_index(session, registry).encode()
return out, IOResult(), ExecutionNode(command="man", exit_code=0)
name = args[0]
hits = _collect_man_hits(name, registry)
if not hits:
spec_key = _SHELL_BUILTIN_MAN.get(name)
spec = SPECS.get(spec_key) if spec_key is not None else None
if spec is not None:
out = _render_shell_builtin_man(name, spec).encode()
return out, IOResult(), ExecutionNode(command=f"man {name}",
exit_code=0)
err = f"man: no entry for {name}\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=f"man {name}",
exit_code=1,
stderr=err)
out = _render_man_entry(name, hits).encode()
return out, IOResult(), ExecutionNode(command=f"man {name}", exit_code=0)
@@ -0,0 +1,32 @@
# ========= 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. =========
from mirage.types import PathSpec
def _to_scope(path: str) -> PathSpec:
"""Wrap a resolved path string as PathSpec."""
last_slash = path.rfind("/")
directory = path[:last_slash + 1] if last_slash >= 0 else "/"
return PathSpec(virtual=path,
directory=directory,
resource_path="",
resolved=True)
def _scope_path(val) -> str:
"""Extract path string from str or PathSpec."""
if isinstance(val, PathSpec):
return val.virtual
return val
@@ -0,0 +1,162 @@
# ========= 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 asyncio
import math
import re
from collections.abc import Callable
from mirage.io import IOResult
from mirage.io.stream import materialize
from mirage.io.types import ByteSource
from mirage.types import PathSpec
from mirage.utils.path import resolve_path
from mirage.workspace.abort import cancellable_sleep
from mirage.workspace.executor.builtins.scope import _scope_path, _to_scope
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
async def handle_source(
dispatch: Callable,
execute_fn: Callable,
path: str | PathSpec,
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Read a script file and execute it."""
raw = _scope_path(path)
resolved = resolve_path(raw, session.cwd)
scope = _to_scope(resolved)
data, _ = await dispatch("read", scope)
if isinstance(data, bytes):
script = data.decode(errors="replace")
else:
script = ""
io = await execute_fn(script, session_id=session.session_id)
return io.stdout, io, ExecutionNode(command=f"source {raw}",
exit_code=io.exit_code)
async def handle_eval(
execute_fn: Callable,
args: list[str],
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
script = " ".join(args)
io = await execute_fn(script, session_id=session.session_id)
return io.stdout, io, ExecutionNode(command="eval", exit_code=io.exit_code)
_BASH_NOOP_SHORT_FLAGS = frozenset({"l", "i", "e", "u", "x"})
_BASH_NOOP_LONG_FLAGS = frozenset(
{"--login", "--norc", "--noprofile", "--posix", "--rcfile"})
async def handle_bash(
execute_fn: Callable,
args: list[str],
session: Session,
stdin: ByteSource | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
script: str | None = None
read_stdin = False
i = 0
while i < len(args):
tok = args[i]
if tok == "--":
i += 1
break
if tok == "-c":
if i + 1 >= len(args):
err = b"bash: -c: option requires an argument\n"
return None, IOResult(exit_code=2, stderr=err), ExecutionNode(
command="bash", exit_code=2, stderr=err)
script = args[i + 1]
break
if tok == "-s":
read_stdin = True
i += 1
continue
if tok in ("-o", "+o"):
i += 2
continue
if tok in _BASH_NOOP_LONG_FLAGS:
i += 1
continue
if (tok.startswith("-") and len(tok) > 1 and not tok.startswith("--")):
chars = tok[1:]
if "c" in chars:
if i + 1 >= len(args):
err = b"bash: -c: option requires an argument\n"
return None, IOResult(
exit_code=2, stderr=err), ExecutionNode(command="bash",
exit_code=2,
stderr=err)
script = args[i + 1]
break
if all(ch in _BASH_NOOP_SHORT_FLAGS or ch == "s" for ch in chars):
if "s" in chars:
read_stdin = True
i += 1
continue
err = (f"bash: {tok}: unsupported option\n").encode()
return None, IOResult(exit_code=2,
stderr=err), ExecutionNode(command="bash",
exit_code=2,
stderr=err)
if script is None:
script = tok
break
i += 1
if script is None and read_stdin and stdin is not None:
stdin_data = await materialize(stdin)
if stdin_data:
script = stdin_data.decode(errors="replace")
stdin = None
if script is None:
return None, IOResult(), ExecutionNode(command="bash", exit_code=0)
io = await execute_fn(script, session_id=session.session_id, stdin=stdin)
return io.stdout, io, ExecutionNode(command=f"bash -c {script}",
exit_code=io.exit_code)
# Finite non-negative decimals only ("0", "0.2", ".5", "1.", "+1", "1e-3").
# GNU sleep additionally accepts "inf" and sleeps forever; an agent shell
# must never hang, so non-finite intervals are rejected (deliberate
# divergence). The regex also keeps Python/TypeScript parsing identical:
# float() alone would accept "inf", "nan", "1_0", and surrounding whitespace
# that Number() rejects, and Number() accepts hex that float() rejects.
SLEEP_INTERVAL = re.compile(r"\+?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?")
async def handle_sleep(
args: list[str],
cancel: asyncio.Event | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
if not args:
err = b"sleep: missing operand\n"
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="sleep",
exit_code=1)
raw = args[0]
# "1e309" passes the regex but overflows to inf, so check both.
seconds = float(raw) if SLEEP_INTERVAL.fullmatch(raw) else math.inf
if not math.isfinite(seconds):
err = f"sleep: invalid time interval '{raw}'\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="sleep",
exit_code=1)
await cancellable_sleep(seconds, cancel)
return None, IOResult(), ExecutionNode(command="sleep", exit_code=0)
@@ -0,0 +1,138 @@
# ========= 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. =========
from mirage.commands.spec.shell import ECHO_OPTION
from mirage.io import IOResult
from mirage.io.types import ByteSource
from mirage.workspace.types import ExecutionNode
_SIMPLE_ESCAPES = {
"\\": "\\",
"n": "\n",
"t": "\t",
"r": "\r",
"a": "\a",
"b": "\b",
"f": "\f",
"v": "\v",
}
_HEX = set("0123456789abcdefABCDEF")
_OCT = set("01234567")
def _interpret_escapes(text: str) -> str:
"""Process C-style escape sequences for echo -e.
Single-pass to handle \\\\ correctly (\\\\b → \\b literal).
Supports: \\\\, \\n, \\t, \\r, \\a, \\b, \\f, \\v,
\\xHH (hex), \\0NNN (octal), \\c (stop output).
Unknown escapes like \\z pass through as \\z.
"""
out: list[str] = []
i = 0
n = len(text)
while i < n:
if text[i] != "\\" or i + 1 >= n:
out.append(text[i])
i += 1
continue
ch = text[i + 1]
if ch in _SIMPLE_ESCAPES:
out.append(_SIMPLE_ESCAPES[ch])
i += 2
elif ch == "c":
break
elif ch == "x":
# \xHH — up to 2 hex digits
digits = []
j = i + 2
while j < n and len(digits) < 2 and text[j] in _HEX:
digits.append(text[j])
j += 1
if digits:
out.append(chr(int("".join(digits), 16)))
i = j
else:
out.append("\\x")
i += 2
elif ch == "0":
# \0NNN — up to 3 octal digits
digits = []
j = i + 2
while j < n and len(digits) < 3 and text[j] in _OCT:
digits.append(text[j])
j += 1
out.append(chr(int("".join(digits), 8)) if digits else "\0")
i = j
else:
# unknown escape — pass through literally
out.append("\\")
out.append(ch)
i += 2
return "".join(out)
async def handle_echo(
args: list[str], # noqa: E125
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Print arguments, honoring GNU echo's option rules.
GNU echo is not getopt: options are LEADING words matching
``-[neE]+`` only. The first word that does not match (including
``-x`` or a repeated ``hi -n``) ends option parsing and prints
literally. Within clusters the last of -e/-E wins; -n sticks.
Args:
args (list[str]): words after the command name, as typed.
"""
no_newline = False
escapes = False
idx = 0
for word in args:
if not ECHO_OPTION.fullmatch(word):
break
for ch in word[1:]:
if ch == "n":
no_newline = True
elif ch == "e":
escapes = True
else:
escapes = False
idx += 1
text = " ".join(args[idx:])
if escapes:
text = _interpret_escapes(text)
if not no_newline:
text += "\n"
out = text.encode()
return out, IOResult(), ExecutionNode(command="echo", exit_code=0)
async def handle_printf(
args: list[str], # noqa: E125
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
if not args:
return b"", IOResult(), ExecutionNode(command="printf", exit_code=0)
fmt = args[0]
fmt = fmt.replace("\\n", "\n").replace("\\t", "\t")
if len(args) > 1:
try:
out = (fmt % tuple(args[1:])).encode()
except TypeError:
out = fmt.encode()
else:
out = fmt.encode()
return out, IOResult(), ExecutionNode(command="printf", exit_code=0)
@@ -0,0 +1,122 @@
# ========= 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 asyncio
import re
import shlex
from collections.abc import Callable
from mirage.commands.spec.shell import SHELL_SPECS, parse_shell_options
from mirage.io import IOResult
from mirage.io.stream import materialize
from mirage.io.types import ByteSource
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
_DURATION = re.compile(r"(\d+(?:\.\d*)?|\.\d+)([smhd]?)")
_UNIT_SECONDS = {"": 1.0, "s": 1.0, "m": 60.0, "h": 3600.0, "d": 86400.0}
_UNSUPPORTED = ("s", "k", "preserve-status")
def _usage_error(message: str) -> tuple[None, IOResult, ExecutionNode]:
# GNU timeout reserves 125 for its own failures; 124 means the
# command was killed at the deadline.
stderr = f"timeout: {message}\n".encode()
return None, IOResult(exit_code=125,
stderr=stderr), ExecutionNode(command="timeout",
exit_code=125)
def parse_duration(raw: str) -> float | None:
"""Parse a GNU timeout duration (float plus optional s/m/h/d).
Args:
raw (str): duration operand as typed.
"""
match = _DURATION.fullmatch(raw)
if match is None:
return None
return float(match.group(1)) * _UNIT_SECONDS[match.group(2)]
async def handle_timeout(
execute_fn: Callable,
args: list[str],
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Run `timeout DURATION COMMAND [ARG...]`, killing at the deadline.
The inner line is built with shlex.join so already-expanded words
survive re-parsing as one token each (GNU timeout execs the command
without a shell). On overrun the inner run is cancelled and the
exit code is 124 like GNU. Signal options (-s, -k,
--preserve-status) are parsed but rejected: the inner run is a
coroutine, not a process, so there is nothing to signal.
Args:
execute_fn (Callable): shell evaluator for the inner line.
args (list[str]): options, duration operand, then the command.
session (Session): shell session state.
"""
parse = parse_shell_options(SHELL_SPECS["timeout"], args or [])
if parse.invalid is not None:
if parse.invalid.startswith("--"):
return _usage_error(f"unrecognized option '{parse.invalid}'")
return _usage_error(f"invalid option -- '{parse.invalid}'")
if parse.needs_value is not None:
return _usage_error(
f"option requires an argument -- '{parse.needs_value}'")
for name in _UNSUPPORTED:
if name in parse.flags:
dashes = "--" if len(name) > 1 else "-"
return _usage_error(f"unsupported option -- '{dashes}{name}'")
if len(parse.operands) < 2:
return _usage_error("missing operand")
raw = parse.operands[0]
seconds = parse_duration(raw)
if seconds is None:
return _usage_error(f"invalid time interval '{raw}'")
inner = shlex.join(parse.operands[1:])
try:
stdout, io = await asyncio.wait_for(
_execute_drained(execute_fn, inner, session.session_id),
timeout=seconds if seconds > 0 else None,
)
except asyncio.TimeoutError:
return None, IOResult(exit_code=124), ExecutionNode(command="timeout",
exit_code=124)
return stdout, io, ExecutionNode(command="timeout", exit_code=io.exit_code)
async def _execute_drained(
execute_fn: Callable,
inner: str,
session_id: str,
) -> tuple[bytes | None, IOResult]:
"""Run the inner line and drain its stdout under the same deadline.
A lazy inner pipeline produces bytes only when consumed; draining
inside the wait_for scope keeps the whole run under the limit.
Args:
execute_fn (Callable): shell evaluator for the inner line.
inner (str): the joined command line.
session_id (str): session to run in.
"""
io = await execute_fn(inner, session_id=session_id)
stdout = await materialize(io.stdout)
return stdout, io
@@ -0,0 +1,286 @@
# ========= 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. =========
from mirage.commands.spec.shell import SHELL_SPECS, parse_shell_options
from mirage.io import IOResult
from mirage.io.async_line_iterator import AsyncLineIterator
from mirage.io.stream import async_chain
from mirage.io.types import ByteSource
from mirage.shell.call_stack import CallStack
from mirage.shell.types import SET_FLAG_TO_OPTION
from mirage.workspace.executor.control import ReturnSignal
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
async def handle_export(
assignments: list[str],
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
for assign in assignments:
if "=" in assign:
key, _, val = assign.partition("=")
if key in session.readonly_vars:
err = f"bash: {key}: readonly variable\n".encode()
return None, IOResult(exit_code=1, stderr=err), ExecutionNode(
command="export", exit_code=1, stderr=err)
session.env[key] = val
else:
session.env.setdefault(assign, "")
return None, IOResult(), ExecutionNode(command="export", exit_code=0)
async def handle_readonly(
assignments: list[str],
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
for assign in assignments:
if "=" in assign:
key, _, val = assign.partition("=")
if key in session.readonly_vars:
err = f"bash: {key}: readonly variable\n".encode()
return None, IOResult(exit_code=1, stderr=err), ExecutionNode(
command="readonly", exit_code=1, stderr=err)
session.env[key] = val
session.readonly_vars.add(key)
else:
session.readonly_vars.add(assign)
return None, IOResult(), ExecutionNode(command="readonly", exit_code=0)
async def handle_unset(
names: list[str],
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
for name in names:
if name in session.readonly_vars:
err = (f"bash: unset: {name}: cannot unset: "
f"readonly variable\n").encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="unset",
exit_code=1,
stderr=err)
session.env.pop(name, None)
return None, IOResult(), ExecutionNode(command="unset", exit_code=0)
async def handle_printenv(
name: str | None,
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
if name:
val = session.env.get(name)
if val is None:
return None, IOResult(exit_code=1), ExecutionNode(
command="printenv", exit_code=1)
out = f"{val}\n".encode()
else:
lines = [f"{k}={v}" for k, v in session.env.items()]
out = ("\n".join(sorted(lines)) + "\n").encode()
return out, IOResult(), ExecutionNode(command="printenv", exit_code=0)
async def handle_whoami(
session: Session, # noqa: E125
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
user = session.env.get("USER")
if user is None:
err = b"whoami: USER not set\n"
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="whoami",
exit_code=1,
stderr=err)
out = f"{user}\n".encode()
return out, IOResult(), ExecutionNode(command="whoami", exit_code=0)
async def handle_read(
args: list[str],
session: Session,
stdin: ByteSource | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Read one line into variables, with bash's option handling.
Only -r is accepted (our read is already raw, so it is consumed
with no effect); anything else errors like bash instead of being
treated as a variable name.
Args:
args (list[str]): words after the command name.
session (Session): shell session state.
stdin (ByteSource | None): line source.
"""
parse = parse_shell_options(SHELL_SPECS["read"], args)
if parse.invalid is not None:
token = (parse.invalid
if parse.invalid.startswith("--") else f"-{parse.invalid}")
err = f"read: {token}: invalid option\n".encode()
return None, IOResult(exit_code=2,
stderr=err), ExecutionNode(command="read",
exit_code=2)
variables = parse.operands or ["REPLY"]
if session._stdin_buffer is None and stdin is not None:
if isinstance(stdin, bytes):
session._stdin_buffer = AsyncLineIterator(async_chain(stdin))
elif hasattr(stdin, "__aiter__"):
session._stdin_buffer = AsyncLineIterator(stdin)
line_bytes: bytes | None = None
if session._stdin_buffer is not None:
line_bytes = await session._stdin_buffer.readline()
if line_bytes is None:
for var in variables:
session.env[var] = ""
return None, IOResult(exit_code=1), ExecutionNode(command="read",
exit_code=1)
line = line_bytes.decode(errors="replace").rstrip("\n")
ifs = session.env.get("IFS", " \t\n")
if ifs == " \t\n":
parts = line.split(None, len(variables) - 1) if variables else []
elif not ifs:
parts = [line]
else:
n_splits = max(0, len(variables) - 1)
chars = set(ifs)
out: list[str] = []
cur: list[str] = []
for ch in line:
if ch in chars and len(out) < n_splits:
out.append("".join(cur))
cur = []
continue
cur.append(ch)
out.append("".join(cur))
parts = out
for i, var in enumerate(variables):
session.env[var] = parts[i] if i < len(parts) else ""
return None, IOResult(), ExecutionNode(command="read", exit_code=0)
async def handle_local(
assignments: list[str],
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
local_vars = getattr(session, "_local_vars", None)
for assign in assignments:
if "=" in assign:
key, _, val = assign.partition("=")
if local_vars is not None and key not in local_vars:
local_vars[key] = session.env.get(key)
session.env[key] = val
else:
if local_vars is not None and assign not in local_vars:
local_vars[assign] = session.env.get(assign)
session.env.setdefault(assign, "")
return None, IOResult(), ExecutionNode(command="local", exit_code=0)
async def handle_shift(
args: list[str],
call_stack: CallStack | None,
session: Session | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Shift positional parameters, with bash's argument checks.
Args:
args (list[str]): words after the command name; at most one,
the shift count.
call_stack (CallStack | None): function-call positional frames.
session (Session | None): shell session state.
"""
if len(args) > 1:
err = b"shift: too many arguments\n"
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="shift",
exit_code=1)
if args and not _is_shift_count(args[0]):
err = f"shift: {args[0]}: numeric argument required\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="shift",
exit_code=1)
n = int(args[0]) if args else 1
shifted = False
if call_stack is not None and call_stack.get_all_positional():
call_stack.shift(n)
shifted = True
if not shifted and session is not None:
pos = getattr(session, "positional_args", None)
if pos is not None:
session.positional_args = pos[n:]
return None, IOResult(), ExecutionNode(command="shift", exit_code=0)
def _is_shift_count(word: str) -> bool:
body = word[1:] if word[:1] in ("-", "+") else word
return body.isdigit()
async def handle_set(
args: list[str],
session: Session,
call_stack: CallStack | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
if not args:
lines = [f"{k}={v}" for k, v in session.env.items()]
out = ("\n".join(sorted(lines)) + "\n").encode()
return out, IOResult(), ExecutionNode(command="set", exit_code=0)
i = 0
while i < len(args):
tok = args[i]
if tok == "--":
session.positional_args = args[i + 1:]
return None, IOResult(), ExecutionNode(command="set", exit_code=0)
if tok in ("-o", "+o"):
if i + 1 < len(args):
session.shell_options[args[i + 1]] = (tok == "-o")
i += 2
continue
i += 1
continue
if (tok.startswith("-") or tok.startswith("+")) and len(tok) > 1:
enable = tok[0] == "-"
for ch in tok[1:]:
opt = SET_FLAG_TO_OPTION.get(ch)
if opt:
session.shell_options[opt] = enable
i += 1
continue
session.positional_args = args[i:]
break
return None, IOResult(), ExecutionNode(command="set", exit_code=0)
async def handle_trap(
session: Session, # noqa: E125
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
return None, IOResult(), ExecutionNode(command="trap", exit_code=0)
async def handle_return(
args: list[str], # noqa: E125
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Return from a function, with bash's argument check.
Args:
args (list[str]): words after the command name; at most one,
the return status.
"""
if args and not _is_shift_count(args[0]):
# bash prints the error and the function returns 2.
raise ReturnSignal(
2,
stderr=f"return: {args[0]}: numeric argument required\n".encode())
raise ReturnSignal(int(args[0]) if args else 0)
@@ -0,0 +1,124 @@
# ========= 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 shlex
from collections.abc import Callable
from mirage.commands.spec.shell import SHELL_SPECS, parse_shell_options
from mirage.io import IOResult
from mirage.io.stream import async_chain, materialize
from mirage.io.types import ByteSource
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
_UNSUPPORTED = ("I", "P")
def _usage_error(message: str) -> tuple[None, IOResult, ExecutionNode]:
stderr = f"xargs: {message}\n".encode()
return None, IOResult(exit_code=1,
stderr=stderr), ExecutionNode(command="xargs",
exit_code=1)
def _split_items(data: bytes, flags: dict[str, str | bool]) -> list[str]:
if flags.get("0") is True:
return [
chunk.decode(errors="replace") for chunk in data.split(b"\0")
if chunk
]
delim = flags.get("d")
if isinstance(delim, str):
delim = delim.replace("\\n", "\n").replace("\\t", "\t")
text = data.decode(errors="replace")
if text.endswith(delim):
text = text[:-len(delim)]
return text.split(delim) if text else []
return data.decode(errors="replace").split()
async def handle_xargs(
execute_fn: Callable,
args: list[str],
session: Session,
stdin: ByteSource | None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Run a command with words read from stdin appended (GNU xargs).
GNU xargs execs the command directly, so every input word must
reach it as exactly one argv token. The inner line is built with
shlex.join: a plain join would be re-parsed by the shell, splitting
words with whitespace and executing $(...) found in input.
Args:
execute_fn (Callable): shell evaluator for the inner line.
args (list[str]): options, then command name and initial
arguments; the command defaults to ["echo"] like GNU.
session (Session): shell session state.
stdin (ByteSource | None): input whose words become arguments.
"""
parse = parse_shell_options(SHELL_SPECS["xargs"], args or [])
if parse.invalid is not None:
if parse.invalid.startswith("--"):
return _usage_error(f"unrecognized option '{parse.invalid}'")
return _usage_error(f"invalid option -- '{parse.invalid}'")
if parse.needs_value is not None:
return _usage_error(
f"option requires an argument -- '{parse.needs_value}'")
for name in _UNSUPPORTED:
if name in parse.flags:
return _usage_error(f"unsupported option -- '{name}'")
max_args: int | None = None
raw_n = parse.flags.get("n")
if isinstance(raw_n, str):
if not raw_n.isdigit():
return _usage_error(f'invalid number "{raw_n}" for -n option')
max_args = int(raw_n)
if max_args < 1:
return _usage_error(f"value {raw_n} for -n option should be >= 1")
data = await materialize(stdin)
if data is None:
data = b""
items = _split_items(data, parse.flags)
if not items and parse.flags.get("r") is True:
return None, IOResult(), ExecutionNode(command="xargs", exit_code=0)
command = parse.operands or ["echo"]
if max_args is None:
batches = [items]
else:
batches = [
items[i:i + max_args] for i in range(0, len(items), max_args)
] or [[]]
stdouts: list[ByteSource] = []
merged = IOResult()
exit_code = 0
for batch in batches:
inner = shlex.join([*command, *batch])
io = await execute_fn(inner, session_id=session.session_id)
if io.stdout is not None:
stdouts.append(io.stdout)
merged = await merged.merge(io)
if io.exit_code in (126, 127):
# GNU xargs stops when the command cannot run or is missing.
exit_code = io.exit_code
break
if io.exit_code != 0:
# GNU exits 123 when any invocation fails, but keeps going.
exit_code = 123
merged.exit_code = exit_code
out = async_chain(*stdouts) if stdouts else None
return out, merged, ExecutionNode(command="xargs", exit_code=exit_code)
+759
View File
@@ -0,0 +1,759 @@
# ========= 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 functools
from collections.abc import Callable
from typing import NamedTuple
from mirage.commands.builtin.find_parse import (FindParseError, find_expr_tail,
parse_find_expression)
from mirage.commands.builtin.generic.crossmount import (handle_cross_mount,
is_cross_mount)
from mirage.commands.builtin.utils.safeguard import maybe_with_timeout
from mirage.commands.errors import UsageError
from mirage.commands.safeguard import resolve_across_mounts, resolve_safeguard
from mirage.commands.spec import (SPECS, CommandSpec, OperandKind,
flag_kwarg_name, parse_command,
parse_to_kwargs)
from mirage.commands.spec.usage import (missing_value_error,
unknown_option_error)
from mirage.io import IOResult
from mirage.io.stream import async_chain, materialize, wrap_cachable_streams
from mirage.io.types import ByteSource
from mirage.shell.call_stack import CallStack
from mirage.shell.job_table import JobTable
from mirage.shell.types import ERREXIT_EXEMPT_TYPES
from mirage.types import PathSpec, word_text
from mirage.utils.errors import FS_ERRORS, format_fs_error
from mirage.workspace.executor.control import ReturnSignal
from mirage.workspace.executor.fanout import (_fan_out_traversal,
_should_fan_out)
from mirage.workspace.executor.find_action_dispatch import _apply_find_actions
from mirage.workspace.executor.jobs import (handle_jobs, handle_kill,
handle_ps, handle_wait)
from mirage.workspace.mount import (MountCommandUnsupported, MountEntry,
MountRegistry)
from mirage.workspace.mount.namespace import Namespace
from mirage.workspace.route import JOB_BUILTINS, Consumer, route
from mirage.workspace.session import Session, assert_mount_allowed
from mirage.workspace.types import ExecutionNode
_FIND_ACTION_FLAGS = frozenset({"delete", "print0", "ls"})
async def _exec_node(cmd_str: str, io: IOResult,
paths: list[PathSpec]) -> ExecutionNode:
"""Build the recorded execution node, materializing any streamed stderr.
Args:
cmd_str (str): Original command text for the record.
io (IOResult): Command result whose stderr/exit_code the node carries.
paths (list[PathSpec]): Classified path operands, carried so the
lazy-stream drain can respell filesystem errors as typed.
"""
# The node is a recorded artifact (compared by value, serialized via a
# sync to_dict, sometimes read twice), so the live lazy io.stderr is
# materialized to concrete bytes here. On the cross-mount path it is bytes.
return ExecutionNode(command=cmd_str,
stderr=await materialize(io.stderr),
exit_code=io.exit_code,
paths=paths)
def _check_mount_root_guard_raw(
cmd_name: str,
paths: list[PathSpec],
registry: MountRegistry,
argv: list[str],
) -> tuple[str, int] | None:
"""Refuse destructive/conflicting ops targeting a mount root.
Fires before mount resolution / cross-mount routing so a refusal
message is consistent regardless of whether the operands span mounts.
Returns (stderr_message, exit_code) when the guard fires, else None.
Args:
cmd_name (str): command name (rm/mv/mkdir/touch/ln/...).
paths (list[PathSpec]): raw positional path arguments.
registry (MountRegistry): mount registry for is_mount_root checks.
argv (list[str]): raw argv after the command name (used to spot
shorthand flags like `mkdir -p` before _parse_flags runs).
"""
if not paths:
return None
def _is_root(p: PathSpec) -> bool:
return registry.is_mount_root(p.virtual)
if cmd_name in ("rm", "rmdir"):
for p in paths:
if _is_root(p):
if cmd_name == "rmdir":
msg = (f"rmdir: failed to remove '{p.virtual}': "
f"Device or resource busy\n")
else:
msg = (f"rm: cannot remove '{p.virtual}': "
f"Device or resource busy\n")
return msg, 1
elif cmd_name == "mv":
if _is_root(paths[0]):
dst = paths[1].virtual if len(paths) > 1 else "?"
msg = (f"mv: cannot move '{paths[0].virtual}' to '{dst}': "
f"Device or resource busy\n")
return msg, 1
elif cmd_name == "mkdir":
# GNU mkdir -p makes "already exists" a no-op.
for tok in argv:
if isinstance(tok,
str) and (tok == "-p" or tok == "--parents" or
(tok.startswith("-") and "p" in tok[1:]
and not tok.startswith("--"))):
return None
for p in paths:
if _is_root(p):
msg = (f"mkdir: cannot create directory '{p.virtual}': "
f"File exists\n")
return msg, 1
elif cmd_name == "touch":
for p in paths:
if _is_root(p):
msg = (f"touch: cannot touch '{p.virtual}': "
f"Is a directory\n")
return msg, 1
elif cmd_name == "ln":
if _is_root(paths[-1]):
msg = (f"ln: failed to create link '{paths[-1].virtual}': "
f"File exists\n")
return msg, 1
return None
def _scalar_find_flags(flag_kwargs: dict) -> dict:
# `repeatable=True` on find value-flags makes parse_to_kwargs emit
# lists; bespoke backend wrappers read these as scalars. Migrated
# backends read the expression from `texts` and ignore flag_kwargs.
return {
k: (v[-1] if isinstance(v, list) and v else v)
for k, v in flag_kwargs.items()
}
async def run_on_mount(
registry: MountRegistry,
session: Session,
dispatch: Callable,
namespace: Namespace | None,
cmd_name: str,
paths: list[PathSpec],
texts: list[str],
flag_kwargs: dict,
stdin: ByteSource | None = None,
resolve_hint: PathSpec | None = None,
mount: MountEntry | None = None,
) -> tuple[ByteSource | None, IOResult]:
"""Run one already-parsed command on the mount that owns its paths.
The shared single-mount execution tail: mount resolution, grant checks,
``execute_cmd``, filesystem-error formatting, ls/find post-processing,
and read/write key prefixing. ``handle_command`` uses it for the normal
path, and passes it (bound) to the cross-mount runners so each operand
executes natively on its owning mount.
Args:
registry (MountRegistry): Mount registry.
session (Session): Session providing cwd/env/session_id.
dispatch (Callable): Workspace operation dispatcher.
namespace (Namespace | None): Addressing authority for ls symlinks.
cmd_name (str): Command name.
paths (list[PathSpec]): Positional path operands (may hold globs;
the mount wrapper expands them natively).
texts (list[str]): Positional text operands.
flag_kwargs (dict): Parsed flags forwarded to the mount command.
stdin (ByteSource | None): Standard input for the command.
resolve_hint (PathSpec | None): Mount-resolution path when ``paths``
is empty (a stream command running in stdin mode).
mount: Pre-resolved mount; skips resolution and grant checks, which
the caller already performed.
"""
if mount is None:
resolve_paths = paths or ([resolve_hint] if resolve_hint else [])
try:
mount = await registry.resolve_mount(cmd_name, resolve_paths,
session.cwd)
except MountCommandUnsupported as exc:
return None, IOResult(exit_code=1, stderr=f"{exc}\n".encode())
if mount is None:
return None, IOResult(
exit_code=127,
stderr=f"{cmd_name}: command not found".encode())
try:
assert_mount_allowed(mount.prefix)
for ps in paths:
target = registry.mount_for(ps.virtual)
assert_mount_allowed(target.prefix)
except PermissionError as exc:
return None, IOResult(exit_code=1, stderr=f"{exc}\n".encode())
if cmd_name == "find":
flag_kwargs = _scalar_find_flags(flag_kwargs)
try:
stdout, io = await mount.execute_cmd(
cmd_name,
paths,
texts,
flag_kwargs,
stdin=stdin,
cwd=session.cwd,
dispatch=dispatch,
session_id=session.session_id,
env=session.env,
exec_allowed=registry.is_exec_allowed(),
)
except UsageError as exc:
# Command-owned usage errors (extra operands, missing patterns)
# become this command's IOResult so the rest of the line keeps
# running, like a real shell (#452).
return None, IOResult(exit_code=exc.exit_code,
stderr=f"{exc}\n".encode())
except FS_ERRORS as exc:
err = format_fs_error(cmd_name, exc, paths)
return None, IOResult(exit_code=1, stderr=err)
if cmd_name == "ls" and io.exit_code == 0:
stdout = await _inject_child_mounts(stdout, registry, paths,
flag_kwargs, session.cwd)
if namespace is not None and namespace.symlinks:
stdout = await _inject_links(stdout, namespace, paths, flag_kwargs,
session.cwd)
if cmd_name == "find":
stdout, action_err = await _apply_find_actions(stdout, flag_kwargs,
registry, session.cwd)
if action_err:
existing = await materialize(io.stderr) if io.stderr else b""
io.stderr = existing + action_err
if io.exit_code == 0:
io.exit_code = 1
prefix = mount.prefix.rstrip("/")
if prefix:
io.reads = {prefix + k: v for k, v in io.reads.items()}
io.writes = {prefix + k: v for k, v in io.writes.items()}
io.cache = [prefix + p for p in io.cache]
return wrap_cachable_streams(stdout, io)
class _ParsedCommand(NamedTuple):
paths: list[PathSpec]
texts: list[str]
flag_kwargs: dict[str, object]
warnings: list[str]
invalid_options: list[str]
needs_value_options: list[str]
def _parse_flags(
parts: list[str | PathSpec],
spec: CommandSpec | None,
cmd_name: str,
cwd: str,
str_flag_paths: bool = False,
) -> _ParsedCommand:
"""Parse flags from classified parts, recovering PathSpec for PATH values.
Single-mount dispatch and cross-mount dispatch both parse through
here, so flags, texts, and parser warnings cannot drift between the
two paths (a cross-mount `grep --bogus` used to lose its warning).
Args:
parts (list[str | PathSpec]): expanded command words after the
command name; path-classified words arrive as PathSpec.
spec (CommandSpec | None): command spec, from the owning mount on
the single-mount path or the shared SPECS registry on the
cross-mount path; None falls back to type separation.
cmd_name (str): command name used in warnings.
cwd (str): current working directory for relative path resolution.
str_flag_paths (bool): keep PATH flag values as their resolved
virtual-path strings instead of PathSpec. Cross-mount
strategies read flags through FlagView, which type-checks
str, so they get the string view.
Returns:
_ParsedCommand: positional paths, positional texts, parsed flag dict
(PATH flag values recovered to PathSpec, repeatable PATH flags to
list[PathSpec]), and parser warnings (e.g. ignored unknown options).
"""
# Build string argv and PathSpec lookup
argv = [
item.virtual if isinstance(item, PathSpec) else item for item in parts
]
scope_map: dict[str, PathSpec] = {}
for item in parts:
if isinstance(item, PathSpec):
scope_map[item.virtual] = item
stripped = item.virtual.rstrip("/")
if stripped and stripped != item.virtual:
scope_map[stripped] = item
if spec is not None:
parsed = parse_command(spec, argv, cwd=cwd)
flag_kwargs = parse_to_kwargs(parsed)
# Recover PathSpec for PATH flag values; repeatable PATH flags
# arrive as a list of resolved paths and become list[PathSpec].
# A relative PATH flag value cwd-resolved by parse_command (e.g.
# csplit -f part -> /data/part) is absent from scope_map, so build a
# PathSpec for it just like positional paths do, otherwise it never
# gets the mount prefix stripped.
repeat_path_keys = {
flag_kwarg_name(name)
for opt in spec.options
if opt.value_kind == OperandKind.PATH and opt.repeatable
for name in (opt.short, opt.long) if name
}
single_path_keys = {
flag_kwarg_name(name)
for opt in spec.options
if opt.value_kind == OperandKind.PATH and not opt.repeatable
for name in (opt.short, opt.long) if name
}
if not str_flag_paths:
for key, value in flag_kwargs.items():
if key in repeat_path_keys and isinstance(value, list):
flag_kwargs[key] = [
scope_map.get(
part,
PathSpec(virtual=part,
directory=part[:part.rfind("/") + 1]
or "/",
resource_path="",
resolved=True)) for part in value
]
elif key in single_path_keys and isinstance(value, str):
flag_kwargs[key] = scope_map.get(
value,
PathSpec(virtual=value,
directory=value[:value.rfind("/") + 1] or "/",
resource_path="",
resolved=True))
elif isinstance(value, str) and value in scope_map:
flag_kwargs[key] = scope_map[value]
# Classify positional args
paths: list[PathSpec] = []
texts: list[str] = []
for value, kind in parsed.args:
if kind == OperandKind.PATH:
scope = scope_map.get(value)
if scope is None:
scope = PathSpec(
virtual=value,
directory=value[:value.rfind("/") + 1] or "/",
resource_path="",
resolved=True,
)
paths.append(scope)
else:
texts.append(value)
return _ParsedCommand(paths, texts, flag_kwargs, parsed.warnings,
parsed.invalid_options,
parsed.needs_value_options)
# No spec: separate by type
paths = [item for item in parts if isinstance(item, PathSpec)]
texts = [item for item in parts if not isinstance(item, PathSpec)]
return _ParsedCommand(paths, texts, {}, [], [], [])
def _option_error(cmd_name: str,
parsed: _ParsedCommand) -> tuple[bytes, int] | None:
"""GNU-shaped refusal for option errors the parser reported.
find is exempt: its expression tokens are validated by
parse_find_expression, which raises the GNU predicate error itself.
Args:
cmd_name (str): command name for message shape and exit code.
parsed (_ParsedCommand): parse result carrying the reports.
"""
if cmd_name == "find":
return None
if parsed.invalid_options:
return unknown_option_error(cmd_name, parsed.invalid_options[0])
if parsed.needs_value_options:
return missing_value_error(cmd_name, parsed.needs_value_options[0])
return None
async def handle_command(
execute_node: Callable,
dispatch: Callable,
registry: MountRegistry,
parts: list[str | PathSpec],
session: Session,
stdin: ByteSource | None = None,
call_stack: CallStack | None = None,
job_table: JobTable | None = None,
namespace: Namespace | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Execute a simple command.
Parts are already classified: strings for text,
PathSpec for paths. Dispatches to mount.execute_cmd.
"""
if not parts:
return None, IOResult(), ExecutionNode(command="", exit_code=0)
cmd_name = str(parts[0])
cmd_str = " ".join(p.virtual if isinstance(p, PathSpec) else p
for p in parts)
# Job builtins
if cmd_name in JOB_BUILTINS and job_table is not None:
text_parts = [
p.virtual if isinstance(p, PathSpec) else p for p in parts
]
if cmd_name in ("wait", "fg"):
return await handle_wait(job_table, text_parts)
if cmd_name == "kill":
return await handle_kill(job_table, text_parts)
if cmd_name == "jobs":
return await handle_jobs(job_table, text_parts)
if cmd_name == "ps":
return await handle_ps(job_table, text_parts)
# Shell functions
if cmd_name in session.functions:
func_body = session.functions[cmd_name]
cs = call_stack or CallStack()
# Positional args carry the word as typed ($1 stays sub/a.txt).
text_args = [word_text(p) for p in parts[1:]]
cs.push(text_args, function_name=cmd_name)
saved_locals: dict[str, str | None] = {}
session._local_vars = saved_locals
try:
all_stdout: list = []
merged_io = IOResult()
last_exec = ExecutionNode(command=cmd_name, exit_code=0)
for cmd in func_body:
try:
stdout, io, last_exec = await execute_node(
cmd, session, stdin, cs)
except ReturnSignal as sig:
if sig.stderr:
merged_io = await merged_io.merge(
IOResult(stderr=sig.stderr))
merged_io.exit_code = sig.exit_code
break
if stdout is not None:
all_stdout.append(stdout)
merged_io = await merged_io.merge(io)
if (io.exit_code != 0 and session.shell_options.get("errexit")
and cmd.type not in ERREXIT_EXEMPT_TYPES):
merged_io.exit_code = io.exit_code
break
combined = async_chain(*all_stdout) if all_stdout else None
last_exec.exit_code = merged_io.exit_code
return combined, merged_io, last_exec
finally:
cs.pop()
for key, old_val in saved_locals.items():
if old_val is None:
session.env.pop(key, None)
else:
session.env[key] = old_val
session._local_vars = None
# Cross-mount: paths span different mounts (e.g. cp /ram/a /disk/b).
# Use dispatch to read/write across mounts directly.
path_scopes = [p for p in parts[1:] if isinstance(p, PathSpec)]
raw_argv = [p.virtual if isinstance(p, PathSpec) else p for p in parts[1:]]
early_guard = _check_mount_root_guard_raw(cmd_name, path_scopes, registry,
raw_argv)
if early_guard is not None:
msg, code = early_guard
return None, IOResult(exit_code=code,
stderr=msg.encode()), ExecutionNode(
command=cmd_str,
exit_code=code,
stderr=msg.encode())
# Unknown name: nobody registers it; fail like bash before any
# backend work. The mount-root guard stays ahead of this so
# protective refusals keep their specific messages.
if route(cmd_name, session, registry) is Consumer.UNKNOWN:
err = f"{cmd_name}: command not found\n".encode()
return None, IOResult(exit_code=127,
stderr=err), ExecutionNode(command=cmd_str,
exit_code=127,
stderr=err)
find_expr_tokens: list[str] | None = None
if cmd_name == "find":
find_expr_tokens = find_expr_tail(raw_argv)
try:
parse_find_expression(find_expr_tokens)
except FindParseError as exc:
msg = f"{exc}\n"
return None, IOResult(exit_code=1,
stderr=msg.encode()), ExecutionNode(
command=cmd_str,
exit_code=1,
stderr=msg.encode())
if is_cross_mount(cmd_name, path_scopes, registry):
# Cross-mount execution bypasses a resource command handler. Parse
# against the shared spec so flags and text operands do not depend on
# the source mount. The bound single-mount runner lets the strategy
# runners execute each operand natively on its owning mount.
cross_parsed = _parse_flags(parts[1:],
SPECS.get(cmd_name),
cmd_name,
session.cwd,
str_flag_paths=True)
cross_texts = (find_expr_tokens
if find_expr_tokens is not None else cross_parsed.texts)
cross_refusal = _option_error(cmd_name, cross_parsed)
if cross_refusal is not None:
msg, code = cross_refusal
return None, IOResult(exit_code=code,
stderr=msg), ExecutionNode(command=cmd_str,
exit_code=code,
stderr=msg)
run_single = functools.partial(run_on_mount, registry, session,
dispatch, namespace)
stdout, io = await handle_cross_mount(cmd_name,
path_scopes,
cross_texts,
cross_parsed.flag_kwargs,
dispatch,
run_single,
stdin=stdin)
if cross_parsed.warnings:
warn = "".join(f"{cmd_name}: {w}\n"
for w in cross_parsed.warnings).encode()
existing = await materialize(io.stderr) if io.stderr else b""
io.stderr = warn + existing
# The native sub-runs carry their own mount's safeguard; the
# cross-mount command as a whole uses the strictest one across the
# operand mounts, regardless of which sub-run merged last.
mounts = []
for s in path_scopes:
try:
mounts.append(registry.mount_for(s.virtual))
except ValueError:
pass
io.safeguard = (resolve_across_mounts(cmd_name, mounts)
if mounts else resolve_safeguard(cmd_name))
stdout = maybe_with_timeout(stdout, io.safeguard, cmd_name)
return stdout, io, await _exec_node(cmd_str, io, path_scopes)
# Reject unsupported cross-mount commands
if len(path_scopes) >= 2:
mount_prefixes = set()
for s in path_scopes:
try:
mount_prefixes.add(registry.mount_for(s.virtual).prefix)
except ValueError:
pass
if len(mount_prefixes) > 1:
prefixes_str = ", ".join(sorted(mount_prefixes))
err = (f"{cmd_name}: paths span multiple mounts "
f"({prefixes_str}), cross-mount not supported\n")
return None, IOResult(
exit_code=1,
stderr=err.encode(),
), ExecutionNode(command=cmd_str, exit_code=1)
try:
mount = await registry.resolve_mount(cmd_name, path_scopes,
session.cwd)
except MountCommandUnsupported as exc:
err = f"{exc}\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=cmd_str,
exit_code=1,
stderr=err)
if mount is None:
return None, IOResult(
exit_code=127,
stderr=f"{cmd_name}: command not found".encode(),
), ExecutionNode(command=cmd_str, exit_code=127)
try:
assert_mount_allowed(mount.prefix)
for ps in path_scopes:
target = registry.mount_for(ps.virtual)
assert_mount_allowed(target.prefix)
except PermissionError as exc:
err = f"{exc}\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=cmd_str,
exit_code=1,
stderr=err)
# Parse flags upstream — mount receives clean args
single_parsed = _parse_flags(parts[1:], mount.spec_for(cmd_name), cmd_name,
session.cwd)
paths, texts, flag_kwargs, parse_warnings = (single_parsed.paths,
single_parsed.texts,
single_parsed.flag_kwargs,
single_parsed.warnings)
refusal = _option_error(cmd_name, single_parsed)
if refusal is not None:
msg, code = refusal
return None, IOResult(exit_code=code,
stderr=msg), ExecutionNode(command=cmd_str,
exit_code=code,
stderr=msg)
if find_expr_tokens is not None:
texts = find_expr_tokens
flag_kwargs = _scalar_find_flags(flag_kwargs)
warn_bytes = ("".join(
f"{cmd_name}: {w}\n"
for w in parse_warnings).encode() if parse_warnings else b"")
if _should_fan_out(cmd_name, paths, flag_kwargs, registry):
stdout, io, node = await _fan_out_traversal(cmd_name, paths, texts,
flag_kwargs, registry,
mount, session.cwd,
cmd_str, stdin)
if warn_bytes:
existing = await materialize(io.stderr) if io.stderr else b""
io.stderr = warn_bytes + existing
node.stderr = warn_bytes + (node.stderr or b"")
return stdout, io, node
stdout, io = await run_on_mount(registry,
session,
dispatch,
namespace,
cmd_name,
paths,
texts,
flag_kwargs,
stdin=stdin,
mount=mount)
if warn_bytes:
existing = await materialize(io.stderr) if io.stderr else b""
io.stderr = warn_bytes + existing
stdout = maybe_with_timeout(stdout, io.safeguard, cmd_name)
io.stderr = maybe_with_timeout(io.stderr, io.safeguard, cmd_name)
return stdout, io, await _exec_node(cmd_str, io, paths)
async def _inject_links(
stdout: ByteSource | None,
namespace: Namespace,
paths: list[PathSpec],
flag_kwargs: dict,
cwd: str,
) -> ByteSource | None:
"""Append symlink entries living under the listed directory.
Links are namespace state, invisible to backend readdir, so ``ls``
surfaces them the same way child mounts are surfaced. Long form
renders GNU-style ``name -> target``.
Args:
stdout (ByteSource | None): backend ls output.
namespace (Namespace): addressing authority holding the link table.
paths (list[PathSpec]): positional ls operands.
flag_kwargs (dict): parsed ls flags.
cwd (str): current working directory fallback operand.
"""
if flag_kwargs.get("d") is True or flag_kwargs.get("R") is True:
return stdout
if len(paths) > 1:
return stdout
listed = paths[0].virtual if paths else cwd
links = namespace.links_under(listed)
if not links:
return stdout
existing_bytes = await materialize(stdout) if stdout is not None else b""
existing = existing_bytes.decode("utf-8")
long_form = flag_kwargs.get("args_l") is True
classify = flag_kwargs.get("F") is True
present: set[str] = set()
for line in existing.split("\n"):
if line == "":
continue
name = line.split("\t")[-1] if long_form else line.rstrip("/*@|=")
if name:
present.add(name)
extras: list[str] = []
for name in sorted(links):
if name in present:
continue
if long_form:
extras.append(f"l\t-\t-\t{name} -> {links[name]}")
else:
extras.append(f"{name}@" if classify else name)
if not extras:
return stdout
sep = "" if existing == "" or existing.endswith("\n") else "\n"
return (existing + sep + "\n".join(extras) + "\n").encode("utf-8")
async def _inject_child_mounts(
stdout: ByteSource | None,
registry: MountRegistry,
paths: list[PathSpec],
flag_kwargs: dict,
cwd: str,
) -> ByteSource | None:
if flag_kwargs.get("d") is True or flag_kwargs.get("R") is True:
return stdout
if len(paths) > 1:
return stdout
listed = paths[0].virtual if paths else cwd
include_hidden = (flag_kwargs.get("a") is True
or flag_kwargs.get("A") is True)
child_names = registry.child_mount_names(listed, include_hidden)
if not child_names:
return stdout
existing_bytes = await materialize(stdout) if stdout is not None else b""
existing = existing_bytes.decode("utf-8")
long_form = flag_kwargs.get("args_l") is True
classify = flag_kwargs.get("F") is True
present: set[str] = set()
for line in existing.split("\n"):
if line == "":
continue
if long_form:
name = line.split("\t")[-1]
else:
name = line.rstrip("/*@|=")
if name:
present.add(name)
extras: list[str] = []
for name in child_names:
if name in present:
continue
if long_form:
extras.append(f"d\t-\t-\t{name}")
else:
extras.append(f"{name}/" if classify else name)
if not extras:
return stdout
sep = "" if existing == "" or existing.endswith("\n") else "\n"
return (existing + sep + "\n".join(extras)).encode("utf-8")
+358
View File
@@ -0,0 +1,358 @@
# ========= 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 fnmatch
from collections.abc import Callable
import tree_sitter
from mirage.io import IOResult
from mirage.io.async_line_iterator import AsyncLineIterator
from mirage.io.stream import async_chain
from mirage.io.types import ByteSource
from mirage.shell.barrier import BarrierPolicy, apply_barrier
from mirage.shell.call_stack import CallStack
from mirage.shell.types import ERREXIT_EXEMPT_TYPES
from mirage.types import PathSpec, word_text
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
# Safety cap on while/until iterations. Independent of stdin size:
# even with lazy stdin (Step 15), a `while read` over a stream longer
# than this cap stops here. Cap-hit emits a stderr warning so callers
# notice silent truncation. Bump if agents process larger streams.
_MAX_WHILE = 10000
def _line_buffer(stdin: ByteSource) -> AsyncLineIterator:
"""Wrap a ByteSource (bytes or chunked async iter) as a line iterator."""
if isinstance(stdin, bytes):
return AsyncLineIterator(async_chain(stdin))
return AsyncLineIterator(stdin)
async def _execute_body(
execute_node: Callable,
body: list[tree_sitter.Node],
session: Session,
stdin: ByteSource | None,
call_stack: CallStack | None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Execute a list of body commands sequentially."""
all_stdout: list[ByteSource | None] = []
merged_io = IOResult()
last_exec = ExecutionNode(command="", exit_code=0)
for cmd in body:
try:
stdout, io, last_exec = await execute_node(cmd, session, stdin,
call_stack)
except BreakSignal as sig:
if sig.stdout is not None:
all_stdout.append(sig.stdout)
merged_io = await merged_io.merge(sig.io)
combined = async_chain(*[s for s in all_stdout
if s is not None]) if any(
s is not None
for s in all_stdout) else None
raise BreakSignal(stdout=combined, io=merged_io)
except ContinueSignal as sig:
if sig.stdout is not None:
all_stdout.append(sig.stdout)
merged_io = await merged_io.merge(sig.io)
combined = async_chain(*[s for s in all_stdout
if s is not None]) if any(
s is not None
for s in all_stdout) else None
raise ContinueSignal(stdout=combined, io=merged_io)
all_stdout.append(stdout)
merged_io = await merged_io.merge(io)
if (io.exit_code != 0 and session.shell_options.get("errexit")
and cmd.type not in ERREXIT_EXEMPT_TYPES):
merged_io.exit_code = io.exit_code
break
non_empty = [s for s in all_stdout if s is not None]
combined = async_chain(*non_empty) if non_empty else None
return combined, merged_io, last_exec
class BreakSignal(Exception):
def __init__(self, stdout=None, io=None):
self.stdout = stdout
self.io = io or IOResult()
class ContinueSignal(Exception):
def __init__(self, stdout=None, io=None):
self.stdout = stdout
self.io = io or IOResult()
class ReturnSignal(Exception):
def __init__(self, exit_code: int = 0, stderr: bytes = b"") -> None:
self.exit_code = exit_code
self.stderr = stderr
def _collect_loop_result(
all_stdout: list[ByteSource | None],
merged_io: IOResult,
label: str,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
exec_node = ExecutionNode(command=label, exit_code=merged_io.exit_code)
non_empty = [s for s in all_stdout if s is not None]
if not non_empty:
return None, merged_io, exec_node
return async_chain(*non_empty), merged_io, exec_node
async def handle_if(
execute_node: Callable,
branches: list[tuple[tree_sitter.Node, list[tree_sitter.Node]]],
else_body: list[tree_sitter.Node] | None,
session: Session,
stdin: ByteSource | None = None,
call_stack: CallStack | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
for condition, body in branches:
cond_stdout, cond_io, _ = await execute_node(condition, session, stdin,
call_stack)
await apply_barrier(cond_stdout, cond_io, BarrierPolicy.STATUS)
session.last_exit_code = cond_io.exit_code
if cond_io.exit_code == 0:
return await _execute_body(execute_node, body, session, stdin,
call_stack)
if else_body is not None:
return await _execute_body(execute_node, else_body, session, stdin,
call_stack)
return None, IOResult(), ExecutionNode(exit_code=0)
async def handle_for(
execute_node: Callable,
variable: str,
values: list[str | PathSpec],
body: list[tree_sitter.Node],
session: Session,
stdin: ByteSource | None = None,
call_stack: CallStack | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
merged_io = IOResult()
all_stdout: list[ByteSource | None] = []
saved = session.env.get(variable)
# Save and materialize stdin for re-reading across iterations
prev_buffer = session._stdin_buffer
if stdin is not None:
session._stdin_buffer = _line_buffer(stdin)
stdin = None
try:
for val in values:
# env stores strings only; bash keeps `for f in sub/*.txt`
# matches relative, so the loop variable takes the typed form
session.env[variable] = word_text(val)
try:
stdout, io, _ = await _execute_body(execute_node, body,
session, stdin, call_stack)
except BreakSignal as sig:
if sig.stdout is not None:
all_stdout.append(sig.stdout)
merged_io = await merged_io.merge(sig.io)
break
except ContinueSignal as sig:
if sig.stdout is not None:
all_stdout.append(sig.stdout)
merged_io = await merged_io.merge(sig.io)
continue
merged_io = await merged_io.merge(io)
all_stdout.append(stdout)
finally:
session._stdin_buffer = prev_buffer
if saved is not None:
session.env[variable] = saved
else:
session.env.pop(variable, None)
return _collect_loop_result(all_stdout, merged_io, "for")
async def _condition_loop(
execute_node: Callable,
condition: tree_sitter.Node,
body: list[tree_sitter.Node],
session: Session,
stdin: ByteSource | None,
call_stack: CallStack | None,
label: str,
break_on_zero: bool,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
merged_io = IOResult()
all_stdout: list[ByteSource | None] = []
prev_buffer = session._stdin_buffer
if stdin is not None:
session._stdin_buffer = _line_buffer(stdin)
stdin = None
try:
hit_limit = True
for _ in range(_MAX_WHILE):
cond_stdout, cond_io, _ = await execute_node(
condition, session, stdin, call_stack)
await apply_barrier(cond_stdout, cond_io, BarrierPolicy.STATUS)
session.last_exit_code = cond_io.exit_code
if break_on_zero and cond_io.exit_code == 0:
hit_limit = False
break
if (not break_on_zero and cond_io.exit_code != 0):
hit_limit = False
break
try:
stdout, io, _ = await _execute_body(execute_node, body,
session, stdin, call_stack)
except BreakSignal as sig:
hit_limit = False
if sig.stdout is not None:
all_stdout.append(sig.stdout)
merged_io = await merged_io.merge(sig.io)
break
except ContinueSignal as sig:
if sig.stdout is not None:
all_stdout.append(sig.stdout)
merged_io = await merged_io.merge(sig.io)
continue
merged_io = await merged_io.merge(io)
all_stdout.append(stdout)
if hit_limit:
warn = (f"warning: {label} loop terminated after "
f"{_MAX_WHILE} iterations\n").encode()
existing = merged_io.stderr
if isinstance(existing, bytes) and existing:
merged_io.stderr = existing + warn
else:
merged_io.stderr = warn
finally:
session._stdin_buffer = prev_buffer
return _collect_loop_result(all_stdout, merged_io, label)
async def handle_while(
execute_node: Callable,
condition: tree_sitter.Node,
body: list[tree_sitter.Node],
session: Session,
stdin: ByteSource | None = None,
call_stack: CallStack | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
return await _condition_loop(execute_node,
condition,
body,
session,
stdin,
call_stack,
"while",
break_on_zero=False)
async def handle_until(
execute_node: Callable,
condition: tree_sitter.Node,
body: list[tree_sitter.Node],
session: Session,
stdin: ByteSource | None = None,
call_stack: CallStack | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
return await _condition_loop(execute_node,
condition,
body,
session,
stdin,
call_stack,
"until",
break_on_zero=True)
async def handle_case(
execute_node: Callable,
word: str,
items: list[tuple[list[str], list[tree_sitter.Node]]],
session: Session,
stdin: ByteSource | None = None,
call_stack: CallStack | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
for patterns, body in items:
if any(fnmatch.fnmatch(word, p.strip()) for p in patterns):
all_stdout: list[ByteSource] = []
merged_io = IOResult()
last_exec = ExecutionNode(command="case", exit_code=0)
for stmt in body:
stdout, io, last_exec = await execute_node(
stmt, session, stdin, call_stack)
stdin = None
if stdout is not None:
all_stdout.append(stdout)
merged_io = await merged_io.merge(io)
if len(all_stdout) == 1:
return all_stdout[0], merged_io, last_exec
combined = async_chain(*all_stdout) if all_stdout else None
return combined, merged_io, last_exec
return None, IOResult(), ExecutionNode(command="case", exit_code=0)
async def handle_select(
execute_node: Callable,
variable: str,
values: list[str | PathSpec],
body: list[tree_sitter.Node],
session: Session,
stdin: ByteSource | None = None,
call_stack: CallStack | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
merged_io = IOResult()
all_stdout: list[ByteSource | None] = []
saved = session.env.get(variable)
# Save and materialize stdin for re-reading across iterations
prev_buffer = session._stdin_buffer
if stdin is not None:
session._stdin_buffer = _line_buffer(stdin)
stdin = None
try:
for val in values:
# env stores strings only; bash keeps `for f in sub/*.txt`
# matches relative, so the loop variable takes the typed form
session.env[variable] = word_text(val)
try:
stdout, io, _ = await _execute_body(execute_node, body,
session, stdin, call_stack)
except BreakSignal as sig:
if sig.stdout is not None:
all_stdout.append(sig.stdout)
merged_io = await merged_io.merge(sig.io)
break
except ContinueSignal as sig:
if sig.stdout is not None:
all_stdout.append(sig.stdout)
merged_io = await merged_io.merge(sig.io)
continue
merged_io = await merged_io.merge(io)
all_stdout.append(stdout)
finally:
session._stdin_buffer = prev_buffer
if saved is not None:
session.env[variable] = saved
else:
session.env.pop(variable, None)
return _collect_loop_result(all_stdout, merged_io, "select")
+347
View File
@@ -0,0 +1,347 @@
# ========= 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. =========
from mirage.commands.builtin.find_eval import FindEntry, keep
from mirage.commands.builtin.find_parse import parse_find_expression
from mirage.commands.errors import FindParseError
from mirage.commands.safeguard import resolve_across_mounts
from mirage.io import IOResult
from mirage.io.stream import materialize
from mirage.io.types import ByteSource
from mirage.types import PathSpec
from mirage.workspace.executor.find_action_dispatch import _apply_find_actions
from mirage.workspace.mount import MountRegistry
from mirage.workspace.types import ExecutionNode
_TRAVERSAL_CMDS = frozenset({"find", "tree", "du"})
def _path_segments(path: str) -> list[str]:
return [s for s in path.strip("/").split("/") if s]
def _should_fan_out(
cmd_name: str,
paths: list[PathSpec],
flag_kwargs: dict,
registry: MountRegistry,
) -> bool:
"""Whether `cmd` on this path should run across multiple mounts.
True when the command is in the traversal whitelist (find/tree/du)
and the path has at least one descendant mount; or for grep with
-r/-R; or for ls -R. Returns False when there's no descendant
mount under the path (single-mount dispatch is correct).
"""
if not paths:
return False
target = paths[0].virtual
if not registry.descendant_mounts(target):
return False
if cmd_name in _TRAVERSAL_CMDS:
return True
if cmd_name == "grep":
return (flag_kwargs.get("r") is True or flag_kwargs.get("R") is True
or flag_kwargs.get("recursive") is True)
if cmd_name == "ls":
return flag_kwargs.get("R") is True
return False
def _adjust_depth_flags(
flag_kwargs: dict,
parent_path: str,
mount_prefix: str,
) -> dict | None:
"""Adjust find's -maxdepth/-mindepth for a fan-out into a child mount.
Returns the new kwargs dict, or None if the child mount falls
outside the depth budget (caller should skip it).
"""
parent_depth = len(_path_segments(parent_path))
mount_depth = len(_path_segments(mount_prefix))
delta = mount_depth - parent_depth
new = dict(flag_kwargs)
if "maxdepth" in new:
raw_md = new["maxdepth"]
if isinstance(raw_md, list):
raw_md = raw_md[0] if raw_md else None
try:
md = int(raw_md) - delta
except (TypeError, ValueError):
md = None
if md is not None:
if md < 0:
return None
new["maxdepth"] = str(md)
if "mindepth" in new:
raw_mn = new["mindepth"]
if isinstance(raw_mn, list):
raw_mn = raw_mn[0] if raw_mn else None
try:
mn = max(0, int(raw_mn) - delta)
new["mindepth"] = str(mn)
except (TypeError, ValueError):
pass
return new
def _adjust_depth_texts(
texts: list[str],
parent_path: str,
mount_prefix: str,
) -> list[str]:
"""Adjust -maxdepth/-mindepth values inside a find expression.
The generic find parses depth from the expression tokens (`texts`),
not from flag kwargs, so a fan-out into a deeper child mount must
rewrite the depth values by the parent-to-mount delta. Mirrors
`_adjust_depth_flags`.
Args:
texts (list[str]): the find expression tokens.
parent_path (str): the find start path the fan-out runs from.
mount_prefix (str): the child mount prefix being descended into.
"""
delta = len(_path_segments(mount_prefix)) - len(
_path_segments(parent_path))
if delta == 0:
return list(texts)
out = list(texts)
i = 0
while i < len(out) - 1:
tok = out[i]
if tok in ("-maxdepth", "-mindepth"):
try:
val = int(out[i + 1])
except ValueError:
i += 2
continue
if tok == "-maxdepth":
out[i + 1] = str(val - delta)
else:
out[i + 1] = str(max(0, val - delta))
i += 2
continue
i += 1
return out
def _synthesize_find_mount_entries(
target_path: str,
descendants: list,
texts: list[str],
) -> str:
"""Return synthetic find lines for descendant mount roots.
`find /` and friends should list mount prefixes as directory
entries even though no per-mount find emits its own root. The find
expression is parsed into a predicate tree and evaluated per mount
root (kind "d"), mirroring the per-backend cores, so -not / -o /
-path / -type and the -maxdepth / -mindepth window all apply.
Args:
target_path (str): the find start path the fan-out runs from.
descendants (list): descendant mounts to inject as entries.
texts (list[str]): the find expression tokens.
"""
try:
expr = parse_find_expression(list(texts))
except FindParseError:
return ""
tree = expr.tree
max_depth = expr.maxdepth
min_depth = expr.mindepth if expr.mindepth is not None else 0
parent_depth = len(_path_segments(target_path))
out: list[str] = []
for m in descendants:
prefix_no_slash = m.prefix.rstrip("/")
depth = len(_path_segments(prefix_no_slash)) - parent_depth
if max_depth is not None and depth > max_depth:
continue
base = prefix_no_slash.rsplit("/", 1)[-1] or prefix_no_slash
entry = FindEntry(key=prefix_no_slash,
name=base,
kind="d",
depth=depth)
if not keep(entry, tree, min_depth):
continue
out.append(prefix_no_slash)
return "\n".join(out)
async def _filter_under_prefixes(
stdout: ByteSource,
descendant_prefixes: list[str],
) -> bytes:
"""Drop lines whose path falls under any descendant mount prefix.
Path is taken from the start of the line up to the first tab,
colon, or whitespace (handles find / du / grep output formats).
Lines that do not start with `/` are passed through.
"""
data = await materialize(stdout)
text = data.decode("utf-8", errors="replace")
out_lines: list[str] = []
for line in text.split("\n"):
if line == "":
continue
path = line
for sep in ("\t", ":"):
if sep in path:
path = path.split(sep, 1)[0]
break
if path.startswith("/"):
shadowed = False
for pre in descendant_prefixes:
if path == pre or path.startswith(pre + "/"):
shadowed = True
break
if shadowed:
continue
out_lines.append(line)
return ("\n".join(out_lines) + "\n").encode("utf-8") if out_lines else b""
async def _drop_mount_root_line(stdout: ByteSource, mount_root: str) -> bytes:
"""Drop a descendant find's own mount-root line.
A per-mount find now emits its start directory, so a descendant
mount's find yields its mount-root path. The mount-point entry is
instead synthesized centrally (with the mount's display name and the
full predicate tree applied), so the raw root line is dropped here to
avoid a duplicate that skips the name filter.
Args:
stdout (ByteSource): descendant find output.
mount_root (str): the descendant mount root path.
"""
data = await materialize(stdout)
text = data.decode("utf-8", errors="replace")
out_lines = [
line for line in text.split("\n") if line != "" and line != mount_root
]
return ("\n".join(out_lines) + "\n").encode("utf-8") if out_lines else b""
async def _fan_out_traversal(
cmd_name: str,
paths: list[PathSpec],
texts: list[str],
flag_kwargs: dict,
registry: MountRegistry,
primary_mount: object,
cwd: str,
cmd_str: str,
stdin: ByteSource | None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Run a traversal command across the parent mount + descendant mounts.
Each mount runs the command with its own root as the path argument
(depth flags adjusted for find/tree). Outputs are concatenated in
mount-prefix-sorted order. The parent mount's output is filtered to
drop lines that fall under any descendant mount (avoids duplicates
when the parent's resource has shadowed keys).
For `find`, mount-prefix paths themselves are injected as synthetic
directory entries (subject to depth and -type filters) because
mirage's per-mount find doesn't emit the path argument itself.
"""
target_path = paths[0].virtual
descendants = registry.descendant_mounts(target_path)
descendant_prefixes = [m.prefix.rstrip("/") for m in descendants]
all_stdout: list[bytes] = []
merged_io = IOResult()
final_exit = 0
success_seen = False
for mount in [primary_mount] + list(descendants):
if mount is primary_mount:
sub_paths = list(paths)
sub_flags = dict(flag_kwargs)
sub_texts = list(texts)
else:
mount_root = mount.prefix.rstrip("/") or "/"
sub_flags = _adjust_depth_flags(flag_kwargs, target_path,
mount.prefix)
if sub_flags is None:
continue
sub_texts = _adjust_depth_texts(texts, target_path, mount.prefix)
sub_paths = [
PathSpec(virtual=mount_root,
directory=mount_root,
resource_path="",
resolved=True)
]
try:
stdout, io = await mount.execute_cmd(cmd_name,
sub_paths,
sub_texts,
sub_flags,
stdin=stdin,
cwd=cwd)
except FindParseError:
# A bad numeric/size/mtime argument is a usage error that
# applies to every mount identically; fail the whole command
# instead of silently skipping mounts and exiting 0.
raise
except Exception:
continue
if mount is primary_mount and descendant_prefixes and stdout:
stdout = await _filter_under_prefixes(stdout, descendant_prefixes)
elif mount is not primary_mount and cmd_name == "find" and stdout:
stdout = await _drop_mount_root_line(stdout, mount_root)
if stdout is not None:
data = await materialize(stdout)
if data:
all_stdout.append(data)
if io.exit_code == 0:
success_seen = True
elif io.exit_code != 0 and final_exit == 0:
final_exit = io.exit_code
merged_io = await merged_io.merge(io)
if cmd_name == "find":
synthetic = _synthesize_find_mount_entries(target_path, descendants,
texts)
if synthetic:
all_stdout.append(synthetic.encode("utf-8"))
combined: ByteSource | None
if all_stdout:
combined = b"\n".join(b.rstrip(b"\n") for b in all_stdout) + b"\n"
else:
combined = None
final_io_exit = 0 if success_seen else final_exit
if cmd_name == "find":
combined, action_err = await _apply_find_actions(
combined, flag_kwargs, registry, cwd)
if action_err:
existing = (await materialize(merged_io.stderr)
if merged_io.stderr else b"")
merged_io.stderr = existing + action_err
if final_io_exit == 0:
final_io_exit = 1
merged_io.exit_code = final_io_exit
merged_io.safeguard = resolve_across_mounts(cmd_name,
[primary_mount, *descendants])
exec_node = ExecutionNode(command=cmd_str,
exit_code=final_io_exit,
stderr=merged_io.stderr)
return combined, merged_io, exec_node
@@ -0,0 +1,131 @@
# ========= 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. =========
from mirage.io.stream import materialize
from mirage.io.types import ByteSource
from mirage.types import PathSpec
from mirage.workspace.mount import MountRegistry
async def _apply_find_actions(
stdout: ByteSource | None,
flag_kwargs: dict,
registry: MountRegistry,
cwd: str,
) -> tuple[ByteSource | None, bytes]:
"""Apply find action flags (-delete / -print0 / -ls) to find output.
Per-resource find handlers only emit matched paths. This dispatcher
layer reads action flags and dispatches the side effect (rm for
-delete, ls -ld for -ls) per match through the appropriate mount,
then re-formats the output.
Args:
stdout (ByteSource | None): newline-joined match list from find.
flag_kwargs (dict): parsed flag dict; action flags read here.
registry (MountRegistry): used to route per-match dispatch.
cwd (str): cwd forwarded to per-match sub-dispatch.
"""
has_delete = flag_kwargs.get("delete") is True
has_print0 = flag_kwargs.get("print0") is True
has_ls = flag_kwargs.get("ls") is True
has_print = flag_kwargs.get("print") is True
if not (has_delete or has_print0 or has_ls):
return stdout, b""
if stdout is None:
return stdout, b""
text = (await materialize(stdout)).decode("utf-8", errors="replace")
matches = [p for p in text.split("\n") if p]
errors: list[bytes] = []
if has_delete:
# Deepest-first so children are removed before parents.
# Skip mount roots: mount points are structural, not
# unlinkable entries — refusing matches Unix semantics.
deletable = [p for p in matches if not registry.is_mount_root(p)]
ordered = sorted(deletable, key=lambda p: p.count("/"), reverse=True)
for path in ordered:
try:
mount = registry.mount_for(path)
except ValueError:
msg = f"find: cannot delete '{path}': no mount\n"
errors.append(msg.encode())
continue
ps = PathSpec(
virtual=path,
directory=path[:path.rfind("/") + 1] or "/",
resource_path="",
resolved=True,
)
try:
_, rm_io = await mount.execute_cmd("rm", [ps], [], {},
stdin=None,
cwd=cwd)
except (FileNotFoundError, NotADirectoryError, PermissionError,
ValueError) as exc:
errors.append(
f"find: cannot delete '{path}': {exc}\n".encode())
continue
if rm_io.exit_code != 0:
err = await materialize(rm_io.stderr) if rm_io.stderr else b""
if not err:
err = f"find: cannot delete '{path}'\n".encode()
errors.append(err)
# GNU find: -delete suppresses default print unless -print also set.
output_matches = matches if has_print else []
elif has_ls:
output_matches = []
for path in matches:
try:
mount = registry.mount_for(path)
except ValueError:
errors.append(f"find: cannot ls '{path}': no mount\n".encode())
continue
ps = PathSpec(
virtual=path,
directory=path[:path.rfind("/") + 1] or "/",
resource_path="",
resolved=True,
)
try:
ls_out, _ = await mount.execute_cmd("ls", [ps], [], {
"args_l": True,
"d": True
},
stdin=None,
cwd=cwd)
except (FileNotFoundError, NotADirectoryError, PermissionError,
ValueError) as exc:
errors.append(f"find: cannot ls '{path}': {exc}\n".encode())
continue
if ls_out is not None:
line = (await materialize(ls_out)).decode(
"utf-8", errors="replace").rstrip("\n")
if line:
output_matches.append(line)
else:
output_matches = matches
err_blob = b"".join(errors)
if not output_matches:
return None, err_blob
if has_print0:
body = b"\x00".join(m.encode("utf-8")
for m in output_matches) + b"\x00"
else:
body = ("\n".join(output_matches) + "\n").encode("utf-8")
return body, err_blob
+179
View File
@@ -0,0 +1,179 @@
# ========= 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 asyncio
import tree_sitter
from mirage.commands.builtin.utils.safeguard import CommandTimeoutError
from mirage.io import IOResult
from mirage.io.types import ByteSource, materialize
from mirage.shell.helpers import get_text
from mirage.shell.job_table import JobTable
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
async def handle_background(
execute_node,
left: tree_sitter.Node,
right: tree_sitter.Node | None,
session: Session,
job_table: JobTable | None,
agent_id: str | None,
stdin: ByteSource | None = None,
call_stack=None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Run left side in background."""
bg_session = session.fork()
async def _run_bg():
# Background jobs don't receive stdin, matching real shell
# behavior where bg processes get /dev/null. This prevents
# race conditions when stdin is an async iterator.
cmd_str_inner = get_text(left) if hasattr(left, "text") else str(left)
try:
stdout, io, exec_node = await execute_node(left, bg_session, None,
call_stack)
except CommandTimeoutError as exc:
msg = (str(exc) + "\n").encode()
io = IOResult(exit_code=124, stderr=msg)
exec_node = ExecutionNode(command=cmd_str_inner,
stderr=msg,
exit_code=124)
return b"", io, exec_node
stdout = await materialize(stdout)
# Eagerly materialize stderr too so JobTable._refresh can read
# the task result synchronously without an async hop.
await io.materialize_stderr()
io.sync_exit_code()
return stdout, io, exec_node
task = asyncio.create_task(_run_bg())
cmd_str = get_text(left) if hasattr(left, 'text') else str(left)
if job_table is not None:
job = job_table.submit(command=cmd_str,
task=task,
cwd=bg_session.cwd,
agent=agent_id or "",
session_id=session.session_id)
job_line = f"[{job.id}]\n".encode()
else:
job_line = b"[bg]\n"
if right is None:
return None, IOResult(stderr=job_line), ExecutionNode(
op="&",
exit_code=0,
children=[ExecutionNode(command=cmd_str, exit_code=0)])
right_stdout, right_io, right_exec = await execute_node(
right, session, stdin, call_stack)
left_stderr = await materialize(right_io.stderr)
right_io.stderr = (job_line + left_stderr if left_stderr else job_line)
children = [
ExecutionNode(command=cmd_str, exit_code=0),
right_exec,
]
return right_stdout, right_io, ExecutionNode(op="&",
exit_code=right_io.exit_code,
children=children)
async def handle_wait(
job_table: JobTable,
parts: list[str],
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
cmd_str = " ".join(parts)
if len(parts) <= 1:
await job_table.wait_all()
return None, IOResult(), ExecutionNode(command=cmd_str, exit_code=0)
raw = parts[1].lstrip("%")
try:
job_id = int(raw)
except ValueError:
err = f"wait: invalid job id: {parts[1]}\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=cmd_str,
exit_code=1,
stderr=err)
job = job_table.get(job_id)
if job is None:
err = f"wait: no such job: {job_id}\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=cmd_str,
exit_code=1,
stderr=err)
job = await job_table.wait(job_id)
return job.stdout, IOResult(
exit_code=job.exit_code,
stderr=job.stderr or None,
), ExecutionNode(command=cmd_str, exit_code=job.exit_code)
async def handle_kill(
job_table: JobTable,
parts: list[str],
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
cmd_str = " ".join(parts)
if len(parts) < 2:
err = b"kill: usage: kill <job_id>\n"
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=cmd_str,
exit_code=1,
stderr=err)
raw = parts[1].lstrip("%")
try:
job_id = int(raw)
except ValueError:
err = f"kill: invalid job id: {parts[1]}\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=cmd_str,
exit_code=1,
stderr=err)
killed = job_table.kill(job_id)
if not killed:
err = f"kill: no such job: {job_id}\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=cmd_str,
exit_code=1,
stderr=err)
return None, IOResult(), ExecutionNode(command=cmd_str, exit_code=0)
async def handle_jobs(
job_table: JobTable,
parts: list[str],
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
cmd_str = " ".join(parts)
lines = []
for job in job_table.list_jobs():
lines.append(f"[{job.id}] {job.status.value} {job.command}")
job_table.pop_completed()
out = ("\n".join(lines) + "\n").encode() if lines else b""
return out, IOResult(), ExecutionNode(command=cmd_str, exit_code=0)
async def handle_ps(
job_table: JobTable,
parts: list[str],
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
cmd_str = " ".join(parts)
running = job_table.running_jobs()
lines = []
for job in running:
lines.append(f"{job.id}\t{job.command}")
out = ("\n".join(lines) + "\n").encode() if lines else b""
return out, IOResult(), ExecutionNode(command=cmd_str, exit_code=0)
+215
View File
@@ -0,0 +1,215 @@
# ========= 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 tree_sitter
from mirage.commands.builtin.utils.safeguard import run_with_timeout
from mirage.io import IOResult
from mirage.io.stream import async_chain, close_quietly, merge_stdout_stderr
from mirage.io.types import ByteSource, materialize
from mirage.shell.barrier import BarrierPolicy, apply_barrier
from mirage.shell.call_stack import CallStack
from mirage.shell.types import ERREXIT_EXEMPT_TYPES
from mirage.shell.types import NodeType as NT
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
async def handle_pipe(
execute_node,
commands: list[tree_sitter.Node],
stderr_flags: list[bool],
session: Session,
stdin: ByteSource | None = None,
call_stack: CallStack | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Connect commands via pipes: stdout -> stdin."""
current_stdin = stdin
last_stdout: ByteSource | None = None
child_nodes: list[ExecutionNode] = []
ios: list[IOResult] = []
intermediate_streams: list[ByteSource] = []
try:
for i, cmd in enumerate(commands):
stdout, io, child_exec = await execute_node(
cmd, session, current_stdin, call_stack)
ios.append(io)
child_nodes.append(child_exec)
if i < len(commands) - 1:
pipe_stderr = (i < len(stderr_flags) and stderr_flags[i])
if pipe_stderr:
current_stdin = merge_stdout_stderr(stdout, io)
else:
current_stdin = stdout
if current_stdin is None:
current_stdin = b""
if not isinstance(current_stdin, bytes):
intermediate_streams.append(current_stdin)
last_stdout = stdout
if last_stdout is not None and not isinstance(last_stdout, bytes):
materialized = await run_with_timeout(
materialize(last_stdout), session.pipeline_timeout_seconds,
"pipeline")
last_stdout = materialized
finally:
# Explicitly close any intermediate generators that may still
# be holding resource resources (HTTP connections, file
# handles). Harmless on exhausted streams.
for s in intermediate_streams:
await close_quietly(s)
last_io = ios[-1]
last_io.sync_exit_code()
if session.shell_options.get("pipefail"):
for io in ios:
io.sync_exit_code()
rightmost_failure = next(
(io.exit_code for io in reversed(ios) if io.exit_code != 0), 0)
if rightmost_failure != 0:
last_io.exit_code = rightmost_failure
merged_stderr_parts: list[bytes] = []
merged_reads: dict[str, ByteSource] = {}
merged_writes: dict[str, ByteSource] = {}
merged_cache: list[str] = []
for io, child in zip(ios, child_nodes):
io.sync_exit_code()
child.exit_code = io.exit_code
stderr_bytes = await materialize(io.stderr)
if stderr_bytes:
merged_stderr_parts.append(stderr_bytes)
merged_reads.update(io.reads)
merged_writes.update(io.writes)
merged_cache.extend(io.cache)
if merged_stderr_parts:
last_io.stderr = b"".join(merged_stderr_parts)
last_io.reads = merged_reads
last_io.writes = merged_writes
last_io.cache = merged_cache
exec_node = ExecutionNode(op="|",
exit_code=last_io.exit_code,
children=child_nodes)
return last_stdout, last_io, exec_node
async def handle_connection(
execute_node,
left: tree_sitter.Node,
op: str,
right: tree_sitter.Node,
session: Session,
stdin: ByteSource | None = None,
call_stack: CallStack | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Handle &&, ||"""
left_stdout, left_io, left_exec = await execute_node(
left, session, stdin, call_stack)
children = [left_exec]
if op == NT.AND:
left_bytes = await apply_barrier(left_stdout, left_io,
BarrierPolicy.VALUE)
session.last_exit_code = left_io.exit_code
if left_io.exit_code != 0:
return left_bytes, left_io, ExecutionNode(
op="&&", exit_code=left_io.exit_code, children=children)
right_stdout, right_io, right_exec = (await execute_node(
right, session, stdin, call_stack))
children.append(right_exec)
right_bytes = await materialize(right_stdout)
merged = await left_io.merge(right_io)
combined = async_chain(left_bytes, right_bytes)
return combined, merged, ExecutionNode(op="&&",
exit_code=merged.exit_code,
children=children)
if op == NT.OR:
left_bytes = await apply_barrier(left_stdout, left_io,
BarrierPolicy.VALUE)
session.last_exit_code = left_io.exit_code
if left_io.exit_code == 0:
return left_bytes, left_io, ExecutionNode(
op="||", exit_code=left_io.exit_code, children=children)
right_stdout, right_io, right_exec = (await execute_node(
right, session, stdin, call_stack))
children.append(right_exec)
right_bytes = await materialize(right_stdout)
merged = await left_io.merge(right_io)
combined = async_chain(left_bytes, right_bytes)
return combined, merged, ExecutionNode(op="||",
exit_code=merged.exit_code,
children=children)
# semicolon or other
left_bytes = await apply_barrier(left_stdout, left_io, BarrierPolicy.VALUE)
session.last_exit_code = left_io.exit_code
right_stdout, right_io, right_exec = await execute_node(
right, session, stdin, call_stack)
children.append(right_exec)
# Materialize right side to match && and || behavior, ensuring
# lazy exit codes (e.g. from exit_on_empty) are finalized before
# the combined stream is returned to the caller.
right_bytes = await materialize(right_stdout)
merged = await left_io.merge(right_io)
combined = async_chain(left_bytes, right_bytes)
return combined, merged, ExecutionNode(op=str(op),
exit_code=merged.exit_code,
children=children)
async def handle_subshell(
execute_node,
body: list[tree_sitter.Node],
session: Session,
stdin: ByteSource | None = None,
call_stack: CallStack | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Execute body in isolated env."""
saved_cwd = session.cwd
saved_env = dict(session.env)
saved_options = dict(session.shell_options)
saved_readonly = set(session.readonly_vars)
saved_arrays = {k: list(v) for k, v in session.arrays.items()}
saved_functions = dict(session.functions)
saved_positional = list(getattr(session, "positional_args", None) or [])
try:
all_stdout: list = []
merged_io = IOResult()
last_exec = ExecutionNode(command="()", exit_code=0)
for child in body:
stdout, io, last_exec = await execute_node(child, session, stdin,
call_stack)
if stdout is not None:
all_stdout.append(stdout)
merged_io = await merged_io.merge(io)
if (io.exit_code != 0 and session.shell_options.get("errexit")
and child.type not in ERREXIT_EXEMPT_TYPES):
merged_io.exit_code = io.exit_code
break
if len(all_stdout) == 1:
return all_stdout[0], merged_io, last_exec
combined = async_chain(*all_stdout) if all_stdout else None
return combined, merged_io, last_exec
finally:
session.cwd = saved_cwd
session.env = saved_env
session.shell_options = saved_options
session.readonly_vars = saved_readonly
session.arrays = saved_arrays
session.functions = saved_functions
session.positional_args = saved_positional
@@ -0,0 +1,148 @@
# ========= 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 tree_sitter
from mirage.io import IOResult
from mirage.io.stream import materialize
from mirage.io.types import ByteSource
from mirage.shell.barrier import BarrierPolicy, apply_barrier
from mirage.shell.call_stack import CallStack
from mirage.shell.types import Redirect, RedirectKind
from mirage.types import PathSpec
from mirage.workspace.executor.builtins import _to_scope
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
async def handle_redirect(
execute_node,
dispatch,
command: tree_sitter.Node,
redirects: list[Redirect],
session: Session,
stdin: ByteSource | None = None,
call_stack: CallStack | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Handle all redirect patterns: >, >>, <, 2>, 2>&1, &>, >&2, <<<."""
cmd_stdin = stdin
for r in redirects:
if r.kind == RedirectKind.STDIN:
scope = _ensure_scope(r.target)
file_data, _ = await dispatch("read", scope)
cmd_stdin = file_data
elif r.kind == RedirectKind.HEREDOC:
cmd_stdin = r.target.encode() if isinstance(r.target,
str) else r.target
elif r.kind == RedirectKind.HERESTRING:
text = r.target
if isinstance(text, str):
if text.startswith('"') and text.endswith('"'):
text = text[1:-1]
elif text.startswith("'") and text.endswith("'"):
text = text[1:-1]
cmd_stdin = (text + "\n").encode()
else:
cmd_stdin = text
stdout, io, exec_node = await execute_node(command, session, cmd_stdin,
call_stack)
stdout_data = await apply_barrier(stdout, io, BarrierPolicy.VALUE)
if stdout_data is None:
stdout_data = b""
if isinstance(stdout_data, memoryview):
stdout_data = bytes(stdout_data)
stderr_data = await materialize(io.stderr)
result_stdout = stdout_data
result_stderr = stderr_data
for r in redirects:
stream = r.kind
append = r.append
fd = r.fd
if stream in (RedirectKind.STDIN, RedirectKind.HEREDOC,
RedirectKind.HERESTRING):
continue
# 2>&1 — merge stderr into stdout
if stream == RedirectKind.STDERR_TO_STDOUT and isinstance(
r.target, int):
result_stdout = (result_stdout or b"") + (result_stderr or b"")
result_stderr = None
continue
# >&2 or 1>&2 — stdout to stderr
if fd == 1 and isinstance(r.target, int) and r.target == 2:
result_stderr = (result_stderr or b"") + (result_stdout or b"")
result_stdout = None
continue
scope = _ensure_scope(r.target)
path = scope.virtual
# &> or &>> — both stdout+stderr to file
if fd == -1:
combined = (result_stdout or b"") + (result_stderr or b"")
if append:
combined = await _append_existing(dispatch, scope, combined)
await dispatch("write", scope, data=combined)
io.writes[path] = combined
result_stdout = None
result_stderr = None
continue
# 2> file — stderr to file
if stream == RedirectKind.STDERR:
data = result_stderr or b""
if append:
data = await _append_existing(dispatch, scope, data)
if data:
await dispatch("write", scope, data=data)
io.writes[path] = data
result_stderr = None
continue
# > or >> — stdout to file
data = result_stdout or b""
if append:
data = await _append_existing(dispatch, scope, data)
await dispatch("write", scope, data=data)
io.writes[path] = data
result_stdout = None
io.stderr = result_stderr
exec_node = ExecutionNode(command="redirect", exit_code=io.exit_code)
return result_stdout if result_stdout else None, io, exec_node
async def _append_existing(dispatch, scope, data):
try:
existing, _ = await dispatch("read", scope)
if isinstance(existing, bytes):
return existing + data
except FileNotFoundError:
pass
return data
def _ensure_scope(target):
if isinstance(target, PathSpec):
return target
if isinstance(target, str):
return _to_scope(target)
return _to_scope(str(target))