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
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:
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user