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,19 @@
|
||||
# ========= 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.node.execute_node import execute_node
|
||||
from mirage.workspace.node.provision_node import provision_node
|
||||
from mirage.workspace.node.run_tree import run_command_tree
|
||||
|
||||
__all__ = ["execute_node", "provision_node", "run_command_tree"]
|
||||
@@ -0,0 +1,437 @@
|
||||
# ========= 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
|
||||
from typing import Any
|
||||
|
||||
from mirage.commands.builtin.utils.safeguard import run_with_timeout
|
||||
from mirage.commands.safeguard import resolve_safeguard
|
||||
from mirage.io import IOResult
|
||||
from mirage.shell.types import NodeType as NT
|
||||
from mirage.shell.types import ShellBuiltin as SB
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.path import CycleError
|
||||
from mirage.workspace.executor.command import handle_command
|
||||
from mirage.workspace.executor.control import BreakSignal, ContinueSignal
|
||||
from mirage.workspace.expand import expand_node
|
||||
from mirage.workspace.expand.argv import Argv, expand_argv
|
||||
from mirage.workspace.expand.classify import classify_bare_path
|
||||
from mirage.workspace.route import NO_FOLLOW_COMMANDS, UNSUPPORTED_BUILTINS
|
||||
from mirage.workspace.session.shell_dirs import home_dir
|
||||
from mirage.workspace.types import ExecutionNode
|
||||
|
||||
from mirage.shell.helpers import ( # isort: skip
|
||||
ProcessSubDirection, get_command_name, get_parts,
|
||||
get_process_sub_direction, get_text, split_env_prefix)
|
||||
from mirage.workspace.executor.builtins import ( # isort: skip
|
||||
follow_paths, handle_bash, handle_cd, handle_echo, handle_eval,
|
||||
handle_export, handle_history, handle_ln, handle_local, handle_man,
|
||||
handle_printenv, handle_printf, handle_read, handle_readlink,
|
||||
handle_return, handle_set, handle_shift, handle_sleep, handle_source,
|
||||
handle_test, handle_timeout, handle_trap, handle_unset, handle_whoami,
|
||||
handle_xargs, link_flags, prepare_mv, strip_link_operands)
|
||||
|
||||
_CdArgs = list[str | PathSpec]
|
||||
|
||||
|
||||
def _split_cd_options(args: _CdArgs) -> tuple[_CdArgs, str | None, bool]:
|
||||
"""Split leading ``cd`` option flags from the directory operand.
|
||||
|
||||
Accepts the GNU ``cd`` options ``-L -P -e -@`` (and clusters such as
|
||||
``-LP``) plus a ``--`` end-of-options marker; a bare ``-`` is the
|
||||
OLDPWD operand, not an option.
|
||||
|
||||
Args:
|
||||
args: The classified arguments after the ``cd`` command name.
|
||||
|
||||
Returns:
|
||||
``(operands, bad, physical)`` where ``operands`` are the non-option
|
||||
args, ``bad`` is the first unknown option character (or ``None``),
|
||||
and ``physical`` is True when ``-P`` is the effective (last-wins)
|
||||
mode.
|
||||
"""
|
||||
operands: _CdArgs = []
|
||||
parsing = True
|
||||
physical = False
|
||||
for arg in args:
|
||||
s = arg.virtual if isinstance(arg, PathSpec) else str(arg)
|
||||
if parsing:
|
||||
if s == "--":
|
||||
parsing = False
|
||||
continue
|
||||
if s != "-" and len(s) >= 2 and s.startswith("-"):
|
||||
bad = next((c for c in s[1:] if c not in "LPe@"), None)
|
||||
if bad is None:
|
||||
for c in s[1:]:
|
||||
if c == "P":
|
||||
physical = True
|
||||
elif c == "L":
|
||||
physical = False
|
||||
continue
|
||||
return operands, bad, physical
|
||||
parsing = False
|
||||
operands.append(arg)
|
||||
return operands, None, physical
|
||||
|
||||
|
||||
async def execute_command(
|
||||
recurse,
|
||||
dispatch,
|
||||
registry,
|
||||
namespace,
|
||||
execute_fn,
|
||||
node,
|
||||
session,
|
||||
stdin,
|
||||
call_stack,
|
||||
job_table,
|
||||
cancel: asyncio.Event | None = None,
|
||||
) -> tuple[Any, IOResult, ExecutionNode]:
|
||||
"""Dispatch a command node by name."""
|
||||
name = get_command_name(node)
|
||||
assignment_nodes, parts = split_env_prefix(get_parts(node))
|
||||
|
||||
prefix_assignments: list[tuple[str, str]] = []
|
||||
for p in assignment_nodes:
|
||||
atext = get_text(p)
|
||||
if "=" not in atext:
|
||||
continue
|
||||
key, _, raw_val = atext.partition("=")
|
||||
val_nodes = [c for c in p.named_children if c.type != NT.VARIABLE_NAME]
|
||||
if val_nodes:
|
||||
v = await expand_node(val_nodes[0], session, execute_fn,
|
||||
call_stack)
|
||||
else:
|
||||
v = raw_val
|
||||
prefix_assignments.append((key, v))
|
||||
|
||||
for k, _ in prefix_assignments:
|
||||
if k in session.readonly_vars:
|
||||
err = f"bash: {k}: readonly variable\n".encode()
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command=name or k,
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
|
||||
if prefix_assignments and not name:
|
||||
for k, v in prefix_assignments:
|
||||
session.env[k] = v
|
||||
return None, IOResult(), ExecutionNode(command=" ".join(
|
||||
f"{k}={v}" for k, v in prefix_assignments),
|
||||
exit_code=0)
|
||||
|
||||
is_function_call = name in session.functions
|
||||
saved_env_overrides: dict[str, str | None] = {}
|
||||
for k, v in prefix_assignments:
|
||||
if not is_function_call:
|
||||
saved_env_overrides[k] = session.env.get(k)
|
||||
session.env[k] = v
|
||||
|
||||
try:
|
||||
return await _dispatch_command_body(recurse, dispatch, registry,
|
||||
namespace, execute_fn, node, parts,
|
||||
name, session, stdin, call_stack,
|
||||
job_table, cancel)
|
||||
finally:
|
||||
for k, prev in saved_env_overrides.items():
|
||||
if prev is None:
|
||||
session.env.pop(k, None)
|
||||
else:
|
||||
session.env[k] = prev
|
||||
|
||||
|
||||
async def _dispatch_command_body(
|
||||
recurse,
|
||||
dispatch,
|
||||
registry,
|
||||
namespace,
|
||||
execute_fn,
|
||||
node,
|
||||
parts,
|
||||
name,
|
||||
session,
|
||||
stdin,
|
||||
call_stack,
|
||||
job_table,
|
||||
cancel: asyncio.Event | None = None,
|
||||
) -> tuple[Any, IOResult, ExecutionNode]:
|
||||
for child in node.named_children:
|
||||
if child.type == NT.HERESTRING_REDIRECT:
|
||||
for sc in child.named_children:
|
||||
content = await expand_node(sc, session, execute_fn,
|
||||
call_stack)
|
||||
stdin = content.encode() + b"\n"
|
||||
break
|
||||
|
||||
# Process substitution: <(cmd) feeds inner stdout as stdin.
|
||||
# Output direction >(cmd) is unsupported; reject early so the
|
||||
# caller sees a capability gap rather than a silent no-op.
|
||||
proc_sub_parts = []
|
||||
clean_parts = []
|
||||
for p in parts:
|
||||
if hasattr(p, "type") and p.type == NT.PROCESS_SUBSTITUTION:
|
||||
if get_process_sub_direction(p) == ProcessSubDirection.OUTPUT:
|
||||
err = b"mirage: unsupported: process substitution >(...)\n"
|
||||
return None, IOResult(exit_code=2, stderr=err), ExecutionNode(
|
||||
command=name or "process_sub", exit_code=2, stderr=err)
|
||||
inner_cmds = [c for c in p.named_children if c.type == NT.COMMAND]
|
||||
if inner_cmds:
|
||||
io_ps = await execute_fn(get_text(inner_cmds[0]),
|
||||
session_id=session.session_id)
|
||||
proc_sub_parts.append(io_ps.stdout or b"")
|
||||
else:
|
||||
clean_parts.append(p)
|
||||
if proc_sub_parts and stdin is None:
|
||||
stdin = b"".join(proc_sub_parts)
|
||||
parts = clean_parts
|
||||
|
||||
argv = await expand_argv(parts, session, execute_fn, call_stack, registry)
|
||||
|
||||
# Safeguards resolve against the expanded name, so `$CMD`-style
|
||||
# invocations get their real command's policy.
|
||||
resolved = resolve_safeguard(argv.name) if argv.name else None
|
||||
timeout = (resolved.timeout_seconds if resolved is not None else None)
|
||||
body = _run_argv(recurse, dispatch, registry, namespace, execute_fn, argv,
|
||||
session, stdin, call_stack, job_table, cancel)
|
||||
return await run_with_timeout(body, timeout, argv.name or "?")
|
||||
|
||||
|
||||
async def _run_argv(
|
||||
recurse,
|
||||
dispatch,
|
||||
registry,
|
||||
namespace,
|
||||
execute_fn,
|
||||
argv: Argv,
|
||||
session,
|
||||
stdin,
|
||||
call_stack,
|
||||
job_table,
|
||||
cancel: asyncio.Event | None = None,
|
||||
) -> tuple[Any, IOResult, ExecutionNode]:
|
||||
"""Route one expanded command to its builtin or mount handler."""
|
||||
name = argv.name
|
||||
args = list(argv.args)
|
||||
operands = list(argv.operands)
|
||||
|
||||
# ── unsupported bash builtins ──────────────
|
||||
# Constructs the parser accepts but the executor cannot honor.
|
||||
# Returning a clear error lets LLMs detect a capability gap instead
|
||||
# of treating it as a missing binary or a silent no-op.
|
||||
if name in UNSUPPORTED_BUILTINS:
|
||||
err = f"mirage: unsupported builtin: {name}\n".encode()
|
||||
return None, IOResult(exit_code=2,
|
||||
stderr=err), ExecutionNode(command=name,
|
||||
exit_code=2,
|
||||
stderr=err)
|
||||
|
||||
# ── shell builtins ──────────────────────────
|
||||
if name == SB.PWD:
|
||||
out = (session.cwd + "\n").encode()
|
||||
return out, IOResult(), ExecutionNode(command="pwd", exit_code=0)
|
||||
|
||||
if name == SB.CD:
|
||||
cd_operands, bad_opt, physical = _split_cd_options(operands)
|
||||
if bad_opt is not None:
|
||||
err = (f"cd: -{bad_opt}: invalid option\n"
|
||||
f"cd: usage: cd [-L|[-P [-e]] [-@]] [dir]\n").encode()
|
||||
return None, IOResult(exit_code=2,
|
||||
stderr=err), ExecutionNode(command="cd",
|
||||
exit_code=2,
|
||||
stderr=err)
|
||||
if len(cd_operands) > 1:
|
||||
err = b"cd: too many arguments\n"
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command="cd",
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
if not cd_operands:
|
||||
home = home_dir(session)
|
||||
if home is None:
|
||||
err = b"cd: HOME not set\n"
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command="cd",
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
return await handle_cd(dispatch,
|
||||
registry.is_mount_root,
|
||||
home,
|
||||
session,
|
||||
links=namespace.symlink_targets(),
|
||||
physical=physical)
|
||||
raw = cd_operands[0]
|
||||
raw_str = raw.virtual if isinstance(raw, PathSpec) else str(raw)
|
||||
if raw_str == "-":
|
||||
old = session.env.get("OLDPWD")
|
||||
if not old:
|
||||
err = b"cd: OLDPWD not set\n"
|
||||
return None, IOResult(exit_code=1, stderr=err), ExecutionNode(
|
||||
command="cd -", exit_code=1, stderr=err)
|
||||
return await handle_cd(dispatch,
|
||||
registry.is_mount_root,
|
||||
old,
|
||||
session,
|
||||
print_path=True,
|
||||
links=namespace.symlink_targets(),
|
||||
physical=physical)
|
||||
if isinstance(raw, PathSpec):
|
||||
path = raw
|
||||
cdpath_target = raw.raw_path
|
||||
elif raw_str.startswith("/"):
|
||||
path = raw_str
|
||||
cdpath_target = raw_str
|
||||
else:
|
||||
path = classify_bare_path(raw_str, registry, session.cwd)
|
||||
cdpath_target = raw_str
|
||||
return await handle_cd(dispatch,
|
||||
registry.is_mount_root,
|
||||
path,
|
||||
session,
|
||||
cdpath_target=cdpath_target,
|
||||
links=namespace.symlink_targets(),
|
||||
physical=physical)
|
||||
|
||||
if name == SB.HISTORY:
|
||||
return await handle_history(registry, args, session)
|
||||
|
||||
if name == SB.TRUE:
|
||||
return None, IOResult(), ExecutionNode(command="true", exit_code=0)
|
||||
|
||||
if name == SB.FALSE:
|
||||
return None, IOResult(exit_code=1), ExecutionNode(command="false",
|
||||
exit_code=1)
|
||||
|
||||
if name in (SB.SOURCE, SB.DOT):
|
||||
path = operands[0] if operands else ""
|
||||
return await handle_source(dispatch, execute_fn, path, session)
|
||||
|
||||
if name == SB.EVAL:
|
||||
return await handle_eval(execute_fn, args, session)
|
||||
|
||||
if name in (SB.BASH, SB.SH):
|
||||
return await handle_bash(execute_fn, args, session, stdin)
|
||||
|
||||
if name == SB.EXPORT:
|
||||
return await handle_export(args, session)
|
||||
|
||||
if name == SB.UNSET:
|
||||
return await handle_unset(args, session)
|
||||
|
||||
if name == SB.LOCAL:
|
||||
return await handle_local(args, session)
|
||||
|
||||
if name == SB.PRINTENV:
|
||||
var_name = args[0] if args else None
|
||||
return await handle_printenv(var_name, session)
|
||||
|
||||
if name == SB.WHOAMI:
|
||||
return await handle_whoami(session)
|
||||
|
||||
if name == SB.MAN:
|
||||
return await handle_man(args, session, registry)
|
||||
|
||||
if name == SB.READ:
|
||||
return await handle_read(args, session, stdin)
|
||||
|
||||
if name == SB.SET:
|
||||
return await handle_set(args, session, call_stack=call_stack)
|
||||
|
||||
if name == SB.SHIFT:
|
||||
return await handle_shift(args, call_stack, session=session)
|
||||
|
||||
if name == SB.TRAP:
|
||||
return await handle_trap(session)
|
||||
|
||||
if name in (SB.TEST, SB.BRACKET, SB.DOUBLE_BRACKET):
|
||||
return await handle_test(dispatch, operands, session)
|
||||
|
||||
if name == SB.ECHO:
|
||||
return await handle_echo(args)
|
||||
|
||||
if name == SB.PRINTF:
|
||||
return await handle_printf(args)
|
||||
|
||||
if name == SB.SLEEP:
|
||||
return await handle_sleep(args, cancel=cancel)
|
||||
|
||||
if name == SB.RETURN:
|
||||
return await handle_return(args)
|
||||
|
||||
if name == SB.XARGS:
|
||||
return await handle_xargs(execute_fn, args, session, stdin)
|
||||
|
||||
if name == SB.TIMEOUT:
|
||||
return await handle_timeout(execute_fn, args, session)
|
||||
|
||||
if name == SB.BREAK:
|
||||
raise BreakSignal()
|
||||
|
||||
if name == SB.CONTINUE:
|
||||
raise ContinueSignal()
|
||||
|
||||
# ── symlinks (namespace-backed; not bash builtins, not mount
|
||||
# commands: they mutate the addressing layer) ──
|
||||
if name == "ln" and "s" in link_flags(operands, "sfnv"):
|
||||
return handle_ln(namespace, session, operands)
|
||||
|
||||
if name == "readlink" and not (link_flags(operands, "fenm")
|
||||
& {"f", "e", "m"}):
|
||||
return handle_readlink(namespace, session, operands)
|
||||
|
||||
# ── symlink-aware dispatch: reads follow links (open(2)); rm/mv act
|
||||
# on the link entry itself (lstat semantics) ──
|
||||
post_unlink: str | None = None
|
||||
if namespace.symlinks:
|
||||
try:
|
||||
if name == "rm":
|
||||
operands, removed = strip_link_operands(namespace, operands)
|
||||
if removed and not any(
|
||||
isinstance(a, PathSpec) for a in operands):
|
||||
return None, IOResult(), ExecutionNode(command=name,
|
||||
exit_code=0)
|
||||
elif name == "mv":
|
||||
operands, post_unlink, early = await prepare_mv(
|
||||
namespace, dispatch, operands)
|
||||
if early is not None:
|
||||
return early
|
||||
elif name not in NO_FOLLOW_COMMANDS:
|
||||
operands = follow_paths(namespace, operands)
|
||||
except CycleError as exc:
|
||||
err = (f"{name}: {exc}: "
|
||||
f"Too many levels of symbolic links\n").encode()
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command=name,
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
argv = argv.with_operands(operands)
|
||||
|
||||
# ── mount command (default) ─────────────────
|
||||
stdout, io, exec_node = await handle_command(recurse,
|
||||
dispatch,
|
||||
registry,
|
||||
argv.words,
|
||||
session,
|
||||
stdin,
|
||||
call_stack,
|
||||
job_table=job_table,
|
||||
namespace=namespace)
|
||||
|
||||
if io.exit_code == 0 and namespace.symlinks:
|
||||
if name == "rm":
|
||||
for item in operands:
|
||||
if isinstance(item, PathSpec):
|
||||
namespace.purge_under(item.virtual)
|
||||
if post_unlink is not None:
|
||||
namespace.unlink(post_unlink)
|
||||
return stdout, io, exec_node
|
||||
@@ -0,0 +1,392 @@
|
||||
# ========= 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
|
||||
from functools import partial
|
||||
from typing import Any, Callable
|
||||
|
||||
from mirage.io import IOResult
|
||||
from mirage.io.stream import async_chain
|
||||
from mirage.shell.arith import evaluate_arith
|
||||
from mirage.shell.call_stack import CallStack
|
||||
from mirage.shell.errors import ArithError
|
||||
from mirage.shell.job_table import JobTable
|
||||
from mirage.shell.node_kind import NodeKind, node_kind
|
||||
from mirage.shell.types import ERREXIT_EXEMPT_TYPES
|
||||
from mirage.shell.types import NodeType as NT
|
||||
from mirage.workspace.abort import MirageAbortError
|
||||
from mirage.workspace.executor.control import (handle_case, handle_for,
|
||||
handle_if, handle_select,
|
||||
handle_until, handle_while)
|
||||
from mirage.workspace.executor.pipes import (handle_connection, handle_pipe,
|
||||
handle_subshell)
|
||||
from mirage.workspace.executor.redirect import handle_redirect
|
||||
from mirage.workspace.expand import (expand_and_classify, expand_node,
|
||||
expand_redirects)
|
||||
from mirage.workspace.expand.globs import resolve_globs
|
||||
from mirage.workspace.expand.node import expand_arith
|
||||
from mirage.workspace.mount import MountRegistry
|
||||
from mirage.workspace.mount.namespace import Namespace
|
||||
from mirage.workspace.node.command_dispatch import execute_command
|
||||
from mirage.workspace.node.program import execute_program
|
||||
from mirage.workspace.node.test_expr import expand_test_expr
|
||||
from mirage.workspace.session import Session
|
||||
from mirage.workspace.types import ExecutionNode
|
||||
|
||||
from mirage.shell.helpers import ( # isort: skip
|
||||
get_case_items, get_case_word, get_declaration_keyword, get_for_parts,
|
||||
get_function_body, get_function_name, get_if_branches, get_list_parts,
|
||||
get_negated_command, get_pipeline_commands, get_redirects,
|
||||
get_subshell_body, get_text, get_unset_names, get_while_parts)
|
||||
from mirage.workspace.executor.builtins import ( # isort: skip
|
||||
handle_export, handle_local, handle_readonly, handle_test, handle_unset)
|
||||
|
||||
|
||||
async def _recurse_reassociated(
|
||||
recurse: Callable,
|
||||
dispatch: Callable,
|
||||
execute_fn: Callable,
|
||||
registry: MountRegistry,
|
||||
redirects: list,
|
||||
right: Any,
|
||||
node: Any,
|
||||
session: Session,
|
||||
stdin: Any = None,
|
||||
call_stack: CallStack | None = None,
|
||||
) -> tuple[Any, IOResult, ExecutionNode]:
|
||||
"""Recurse wrapper for a re-associated trailing redirect.
|
||||
|
||||
Executes the list's last command with the hoisted redirects,
|
||||
expanding targets only at that point (after the left side ran, so
|
||||
cwd changes apply); every other node recurses normally.
|
||||
|
||||
Args:
|
||||
recurse (Callable): the plain execute_node recursion.
|
||||
dispatch (Callable): VFS op dispatcher.
|
||||
execute_fn (Callable): recursive execute (for expansions).
|
||||
registry (MountRegistry): mount registry.
|
||||
redirects (list): parsed redirects hoisted off the list.
|
||||
right (Any): the list's last command node.
|
||||
node (Any): node being executed by handle_connection.
|
||||
session (Session): shell session state.
|
||||
stdin (Any): input stream.
|
||||
call_stack (CallStack | None): shell call stack.
|
||||
"""
|
||||
if node is not right:
|
||||
return await recurse(node, session, stdin, call_stack)
|
||||
expanded, pipe_node = await expand_redirects(redirects, session,
|
||||
execute_fn, registry,
|
||||
call_stack)
|
||||
stdout, io, exec_node = await handle_redirect(recurse, dispatch, right,
|
||||
expanded, session, stdin,
|
||||
call_stack)
|
||||
if pipe_node is not None and stdout is not None:
|
||||
stdout, io2, exec_node2 = await recurse(pipe_node, session, stdout,
|
||||
call_stack)
|
||||
io = await io.merge(io2)
|
||||
exec_node = exec_node2
|
||||
return stdout, io, exec_node
|
||||
|
||||
|
||||
async def execute_node(
|
||||
dispatch: Callable,
|
||||
registry: MountRegistry,
|
||||
namespace: Namespace,
|
||||
job_table: JobTable,
|
||||
execute_fn: Callable,
|
||||
agent_id: str,
|
||||
node: Any,
|
||||
session: Session,
|
||||
stdin: Any = None,
|
||||
call_stack: CallStack | None = None,
|
||||
cancel: asyncio.Event | None = None,
|
||||
) -> tuple[Any, IOResult, ExecutionNode]:
|
||||
"""Walk tree-sitter AST and dispatch each node.
|
||||
|
||||
Args:
|
||||
dispatch (Callable): VFS op dispatcher (op, path, **kw).
|
||||
registry (MountRegistry): mount registry for path resolution.
|
||||
namespace (Namespace): addressing authority for symlink ops.
|
||||
job_table (JobTable): background job management.
|
||||
execute_fn (Callable): recursive execute (for source/eval).
|
||||
agent_id (str): current agent ID for jobs.
|
||||
node (Any): tree-sitter node to execute.
|
||||
session (Session): shell session state.
|
||||
stdin (Any): input stream.
|
||||
call_stack (CallStack): shell call stack.
|
||||
cancel (asyncio.Event | None): event used to abort mid-flight.
|
||||
"""
|
||||
if cancel is not None and cancel.is_set():
|
||||
raise MirageAbortError()
|
||||
cs = call_stack or CallStack()
|
||||
|
||||
recurse = partial(execute_node,
|
||||
dispatch,
|
||||
registry,
|
||||
namespace,
|
||||
job_table,
|
||||
execute_fn,
|
||||
agent_id,
|
||||
cancel=cancel)
|
||||
|
||||
kind = node_kind(node)
|
||||
|
||||
if kind == NodeKind.COMMENT:
|
||||
return None, IOResult(), ExecutionNode(command="", exit_code=0)
|
||||
|
||||
# ── program (root / semicolons) ─────────────
|
||||
if kind == NodeKind.PROGRAM:
|
||||
return await execute_program(recurse, node, session, stdin, cs,
|
||||
job_table, agent_id)
|
||||
|
||||
# ── command ─────────────────────────────────
|
||||
if kind == NodeKind.COMMAND:
|
||||
return await execute_command(recurse,
|
||||
dispatch,
|
||||
registry,
|
||||
namespace,
|
||||
execute_fn,
|
||||
node,
|
||||
session,
|
||||
stdin,
|
||||
cs,
|
||||
job_table,
|
||||
cancel=cancel)
|
||||
|
||||
# ── pipeline ────────────────────────────────
|
||||
if kind == NodeKind.PIPELINE:
|
||||
commands, stderr_flags = get_pipeline_commands(node)
|
||||
return await handle_pipe(recurse, commands, stderr_flags, session,
|
||||
stdin, cs)
|
||||
|
||||
# ── list (&&, ||) ───────────────────────────
|
||||
if kind == NodeKind.LIST:
|
||||
left, op, right = get_list_parts(node)
|
||||
return await handle_connection(recurse, left, op, right, session,
|
||||
stdin, cs)
|
||||
|
||||
# ── redirected statement ────────────────────
|
||||
if kind == NodeKind.REDIRECT:
|
||||
command, redirects = get_redirects(node)
|
||||
if command.type == NT.LIST:
|
||||
# tree-sitter hoists a trailing redirect over the whole
|
||||
# &&/|| list; bash binds it to the last command:
|
||||
# redirected(list(L, op, R), r) == list(L, op, redirected(R, r))
|
||||
# Re-associate and defer target expansion until R runs, so
|
||||
# `cd /x && echo hi > f` writes under /x. Compound and
|
||||
# subshell bodies keep the whole-body redirect (bash group
|
||||
# semantics).
|
||||
left, op, right = get_list_parts(command)
|
||||
wrapped = partial(_recurse_reassociated, recurse, dispatch,
|
||||
execute_fn, registry, redirects, right)
|
||||
return await handle_connection(wrapped, left, op, right, session,
|
||||
stdin, cs)
|
||||
expanded_redirects, pipe_node = await expand_redirects(
|
||||
redirects, session, execute_fn, registry, cs)
|
||||
stdout, io, exec_node = await handle_redirect(recurse, dispatch,
|
||||
command,
|
||||
expanded_redirects,
|
||||
session, stdin, cs)
|
||||
if pipe_node is not None and stdout is not None:
|
||||
stdout, io2, exec_node2 = await recurse(pipe_node, session, stdout,
|
||||
cs)
|
||||
io = await io.merge(io2)
|
||||
exec_node = exec_node2
|
||||
return stdout, io, exec_node
|
||||
|
||||
# ── subshell ────────────────────────────────
|
||||
if kind == NodeKind.SUBSHELL:
|
||||
body = get_subshell_body(node)
|
||||
return await handle_subshell(recurse, body, session, stdin, cs)
|
||||
|
||||
# ── arithmetic command ((( ... ))) ──────────
|
||||
if (kind == NodeKind.COMPOUND and node.children
|
||||
and node.children[0].type == NT.ARITH_OPEN):
|
||||
text = get_text(node)
|
||||
expr = await expand_arith(node, session, execute_fn, cs)
|
||||
try:
|
||||
value, updates = evaluate_arith(expr, session.env)
|
||||
except ArithError as exc:
|
||||
err = f"bash: ((: {expr}: {exc}\n".encode()
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command=text,
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
for name in updates:
|
||||
if name in session.readonly_vars:
|
||||
err = f"bash: {name}: readonly variable\n".encode()
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command=text,
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
session.env.update(updates)
|
||||
code = 0 if value != 0 else 1
|
||||
return None, IOResult(exit_code=code), ExecutionNode(command=text,
|
||||
exit_code=code)
|
||||
|
||||
# ── compound statement ({ ... }) ───────────
|
||||
if kind == NodeKind.COMPOUND:
|
||||
all_stdout: list = []
|
||||
merged_io = IOResult()
|
||||
last_exec = ExecutionNode(command="{}", exit_code=0)
|
||||
for child in node.named_children:
|
||||
if child.type == NT.COMMENT:
|
||||
continue
|
||||
stdout, io, last_exec = await recurse(child, session, stdin, cs)
|
||||
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
|
||||
|
||||
# ── if ──────────────────────────────────────
|
||||
if kind == NodeKind.IF:
|
||||
branches, else_body = get_if_branches(node)
|
||||
return await handle_if(recurse, branches, else_body, session, stdin,
|
||||
cs)
|
||||
|
||||
# ── for / select ────────────────────────────
|
||||
if kind in (NodeKind.FOR, NodeKind.SELECT):
|
||||
var, values, body = get_for_parts(node)
|
||||
classified = await expand_and_classify(values, session, execute_fn,
|
||||
registry, session.cwd, cs)
|
||||
# The loop word list is consumed by the shell (WordPolicy.SHELL):
|
||||
# globs resolve to matches before iteration starts.
|
||||
classified = await resolve_globs(classified, registry)
|
||||
if kind == NodeKind.SELECT:
|
||||
return await handle_select(recurse, var, classified, body, session,
|
||||
stdin, cs)
|
||||
return await handle_for(recurse, var, classified, body, session, stdin,
|
||||
cs)
|
||||
|
||||
# ── while / until ───────────────────────────
|
||||
if kind in (NodeKind.WHILE, NodeKind.UNTIL):
|
||||
condition, body = get_while_parts(node)
|
||||
if kind == NodeKind.UNTIL:
|
||||
return await handle_until(recurse, condition, body, session, stdin,
|
||||
cs)
|
||||
return await handle_while(recurse, condition, body, session, stdin, cs)
|
||||
|
||||
# ── case ────────────────────────────────────
|
||||
if kind == NodeKind.CASE:
|
||||
word_node = get_case_word(node)
|
||||
word = await expand_node(word_node, session, execute_fn, cs)
|
||||
items = get_case_items(node)
|
||||
return await handle_case(recurse, word, items, session, stdin, cs)
|
||||
|
||||
# ── function definition ─────────────────────
|
||||
if kind == NodeKind.FUNCTION_DEF:
|
||||
name = get_function_name(node)
|
||||
body = get_function_body(node)
|
||||
session.functions[name] = body
|
||||
return None, IOResult(), ExecutionNode(command=f"function {name}",
|
||||
exit_code=0)
|
||||
|
||||
# ── declaration (export/local/declare/readonly) ──
|
||||
if kind == NodeKind.DECLARATION:
|
||||
keyword = get_declaration_keyword(node)
|
||||
assignments = []
|
||||
flag_chars: set[str] = set()
|
||||
for child in node.named_children:
|
||||
if child.type == NT.VARIABLE_ASSIGNMENT:
|
||||
val_nodes = [
|
||||
c for c in child.named_children
|
||||
if c.type != NT.VARIABLE_NAME
|
||||
]
|
||||
if val_nodes and val_nodes[0].type == NT.ARRAY:
|
||||
key = get_text(child).partition("=")[0]
|
||||
items = [
|
||||
await expand_node(ac, session, execute_fn, cs)
|
||||
for ac in val_nodes[0].named_children
|
||||
]
|
||||
session.arrays[key] = items
|
||||
continue
|
||||
expanded = await expand_node(child, session, execute_fn, cs)
|
||||
assignments.append(expanded)
|
||||
elif child.type in (NT.SIMPLE_EXPANSION, NT.EXPANSION,
|
||||
NT.CONCATENATION, NT.WORD):
|
||||
expanded = await expand_node(child, session, execute_fn, cs)
|
||||
if not expanded:
|
||||
continue
|
||||
if expanded.startswith("-") and len(expanded) > 1:
|
||||
flag_chars.update(expanded[1:])
|
||||
else:
|
||||
assignments.append(expanded)
|
||||
if keyword == NT.LOCAL:
|
||||
return await handle_local(assignments, session)
|
||||
if keyword == "readonly" or "r" in flag_chars:
|
||||
return await handle_readonly(assignments, session)
|
||||
return await handle_export(assignments, session)
|
||||
|
||||
# ── unset ───────────────────────────────────
|
||||
if kind == NodeKind.UNSET:
|
||||
names = get_unset_names(node)
|
||||
return await handle_unset(names, session)
|
||||
|
||||
# ── test ([ ] or [[ ]]) ─────────────────────
|
||||
if kind == NodeKind.TEST:
|
||||
expanded = await expand_test_expr(node, session, execute_fn, cs,
|
||||
registry)
|
||||
return await handle_test(dispatch, expanded, session)
|
||||
|
||||
# ── negated command ─────────────────────────
|
||||
if kind == NodeKind.NEGATED:
|
||||
inner = get_negated_command(node)
|
||||
stdout, io, exec_node = await recurse(inner, session, stdin, cs)
|
||||
io = IOResult(
|
||||
exit_code=0 if io.exit_code != 0 else 1,
|
||||
stderr=io.stderr,
|
||||
reads=io.reads,
|
||||
writes=io.writes,
|
||||
cache=io.cache,
|
||||
)
|
||||
exec_node.exit_code = io.exit_code
|
||||
return stdout, io, exec_node
|
||||
|
||||
# ── variable assignment at top level ────────
|
||||
if kind == NodeKind.VAR_ASSIGN:
|
||||
text = get_text(node)
|
||||
if "=" in text:
|
||||
key, _, val = text.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=text,
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
val_nodes = [
|
||||
c for c in node.named_children if c.type != NT.VARIABLE_NAME
|
||||
]
|
||||
if val_nodes and val_nodes[0].type == NT.ARRAY:
|
||||
items = []
|
||||
for ac in val_nodes[0].named_children:
|
||||
items.append(await expand_node(ac, session, execute_fn,
|
||||
cs))
|
||||
session.arrays[key] = items
|
||||
session.env.pop(key, None)
|
||||
return None, IOResult(), ExecutionNode(command=text,
|
||||
exit_code=0)
|
||||
if val_nodes:
|
||||
val = await expand_node(val_nodes[0], session, execute_fn, cs)
|
||||
session.env[key] = val
|
||||
session.arrays.pop(key, None)
|
||||
return None, IOResult(), ExecutionNode(command=text, exit_code=0)
|
||||
|
||||
raise TypeError(f"unsupported tree-sitter node type: {node.type}")
|
||||
@@ -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 typing import Any
|
||||
|
||||
from mirage.io import IOResult
|
||||
from mirage.io.stream import async_chain, materialize
|
||||
from mirage.shell.types import ERREXIT_EXEMPT_TYPES
|
||||
from mirage.shell.types import NodeType as NT
|
||||
from mirage.utils.errors import format_fs_error
|
||||
from mirage.workspace.executor.jobs import handle_background
|
||||
from mirage.workspace.types import ExecutionNode
|
||||
|
||||
|
||||
async def execute_program(
|
||||
recurse,
|
||||
node,
|
||||
session,
|
||||
stdin,
|
||||
call_stack,
|
||||
job_table,
|
||||
agent_id,
|
||||
) -> tuple[Any, IOResult, ExecutionNode]:
|
||||
"""Execute program node (root / semicolon-separated)."""
|
||||
children = node.children
|
||||
all_stdout: list = []
|
||||
merged_io = IOResult()
|
||||
last_exec = ExecutionNode(command="", exit_code=0)
|
||||
|
||||
i = 0
|
||||
while i < len(children):
|
||||
child = children[i]
|
||||
|
||||
if (not child.is_named or child.type == NT.ERROR
|
||||
or child.type == NT.COMMENT):
|
||||
if child.type == NT.SEMI:
|
||||
i += 1
|
||||
continue
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Check for background: named node followed by & token
|
||||
is_bg = (i + 1 < len(children)
|
||||
and children[i + 1].type == NT.BACKGROUND)
|
||||
|
||||
if is_bg:
|
||||
stdout, io, last_exec = await handle_background(
|
||||
recurse, child, None, session, job_table, agent_id, stdin,
|
||||
call_stack)
|
||||
i += 2
|
||||
else:
|
||||
stdout, io, last_exec = await recurse(child, session, stdin,
|
||||
call_stack)
|
||||
# Materialize stdout so lazy exit codes (e.g. from
|
||||
# exit_on_empty in grep) are finalized before $? is set.
|
||||
drain_err: bytes | None = None
|
||||
try:
|
||||
stdout = await materialize(stdout)
|
||||
except OSError as exc:
|
||||
# Lazy reads (head/tail opening the stream mid-pipeline) can
|
||||
# fail on the first pull; format as a GNU coreutils line,
|
||||
# respelling the path as typed via the operands the leaf
|
||||
# node carries, mirroring the eager executor chokepoint.
|
||||
cmd_name = (last_exec.command.split()[0]
|
||||
if last_exec.command else "")
|
||||
drain_err = format_fs_error(cmd_name, exc, last_exec.paths)
|
||||
stdout = None
|
||||
except Exception as exc:
|
||||
drain_err = f"{exc}\n".encode()
|
||||
stdout = None
|
||||
io.sync_exit_code()
|
||||
if drain_err is not None:
|
||||
existing = await materialize(io.stderr) or b""
|
||||
io.stderr = existing + drain_err
|
||||
io.exit_code = 1
|
||||
session.last_exit_code = io.exit_code
|
||||
i += 1
|
||||
|
||||
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 not is_bg 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
|
||||
@@ -0,0 +1,315 @@
|
||||
# ========= 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, field
|
||||
from functools import partial
|
||||
from typing import Any, Callable
|
||||
|
||||
from mirage.provision import Precision, ProvisionResult
|
||||
from mirage.shell.node_kind import NodeKind, node_kind
|
||||
from mirage.shell.types import NodeType as NT
|
||||
from mirage.shell.types import RedirectKind
|
||||
from mirage.shell.types import ShellBuiltin as SB
|
||||
from mirage.types import PathSpec
|
||||
from mirage.workspace.expand import (classify_parts, expand_and_classify,
|
||||
expand_parts, expand_redirects)
|
||||
from mirage.workspace.mount import MountRegistry
|
||||
from mirage.workspace.mount.namespace import Namespace
|
||||
from mirage.workspace.provision.builtins import handle_builtin_provision
|
||||
from mirage.workspace.provision.command import handle_command_provision
|
||||
from mirage.workspace.provision.control import (handle_for_provision,
|
||||
handle_function_provision,
|
||||
handle_if_provision,
|
||||
handle_while_provision)
|
||||
from mirage.workspace.provision.pipes import (handle_connection_provision,
|
||||
handle_pipe_provision)
|
||||
from mirage.workspace.provision.redirect import handle_redirect_provision
|
||||
from mirage.workspace.provision.rollup import rollup_list, rollup_pipe
|
||||
from mirage.workspace.session import Session
|
||||
|
||||
from mirage.shell.helpers import ( # isort: skip
|
||||
get_case_items, get_command_name, get_for_parts, get_function_body,
|
||||
get_function_name, get_if_branches, get_list_parts, get_negated_command,
|
||||
get_parts, get_pipeline_commands, get_redirects, get_subshell_body,
|
||||
get_text, get_while_parts, has_command_substitution, split_env_prefix)
|
||||
|
||||
# eval / source execute their payload, so they are NOT free builtins:
|
||||
# leaving them out lets them fall through to command resolution, which
|
||||
# honestly reports UNKNOWN instead of a zero-cost EXACT.
|
||||
_BUILTIN_NAMES = frozenset({
|
||||
SB.CD,
|
||||
SB.TRUE,
|
||||
SB.FALSE,
|
||||
SB.EXPORT,
|
||||
SB.UNSET,
|
||||
SB.LOCAL,
|
||||
SB.PRINTENV,
|
||||
SB.READ,
|
||||
SB.SET,
|
||||
SB.SHIFT,
|
||||
SB.TRAP,
|
||||
SB.TEST,
|
||||
SB.BRACKET,
|
||||
SB.DOUBLE_BRACKET,
|
||||
SB.WAIT,
|
||||
SB.FG,
|
||||
SB.KILL,
|
||||
SB.JOBS,
|
||||
SB.PS,
|
||||
SB.ECHO,
|
||||
SB.PRINTF,
|
||||
SB.SLEEP,
|
||||
"return",
|
||||
"break",
|
||||
"continue",
|
||||
})
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanScope:
|
||||
"""Walk-local planner state.
|
||||
|
||||
Function definitions seen during this plan are recorded here (not
|
||||
on the session: planning must not mutate shell state), and
|
||||
`planning` guards recursive functions from looping the planner.
|
||||
"""
|
||||
functions: dict[str, list] = field(default_factory=dict)
|
||||
planning: set[str] = field(default_factory=set)
|
||||
|
||||
|
||||
async def _provision_redirected(
|
||||
recurse: Callable,
|
||||
registry: MountRegistry,
|
||||
namespace: Namespace | None,
|
||||
execute_fn: Callable,
|
||||
command: Any,
|
||||
redirects: list,
|
||||
session: Session,
|
||||
) -> ProvisionResult:
|
||||
"""Plan one redirected command: expand targets, cost, degrade.
|
||||
|
||||
Args:
|
||||
recurse (Callable): the provision recursion.
|
||||
registry (MountRegistry): mount registry.
|
||||
namespace (Namespace | None): addressing authority.
|
||||
execute_fn (Callable): recursive execute (for expansions).
|
||||
command (Any): the redirected command node.
|
||||
redirects (list): parsed redirects.
|
||||
session (Session): shell session state.
|
||||
"""
|
||||
expanded, pipe_node = await expand_redirects(redirects, session,
|
||||
execute_fn, registry)
|
||||
# A cmdsub target expands empty under provision, so its
|
||||
# classification is garbage; the precision degrade below keeps
|
||||
# the plan honest without costing a phantom write (mirrors TS).
|
||||
targets = [
|
||||
(r.kind, r.target) for r in expanded
|
||||
if r.kind in (RedirectKind.STDIN, RedirectKind.STDOUT) and isinstance(
|
||||
r.target, PathSpec) and not r.target.virtual.startswith("/dev/")
|
||||
and not (r.target_node is not None
|
||||
and has_command_substitution(r.target_node))
|
||||
]
|
||||
result = await handle_redirect_provision(recurse, registry, command,
|
||||
targets, session, namespace)
|
||||
if any(r.target_node is not None
|
||||
and has_command_substitution(r.target_node) for r in redirects):
|
||||
# A suppressed substitution hid the real redirect target.
|
||||
result.precision = Precision.UNKNOWN
|
||||
if pipe_node is not None:
|
||||
return rollup_pipe([result, await recurse(pipe_node, session)])
|
||||
return result
|
||||
|
||||
|
||||
async def _provision_reassociated(
|
||||
recurse: Callable,
|
||||
registry: MountRegistry,
|
||||
namespace: Namespace | None,
|
||||
execute_fn: Callable,
|
||||
redirects: list,
|
||||
right: Any,
|
||||
node: Any,
|
||||
session: Session,
|
||||
) -> ProvisionResult:
|
||||
"""Provision recurse wrapper for a re-associated trailing redirect.
|
||||
|
||||
Mirrors the executor: the list's last command carries the hoisted
|
||||
redirects; every other node provisions normally.
|
||||
|
||||
Args:
|
||||
recurse (Callable): the provision recursion.
|
||||
registry (MountRegistry): mount registry.
|
||||
namespace (Namespace | None): addressing authority.
|
||||
execute_fn (Callable): recursive execute (for expansions).
|
||||
redirects (list): parsed redirects hoisted off the list.
|
||||
right (Any): the list's last command node.
|
||||
node (Any): node being provisioned by the connection handler.
|
||||
session (Session): shell session state.
|
||||
"""
|
||||
if node is not right:
|
||||
return await recurse(node, session)
|
||||
return await _provision_redirected(recurse, registry, namespace,
|
||||
execute_fn, right, redirects, session)
|
||||
|
||||
|
||||
async def provision_node(
|
||||
registry: MountRegistry,
|
||||
dispatch: Callable,
|
||||
execute_fn: Callable,
|
||||
namespace: Namespace | None,
|
||||
node: Any,
|
||||
session: Session,
|
||||
scope: PlanScope | None = None,
|
||||
) -> ProvisionResult:
|
||||
"""Walk tree-sitter AST and estimate execution cost.
|
||||
|
||||
Dispatches on the same NodeKind classification as the executor
|
||||
(`mirage.shell.classify`), so every construct the executor runs
|
||||
has a planner branch; kinds neither walker supports fall through
|
||||
to an honest UNKNOWN.
|
||||
|
||||
Args:
|
||||
registry (MountRegistry): mount registry for path resolution.
|
||||
dispatch (Callable): VFS op dispatcher (op, path, **kw).
|
||||
execute_fn (Callable): recursive execute (for expansions).
|
||||
node (Any): tree-sitter node to plan.
|
||||
session (Session): shell session state.
|
||||
scope (PlanScope | None): walk-local planner state; created at
|
||||
the root and threaded through recursion.
|
||||
"""
|
||||
plan_scope = scope if scope is not None else PlanScope()
|
||||
recurse = partial(provision_node,
|
||||
registry,
|
||||
dispatch,
|
||||
execute_fn,
|
||||
namespace,
|
||||
scope=plan_scope)
|
||||
kind = node_kind(node)
|
||||
|
||||
if kind == NodeKind.COMMENT:
|
||||
return ProvisionResult(precision=Precision.EXACT)
|
||||
|
||||
if kind in (NodeKind.PROGRAM, NodeKind.SUBSHELL, NodeKind.COMPOUND):
|
||||
if kind == NodeKind.SUBSHELL:
|
||||
body = get_subshell_body(node)
|
||||
else:
|
||||
body = [c for c in node.named_children if c.type != NT.COMMENT]
|
||||
children = []
|
||||
for child in body:
|
||||
children.append(await recurse(child, session))
|
||||
if not children:
|
||||
return ProvisionResult(precision=Precision.EXACT)
|
||||
return rollup_list(";", children)
|
||||
|
||||
if kind == NodeKind.COMMAND:
|
||||
name = get_command_name(node)
|
||||
func_body = plan_scope.functions.get(name)
|
||||
if func_body is None:
|
||||
func_body = session.functions.get(name)
|
||||
if func_body is not None:
|
||||
return await handle_function_provision(recurse, name, func_body,
|
||||
plan_scope.planning,
|
||||
session)
|
||||
if name in _BUILTIN_NAMES:
|
||||
return await handle_builtin_provision()
|
||||
_, parts = split_env_prefix(get_parts(node))
|
||||
if not parts:
|
||||
return ProvisionResult(precision=Precision.EXACT)
|
||||
expanded = await expand_parts(parts, session, execute_fn)
|
||||
classified = classify_parts(expanded, registry, session.cwd)
|
||||
result = await handle_command_provision(registry, classified, session,
|
||||
namespace)
|
||||
if any(has_command_substitution(p) for p in parts):
|
||||
# The plan walk suppressed the substitution, so the operand
|
||||
# list is incomplete: the totals are floors, not answers.
|
||||
result.precision = Precision.UNKNOWN
|
||||
return result
|
||||
|
||||
if kind == NodeKind.PIPELINE:
|
||||
commands, _ = get_pipeline_commands(node)
|
||||
return await handle_pipe_provision(recurse, commands, session)
|
||||
|
||||
if kind == NodeKind.LIST:
|
||||
left, op, right = get_list_parts(node)
|
||||
return await handle_connection_provision(recurse, left, op, right,
|
||||
session)
|
||||
|
||||
if kind == NodeKind.REDIRECT:
|
||||
command, redirects = get_redirects(node)
|
||||
if command.type == NT.LIST:
|
||||
# Mirror the executor: a trailing redirect hoisted over an
|
||||
# &&/|| list binds to the last command.
|
||||
left, op, right = get_list_parts(command)
|
||||
wrapped = partial(_provision_reassociated, recurse, registry,
|
||||
namespace, execute_fn, redirects, right)
|
||||
return await handle_connection_provision(wrapped, left, op, right,
|
||||
session)
|
||||
return await _provision_redirected(recurse, registry, namespace,
|
||||
execute_fn, command, redirects,
|
||||
session)
|
||||
|
||||
if kind == NodeKind.IF:
|
||||
branches, else_body = get_if_branches(node)
|
||||
return await handle_if_provision(recurse, branches, else_body, session)
|
||||
|
||||
if kind == NodeKind.FOR:
|
||||
_, values, body = get_for_parts(node)
|
||||
if any(has_command_substitution(v) for v in values):
|
||||
# The iteration count comes from a suppressed substitution:
|
||||
# plan one pass as a floor and degrade.
|
||||
result = await handle_for_provision(recurse, body, 1, session)
|
||||
result.precision = Precision.UNKNOWN
|
||||
return result
|
||||
classified = await expand_and_classify(values, session, execute_fn,
|
||||
registry, session.cwd)
|
||||
n = len(classified) or 1
|
||||
return await handle_for_provision(recurse, body, n, session)
|
||||
|
||||
if kind == NodeKind.SELECT:
|
||||
# select re-prompts until break: unbounded like while.
|
||||
_, _, body = get_for_parts(node)
|
||||
return await handle_while_provision(recurse, body, session)
|
||||
|
||||
if kind in (NodeKind.WHILE, NodeKind.UNTIL):
|
||||
_, body = get_while_parts(node)
|
||||
return await handle_while_provision(recurse, body, session)
|
||||
|
||||
if kind == NodeKind.CASE:
|
||||
items = get_case_items(node)
|
||||
children = []
|
||||
for _, body in items:
|
||||
if not body:
|
||||
continue
|
||||
stmts = [await recurse(stmt, session) for stmt in body]
|
||||
children.append(stmts[0] if len(stmts) ==
|
||||
1 else rollup_list(";", stmts))
|
||||
if children:
|
||||
return rollup_list("||", children)
|
||||
return ProvisionResult(precision=Precision.EXACT)
|
||||
|
||||
if kind == NodeKind.FUNCTION_DEF:
|
||||
name = get_function_name(node)
|
||||
body = get_function_body(node)
|
||||
if name and body is not None:
|
||||
plan_scope.functions[name] = body
|
||||
return await handle_builtin_provision()
|
||||
|
||||
if kind in (NodeKind.DECLARATION, NodeKind.UNSET, NodeKind.TEST,
|
||||
NodeKind.VAR_ASSIGN):
|
||||
return await handle_builtin_provision()
|
||||
|
||||
if kind == NodeKind.NEGATED:
|
||||
inner = get_negated_command(node)
|
||||
return await recurse(inner, session)
|
||||
|
||||
return ProvisionResult(command=get_text(node), precision=Precision.UNKNOWN)
|
||||
@@ -0,0 +1,91 @@
|
||||
# ========= 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
|
||||
from typing import Any, Callable
|
||||
|
||||
from mirage.commands.builtin.utils.safeguard import apply_safeguard
|
||||
from mirage.io import IOResult
|
||||
from mirage.io.types import materialize
|
||||
from mirage.shell.barrier import BarrierPolicy, apply_barrier
|
||||
from mirage.shell.job_table import JobTable
|
||||
from mirage.workspace.mount import MountRegistry
|
||||
from mirage.workspace.mount.namespace import Namespace
|
||||
from mirage.workspace.node.execute_node import execute_node
|
||||
from mirage.workspace.session import Session
|
||||
from mirage.workspace.types import ExecutionNode
|
||||
|
||||
|
||||
async def run_command_tree(
|
||||
dispatch: Callable,
|
||||
registry: MountRegistry,
|
||||
namespace: Namespace,
|
||||
job_table: JobTable,
|
||||
execute_fn: Callable,
|
||||
agent_id: str,
|
||||
ast: Any,
|
||||
session: Session,
|
||||
stdin: Any,
|
||||
cancel: asyncio.Event | None,
|
||||
) -> tuple[IOResult, ExecutionNode]:
|
||||
"""Run a parsed command tree and finalize its output stream.
|
||||
|
||||
Executes the AST root, then applies the value barrier and the
|
||||
command safeguard, folding the safeguard's stderr and exit code
|
||||
into the result. This is the seam between the Workspace shell
|
||||
(sessions, drift, recording) and the command executor: a caller
|
||||
hands in a parsed tree plus its dependencies and gets back the
|
||||
resolved result. Byte recording is the caller's responsibility, so
|
||||
the active recorder spans the stream consumption that happens
|
||||
inside the barrier here.
|
||||
|
||||
Args:
|
||||
dispatch (Callable): VFS op dispatcher (op, path, **kw).
|
||||
registry (MountRegistry): mount registry for path resolution.
|
||||
namespace (Namespace): addressing authority for symlink ops.
|
||||
job_table (JobTable): background job management.
|
||||
execute_fn (Callable): recursive execute (for source/eval).
|
||||
agent_id (str): current agent ID for jobs.
|
||||
ast (Any): parsed tree-sitter root node.
|
||||
session (Session): shell session state.
|
||||
stdin (Any): input stream.
|
||||
cancel (asyncio.Event | None): event used to abort mid-flight.
|
||||
|
||||
Returns:
|
||||
tuple[IOResult, ExecutionNode]: the finalized result (with
|
||||
``io.stdout`` set to the barrier-resolved value) and the
|
||||
execution node.
|
||||
"""
|
||||
stdout, io, exec_node = await execute_node(
|
||||
dispatch,
|
||||
registry,
|
||||
namespace,
|
||||
job_table,
|
||||
execute_fn,
|
||||
agent_id,
|
||||
ast,
|
||||
session,
|
||||
stdin,
|
||||
cancel=cancel,
|
||||
)
|
||||
stdout = await apply_barrier(stdout, io, BarrierPolicy.VALUE)
|
||||
if io.safeguard is not None:
|
||||
stdout, sg_io = await apply_safeguard(stdout, io.safeguard)
|
||||
if sg_io.stderr is not None:
|
||||
existing = await materialize(io.stderr)
|
||||
io.stderr = existing + await materialize(sg_io.stderr)
|
||||
if sg_io.exit_code != 0:
|
||||
io.exit_code = sg_io.exit_code
|
||||
io.stdout = stdout
|
||||
return io, exec_node
|
||||
@@ -0,0 +1,68 @@
|
||||
# ========= 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.shell.types import NodeType as NT
|
||||
from mirage.workspace.expand import expand_node
|
||||
|
||||
|
||||
async def expand_test_expr(node, session, execute_fn, cs, registry):
|
||||
"""Expand test_command [ ... ] into a flat list of strings."""
|
||||
result = []
|
||||
for child in node.named_children:
|
||||
if child.type == NT.BINARY_EXPRESSION:
|
||||
for part in child.children:
|
||||
if part.type in ("=", "!=", "=="):
|
||||
result.append(part.text.decode())
|
||||
elif part.is_named:
|
||||
exp = await expand_node(part, session, execute_fn, cs)
|
||||
result.append(exp)
|
||||
elif child.type == NT.UNARY_EXPRESSION:
|
||||
for part in child.children:
|
||||
if part.type == NT.TEST_OPERATOR:
|
||||
result.append(part.text.decode())
|
||||
elif part.is_named:
|
||||
exp = await expand_node(part, session, execute_fn, cs)
|
||||
result.append(exp)
|
||||
elif child.type == NT.NEGATION_EXPRESSION:
|
||||
result.append("!")
|
||||
for part in child.named_children:
|
||||
sub = await _expand_inner(part, session, execute_fn, cs)
|
||||
result.extend(sub)
|
||||
else:
|
||||
exp = await expand_node(child, session, execute_fn, cs)
|
||||
result.append(exp)
|
||||
return result
|
||||
|
||||
|
||||
async def _expand_inner(node, session, execute_fn, cs):
|
||||
"""Expand a single test expression node into args."""
|
||||
result = []
|
||||
if node.type == NT.BINARY_EXPRESSION:
|
||||
for part in node.children:
|
||||
if part.type in ("=", "!=", "=="):
|
||||
result.append(part.text.decode())
|
||||
elif part.is_named:
|
||||
exp = await expand_node(part, session, execute_fn, cs)
|
||||
result.append(exp)
|
||||
elif node.type == NT.UNARY_EXPRESSION:
|
||||
for part in node.children:
|
||||
if part.type == NT.TEST_OPERATOR:
|
||||
result.append(part.text.decode())
|
||||
elif part.is_named:
|
||||
exp = await expand_node(part, session, execute_fn, cs)
|
||||
result.append(exp)
|
||||
else:
|
||||
exp = await expand_node(node, session, execute_fn, cs)
|
||||
result.append(exp)
|
||||
return result
|
||||
Reference in New Issue
Block a user