bcbd1bdb22
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
438 lines
17 KiB
Python
438 lines
17 KiB
Python
# ========= 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
|