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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:30:44 +08:00
commit bcbd1bdb22
5748 changed files with 562488 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
# ========= 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 DEFAULT_AGENT_ID, DEFAULT_SESSION_ID
from mirage.workspace.runner import WorkspaceRunner
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
from mirage.workspace.workspace import Workspace
__all__ = [
"DEFAULT_AGENT_ID",
"DEFAULT_SESSION_ID",
"ExecutionNode",
"Session",
"Workspace",
"WorkspaceRunner",
]
+42
View File
@@ -0,0 +1,42 @@
# ========= 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
class MirageAbortError(RuntimeError):
def __init__(self) -> None:
super().__init__("execute aborted")
async def cancellable_sleep(
seconds: float,
cancel: asyncio.Event | None = None,
) -> None:
if cancel is None:
await asyncio.sleep(seconds)
return
if cancel.is_set():
raise MirageAbortError()
sleep_task = asyncio.create_task(asyncio.sleep(seconds))
cancel_task = asyncio.create_task(cancel.wait())
done, pending = await asyncio.wait(
{sleep_task, cancel_task},
return_when=asyncio.FIRST_COMPLETED,
)
for p in pending:
p.cancel()
if cancel_task in done:
raise MirageAbortError()
+146
View File
@@ -0,0 +1,146 @@
# ========= 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.cache.file import io as cache_io
from mirage.cache.manager import CacheManager
from mirage.io import IOResult
from mirage.observe.record import OpRecord
from mirage.types import ConsistencyPolicy, FileStat, PathSpec
from mirage.workspace.mount import MountEntry
from mirage.workspace.mount.namespace import Namespace
from mirage.workspace.session import assert_mount_allowed
_DISPATCH_READ_OPS = frozenset({"read", "read_bytes"})
_DISPATCH_WRITE_OPS = frozenset(
{"write", "write_bytes", "append", "unlink", "create", "truncate"})
# Ops that act on the path entry itself (lstat semantics); every other op
# follows symlinks before mount lookup, so reads/writes go to the target
# and the cache keys under the real path.
_NO_FOLLOW_OPS = frozenset({"unlink", "rename", "rmdir"})
class Dispatcher:
"""Route a single VFS op to its mount and keep the file cache + index
consistent.
Owns the cache/IO coordination that used to live on Workspace: cache
lookups for read-caching backends, post-write file-cache eviction,
and parent index invalidation. Constructed with the namespace (for
addressing), cache store, and consistency policy; holds no other
workspace state. Drift checking stays on Workspace (it reads/writes
snapshot-owned state), which guards its own dispatch wrapper before
delegating here.
"""
def __init__(self, namespace: Namespace, cache,
consistency: ConsistencyPolicy) -> None:
self._namespace = namespace
self._cache = cache
self._consistency = consistency
async def dispatch(self, op: str, path: PathSpec,
**kwargs: Any) -> tuple[Any, IOResult]:
if op not in _NO_FOLLOW_OPS:
followed = self._namespace.follow(path.virtual)
if followed != path.virtual:
path = PathSpec.from_str_path(followed)
mount = self._namespace.mount_for(path.virtual)
assert_mount_allowed(mount.prefix)
caches_reads = mount.resource.caches_reads
if caches_reads and op in _DISPATCH_READ_OPS:
cached = await self._cache.get(path.virtual)
if cached is not None:
if self._consistency == ConsistencyPolicy.ALWAYS:
try:
remote_stat = await mount.execute_op(
"stat", path.virtual)
except FileNotFoundError:
await self._cache.remove(path.virtual)
raise
if (remote_stat is not None
and remote_stat.fingerprint is not None):
fresh = await self._cache.is_fresh(
path.virtual, remote_stat.fingerprint)
if not fresh:
await self._cache.remove(path.virtual)
cached = None
if cached is not None:
return cached, IOResult(reads={path.virtual: cached})
result = await mount.execute_op(op, path.virtual, **kwargs)
if op in _DISPATCH_WRITE_OPS:
await self.invalidate_after_write(mount, path.virtual)
return result, IOResult()
async def stat(self, path: str) -> FileStat:
scope = PathSpec(virtual=path,
directory=path,
resource_path="",
resolved=True)
result, _ = await self.dispatch("stat", scope)
return result
async def readdir(self, path: str) -> list[str]:
scope = PathSpec(virtual=path,
directory=path,
resource_path="",
resolved=False)
raw, _ = await self.dispatch("readdir", scope)
return raw
async def apply_io(self,
io: IOResult,
records: list[OpRecord] | None = None) -> None:
await cache_io.apply_io(self._cache,
io,
self.is_cacheable_path,
records=records)
def is_cacheable_path(self, path: str) -> bool:
try:
mount = self._namespace.mount_for(path)
except ValueError:
return False
return mount.resource.caches_reads
async def invalidate_after_write_by_path(self, path: str) -> None:
"""Drop file-cache + stale parent index after a write to `path`.
Single source of truth for post-write invalidation. Called from
both `Workspace.dispatch()` and `Ops._call(write=True)` so a
write through any code path sees the same invalidation rules:
file cache is dropped only for read-caching mounts, and the
parent directory index is dirtied for any mount that maintains
an index. No-op for paths that resolve to no known mount.
Args:
path (str): absolute mount path that was written.
"""
try:
mount = self._namespace.mount_for(path)
except ValueError:
return
await self.invalidate_after_write(mount, path)
async def invalidate_after_write(self, mount: MountEntry,
path: str) -> None:
manager = mount.cache_manager
if manager is None:
manager = CacheManager(self._cache,
getattr(mount.resource, "index", None),
mount.prefix, mount.resource.caches_reads)
await manager.invalidate_after_write(path)
@@ -0,0 +1,13 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
@@ -0,0 +1,78 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.workspace.executor.builtins.condition import handle_test
from mirage.workspace.executor.builtins.dirs import handle_cd
from mirage.workspace.executor.builtins.history import handle_history
from mirage.workspace.executor.builtins.links import (follow_paths, handle_ln,
handle_readlink,
link_flags, prepare_mv,
strip_link_operands)
from mirage.workspace.executor.builtins.man import (_collect_man_hits,
_render_man_entry,
_render_man_index,
handle_man)
from mirage.workspace.executor.builtins.scope import _scope_path, _to_scope
from mirage.workspace.executor.builtins.script import (handle_bash,
handle_eval,
handle_sleep,
handle_source)
from mirage.workspace.executor.builtins.text import (_interpret_escapes,
handle_echo,
handle_printf)
from mirage.workspace.executor.builtins.timeout import handle_timeout
from mirage.workspace.executor.builtins.xargs import handle_xargs
from mirage.workspace.executor.builtins.vars import ( # isort: skip
handle_export, handle_local, handle_printenv, handle_read, handle_readonly,
handle_return, handle_set, handle_shift, handle_trap, handle_unset,
handle_whoami)
__all__ = [
'_collect_man_hits',
'_interpret_escapes',
'_render_man_entry',
'_render_man_index',
'_scope_path',
'_to_scope',
'handle_bash',
'handle_cd',
'handle_echo',
'handle_eval',
'handle_export',
'handle_history',
'handle_ln',
'handle_local',
'handle_readlink',
'link_flags',
'follow_paths',
'prepare_mv',
'strip_link_operands',
'handle_man',
'handle_printenv',
'handle_printf',
'handle_read',
'handle_readonly',
'handle_return',
'handle_set',
'handle_shift',
'handle_sleep',
'handle_source',
'handle_test',
'handle_timeout',
'handle_trap',
'handle_unset',
'handle_whoami',
'handle_xargs',
]
@@ -0,0 +1,102 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from collections.abc import Callable
from mirage.io import IOResult
from mirage.io.types import ByteSource
from mirage.types import PathSpec
from mirage.utils.path import resolve_path
from mirage.workspace.executor.builtins.scope import _scope_path, _to_scope
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
async def _eval_test(dispatch: Callable, argv: list, cwd: str) -> bool:
if not argv:
return False
first = _scope_path(argv[0])
if first == "!" and len(argv) > 1:
return not await _eval_test(dispatch, argv[1:], cwd)
if len(argv) == 1:
return bool(first)
if len(argv) == 2:
op = _scope_path(argv[0])
val = argv[1]
if op == "-z":
return _scope_path(val) == ""
if op == "-n":
return _scope_path(val) != ""
if op == "-f":
# A relative string operand resolves against cwd, like bash:
# `cd /data && test -f plain.txt` checks /data/plain.txt.
path = _scope_path(val)
if not isinstance(val, PathSpec) and not path:
return False
scope = val if isinstance(val, PathSpec) else _to_scope(
resolve_path(path, cwd))
try:
await dispatch("stat", scope)
return True
except (FileNotFoundError, ValueError):
return False
if op == "-d":
path = _scope_path(val)
if not isinstance(val, PathSpec) and not path:
return False
if not isinstance(val, PathSpec):
path = resolve_path(path, cwd)
scope = val if isinstance(val, PathSpec) else PathSpec(
virtual=path, directory=path, resource_path="", resolved=False)
try:
await dispatch("readdir", scope)
return True
except (FileNotFoundError, ValueError, NotADirectoryError):
return False
if len(argv) == 3:
left = _scope_path(argv[0])
op = _scope_path(argv[1])
right = _scope_path(argv[2])
if op == "=" or op == "==":
return left == right
if op == "!=":
return left != right
try:
li, ri = int(left), int(right)
except (ValueError, TypeError):
return False
if op == "-eq":
return li == ri
if op == "-ne":
return li != ri
if op == "-lt":
return li < ri
if op == "-le":
return li <= ri
if op == "-gt":
return li > ri
if op == "-ge":
return li >= ri
return False
async def handle_test(
dispatch: Callable,
argv: list,
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
result = await _eval_test(dispatch, argv, session.cwd)
code = 0 if result else 1
return None, IOResult(exit_code=code), ExecutionNode(command="test",
exit_code=code)
@@ -0,0 +1,168 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import posixpath
from collections.abc import Callable
from mirage.io import IOResult
from mirage.io.types import ByteSource
from mirage.types import FileType, PathSpec
from mirage.utils.path import (MAX_SYMLINK_HOPS, CycleError, resolve_path,
resolve_symlinks)
from mirage.workspace.executor.builtins.scope import _scope_path, _to_scope
from mirage.workspace.session import Session
from mirage.workspace.session.shell_dirs import change_dir
from mirage.workspace.types import ExecutionNode
def _norm(path: str) -> str:
resolved = posixpath.normpath(path)
if resolved.startswith("//"):
resolved = "/" + resolved.lstrip("/")
return resolved
def _resolve_target(combined: str, links: dict[str, str],
physical: bool) -> str:
"""Resolve a combined ``cd`` path, following symlinks per mode.
Logical (``-L``, default) simplifies ``..`` textually first, then
follows links. Physical (``-P``) follows links first so ``..`` acts on
the link target. Both loop resolve<->normalize until stable.
Args:
combined (str): The absolute target (cwd joined to arg).
links (dict[str, str]): The symlink table (link -> target).
physical (bool): True for ``-P``, False for ``-L``.
Returns:
str: The final absolute path with links resolved.
Raises:
CycleError: On a symlink loop or unbounded expansion (ELOOP).
"""
p = combined if physical else _norm(combined)
for _ in range(MAX_SYMLINK_HOPS):
n = _norm(resolve_symlinks(p, links))
if n == p:
return n
p = n
raise CycleError(p)
def _cdpath_searchable(target: str) -> bool:
"""Return whether ``target`` triggers a ``$CDPATH`` search.
Args:
target: The as-typed ``cd`` operand.
Returns:
True when ``target`` is relative and does not begin with ``./``
or ``../`` (mirroring GNU bash's ``cd`` search rule).
"""
if target.startswith(("/", "./", "../")):
return False
return target not in (".", "..")
def _cd_candidates(
raw: str,
cdpath_target: str | None,
session: Session,
) -> list[tuple[str, bool]]:
"""Build the ordered list of directories ``cd`` should try.
Args:
raw: The resolved operand path string.
cdpath_target: The as-typed operand when a ``$CDPATH`` search
applies, else ``None``.
session: The shell session (provides cwd and env).
Returns:
``(resolved_path, announce)`` pairs in trial order; ``announce``
marks a non-empty ``$CDPATH`` hit whose absolute path GNU prints.
"""
cwd = session.cwd
fallback = resolve_path(raw, cwd)
cdpath = session.env.get("CDPATH")
if (not cdpath or not cdpath_target
or not _cdpath_searchable(cdpath_target)):
return [(fallback, False)]
out: list[tuple[str, bool]] = []
for entry in cdpath.split(":"):
base = resolve_path(entry, cwd) if entry else cwd
out.append((resolve_path(cdpath_target, base), entry != ""))
out.append((fallback, False))
return out
async def handle_cd(
dispatch: Callable,
is_mount_root: Callable[[str], bool],
path: str | PathSpec,
session: Session,
print_path: bool = False,
cdpath_target: str | None = None,
links: dict[str, str] | None = None,
physical: bool = False,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
raw = _scope_path(path)
table = links or {}
candidates = _cd_candidates(raw, cdpath_target, session)
error: str | None = None
for resolved, announce in candidates:
if table:
try:
resolved = _resolve_target(resolved, table, physical)
except CycleError:
error = f"cd: {raw}: Too many levels of symbolic links\n"
continue
if resolved == "/":
return _cd_success(session, "/", raw, print_path or announce)
scope = _to_scope(resolved)
s = None
not_found = False
try:
s, _ = await dispatch("stat", scope)
except FileNotFoundError:
not_found = True
except ValueError as exc:
error = f"cd: {raw}: {exc}\n"
continue
if s is None or not_found:
if is_mount_root(resolved):
return _cd_success(session, resolved, raw, print_path
or announce)
error = f"cd: {raw}: No such file or directory\n"
continue
if s.type != FileType.DIRECTORY:
error = f"cd: {raw}: Not a directory\n"
continue
return _cd_success(session, resolved, raw, print_path or announce)
err = (error or f"cd: {raw}: No such file or directory\n").encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=f"cd {raw}",
exit_code=1,
stderr=err)
def _cd_success(
session: Session,
resolved: str,
raw: str,
print_path: bool,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
change_dir(session, resolved)
out = (resolved + "\n").encode() if print_path else None
return out, IOResult(), ExecutionNode(command=f"cd {raw}", exit_code=0)
@@ -0,0 +1,121 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.io.types import ByteSource, IOResult
from mirage.resource.history import HISTORY_PREFIX
from mirage.workspace.session.session import Session
from mirage.workspace.types import ExecutionNode
USAGE = ("history: usage: history [-c] [-d offset] [n] or "
"history -awrn [filename] or history -ps arg [arg...]\n")
OPTION_CHARS = "cdanrwsp"
def _usage_error(message: str) -> tuple[None, IOResult, ExecutionNode]:
err = (message + USAGE).encode()
io = IOResult(exit_code=2, stderr=err)
return None, io, ExecutionNode(command="history", exit_code=2, stderr=err)
def _parse_args(
args: list[str]) -> tuple[dict[str, object], list[str], str | None]:
"""Parse history builtin args the way bash getopt does.
Args:
args (list[str]): Raw tokens after the command name.
Returns:
tuple[dict[str, object], list[str], str | None]: Flags dict,
operand texts, and an error message (None when parsing
succeeded). Any dash-leading token is option-parsed, digits
included (`history -1` is an invalid option in bash); `--` or
the first operand ends option parsing, so `history -s rm -rf`
stores "rm -rf" as text. `-d` takes the rest of its token as
the offset when attached (`-d3`, and `-dc` deletes entry "c"),
otherwise the next token.
"""
flags: dict[str, object] = {}
texts: list[str] = []
options_done = False
i = 0
while i < len(args):
token = args[i]
if options_done or token == "-" or not token.startswith("-"):
texts.append(token)
options_done = True
elif token == "--":
options_done = True
else:
j = 1
while j < len(token):
ch = token[j]
if ch not in OPTION_CHARS:
return {}, [], f"history: -{ch}: invalid option\n"
flags[ch] = True
if ch == "d":
rest = token[j + 1:]
if rest:
flags["d"] = rest
elif i + 1 < len(args):
i += 1
flags["d"] = args[i]
else:
return ({}, [],
"history: -d: option requires an argument\n")
break
j += 1
i += 1
return flags, texts, None
async def handle_history(
registry,
args: list[str],
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Dispatch the history shell builtin to the view mount.
GNU lookup order: builtins resolve before mount commands, so a
mount-local command named "history" can never shadow this one.
The actual semantics live on the /.bash_history view resource;
this handler only parses options and routes.
Args:
registry (MountRegistry): The workspace's mount registry.
args (list[str]): Raw builtin args (flags and counts).
session (Session): Calling session.
"""
flags, texts, error = _parse_args(args)
if error is not None:
return _usage_error(error)
try:
mount = registry.mount_for(HISTORY_PREFIX)
except ValueError:
err = b"history: not enabled for this workspace\n"
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="history",
exit_code=1,
stderr=err)
stream, io = await mount.execute_cmd("history", [],
texts,
flags,
cwd=session.cwd,
session_id=session.session_id)
# The view command always returns byte stderr, but io.stderr is typed
# as a ByteSource (a possible lazy stream); resolve it to bytes so the
# execution-tree node holds concrete stderr, never an unread stream.
stderr = await io.materialize_stderr()
return stream, io, ExecutionNode(command="history",
exit_code=io.exit_code,
stderr=stderr)
@@ -0,0 +1,260 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import dataclasses
import time
from collections.abc import Callable
from mirage.io import IOResult
from mirage.io.types import ByteSource
from mirage.types import FileType, PathSpec, word_text
from mirage.utils.path import CycleError, resolve_path
from mirage.workspace.mount.namespace import Namespace
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
def _split_flags(
args: list[str | PathSpec],
known: str,
) -> tuple[set[str], list[str | PathSpec]]:
flags: set[str] = set()
operands: list[str | PathSpec] = []
parsing = True
for arg in args:
s = arg.virtual if isinstance(arg, PathSpec) else str(arg)
if parsing and s == "--":
parsing = False
continue
if (parsing and s != "-" and len(s) >= 2 and s.startswith("-")
and all(c in known for c in s[1:])):
flags.update(s[1:])
continue
parsing = False
operands.append(arg)
return flags, operands
def link_flags(args: list[str | PathSpec], known: str) -> set[str]:
flags, _ = _split_flags(args, known)
return flags
def _abs(arg: str | PathSpec, cwd: str) -> str:
if isinstance(arg, PathSpec):
return arg.virtual
return resolve_path(arg, cwd)
def handle_ln(
namespace: Namespace,
session: Session,
args: list[str | PathSpec],
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
flags, operands = _split_flags(args, "sfnv")
if len(operands) < 2:
err = b"ln: missing file operand\n"
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="ln",
exit_code=1,
stderr=err)
# GNU: with more than two operands the last must be a directory;
# namespace links never name directories, so this is always an error
# (an expanded multi-match glob source lands here).
if len(operands) > 2:
err = (f"ln: target '{word_text(operands[-1])}' "
f"is not a directory\n").encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="ln",
exit_code=1,
stderr=err)
link_abs = _abs(operands[1], session.cwd)
target_typed = word_text(operands[0])
exists = namespace.is_link(link_abs) and "f" not in flags
if namespace.is_mount_root(link_abs) or exists:
err = (f"ln: failed to create symbolic link "
f"'{word_text(operands[1])}': File exists\n").encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="ln",
exit_code=1,
stderr=err)
namespace.symlink(link_abs, target_typed, time.time())
out = None
if "v" in flags:
out = (f"'{word_text(operands[1])}' -> '{target_typed}'\n").encode()
return out, IOResult(), ExecutionNode(command="ln", exit_code=0)
def follow_paths(
namespace: Namespace,
items: list[str | PathSpec],
) -> list[str | PathSpec]:
"""Rewrite path operands through the symlink table (open(2) semantics).
Non-path items and paths that resolve to themselves pass through
untouched. A rewritten spec keeps the user-typed form in ``raw_path``
so error messages still name the operand as typed; the mount re-stamps
``resource_path`` at dispatch.
Args:
namespace (Namespace): addressing authority holding the link table.
items (list[str | PathSpec]): classified command parts.
Raises:
CycleError: when a path loops past the hop limit (ELOOP).
"""
out: list[str | PathSpec] = []
for item in items:
if not isinstance(item, PathSpec):
out.append(item)
continue
try:
virtual = namespace.follow(item.virtual)
except CycleError:
raise CycleError(item.raw_path) from None
if virtual == item.virtual:
out.append(item)
continue
out.append(
dataclasses.replace(item,
virtual=virtual,
directory=virtual[:virtual.rfind("/") + 1]
or "/",
resource_path=""))
return out
def strip_link_operands(
namespace: Namespace,
items: list[str | PathSpec],
) -> tuple[list[str | PathSpec], int]:
"""Unlink and drop ``rm`` operands that are symlinks.
GNU ``rm`` removes the link itself and never follows it; a dangling
link removes fine. Remaining operands stay for backend dispatch.
Args:
namespace (Namespace): addressing authority holding the link table.
items (list[str | PathSpec]): classified command parts.
Returns:
tuple[list[str | PathSpec], int]: surviving parts and the number
of link entries removed.
"""
removed = 0
kept: list[str | PathSpec] = []
for item in items:
if isinstance(item, PathSpec) and namespace.is_link(item.virtual):
namespace.unlink(item.virtual)
removed += 1
continue
kept.append(item)
return kept, removed
async def prepare_mv(
namespace: Namespace,
dispatch: Callable,
items: list[str | PathSpec],
) -> tuple[list[str | PathSpec], str | None, tuple[ByteSource | None, IOResult,
ExecutionNode] | None]:
"""Adjust a two-operand ``mv`` for symlink operands.
A link source renames the link entry itself (into a destination
directory when one exists, mirroring rename(2) preceded by mv's dst
stat). A link destination whose target is a directory is followed
(mv moves into it); any other link destination is replaced, so its
entry must drop once the backend move succeeds.
Args:
namespace (Namespace): addressing authority holding the link table.
dispatch (Callable): op dispatcher used to stat the destination.
items (list[str | PathSpec]): classified command parts.
Returns:
tuple: (possibly rewritten parts, link path to unlink after a
successful backend move, early result when the mv completed as a
pure namespace rename).
"""
paths = [p for p in items if isinstance(p, PathSpec)]
if len(paths) != 2:
return items, None, None
src, dst = paths
if namespace.is_link(src.virtual):
target_dst = dst.virtual
stat = await _stat_or_none(dispatch, dst)
if stat is not None and stat.type == FileType.DIRECTORY:
target_dst = (dst.virtual.rstrip("/") + "/" +
src.virtual.rsplit("/", 1)[-1])
namespace.unlink(target_dst)
namespace.rename(src.virtual, target_dst)
return items, None, (None, IOResult(),
ExecutionNode(command="mv", exit_code=0))
if namespace.is_link(dst.virtual):
followed = namespace.follow(dst.virtual)
stat = await _stat_or_none(dispatch, PathSpec.from_str_path(followed))
if stat is not None and stat.type == FileType.DIRECTORY:
return follow_paths(namespace, items), None, None
return items, dst.virtual, None
return items, None, None
async def _stat_or_none(dispatch: Callable, path: PathSpec):
"""Stat a path via dispatch, mapping a missing file to ``None``.
Args:
dispatch (Callable): op dispatcher.
path (PathSpec): path to stat.
"""
# A missing destination is an expected mv case (plain rename), not an
# error to surface.
try:
stat, _ = await dispatch("stat", path)
except FileNotFoundError:
return None
return stat
def handle_readlink(
namespace: Namespace,
session: Session,
args: list[str | PathSpec],
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
flags, operands = _split_flags(args, "fenm")
if not operands:
err = b"readlink: missing operand\n"
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="readlink",
exit_code=1,
stderr=err)
lines: list[str] = []
exit_code = 0
for op in operands:
target = namespace.readlink(_abs(op, session.cwd))
if target is None:
exit_code = 1
continue
lines.append(target)
if not lines:
return None, IOResult(exit_code=exit_code), ExecutionNode(
command="readlink", exit_code=exit_code)
if "n" in flags:
text = "".join(lines)
else:
text = "".join(line + "\n" for line in lines)
return text.encode(), IOResult(exit_code=exit_code), ExecutionNode(
command="readlink", exit_code=exit_code)
@@ -0,0 +1,200 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from dataclasses import dataclass
from mirage.commands.config import RegisteredCommand
from mirage.commands.spec import SPECS, CommandSpec
from mirage.io import IOResult
from mirage.io.types import ByteSource
from mirage.workspace.mount.mount import MountEntry
from mirage.workspace.mount.registry import DEV_PREFIX, MountRegistry
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
@dataclass
class _ManHit:
mount: MountEntry
cmd: RegisteredCommand
is_general: bool
def _collect_man_hits(name: str, registry: MountRegistry) -> list[_ManHit]:
hits: list[_ManHit] = []
for mount in registry.mounts():
if mount.prefix == DEV_PREFIX:
continue
cmd = mount.resolve_command(name)
if cmd is None:
continue
hits.append(
_ManHit(mount=mount,
cmd=cmd,
is_general=mount.is_general_command(name)))
return hits
def _render_options_table(spec: CommandSpec) -> list[str]:
if not spec.options:
return []
lines: list[str] = []
lines.append("## OPTIONS")
lines.append("")
lines.append("| short | long | value | description |")
lines.append("| ----- | ---- | ----- | ----------- |")
for opt in spec.options:
short = opt.short if opt.short is not None else ""
long = opt.long if opt.long is not None else ""
desc = opt.description if opt.description is not None else ""
lines.append(f"| {short} | {long} | {opt.value_kind.value} | {desc} |")
lines.append("")
return lines
def _render_man_entry(name: str, hits: list[_ManHit]) -> str:
first = hits[0]
spec = first.cmd.spec
lines: list[str] = []
lines.append(f"# {name}")
lines.append("")
lines.append(spec.description if spec.
description is not None else "(no description)")
lines.append("")
lines.extend(_render_options_table(spec))
lines.append("## RESOURCES")
lines.append("")
seen: set[str] = set()
has_general = False
rows: list[str] = []
for h in hits:
if h.is_general:
has_general = True
continue
kind = h.mount.resource.name
filetype = h.cmd.filetype
key = f"{kind}\x00{filetype or ''}"
if key in seen:
continue
seen.add(key)
if filetype is not None:
rows.append(f"- {kind} (filetype: {filetype})")
else:
rows.append(f"- {kind}")
rows.sort()
if has_general:
lines.append("- general")
for r in rows:
lines.append(r)
return "\n".join(lines) + "\n"
_SHELL_BUILTIN_MAN: dict[str, str] = {
"bash": "bash",
"sh": "bash",
}
def _render_shell_builtin_man(name: str, spec: CommandSpec) -> str:
lines: list[str] = []
lines.append(f"# {name}")
lines.append("")
lines.append(spec.description if spec.
description is not None else "(no description)")
lines.append("")
lines.extend(_render_options_table(spec))
lines.append("## RESOURCES")
lines.append("")
lines.append("- shell builtin")
return "\n".join(lines) + "\n"
def _render_man_index(session: Session, registry: MountRegistry) -> str:
by_kind: dict[str, MountEntry] = {}
for m in registry.mounts():
if m.prefix == DEV_PREFIX:
continue
if m.resource.name not in by_kind:
by_kind[m.resource.name] = m
try:
cwd_mount: MountEntry | None = registry.mount_for(session.cwd)
except ValueError:
cwd_mount = None
cwd_kind: str | None = None
if cwd_mount is not None and cwd_mount.prefix != DEV_PREFIX:
cwd_kind = cwd_mount.resource.name
kinds = sorted(by_kind.keys())
ordered: list[str] = []
if cwd_kind is not None and cwd_kind in by_kind:
ordered.append(cwd_kind)
for k in kinds:
if k == cwd_kind:
continue
ordered.append(k)
lines: list[str] = []
general_seen: dict[str, RegisteredCommand] = {}
for kind in ordered:
m = by_kind[kind]
lines.append(f"# {kind}")
lines.append("")
all_cmds = m.all_commands()
resource_cmds = sorted(
(c for c in all_cmds if not m.is_general_command(c.name)),
key=lambda c: c.name,
)
for cmd in resource_cmds:
desc = (cmd.spec.description if cmd.spec.description is not None
else "(no description)")
lines.append(f"- {cmd.name} \u2014 {desc}")
for cmd in all_cmds:
if (m.is_general_command(cmd.name)
and cmd.name not in general_seen):
general_seen[cmd.name] = cmd
lines.append("")
lines.append("# general")
lines.append("")
for name in sorted(general_seen):
cmd = general_seen[name]
desc = (cmd.spec.description
if cmd.spec.description is not None else "(no description)")
lines.append(f"- {name} \u2014 {desc}")
return "\n".join(lines) + "\n"
async def handle_man(
args: list[str],
session: Session,
registry: MountRegistry,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
if not args:
out = _render_man_index(session, registry).encode()
return out, IOResult(), ExecutionNode(command="man", exit_code=0)
name = args[0]
hits = _collect_man_hits(name, registry)
if not hits:
spec_key = _SHELL_BUILTIN_MAN.get(name)
spec = SPECS.get(spec_key) if spec_key is not None else None
if spec is not None:
out = _render_shell_builtin_man(name, spec).encode()
return out, IOResult(), ExecutionNode(command=f"man {name}",
exit_code=0)
err = f"man: no entry for {name}\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=f"man {name}",
exit_code=1,
stderr=err)
out = _render_man_entry(name, hits).encode()
return out, IOResult(), ExecutionNode(command=f"man {name}", exit_code=0)
@@ -0,0 +1,32 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.types import PathSpec
def _to_scope(path: str) -> PathSpec:
"""Wrap a resolved path string as PathSpec."""
last_slash = path.rfind("/")
directory = path[:last_slash + 1] if last_slash >= 0 else "/"
return PathSpec(virtual=path,
directory=directory,
resource_path="",
resolved=True)
def _scope_path(val) -> str:
"""Extract path string from str or PathSpec."""
if isinstance(val, PathSpec):
return val.virtual
return val
@@ -0,0 +1,162 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import asyncio
import math
import re
from collections.abc import Callable
from mirage.io import IOResult
from mirage.io.stream import materialize
from mirage.io.types import ByteSource
from mirage.types import PathSpec
from mirage.utils.path import resolve_path
from mirage.workspace.abort import cancellable_sleep
from mirage.workspace.executor.builtins.scope import _scope_path, _to_scope
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
async def handle_source(
dispatch: Callable,
execute_fn: Callable,
path: str | PathSpec,
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Read a script file and execute it."""
raw = _scope_path(path)
resolved = resolve_path(raw, session.cwd)
scope = _to_scope(resolved)
data, _ = await dispatch("read", scope)
if isinstance(data, bytes):
script = data.decode(errors="replace")
else:
script = ""
io = await execute_fn(script, session_id=session.session_id)
return io.stdout, io, ExecutionNode(command=f"source {raw}",
exit_code=io.exit_code)
async def handle_eval(
execute_fn: Callable,
args: list[str],
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
script = " ".join(args)
io = await execute_fn(script, session_id=session.session_id)
return io.stdout, io, ExecutionNode(command="eval", exit_code=io.exit_code)
_BASH_NOOP_SHORT_FLAGS = frozenset({"l", "i", "e", "u", "x"})
_BASH_NOOP_LONG_FLAGS = frozenset(
{"--login", "--norc", "--noprofile", "--posix", "--rcfile"})
async def handle_bash(
execute_fn: Callable,
args: list[str],
session: Session,
stdin: ByteSource | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
script: str | None = None
read_stdin = False
i = 0
while i < len(args):
tok = args[i]
if tok == "--":
i += 1
break
if tok == "-c":
if i + 1 >= len(args):
err = b"bash: -c: option requires an argument\n"
return None, IOResult(exit_code=2, stderr=err), ExecutionNode(
command="bash", exit_code=2, stderr=err)
script = args[i + 1]
break
if tok == "-s":
read_stdin = True
i += 1
continue
if tok in ("-o", "+o"):
i += 2
continue
if tok in _BASH_NOOP_LONG_FLAGS:
i += 1
continue
if (tok.startswith("-") and len(tok) > 1 and not tok.startswith("--")):
chars = tok[1:]
if "c" in chars:
if i + 1 >= len(args):
err = b"bash: -c: option requires an argument\n"
return None, IOResult(
exit_code=2, stderr=err), ExecutionNode(command="bash",
exit_code=2,
stderr=err)
script = args[i + 1]
break
if all(ch in _BASH_NOOP_SHORT_FLAGS or ch == "s" for ch in chars):
if "s" in chars:
read_stdin = True
i += 1
continue
err = (f"bash: {tok}: unsupported option\n").encode()
return None, IOResult(exit_code=2,
stderr=err), ExecutionNode(command="bash",
exit_code=2,
stderr=err)
if script is None:
script = tok
break
i += 1
if script is None and read_stdin and stdin is not None:
stdin_data = await materialize(stdin)
if stdin_data:
script = stdin_data.decode(errors="replace")
stdin = None
if script is None:
return None, IOResult(), ExecutionNode(command="bash", exit_code=0)
io = await execute_fn(script, session_id=session.session_id, stdin=stdin)
return io.stdout, io, ExecutionNode(command=f"bash -c {script}",
exit_code=io.exit_code)
# Finite non-negative decimals only ("0", "0.2", ".5", "1.", "+1", "1e-3").
# GNU sleep additionally accepts "inf" and sleeps forever; an agent shell
# must never hang, so non-finite intervals are rejected (deliberate
# divergence). The regex also keeps Python/TypeScript parsing identical:
# float() alone would accept "inf", "nan", "1_0", and surrounding whitespace
# that Number() rejects, and Number() accepts hex that float() rejects.
SLEEP_INTERVAL = re.compile(r"\+?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?")
async def handle_sleep(
args: list[str],
cancel: asyncio.Event | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
if not args:
err = b"sleep: missing operand\n"
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="sleep",
exit_code=1)
raw = args[0]
# "1e309" passes the regex but overflows to inf, so check both.
seconds = float(raw) if SLEEP_INTERVAL.fullmatch(raw) else math.inf
if not math.isfinite(seconds):
err = f"sleep: invalid time interval '{raw}'\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="sleep",
exit_code=1)
await cancellable_sleep(seconds, cancel)
return None, IOResult(), ExecutionNode(command="sleep", exit_code=0)
@@ -0,0 +1,138 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.commands.spec.shell import ECHO_OPTION
from mirage.io import IOResult
from mirage.io.types import ByteSource
from mirage.workspace.types import ExecutionNode
_SIMPLE_ESCAPES = {
"\\": "\\",
"n": "\n",
"t": "\t",
"r": "\r",
"a": "\a",
"b": "\b",
"f": "\f",
"v": "\v",
}
_HEX = set("0123456789abcdefABCDEF")
_OCT = set("01234567")
def _interpret_escapes(text: str) -> str:
"""Process C-style escape sequences for echo -e.
Single-pass to handle \\\\ correctly (\\\\b → \\b literal).
Supports: \\\\, \\n, \\t, \\r, \\a, \\b, \\f, \\v,
\\xHH (hex), \\0NNN (octal), \\c (stop output).
Unknown escapes like \\z pass through as \\z.
"""
out: list[str] = []
i = 0
n = len(text)
while i < n:
if text[i] != "\\" or i + 1 >= n:
out.append(text[i])
i += 1
continue
ch = text[i + 1]
if ch in _SIMPLE_ESCAPES:
out.append(_SIMPLE_ESCAPES[ch])
i += 2
elif ch == "c":
break
elif ch == "x":
# \xHH — up to 2 hex digits
digits = []
j = i + 2
while j < n and len(digits) < 2 and text[j] in _HEX:
digits.append(text[j])
j += 1
if digits:
out.append(chr(int("".join(digits), 16)))
i = j
else:
out.append("\\x")
i += 2
elif ch == "0":
# \0NNN — up to 3 octal digits
digits = []
j = i + 2
while j < n and len(digits) < 3 and text[j] in _OCT:
digits.append(text[j])
j += 1
out.append(chr(int("".join(digits), 8)) if digits else "\0")
i = j
else:
# unknown escape — pass through literally
out.append("\\")
out.append(ch)
i += 2
return "".join(out)
async def handle_echo(
args: list[str], # noqa: E125
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Print arguments, honoring GNU echo's option rules.
GNU echo is not getopt: options are LEADING words matching
``-[neE]+`` only. The first word that does not match (including
``-x`` or a repeated ``hi -n``) ends option parsing and prints
literally. Within clusters the last of -e/-E wins; -n sticks.
Args:
args (list[str]): words after the command name, as typed.
"""
no_newline = False
escapes = False
idx = 0
for word in args:
if not ECHO_OPTION.fullmatch(word):
break
for ch in word[1:]:
if ch == "n":
no_newline = True
elif ch == "e":
escapes = True
else:
escapes = False
idx += 1
text = " ".join(args[idx:])
if escapes:
text = _interpret_escapes(text)
if not no_newline:
text += "\n"
out = text.encode()
return out, IOResult(), ExecutionNode(command="echo", exit_code=0)
async def handle_printf(
args: list[str], # noqa: E125
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
if not args:
return b"", IOResult(), ExecutionNode(command="printf", exit_code=0)
fmt = args[0]
fmt = fmt.replace("\\n", "\n").replace("\\t", "\t")
if len(args) > 1:
try:
out = (fmt % tuple(args[1:])).encode()
except TypeError:
out = fmt.encode()
else:
out = fmt.encode()
return out, IOResult(), ExecutionNode(command="printf", exit_code=0)
@@ -0,0 +1,122 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import asyncio
import re
import shlex
from collections.abc import Callable
from mirage.commands.spec.shell import SHELL_SPECS, parse_shell_options
from mirage.io import IOResult
from mirage.io.stream import materialize
from mirage.io.types import ByteSource
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
_DURATION = re.compile(r"(\d+(?:\.\d*)?|\.\d+)([smhd]?)")
_UNIT_SECONDS = {"": 1.0, "s": 1.0, "m": 60.0, "h": 3600.0, "d": 86400.0}
_UNSUPPORTED = ("s", "k", "preserve-status")
def _usage_error(message: str) -> tuple[None, IOResult, ExecutionNode]:
# GNU timeout reserves 125 for its own failures; 124 means the
# command was killed at the deadline.
stderr = f"timeout: {message}\n".encode()
return None, IOResult(exit_code=125,
stderr=stderr), ExecutionNode(command="timeout",
exit_code=125)
def parse_duration(raw: str) -> float | None:
"""Parse a GNU timeout duration (float plus optional s/m/h/d).
Args:
raw (str): duration operand as typed.
"""
match = _DURATION.fullmatch(raw)
if match is None:
return None
return float(match.group(1)) * _UNIT_SECONDS[match.group(2)]
async def handle_timeout(
execute_fn: Callable,
args: list[str],
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Run `timeout DURATION COMMAND [ARG...]`, killing at the deadline.
The inner line is built with shlex.join so already-expanded words
survive re-parsing as one token each (GNU timeout execs the command
without a shell). On overrun the inner run is cancelled and the
exit code is 124 like GNU. Signal options (-s, -k,
--preserve-status) are parsed but rejected: the inner run is a
coroutine, not a process, so there is nothing to signal.
Args:
execute_fn (Callable): shell evaluator for the inner line.
args (list[str]): options, duration operand, then the command.
session (Session): shell session state.
"""
parse = parse_shell_options(SHELL_SPECS["timeout"], args or [])
if parse.invalid is not None:
if parse.invalid.startswith("--"):
return _usage_error(f"unrecognized option '{parse.invalid}'")
return _usage_error(f"invalid option -- '{parse.invalid}'")
if parse.needs_value is not None:
return _usage_error(
f"option requires an argument -- '{parse.needs_value}'")
for name in _UNSUPPORTED:
if name in parse.flags:
dashes = "--" if len(name) > 1 else "-"
return _usage_error(f"unsupported option -- '{dashes}{name}'")
if len(parse.operands) < 2:
return _usage_error("missing operand")
raw = parse.operands[0]
seconds = parse_duration(raw)
if seconds is None:
return _usage_error(f"invalid time interval '{raw}'")
inner = shlex.join(parse.operands[1:])
try:
stdout, io = await asyncio.wait_for(
_execute_drained(execute_fn, inner, session.session_id),
timeout=seconds if seconds > 0 else None,
)
except asyncio.TimeoutError:
return None, IOResult(exit_code=124), ExecutionNode(command="timeout",
exit_code=124)
return stdout, io, ExecutionNode(command="timeout", exit_code=io.exit_code)
async def _execute_drained(
execute_fn: Callable,
inner: str,
session_id: str,
) -> tuple[bytes | None, IOResult]:
"""Run the inner line and drain its stdout under the same deadline.
A lazy inner pipeline produces bytes only when consumed; draining
inside the wait_for scope keeps the whole run under the limit.
Args:
execute_fn (Callable): shell evaluator for the inner line.
inner (str): the joined command line.
session_id (str): session to run in.
"""
io = await execute_fn(inner, session_id=session_id)
stdout = await materialize(io.stdout)
return stdout, io
@@ -0,0 +1,286 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.commands.spec.shell import SHELL_SPECS, parse_shell_options
from mirage.io import IOResult
from mirage.io.async_line_iterator import AsyncLineIterator
from mirage.io.stream import async_chain
from mirage.io.types import ByteSource
from mirage.shell.call_stack import CallStack
from mirage.shell.types import SET_FLAG_TO_OPTION
from mirage.workspace.executor.control import ReturnSignal
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
async def handle_export(
assignments: list[str],
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
for assign in assignments:
if "=" in assign:
key, _, val = assign.partition("=")
if key in session.readonly_vars:
err = f"bash: {key}: readonly variable\n".encode()
return None, IOResult(exit_code=1, stderr=err), ExecutionNode(
command="export", exit_code=1, stderr=err)
session.env[key] = val
else:
session.env.setdefault(assign, "")
return None, IOResult(), ExecutionNode(command="export", exit_code=0)
async def handle_readonly(
assignments: list[str],
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
for assign in assignments:
if "=" in assign:
key, _, val = assign.partition("=")
if key in session.readonly_vars:
err = f"bash: {key}: readonly variable\n".encode()
return None, IOResult(exit_code=1, stderr=err), ExecutionNode(
command="readonly", exit_code=1, stderr=err)
session.env[key] = val
session.readonly_vars.add(key)
else:
session.readonly_vars.add(assign)
return None, IOResult(), ExecutionNode(command="readonly", exit_code=0)
async def handle_unset(
names: list[str],
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
for name in names:
if name in session.readonly_vars:
err = (f"bash: unset: {name}: cannot unset: "
f"readonly variable\n").encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="unset",
exit_code=1,
stderr=err)
session.env.pop(name, None)
return None, IOResult(), ExecutionNode(command="unset", exit_code=0)
async def handle_printenv(
name: str | None,
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
if name:
val = session.env.get(name)
if val is None:
return None, IOResult(exit_code=1), ExecutionNode(
command="printenv", exit_code=1)
out = f"{val}\n".encode()
else:
lines = [f"{k}={v}" for k, v in session.env.items()]
out = ("\n".join(sorted(lines)) + "\n").encode()
return out, IOResult(), ExecutionNode(command="printenv", exit_code=0)
async def handle_whoami(
session: Session, # noqa: E125
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
user = session.env.get("USER")
if user is None:
err = b"whoami: USER not set\n"
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="whoami",
exit_code=1,
stderr=err)
out = f"{user}\n".encode()
return out, IOResult(), ExecutionNode(command="whoami", exit_code=0)
async def handle_read(
args: list[str],
session: Session,
stdin: ByteSource | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Read one line into variables, with bash's option handling.
Only -r is accepted (our read is already raw, so it is consumed
with no effect); anything else errors like bash instead of being
treated as a variable name.
Args:
args (list[str]): words after the command name.
session (Session): shell session state.
stdin (ByteSource | None): line source.
"""
parse = parse_shell_options(SHELL_SPECS["read"], args)
if parse.invalid is not None:
token = (parse.invalid
if parse.invalid.startswith("--") else f"-{parse.invalid}")
err = f"read: {token}: invalid option\n".encode()
return None, IOResult(exit_code=2,
stderr=err), ExecutionNode(command="read",
exit_code=2)
variables = parse.operands or ["REPLY"]
if session._stdin_buffer is None and stdin is not None:
if isinstance(stdin, bytes):
session._stdin_buffer = AsyncLineIterator(async_chain(stdin))
elif hasattr(stdin, "__aiter__"):
session._stdin_buffer = AsyncLineIterator(stdin)
line_bytes: bytes | None = None
if session._stdin_buffer is not None:
line_bytes = await session._stdin_buffer.readline()
if line_bytes is None:
for var in variables:
session.env[var] = ""
return None, IOResult(exit_code=1), ExecutionNode(command="read",
exit_code=1)
line = line_bytes.decode(errors="replace").rstrip("\n")
ifs = session.env.get("IFS", " \t\n")
if ifs == " \t\n":
parts = line.split(None, len(variables) - 1) if variables else []
elif not ifs:
parts = [line]
else:
n_splits = max(0, len(variables) - 1)
chars = set(ifs)
out: list[str] = []
cur: list[str] = []
for ch in line:
if ch in chars and len(out) < n_splits:
out.append("".join(cur))
cur = []
continue
cur.append(ch)
out.append("".join(cur))
parts = out
for i, var in enumerate(variables):
session.env[var] = parts[i] if i < len(parts) else ""
return None, IOResult(), ExecutionNode(command="read", exit_code=0)
async def handle_local(
assignments: list[str],
session: Session,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
local_vars = getattr(session, "_local_vars", None)
for assign in assignments:
if "=" in assign:
key, _, val = assign.partition("=")
if local_vars is not None and key not in local_vars:
local_vars[key] = session.env.get(key)
session.env[key] = val
else:
if local_vars is not None and assign not in local_vars:
local_vars[assign] = session.env.get(assign)
session.env.setdefault(assign, "")
return None, IOResult(), ExecutionNode(command="local", exit_code=0)
async def handle_shift(
args: list[str],
call_stack: CallStack | None,
session: Session | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Shift positional parameters, with bash's argument checks.
Args:
args (list[str]): words after the command name; at most one,
the shift count.
call_stack (CallStack | None): function-call positional frames.
session (Session | None): shell session state.
"""
if len(args) > 1:
err = b"shift: too many arguments\n"
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="shift",
exit_code=1)
if args and not _is_shift_count(args[0]):
err = f"shift: {args[0]}: numeric argument required\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="shift",
exit_code=1)
n = int(args[0]) if args else 1
shifted = False
if call_stack is not None and call_stack.get_all_positional():
call_stack.shift(n)
shifted = True
if not shifted and session is not None:
pos = getattr(session, "positional_args", None)
if pos is not None:
session.positional_args = pos[n:]
return None, IOResult(), ExecutionNode(command="shift", exit_code=0)
def _is_shift_count(word: str) -> bool:
body = word[1:] if word[:1] in ("-", "+") else word
return body.isdigit()
async def handle_set(
args: list[str],
session: Session,
call_stack: CallStack | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
if not args:
lines = [f"{k}={v}" for k, v in session.env.items()]
out = ("\n".join(sorted(lines)) + "\n").encode()
return out, IOResult(), ExecutionNode(command="set", exit_code=0)
i = 0
while i < len(args):
tok = args[i]
if tok == "--":
session.positional_args = args[i + 1:]
return None, IOResult(), ExecutionNode(command="set", exit_code=0)
if tok in ("-o", "+o"):
if i + 1 < len(args):
session.shell_options[args[i + 1]] = (tok == "-o")
i += 2
continue
i += 1
continue
if (tok.startswith("-") or tok.startswith("+")) and len(tok) > 1:
enable = tok[0] == "-"
for ch in tok[1:]:
opt = SET_FLAG_TO_OPTION.get(ch)
if opt:
session.shell_options[opt] = enable
i += 1
continue
session.positional_args = args[i:]
break
return None, IOResult(), ExecutionNode(command="set", exit_code=0)
async def handle_trap(
session: Session, # noqa: E125
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
return None, IOResult(), ExecutionNode(command="trap", exit_code=0)
async def handle_return(
args: list[str], # noqa: E125
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Return from a function, with bash's argument check.
Args:
args (list[str]): words after the command name; at most one,
the return status.
"""
if args and not _is_shift_count(args[0]):
# bash prints the error and the function returns 2.
raise ReturnSignal(
2,
stderr=f"return: {args[0]}: numeric argument required\n".encode())
raise ReturnSignal(int(args[0]) if args else 0)
@@ -0,0 +1,124 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import shlex
from collections.abc import Callable
from mirage.commands.spec.shell import SHELL_SPECS, parse_shell_options
from mirage.io import IOResult
from mirage.io.stream import async_chain, materialize
from mirage.io.types import ByteSource
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
_UNSUPPORTED = ("I", "P")
def _usage_error(message: str) -> tuple[None, IOResult, ExecutionNode]:
stderr = f"xargs: {message}\n".encode()
return None, IOResult(exit_code=1,
stderr=stderr), ExecutionNode(command="xargs",
exit_code=1)
def _split_items(data: bytes, flags: dict[str, str | bool]) -> list[str]:
if flags.get("0") is True:
return [
chunk.decode(errors="replace") for chunk in data.split(b"\0")
if chunk
]
delim = flags.get("d")
if isinstance(delim, str):
delim = delim.replace("\\n", "\n").replace("\\t", "\t")
text = data.decode(errors="replace")
if text.endswith(delim):
text = text[:-len(delim)]
return text.split(delim) if text else []
return data.decode(errors="replace").split()
async def handle_xargs(
execute_fn: Callable,
args: list[str],
session: Session,
stdin: ByteSource | None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Run a command with words read from stdin appended (GNU xargs).
GNU xargs execs the command directly, so every input word must
reach it as exactly one argv token. The inner line is built with
shlex.join: a plain join would be re-parsed by the shell, splitting
words with whitespace and executing $(...) found in input.
Args:
execute_fn (Callable): shell evaluator for the inner line.
args (list[str]): options, then command name and initial
arguments; the command defaults to ["echo"] like GNU.
session (Session): shell session state.
stdin (ByteSource | None): input whose words become arguments.
"""
parse = parse_shell_options(SHELL_SPECS["xargs"], args or [])
if parse.invalid is not None:
if parse.invalid.startswith("--"):
return _usage_error(f"unrecognized option '{parse.invalid}'")
return _usage_error(f"invalid option -- '{parse.invalid}'")
if parse.needs_value is not None:
return _usage_error(
f"option requires an argument -- '{parse.needs_value}'")
for name in _UNSUPPORTED:
if name in parse.flags:
return _usage_error(f"unsupported option -- '{name}'")
max_args: int | None = None
raw_n = parse.flags.get("n")
if isinstance(raw_n, str):
if not raw_n.isdigit():
return _usage_error(f'invalid number "{raw_n}" for -n option')
max_args = int(raw_n)
if max_args < 1:
return _usage_error(f"value {raw_n} for -n option should be >= 1")
data = await materialize(stdin)
if data is None:
data = b""
items = _split_items(data, parse.flags)
if not items and parse.flags.get("r") is True:
return None, IOResult(), ExecutionNode(command="xargs", exit_code=0)
command = parse.operands or ["echo"]
if max_args is None:
batches = [items]
else:
batches = [
items[i:i + max_args] for i in range(0, len(items), max_args)
] or [[]]
stdouts: list[ByteSource] = []
merged = IOResult()
exit_code = 0
for batch in batches:
inner = shlex.join([*command, *batch])
io = await execute_fn(inner, session_id=session.session_id)
if io.stdout is not None:
stdouts.append(io.stdout)
merged = await merged.merge(io)
if io.exit_code in (126, 127):
# GNU xargs stops when the command cannot run or is missing.
exit_code = io.exit_code
break
if io.exit_code != 0:
# GNU exits 123 when any invocation fails, but keeps going.
exit_code = 123
merged.exit_code = exit_code
out = async_chain(*stdouts) if stdouts else None
return out, merged, ExecutionNode(command="xargs", exit_code=exit_code)
+759
View File
@@ -0,0 +1,759 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import functools
from collections.abc import Callable
from typing import NamedTuple
from mirage.commands.builtin.find_parse import (FindParseError, find_expr_tail,
parse_find_expression)
from mirage.commands.builtin.generic.crossmount import (handle_cross_mount,
is_cross_mount)
from mirage.commands.builtin.utils.safeguard import maybe_with_timeout
from mirage.commands.errors import UsageError
from mirage.commands.safeguard import resolve_across_mounts, resolve_safeguard
from mirage.commands.spec import (SPECS, CommandSpec, OperandKind,
flag_kwarg_name, parse_command,
parse_to_kwargs)
from mirage.commands.spec.usage import (missing_value_error,
unknown_option_error)
from mirage.io import IOResult
from mirage.io.stream import async_chain, materialize, wrap_cachable_streams
from mirage.io.types import ByteSource
from mirage.shell.call_stack import CallStack
from mirage.shell.job_table import JobTable
from mirage.shell.types import ERREXIT_EXEMPT_TYPES
from mirage.types import PathSpec, word_text
from mirage.utils.errors import FS_ERRORS, format_fs_error
from mirage.workspace.executor.control import ReturnSignal
from mirage.workspace.executor.fanout import (_fan_out_traversal,
_should_fan_out)
from mirage.workspace.executor.find_action_dispatch import _apply_find_actions
from mirage.workspace.executor.jobs import (handle_jobs, handle_kill,
handle_ps, handle_wait)
from mirage.workspace.mount import (MountCommandUnsupported, MountEntry,
MountRegistry)
from mirage.workspace.mount.namespace import Namespace
from mirage.workspace.route import JOB_BUILTINS, Consumer, route
from mirage.workspace.session import Session, assert_mount_allowed
from mirage.workspace.types import ExecutionNode
_FIND_ACTION_FLAGS = frozenset({"delete", "print0", "ls"})
async def _exec_node(cmd_str: str, io: IOResult,
paths: list[PathSpec]) -> ExecutionNode:
"""Build the recorded execution node, materializing any streamed stderr.
Args:
cmd_str (str): Original command text for the record.
io (IOResult): Command result whose stderr/exit_code the node carries.
paths (list[PathSpec]): Classified path operands, carried so the
lazy-stream drain can respell filesystem errors as typed.
"""
# The node is a recorded artifact (compared by value, serialized via a
# sync to_dict, sometimes read twice), so the live lazy io.stderr is
# materialized to concrete bytes here. On the cross-mount path it is bytes.
return ExecutionNode(command=cmd_str,
stderr=await materialize(io.stderr),
exit_code=io.exit_code,
paths=paths)
def _check_mount_root_guard_raw(
cmd_name: str,
paths: list[PathSpec],
registry: MountRegistry,
argv: list[str],
) -> tuple[str, int] | None:
"""Refuse destructive/conflicting ops targeting a mount root.
Fires before mount resolution / cross-mount routing so a refusal
message is consistent regardless of whether the operands span mounts.
Returns (stderr_message, exit_code) when the guard fires, else None.
Args:
cmd_name (str): command name (rm/mv/mkdir/touch/ln/...).
paths (list[PathSpec]): raw positional path arguments.
registry (MountRegistry): mount registry for is_mount_root checks.
argv (list[str]): raw argv after the command name (used to spot
shorthand flags like `mkdir -p` before _parse_flags runs).
"""
if not paths:
return None
def _is_root(p: PathSpec) -> bool:
return registry.is_mount_root(p.virtual)
if cmd_name in ("rm", "rmdir"):
for p in paths:
if _is_root(p):
if cmd_name == "rmdir":
msg = (f"rmdir: failed to remove '{p.virtual}': "
f"Device or resource busy\n")
else:
msg = (f"rm: cannot remove '{p.virtual}': "
f"Device or resource busy\n")
return msg, 1
elif cmd_name == "mv":
if _is_root(paths[0]):
dst = paths[1].virtual if len(paths) > 1 else "?"
msg = (f"mv: cannot move '{paths[0].virtual}' to '{dst}': "
f"Device or resource busy\n")
return msg, 1
elif cmd_name == "mkdir":
# GNU mkdir -p makes "already exists" a no-op.
for tok in argv:
if isinstance(tok,
str) and (tok == "-p" or tok == "--parents" or
(tok.startswith("-") and "p" in tok[1:]
and not tok.startswith("--"))):
return None
for p in paths:
if _is_root(p):
msg = (f"mkdir: cannot create directory '{p.virtual}': "
f"File exists\n")
return msg, 1
elif cmd_name == "touch":
for p in paths:
if _is_root(p):
msg = (f"touch: cannot touch '{p.virtual}': "
f"Is a directory\n")
return msg, 1
elif cmd_name == "ln":
if _is_root(paths[-1]):
msg = (f"ln: failed to create link '{paths[-1].virtual}': "
f"File exists\n")
return msg, 1
return None
def _scalar_find_flags(flag_kwargs: dict) -> dict:
# `repeatable=True` on find value-flags makes parse_to_kwargs emit
# lists; bespoke backend wrappers read these as scalars. Migrated
# backends read the expression from `texts` and ignore flag_kwargs.
return {
k: (v[-1] if isinstance(v, list) and v else v)
for k, v in flag_kwargs.items()
}
async def run_on_mount(
registry: MountRegistry,
session: Session,
dispatch: Callable,
namespace: Namespace | None,
cmd_name: str,
paths: list[PathSpec],
texts: list[str],
flag_kwargs: dict,
stdin: ByteSource | None = None,
resolve_hint: PathSpec | None = None,
mount: MountEntry | None = None,
) -> tuple[ByteSource | None, IOResult]:
"""Run one already-parsed command on the mount that owns its paths.
The shared single-mount execution tail: mount resolution, grant checks,
``execute_cmd``, filesystem-error formatting, ls/find post-processing,
and read/write key prefixing. ``handle_command`` uses it for the normal
path, and passes it (bound) to the cross-mount runners so each operand
executes natively on its owning mount.
Args:
registry (MountRegistry): Mount registry.
session (Session): Session providing cwd/env/session_id.
dispatch (Callable): Workspace operation dispatcher.
namespace (Namespace | None): Addressing authority for ls symlinks.
cmd_name (str): Command name.
paths (list[PathSpec]): Positional path operands (may hold globs;
the mount wrapper expands them natively).
texts (list[str]): Positional text operands.
flag_kwargs (dict): Parsed flags forwarded to the mount command.
stdin (ByteSource | None): Standard input for the command.
resolve_hint (PathSpec | None): Mount-resolution path when ``paths``
is empty (a stream command running in stdin mode).
mount: Pre-resolved mount; skips resolution and grant checks, which
the caller already performed.
"""
if mount is None:
resolve_paths = paths or ([resolve_hint] if resolve_hint else [])
try:
mount = await registry.resolve_mount(cmd_name, resolve_paths,
session.cwd)
except MountCommandUnsupported as exc:
return None, IOResult(exit_code=1, stderr=f"{exc}\n".encode())
if mount is None:
return None, IOResult(
exit_code=127,
stderr=f"{cmd_name}: command not found".encode())
try:
assert_mount_allowed(mount.prefix)
for ps in paths:
target = registry.mount_for(ps.virtual)
assert_mount_allowed(target.prefix)
except PermissionError as exc:
return None, IOResult(exit_code=1, stderr=f"{exc}\n".encode())
if cmd_name == "find":
flag_kwargs = _scalar_find_flags(flag_kwargs)
try:
stdout, io = await mount.execute_cmd(
cmd_name,
paths,
texts,
flag_kwargs,
stdin=stdin,
cwd=session.cwd,
dispatch=dispatch,
session_id=session.session_id,
env=session.env,
exec_allowed=registry.is_exec_allowed(),
)
except UsageError as exc:
# Command-owned usage errors (extra operands, missing patterns)
# become this command's IOResult so the rest of the line keeps
# running, like a real shell (#452).
return None, IOResult(exit_code=exc.exit_code,
stderr=f"{exc}\n".encode())
except FS_ERRORS as exc:
err = format_fs_error(cmd_name, exc, paths)
return None, IOResult(exit_code=1, stderr=err)
if cmd_name == "ls" and io.exit_code == 0:
stdout = await _inject_child_mounts(stdout, registry, paths,
flag_kwargs, session.cwd)
if namespace is not None and namespace.symlinks:
stdout = await _inject_links(stdout, namespace, paths, flag_kwargs,
session.cwd)
if cmd_name == "find":
stdout, action_err = await _apply_find_actions(stdout, flag_kwargs,
registry, session.cwd)
if action_err:
existing = await materialize(io.stderr) if io.stderr else b""
io.stderr = existing + action_err
if io.exit_code == 0:
io.exit_code = 1
prefix = mount.prefix.rstrip("/")
if prefix:
io.reads = {prefix + k: v for k, v in io.reads.items()}
io.writes = {prefix + k: v for k, v in io.writes.items()}
io.cache = [prefix + p for p in io.cache]
return wrap_cachable_streams(stdout, io)
class _ParsedCommand(NamedTuple):
paths: list[PathSpec]
texts: list[str]
flag_kwargs: dict[str, object]
warnings: list[str]
invalid_options: list[str]
needs_value_options: list[str]
def _parse_flags(
parts: list[str | PathSpec],
spec: CommandSpec | None,
cmd_name: str,
cwd: str,
str_flag_paths: bool = False,
) -> _ParsedCommand:
"""Parse flags from classified parts, recovering PathSpec for PATH values.
Single-mount dispatch and cross-mount dispatch both parse through
here, so flags, texts, and parser warnings cannot drift between the
two paths (a cross-mount `grep --bogus` used to lose its warning).
Args:
parts (list[str | PathSpec]): expanded command words after the
command name; path-classified words arrive as PathSpec.
spec (CommandSpec | None): command spec, from the owning mount on
the single-mount path or the shared SPECS registry on the
cross-mount path; None falls back to type separation.
cmd_name (str): command name used in warnings.
cwd (str): current working directory for relative path resolution.
str_flag_paths (bool): keep PATH flag values as their resolved
virtual-path strings instead of PathSpec. Cross-mount
strategies read flags through FlagView, which type-checks
str, so they get the string view.
Returns:
_ParsedCommand: positional paths, positional texts, parsed flag dict
(PATH flag values recovered to PathSpec, repeatable PATH flags to
list[PathSpec]), and parser warnings (e.g. ignored unknown options).
"""
# Build string argv and PathSpec lookup
argv = [
item.virtual if isinstance(item, PathSpec) else item for item in parts
]
scope_map: dict[str, PathSpec] = {}
for item in parts:
if isinstance(item, PathSpec):
scope_map[item.virtual] = item
stripped = item.virtual.rstrip("/")
if stripped and stripped != item.virtual:
scope_map[stripped] = item
if spec is not None:
parsed = parse_command(spec, argv, cwd=cwd)
flag_kwargs = parse_to_kwargs(parsed)
# Recover PathSpec for PATH flag values; repeatable PATH flags
# arrive as a list of resolved paths and become list[PathSpec].
# A relative PATH flag value cwd-resolved by parse_command (e.g.
# csplit -f part -> /data/part) is absent from scope_map, so build a
# PathSpec for it just like positional paths do, otherwise it never
# gets the mount prefix stripped.
repeat_path_keys = {
flag_kwarg_name(name)
for opt in spec.options
if opt.value_kind == OperandKind.PATH and opt.repeatable
for name in (opt.short, opt.long) if name
}
single_path_keys = {
flag_kwarg_name(name)
for opt in spec.options
if opt.value_kind == OperandKind.PATH and not opt.repeatable
for name in (opt.short, opt.long) if name
}
if not str_flag_paths:
for key, value in flag_kwargs.items():
if key in repeat_path_keys and isinstance(value, list):
flag_kwargs[key] = [
scope_map.get(
part,
PathSpec(virtual=part,
directory=part[:part.rfind("/") + 1]
or "/",
resource_path="",
resolved=True)) for part in value
]
elif key in single_path_keys and isinstance(value, str):
flag_kwargs[key] = scope_map.get(
value,
PathSpec(virtual=value,
directory=value[:value.rfind("/") + 1] or "/",
resource_path="",
resolved=True))
elif isinstance(value, str) and value in scope_map:
flag_kwargs[key] = scope_map[value]
# Classify positional args
paths: list[PathSpec] = []
texts: list[str] = []
for value, kind in parsed.args:
if kind == OperandKind.PATH:
scope = scope_map.get(value)
if scope is None:
scope = PathSpec(
virtual=value,
directory=value[:value.rfind("/") + 1] or "/",
resource_path="",
resolved=True,
)
paths.append(scope)
else:
texts.append(value)
return _ParsedCommand(paths, texts, flag_kwargs, parsed.warnings,
parsed.invalid_options,
parsed.needs_value_options)
# No spec: separate by type
paths = [item for item in parts if isinstance(item, PathSpec)]
texts = [item for item in parts if not isinstance(item, PathSpec)]
return _ParsedCommand(paths, texts, {}, [], [], [])
def _option_error(cmd_name: str,
parsed: _ParsedCommand) -> tuple[bytes, int] | None:
"""GNU-shaped refusal for option errors the parser reported.
find is exempt: its expression tokens are validated by
parse_find_expression, which raises the GNU predicate error itself.
Args:
cmd_name (str): command name for message shape and exit code.
parsed (_ParsedCommand): parse result carrying the reports.
"""
if cmd_name == "find":
return None
if parsed.invalid_options:
return unknown_option_error(cmd_name, parsed.invalid_options[0])
if parsed.needs_value_options:
return missing_value_error(cmd_name, parsed.needs_value_options[0])
return None
async def handle_command(
execute_node: Callable,
dispatch: Callable,
registry: MountRegistry,
parts: list[str | PathSpec],
session: Session,
stdin: ByteSource | None = None,
call_stack: CallStack | None = None,
job_table: JobTable | None = None,
namespace: Namespace | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Execute a simple command.
Parts are already classified: strings for text,
PathSpec for paths. Dispatches to mount.execute_cmd.
"""
if not parts:
return None, IOResult(), ExecutionNode(command="", exit_code=0)
cmd_name = str(parts[0])
cmd_str = " ".join(p.virtual if isinstance(p, PathSpec) else p
for p in parts)
# Job builtins
if cmd_name in JOB_BUILTINS and job_table is not None:
text_parts = [
p.virtual if isinstance(p, PathSpec) else p for p in parts
]
if cmd_name in ("wait", "fg"):
return await handle_wait(job_table, text_parts)
if cmd_name == "kill":
return await handle_kill(job_table, text_parts)
if cmd_name == "jobs":
return await handle_jobs(job_table, text_parts)
if cmd_name == "ps":
return await handle_ps(job_table, text_parts)
# Shell functions
if cmd_name in session.functions:
func_body = session.functions[cmd_name]
cs = call_stack or CallStack()
# Positional args carry the word as typed ($1 stays sub/a.txt).
text_args = [word_text(p) for p in parts[1:]]
cs.push(text_args, function_name=cmd_name)
saved_locals: dict[str, str | None] = {}
session._local_vars = saved_locals
try:
all_stdout: list = []
merged_io = IOResult()
last_exec = ExecutionNode(command=cmd_name, exit_code=0)
for cmd in func_body:
try:
stdout, io, last_exec = await execute_node(
cmd, session, stdin, cs)
except ReturnSignal as sig:
if sig.stderr:
merged_io = await merged_io.merge(
IOResult(stderr=sig.stderr))
merged_io.exit_code = sig.exit_code
break
if stdout is not None:
all_stdout.append(stdout)
merged_io = await merged_io.merge(io)
if (io.exit_code != 0 and session.shell_options.get("errexit")
and cmd.type not in ERREXIT_EXEMPT_TYPES):
merged_io.exit_code = io.exit_code
break
combined = async_chain(*all_stdout) if all_stdout else None
last_exec.exit_code = merged_io.exit_code
return combined, merged_io, last_exec
finally:
cs.pop()
for key, old_val in saved_locals.items():
if old_val is None:
session.env.pop(key, None)
else:
session.env[key] = old_val
session._local_vars = None
# Cross-mount: paths span different mounts (e.g. cp /ram/a /disk/b).
# Use dispatch to read/write across mounts directly.
path_scopes = [p for p in parts[1:] if isinstance(p, PathSpec)]
raw_argv = [p.virtual if isinstance(p, PathSpec) else p for p in parts[1:]]
early_guard = _check_mount_root_guard_raw(cmd_name, path_scopes, registry,
raw_argv)
if early_guard is not None:
msg, code = early_guard
return None, IOResult(exit_code=code,
stderr=msg.encode()), ExecutionNode(
command=cmd_str,
exit_code=code,
stderr=msg.encode())
# Unknown name: nobody registers it; fail like bash before any
# backend work. The mount-root guard stays ahead of this so
# protective refusals keep their specific messages.
if route(cmd_name, session, registry) is Consumer.UNKNOWN:
err = f"{cmd_name}: command not found\n".encode()
return None, IOResult(exit_code=127,
stderr=err), ExecutionNode(command=cmd_str,
exit_code=127,
stderr=err)
find_expr_tokens: list[str] | None = None
if cmd_name == "find":
find_expr_tokens = find_expr_tail(raw_argv)
try:
parse_find_expression(find_expr_tokens)
except FindParseError as exc:
msg = f"{exc}\n"
return None, IOResult(exit_code=1,
stderr=msg.encode()), ExecutionNode(
command=cmd_str,
exit_code=1,
stderr=msg.encode())
if is_cross_mount(cmd_name, path_scopes, registry):
# Cross-mount execution bypasses a resource command handler. Parse
# against the shared spec so flags and text operands do not depend on
# the source mount. The bound single-mount runner lets the strategy
# runners execute each operand natively on its owning mount.
cross_parsed = _parse_flags(parts[1:],
SPECS.get(cmd_name),
cmd_name,
session.cwd,
str_flag_paths=True)
cross_texts = (find_expr_tokens
if find_expr_tokens is not None else cross_parsed.texts)
cross_refusal = _option_error(cmd_name, cross_parsed)
if cross_refusal is not None:
msg, code = cross_refusal
return None, IOResult(exit_code=code,
stderr=msg), ExecutionNode(command=cmd_str,
exit_code=code,
stderr=msg)
run_single = functools.partial(run_on_mount, registry, session,
dispatch, namespace)
stdout, io = await handle_cross_mount(cmd_name,
path_scopes,
cross_texts,
cross_parsed.flag_kwargs,
dispatch,
run_single,
stdin=stdin)
if cross_parsed.warnings:
warn = "".join(f"{cmd_name}: {w}\n"
for w in cross_parsed.warnings).encode()
existing = await materialize(io.stderr) if io.stderr else b""
io.stderr = warn + existing
# The native sub-runs carry their own mount's safeguard; the
# cross-mount command as a whole uses the strictest one across the
# operand mounts, regardless of which sub-run merged last.
mounts = []
for s in path_scopes:
try:
mounts.append(registry.mount_for(s.virtual))
except ValueError:
pass
io.safeguard = (resolve_across_mounts(cmd_name, mounts)
if mounts else resolve_safeguard(cmd_name))
stdout = maybe_with_timeout(stdout, io.safeguard, cmd_name)
return stdout, io, await _exec_node(cmd_str, io, path_scopes)
# Reject unsupported cross-mount commands
if len(path_scopes) >= 2:
mount_prefixes = set()
for s in path_scopes:
try:
mount_prefixes.add(registry.mount_for(s.virtual).prefix)
except ValueError:
pass
if len(mount_prefixes) > 1:
prefixes_str = ", ".join(sorted(mount_prefixes))
err = (f"{cmd_name}: paths span multiple mounts "
f"({prefixes_str}), cross-mount not supported\n")
return None, IOResult(
exit_code=1,
stderr=err.encode(),
), ExecutionNode(command=cmd_str, exit_code=1)
try:
mount = await registry.resolve_mount(cmd_name, path_scopes,
session.cwd)
except MountCommandUnsupported as exc:
err = f"{exc}\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=cmd_str,
exit_code=1,
stderr=err)
if mount is None:
return None, IOResult(
exit_code=127,
stderr=f"{cmd_name}: command not found".encode(),
), ExecutionNode(command=cmd_str, exit_code=127)
try:
assert_mount_allowed(mount.prefix)
for ps in path_scopes:
target = registry.mount_for(ps.virtual)
assert_mount_allowed(target.prefix)
except PermissionError as exc:
err = f"{exc}\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=cmd_str,
exit_code=1,
stderr=err)
# Parse flags upstream — mount receives clean args
single_parsed = _parse_flags(parts[1:], mount.spec_for(cmd_name), cmd_name,
session.cwd)
paths, texts, flag_kwargs, parse_warnings = (single_parsed.paths,
single_parsed.texts,
single_parsed.flag_kwargs,
single_parsed.warnings)
refusal = _option_error(cmd_name, single_parsed)
if refusal is not None:
msg, code = refusal
return None, IOResult(exit_code=code,
stderr=msg), ExecutionNode(command=cmd_str,
exit_code=code,
stderr=msg)
if find_expr_tokens is not None:
texts = find_expr_tokens
flag_kwargs = _scalar_find_flags(flag_kwargs)
warn_bytes = ("".join(
f"{cmd_name}: {w}\n"
for w in parse_warnings).encode() if parse_warnings else b"")
if _should_fan_out(cmd_name, paths, flag_kwargs, registry):
stdout, io, node = await _fan_out_traversal(cmd_name, paths, texts,
flag_kwargs, registry,
mount, session.cwd,
cmd_str, stdin)
if warn_bytes:
existing = await materialize(io.stderr) if io.stderr else b""
io.stderr = warn_bytes + existing
node.stderr = warn_bytes + (node.stderr or b"")
return stdout, io, node
stdout, io = await run_on_mount(registry,
session,
dispatch,
namespace,
cmd_name,
paths,
texts,
flag_kwargs,
stdin=stdin,
mount=mount)
if warn_bytes:
existing = await materialize(io.stderr) if io.stderr else b""
io.stderr = warn_bytes + existing
stdout = maybe_with_timeout(stdout, io.safeguard, cmd_name)
io.stderr = maybe_with_timeout(io.stderr, io.safeguard, cmd_name)
return stdout, io, await _exec_node(cmd_str, io, paths)
async def _inject_links(
stdout: ByteSource | None,
namespace: Namespace,
paths: list[PathSpec],
flag_kwargs: dict,
cwd: str,
) -> ByteSource | None:
"""Append symlink entries living under the listed directory.
Links are namespace state, invisible to backend readdir, so ``ls``
surfaces them the same way child mounts are surfaced. Long form
renders GNU-style ``name -> target``.
Args:
stdout (ByteSource | None): backend ls output.
namespace (Namespace): addressing authority holding the link table.
paths (list[PathSpec]): positional ls operands.
flag_kwargs (dict): parsed ls flags.
cwd (str): current working directory fallback operand.
"""
if flag_kwargs.get("d") is True or flag_kwargs.get("R") is True:
return stdout
if len(paths) > 1:
return stdout
listed = paths[0].virtual if paths else cwd
links = namespace.links_under(listed)
if not links:
return stdout
existing_bytes = await materialize(stdout) if stdout is not None else b""
existing = existing_bytes.decode("utf-8")
long_form = flag_kwargs.get("args_l") is True
classify = flag_kwargs.get("F") is True
present: set[str] = set()
for line in existing.split("\n"):
if line == "":
continue
name = line.split("\t")[-1] if long_form else line.rstrip("/*@|=")
if name:
present.add(name)
extras: list[str] = []
for name in sorted(links):
if name in present:
continue
if long_form:
extras.append(f"l\t-\t-\t{name} -> {links[name]}")
else:
extras.append(f"{name}@" if classify else name)
if not extras:
return stdout
sep = "" if existing == "" or existing.endswith("\n") else "\n"
return (existing + sep + "\n".join(extras) + "\n").encode("utf-8")
async def _inject_child_mounts(
stdout: ByteSource | None,
registry: MountRegistry,
paths: list[PathSpec],
flag_kwargs: dict,
cwd: str,
) -> ByteSource | None:
if flag_kwargs.get("d") is True or flag_kwargs.get("R") is True:
return stdout
if len(paths) > 1:
return stdout
listed = paths[0].virtual if paths else cwd
include_hidden = (flag_kwargs.get("a") is True
or flag_kwargs.get("A") is True)
child_names = registry.child_mount_names(listed, include_hidden)
if not child_names:
return stdout
existing_bytes = await materialize(stdout) if stdout is not None else b""
existing = existing_bytes.decode("utf-8")
long_form = flag_kwargs.get("args_l") is True
classify = flag_kwargs.get("F") is True
present: set[str] = set()
for line in existing.split("\n"):
if line == "":
continue
if long_form:
name = line.split("\t")[-1]
else:
name = line.rstrip("/*@|=")
if name:
present.add(name)
extras: list[str] = []
for name in child_names:
if name in present:
continue
if long_form:
extras.append(f"d\t-\t-\t{name}")
else:
extras.append(f"{name}/" if classify else name)
if not extras:
return stdout
sep = "" if existing == "" or existing.endswith("\n") else "\n"
return (existing + sep + "\n".join(extras)).encode("utf-8")
+358
View File
@@ -0,0 +1,358 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import fnmatch
from collections.abc import Callable
import tree_sitter
from mirage.io import IOResult
from mirage.io.async_line_iterator import AsyncLineIterator
from mirage.io.stream import async_chain
from mirage.io.types import ByteSource
from mirage.shell.barrier import BarrierPolicy, apply_barrier
from mirage.shell.call_stack import CallStack
from mirage.shell.types import ERREXIT_EXEMPT_TYPES
from mirage.types import PathSpec, word_text
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
# Safety cap on while/until iterations. Independent of stdin size:
# even with lazy stdin (Step 15), a `while read` over a stream longer
# than this cap stops here. Cap-hit emits a stderr warning so callers
# notice silent truncation. Bump if agents process larger streams.
_MAX_WHILE = 10000
def _line_buffer(stdin: ByteSource) -> AsyncLineIterator:
"""Wrap a ByteSource (bytes or chunked async iter) as a line iterator."""
if isinstance(stdin, bytes):
return AsyncLineIterator(async_chain(stdin))
return AsyncLineIterator(stdin)
async def _execute_body(
execute_node: Callable,
body: list[tree_sitter.Node],
session: Session,
stdin: ByteSource | None,
call_stack: CallStack | None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Execute a list of body commands sequentially."""
all_stdout: list[ByteSource | None] = []
merged_io = IOResult()
last_exec = ExecutionNode(command="", exit_code=0)
for cmd in body:
try:
stdout, io, last_exec = await execute_node(cmd, session, stdin,
call_stack)
except BreakSignal as sig:
if sig.stdout is not None:
all_stdout.append(sig.stdout)
merged_io = await merged_io.merge(sig.io)
combined = async_chain(*[s for s in all_stdout
if s is not None]) if any(
s is not None
for s in all_stdout) else None
raise BreakSignal(stdout=combined, io=merged_io)
except ContinueSignal as sig:
if sig.stdout is not None:
all_stdout.append(sig.stdout)
merged_io = await merged_io.merge(sig.io)
combined = async_chain(*[s for s in all_stdout
if s is not None]) if any(
s is not None
for s in all_stdout) else None
raise ContinueSignal(stdout=combined, io=merged_io)
all_stdout.append(stdout)
merged_io = await merged_io.merge(io)
if (io.exit_code != 0 and session.shell_options.get("errexit")
and cmd.type not in ERREXIT_EXEMPT_TYPES):
merged_io.exit_code = io.exit_code
break
non_empty = [s for s in all_stdout if s is not None]
combined = async_chain(*non_empty) if non_empty else None
return combined, merged_io, last_exec
class BreakSignal(Exception):
def __init__(self, stdout=None, io=None):
self.stdout = stdout
self.io = io or IOResult()
class ContinueSignal(Exception):
def __init__(self, stdout=None, io=None):
self.stdout = stdout
self.io = io or IOResult()
class ReturnSignal(Exception):
def __init__(self, exit_code: int = 0, stderr: bytes = b"") -> None:
self.exit_code = exit_code
self.stderr = stderr
def _collect_loop_result(
all_stdout: list[ByteSource | None],
merged_io: IOResult,
label: str,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
exec_node = ExecutionNode(command=label, exit_code=merged_io.exit_code)
non_empty = [s for s in all_stdout if s is not None]
if not non_empty:
return None, merged_io, exec_node
return async_chain(*non_empty), merged_io, exec_node
async def handle_if(
execute_node: Callable,
branches: list[tuple[tree_sitter.Node, list[tree_sitter.Node]]],
else_body: list[tree_sitter.Node] | None,
session: Session,
stdin: ByteSource | None = None,
call_stack: CallStack | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
for condition, body in branches:
cond_stdout, cond_io, _ = await execute_node(condition, session, stdin,
call_stack)
await apply_barrier(cond_stdout, cond_io, BarrierPolicy.STATUS)
session.last_exit_code = cond_io.exit_code
if cond_io.exit_code == 0:
return await _execute_body(execute_node, body, session, stdin,
call_stack)
if else_body is not None:
return await _execute_body(execute_node, else_body, session, stdin,
call_stack)
return None, IOResult(), ExecutionNode(exit_code=0)
async def handle_for(
execute_node: Callable,
variable: str,
values: list[str | PathSpec],
body: list[tree_sitter.Node],
session: Session,
stdin: ByteSource | None = None,
call_stack: CallStack | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
merged_io = IOResult()
all_stdout: list[ByteSource | None] = []
saved = session.env.get(variable)
# Save and materialize stdin for re-reading across iterations
prev_buffer = session._stdin_buffer
if stdin is not None:
session._stdin_buffer = _line_buffer(stdin)
stdin = None
try:
for val in values:
# env stores strings only; bash keeps `for f in sub/*.txt`
# matches relative, so the loop variable takes the typed form
session.env[variable] = word_text(val)
try:
stdout, io, _ = await _execute_body(execute_node, body,
session, stdin, call_stack)
except BreakSignal as sig:
if sig.stdout is not None:
all_stdout.append(sig.stdout)
merged_io = await merged_io.merge(sig.io)
break
except ContinueSignal as sig:
if sig.stdout is not None:
all_stdout.append(sig.stdout)
merged_io = await merged_io.merge(sig.io)
continue
merged_io = await merged_io.merge(io)
all_stdout.append(stdout)
finally:
session._stdin_buffer = prev_buffer
if saved is not None:
session.env[variable] = saved
else:
session.env.pop(variable, None)
return _collect_loop_result(all_stdout, merged_io, "for")
async def _condition_loop(
execute_node: Callable,
condition: tree_sitter.Node,
body: list[tree_sitter.Node],
session: Session,
stdin: ByteSource | None,
call_stack: CallStack | None,
label: str,
break_on_zero: bool,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
merged_io = IOResult()
all_stdout: list[ByteSource | None] = []
prev_buffer = session._stdin_buffer
if stdin is not None:
session._stdin_buffer = _line_buffer(stdin)
stdin = None
try:
hit_limit = True
for _ in range(_MAX_WHILE):
cond_stdout, cond_io, _ = await execute_node(
condition, session, stdin, call_stack)
await apply_barrier(cond_stdout, cond_io, BarrierPolicy.STATUS)
session.last_exit_code = cond_io.exit_code
if break_on_zero and cond_io.exit_code == 0:
hit_limit = False
break
if (not break_on_zero and cond_io.exit_code != 0):
hit_limit = False
break
try:
stdout, io, _ = await _execute_body(execute_node, body,
session, stdin, call_stack)
except BreakSignal as sig:
hit_limit = False
if sig.stdout is not None:
all_stdout.append(sig.stdout)
merged_io = await merged_io.merge(sig.io)
break
except ContinueSignal as sig:
if sig.stdout is not None:
all_stdout.append(sig.stdout)
merged_io = await merged_io.merge(sig.io)
continue
merged_io = await merged_io.merge(io)
all_stdout.append(stdout)
if hit_limit:
warn = (f"warning: {label} loop terminated after "
f"{_MAX_WHILE} iterations\n").encode()
existing = merged_io.stderr
if isinstance(existing, bytes) and existing:
merged_io.stderr = existing + warn
else:
merged_io.stderr = warn
finally:
session._stdin_buffer = prev_buffer
return _collect_loop_result(all_stdout, merged_io, label)
async def handle_while(
execute_node: Callable,
condition: tree_sitter.Node,
body: list[tree_sitter.Node],
session: Session,
stdin: ByteSource | None = None,
call_stack: CallStack | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
return await _condition_loop(execute_node,
condition,
body,
session,
stdin,
call_stack,
"while",
break_on_zero=False)
async def handle_until(
execute_node: Callable,
condition: tree_sitter.Node,
body: list[tree_sitter.Node],
session: Session,
stdin: ByteSource | None = None,
call_stack: CallStack | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
return await _condition_loop(execute_node,
condition,
body,
session,
stdin,
call_stack,
"until",
break_on_zero=True)
async def handle_case(
execute_node: Callable,
word: str,
items: list[tuple[list[str], list[tree_sitter.Node]]],
session: Session,
stdin: ByteSource | None = None,
call_stack: CallStack | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
for patterns, body in items:
if any(fnmatch.fnmatch(word, p.strip()) for p in patterns):
all_stdout: list[ByteSource] = []
merged_io = IOResult()
last_exec = ExecutionNode(command="case", exit_code=0)
for stmt in body:
stdout, io, last_exec = await execute_node(
stmt, session, stdin, call_stack)
stdin = None
if stdout is not None:
all_stdout.append(stdout)
merged_io = await merged_io.merge(io)
if len(all_stdout) == 1:
return all_stdout[0], merged_io, last_exec
combined = async_chain(*all_stdout) if all_stdout else None
return combined, merged_io, last_exec
return None, IOResult(), ExecutionNode(command="case", exit_code=0)
async def handle_select(
execute_node: Callable,
variable: str,
values: list[str | PathSpec],
body: list[tree_sitter.Node],
session: Session,
stdin: ByteSource | None = None,
call_stack: CallStack | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
merged_io = IOResult()
all_stdout: list[ByteSource | None] = []
saved = session.env.get(variable)
# Save and materialize stdin for re-reading across iterations
prev_buffer = session._stdin_buffer
if stdin is not None:
session._stdin_buffer = _line_buffer(stdin)
stdin = None
try:
for val in values:
# env stores strings only; bash keeps `for f in sub/*.txt`
# matches relative, so the loop variable takes the typed form
session.env[variable] = word_text(val)
try:
stdout, io, _ = await _execute_body(execute_node, body,
session, stdin, call_stack)
except BreakSignal as sig:
if sig.stdout is not None:
all_stdout.append(sig.stdout)
merged_io = await merged_io.merge(sig.io)
break
except ContinueSignal as sig:
if sig.stdout is not None:
all_stdout.append(sig.stdout)
merged_io = await merged_io.merge(sig.io)
continue
merged_io = await merged_io.merge(io)
all_stdout.append(stdout)
finally:
session._stdin_buffer = prev_buffer
if saved is not None:
session.env[variable] = saved
else:
session.env.pop(variable, None)
return _collect_loop_result(all_stdout, merged_io, "select")
+347
View File
@@ -0,0 +1,347 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.commands.builtin.find_eval import FindEntry, keep
from mirage.commands.builtin.find_parse import parse_find_expression
from mirage.commands.errors import FindParseError
from mirage.commands.safeguard import resolve_across_mounts
from mirage.io import IOResult
from mirage.io.stream import materialize
from mirage.io.types import ByteSource
from mirage.types import PathSpec
from mirage.workspace.executor.find_action_dispatch import _apply_find_actions
from mirage.workspace.mount import MountRegistry
from mirage.workspace.types import ExecutionNode
_TRAVERSAL_CMDS = frozenset({"find", "tree", "du"})
def _path_segments(path: str) -> list[str]:
return [s for s in path.strip("/").split("/") if s]
def _should_fan_out(
cmd_name: str,
paths: list[PathSpec],
flag_kwargs: dict,
registry: MountRegistry,
) -> bool:
"""Whether `cmd` on this path should run across multiple mounts.
True when the command is in the traversal whitelist (find/tree/du)
and the path has at least one descendant mount; or for grep with
-r/-R; or for ls -R. Returns False when there's no descendant
mount under the path (single-mount dispatch is correct).
"""
if not paths:
return False
target = paths[0].virtual
if not registry.descendant_mounts(target):
return False
if cmd_name in _TRAVERSAL_CMDS:
return True
if cmd_name == "grep":
return (flag_kwargs.get("r") is True or flag_kwargs.get("R") is True
or flag_kwargs.get("recursive") is True)
if cmd_name == "ls":
return flag_kwargs.get("R") is True
return False
def _adjust_depth_flags(
flag_kwargs: dict,
parent_path: str,
mount_prefix: str,
) -> dict | None:
"""Adjust find's -maxdepth/-mindepth for a fan-out into a child mount.
Returns the new kwargs dict, or None if the child mount falls
outside the depth budget (caller should skip it).
"""
parent_depth = len(_path_segments(parent_path))
mount_depth = len(_path_segments(mount_prefix))
delta = mount_depth - parent_depth
new = dict(flag_kwargs)
if "maxdepth" in new:
raw_md = new["maxdepth"]
if isinstance(raw_md, list):
raw_md = raw_md[0] if raw_md else None
try:
md = int(raw_md) - delta
except (TypeError, ValueError):
md = None
if md is not None:
if md < 0:
return None
new["maxdepth"] = str(md)
if "mindepth" in new:
raw_mn = new["mindepth"]
if isinstance(raw_mn, list):
raw_mn = raw_mn[0] if raw_mn else None
try:
mn = max(0, int(raw_mn) - delta)
new["mindepth"] = str(mn)
except (TypeError, ValueError):
pass
return new
def _adjust_depth_texts(
texts: list[str],
parent_path: str,
mount_prefix: str,
) -> list[str]:
"""Adjust -maxdepth/-mindepth values inside a find expression.
The generic find parses depth from the expression tokens (`texts`),
not from flag kwargs, so a fan-out into a deeper child mount must
rewrite the depth values by the parent-to-mount delta. Mirrors
`_adjust_depth_flags`.
Args:
texts (list[str]): the find expression tokens.
parent_path (str): the find start path the fan-out runs from.
mount_prefix (str): the child mount prefix being descended into.
"""
delta = len(_path_segments(mount_prefix)) - len(
_path_segments(parent_path))
if delta == 0:
return list(texts)
out = list(texts)
i = 0
while i < len(out) - 1:
tok = out[i]
if tok in ("-maxdepth", "-mindepth"):
try:
val = int(out[i + 1])
except ValueError:
i += 2
continue
if tok == "-maxdepth":
out[i + 1] = str(val - delta)
else:
out[i + 1] = str(max(0, val - delta))
i += 2
continue
i += 1
return out
def _synthesize_find_mount_entries(
target_path: str,
descendants: list,
texts: list[str],
) -> str:
"""Return synthetic find lines for descendant mount roots.
`find /` and friends should list mount prefixes as directory
entries even though no per-mount find emits its own root. The find
expression is parsed into a predicate tree and evaluated per mount
root (kind "d"), mirroring the per-backend cores, so -not / -o /
-path / -type and the -maxdepth / -mindepth window all apply.
Args:
target_path (str): the find start path the fan-out runs from.
descendants (list): descendant mounts to inject as entries.
texts (list[str]): the find expression tokens.
"""
try:
expr = parse_find_expression(list(texts))
except FindParseError:
return ""
tree = expr.tree
max_depth = expr.maxdepth
min_depth = expr.mindepth if expr.mindepth is not None else 0
parent_depth = len(_path_segments(target_path))
out: list[str] = []
for m in descendants:
prefix_no_slash = m.prefix.rstrip("/")
depth = len(_path_segments(prefix_no_slash)) - parent_depth
if max_depth is not None and depth > max_depth:
continue
base = prefix_no_slash.rsplit("/", 1)[-1] or prefix_no_slash
entry = FindEntry(key=prefix_no_slash,
name=base,
kind="d",
depth=depth)
if not keep(entry, tree, min_depth):
continue
out.append(prefix_no_slash)
return "\n".join(out)
async def _filter_under_prefixes(
stdout: ByteSource,
descendant_prefixes: list[str],
) -> bytes:
"""Drop lines whose path falls under any descendant mount prefix.
Path is taken from the start of the line up to the first tab,
colon, or whitespace (handles find / du / grep output formats).
Lines that do not start with `/` are passed through.
"""
data = await materialize(stdout)
text = data.decode("utf-8", errors="replace")
out_lines: list[str] = []
for line in text.split("\n"):
if line == "":
continue
path = line
for sep in ("\t", ":"):
if sep in path:
path = path.split(sep, 1)[0]
break
if path.startswith("/"):
shadowed = False
for pre in descendant_prefixes:
if path == pre or path.startswith(pre + "/"):
shadowed = True
break
if shadowed:
continue
out_lines.append(line)
return ("\n".join(out_lines) + "\n").encode("utf-8") if out_lines else b""
async def _drop_mount_root_line(stdout: ByteSource, mount_root: str) -> bytes:
"""Drop a descendant find's own mount-root line.
A per-mount find now emits its start directory, so a descendant
mount's find yields its mount-root path. The mount-point entry is
instead synthesized centrally (with the mount's display name and the
full predicate tree applied), so the raw root line is dropped here to
avoid a duplicate that skips the name filter.
Args:
stdout (ByteSource): descendant find output.
mount_root (str): the descendant mount root path.
"""
data = await materialize(stdout)
text = data.decode("utf-8", errors="replace")
out_lines = [
line for line in text.split("\n") if line != "" and line != mount_root
]
return ("\n".join(out_lines) + "\n").encode("utf-8") if out_lines else b""
async def _fan_out_traversal(
cmd_name: str,
paths: list[PathSpec],
texts: list[str],
flag_kwargs: dict,
registry: MountRegistry,
primary_mount: object,
cwd: str,
cmd_str: str,
stdin: ByteSource | None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Run a traversal command across the parent mount + descendant mounts.
Each mount runs the command with its own root as the path argument
(depth flags adjusted for find/tree). Outputs are concatenated in
mount-prefix-sorted order. The parent mount's output is filtered to
drop lines that fall under any descendant mount (avoids duplicates
when the parent's resource has shadowed keys).
For `find`, mount-prefix paths themselves are injected as synthetic
directory entries (subject to depth and -type filters) because
mirage's per-mount find doesn't emit the path argument itself.
"""
target_path = paths[0].virtual
descendants = registry.descendant_mounts(target_path)
descendant_prefixes = [m.prefix.rstrip("/") for m in descendants]
all_stdout: list[bytes] = []
merged_io = IOResult()
final_exit = 0
success_seen = False
for mount in [primary_mount] + list(descendants):
if mount is primary_mount:
sub_paths = list(paths)
sub_flags = dict(flag_kwargs)
sub_texts = list(texts)
else:
mount_root = mount.prefix.rstrip("/") or "/"
sub_flags = _adjust_depth_flags(flag_kwargs, target_path,
mount.prefix)
if sub_flags is None:
continue
sub_texts = _adjust_depth_texts(texts, target_path, mount.prefix)
sub_paths = [
PathSpec(virtual=mount_root,
directory=mount_root,
resource_path="",
resolved=True)
]
try:
stdout, io = await mount.execute_cmd(cmd_name,
sub_paths,
sub_texts,
sub_flags,
stdin=stdin,
cwd=cwd)
except FindParseError:
# A bad numeric/size/mtime argument is a usage error that
# applies to every mount identically; fail the whole command
# instead of silently skipping mounts and exiting 0.
raise
except Exception:
continue
if mount is primary_mount and descendant_prefixes and stdout:
stdout = await _filter_under_prefixes(stdout, descendant_prefixes)
elif mount is not primary_mount and cmd_name == "find" and stdout:
stdout = await _drop_mount_root_line(stdout, mount_root)
if stdout is not None:
data = await materialize(stdout)
if data:
all_stdout.append(data)
if io.exit_code == 0:
success_seen = True
elif io.exit_code != 0 and final_exit == 0:
final_exit = io.exit_code
merged_io = await merged_io.merge(io)
if cmd_name == "find":
synthetic = _synthesize_find_mount_entries(target_path, descendants,
texts)
if synthetic:
all_stdout.append(synthetic.encode("utf-8"))
combined: ByteSource | None
if all_stdout:
combined = b"\n".join(b.rstrip(b"\n") for b in all_stdout) + b"\n"
else:
combined = None
final_io_exit = 0 if success_seen else final_exit
if cmd_name == "find":
combined, action_err = await _apply_find_actions(
combined, flag_kwargs, registry, cwd)
if action_err:
existing = (await materialize(merged_io.stderr)
if merged_io.stderr else b"")
merged_io.stderr = existing + action_err
if final_io_exit == 0:
final_io_exit = 1
merged_io.exit_code = final_io_exit
merged_io.safeguard = resolve_across_mounts(cmd_name,
[primary_mount, *descendants])
exec_node = ExecutionNode(command=cmd_str,
exit_code=final_io_exit,
stderr=merged_io.stderr)
return combined, merged_io, exec_node
@@ -0,0 +1,131 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.io.stream import materialize
from mirage.io.types import ByteSource
from mirage.types import PathSpec
from mirage.workspace.mount import MountRegistry
async def _apply_find_actions(
stdout: ByteSource | None,
flag_kwargs: dict,
registry: MountRegistry,
cwd: str,
) -> tuple[ByteSource | None, bytes]:
"""Apply find action flags (-delete / -print0 / -ls) to find output.
Per-resource find handlers only emit matched paths. This dispatcher
layer reads action flags and dispatches the side effect (rm for
-delete, ls -ld for -ls) per match through the appropriate mount,
then re-formats the output.
Args:
stdout (ByteSource | None): newline-joined match list from find.
flag_kwargs (dict): parsed flag dict; action flags read here.
registry (MountRegistry): used to route per-match dispatch.
cwd (str): cwd forwarded to per-match sub-dispatch.
"""
has_delete = flag_kwargs.get("delete") is True
has_print0 = flag_kwargs.get("print0") is True
has_ls = flag_kwargs.get("ls") is True
has_print = flag_kwargs.get("print") is True
if not (has_delete or has_print0 or has_ls):
return stdout, b""
if stdout is None:
return stdout, b""
text = (await materialize(stdout)).decode("utf-8", errors="replace")
matches = [p for p in text.split("\n") if p]
errors: list[bytes] = []
if has_delete:
# Deepest-first so children are removed before parents.
# Skip mount roots: mount points are structural, not
# unlinkable entries — refusing matches Unix semantics.
deletable = [p for p in matches if not registry.is_mount_root(p)]
ordered = sorted(deletable, key=lambda p: p.count("/"), reverse=True)
for path in ordered:
try:
mount = registry.mount_for(path)
except ValueError:
msg = f"find: cannot delete '{path}': no mount\n"
errors.append(msg.encode())
continue
ps = PathSpec(
virtual=path,
directory=path[:path.rfind("/") + 1] or "/",
resource_path="",
resolved=True,
)
try:
_, rm_io = await mount.execute_cmd("rm", [ps], [], {},
stdin=None,
cwd=cwd)
except (FileNotFoundError, NotADirectoryError, PermissionError,
ValueError) as exc:
errors.append(
f"find: cannot delete '{path}': {exc}\n".encode())
continue
if rm_io.exit_code != 0:
err = await materialize(rm_io.stderr) if rm_io.stderr else b""
if not err:
err = f"find: cannot delete '{path}'\n".encode()
errors.append(err)
# GNU find: -delete suppresses default print unless -print also set.
output_matches = matches if has_print else []
elif has_ls:
output_matches = []
for path in matches:
try:
mount = registry.mount_for(path)
except ValueError:
errors.append(f"find: cannot ls '{path}': no mount\n".encode())
continue
ps = PathSpec(
virtual=path,
directory=path[:path.rfind("/") + 1] or "/",
resource_path="",
resolved=True,
)
try:
ls_out, _ = await mount.execute_cmd("ls", [ps], [], {
"args_l": True,
"d": True
},
stdin=None,
cwd=cwd)
except (FileNotFoundError, NotADirectoryError, PermissionError,
ValueError) as exc:
errors.append(f"find: cannot ls '{path}': {exc}\n".encode())
continue
if ls_out is not None:
line = (await materialize(ls_out)).decode(
"utf-8", errors="replace").rstrip("\n")
if line:
output_matches.append(line)
else:
output_matches = matches
err_blob = b"".join(errors)
if not output_matches:
return None, err_blob
if has_print0:
body = b"\x00".join(m.encode("utf-8")
for m in output_matches) + b"\x00"
else:
body = ("\n".join(output_matches) + "\n").encode("utf-8")
return body, err_blob
+179
View File
@@ -0,0 +1,179 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import asyncio
import tree_sitter
from mirage.commands.builtin.utils.safeguard import CommandTimeoutError
from mirage.io import IOResult
from mirage.io.types import ByteSource, materialize
from mirage.shell.helpers import get_text
from mirage.shell.job_table import JobTable
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
async def handle_background(
execute_node,
left: tree_sitter.Node,
right: tree_sitter.Node | None,
session: Session,
job_table: JobTable | None,
agent_id: str | None,
stdin: ByteSource | None = None,
call_stack=None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Run left side in background."""
bg_session = session.fork()
async def _run_bg():
# Background jobs don't receive stdin, matching real shell
# behavior where bg processes get /dev/null. This prevents
# race conditions when stdin is an async iterator.
cmd_str_inner = get_text(left) if hasattr(left, "text") else str(left)
try:
stdout, io, exec_node = await execute_node(left, bg_session, None,
call_stack)
except CommandTimeoutError as exc:
msg = (str(exc) + "\n").encode()
io = IOResult(exit_code=124, stderr=msg)
exec_node = ExecutionNode(command=cmd_str_inner,
stderr=msg,
exit_code=124)
return b"", io, exec_node
stdout = await materialize(stdout)
# Eagerly materialize stderr too so JobTable._refresh can read
# the task result synchronously without an async hop.
await io.materialize_stderr()
io.sync_exit_code()
return stdout, io, exec_node
task = asyncio.create_task(_run_bg())
cmd_str = get_text(left) if hasattr(left, 'text') else str(left)
if job_table is not None:
job = job_table.submit(command=cmd_str,
task=task,
cwd=bg_session.cwd,
agent=agent_id or "",
session_id=session.session_id)
job_line = f"[{job.id}]\n".encode()
else:
job_line = b"[bg]\n"
if right is None:
return None, IOResult(stderr=job_line), ExecutionNode(
op="&",
exit_code=0,
children=[ExecutionNode(command=cmd_str, exit_code=0)])
right_stdout, right_io, right_exec = await execute_node(
right, session, stdin, call_stack)
left_stderr = await materialize(right_io.stderr)
right_io.stderr = (job_line + left_stderr if left_stderr else job_line)
children = [
ExecutionNode(command=cmd_str, exit_code=0),
right_exec,
]
return right_stdout, right_io, ExecutionNode(op="&",
exit_code=right_io.exit_code,
children=children)
async def handle_wait(
job_table: JobTable,
parts: list[str],
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
cmd_str = " ".join(parts)
if len(parts) <= 1:
await job_table.wait_all()
return None, IOResult(), ExecutionNode(command=cmd_str, exit_code=0)
raw = parts[1].lstrip("%")
try:
job_id = int(raw)
except ValueError:
err = f"wait: invalid job id: {parts[1]}\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=cmd_str,
exit_code=1,
stderr=err)
job = job_table.get(job_id)
if job is None:
err = f"wait: no such job: {job_id}\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=cmd_str,
exit_code=1,
stderr=err)
job = await job_table.wait(job_id)
return job.stdout, IOResult(
exit_code=job.exit_code,
stderr=job.stderr or None,
), ExecutionNode(command=cmd_str, exit_code=job.exit_code)
async def handle_kill(
job_table: JobTable,
parts: list[str],
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
cmd_str = " ".join(parts)
if len(parts) < 2:
err = b"kill: usage: kill <job_id>\n"
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=cmd_str,
exit_code=1,
stderr=err)
raw = parts[1].lstrip("%")
try:
job_id = int(raw)
except ValueError:
err = f"kill: invalid job id: {parts[1]}\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=cmd_str,
exit_code=1,
stderr=err)
killed = job_table.kill(job_id)
if not killed:
err = f"kill: no such job: {job_id}\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=cmd_str,
exit_code=1,
stderr=err)
return None, IOResult(), ExecutionNode(command=cmd_str, exit_code=0)
async def handle_jobs(
job_table: JobTable,
parts: list[str],
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
cmd_str = " ".join(parts)
lines = []
for job in job_table.list_jobs():
lines.append(f"[{job.id}] {job.status.value} {job.command}")
job_table.pop_completed()
out = ("\n".join(lines) + "\n").encode() if lines else b""
return out, IOResult(), ExecutionNode(command=cmd_str, exit_code=0)
async def handle_ps(
job_table: JobTable,
parts: list[str],
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
cmd_str = " ".join(parts)
running = job_table.running_jobs()
lines = []
for job in running:
lines.append(f"{job.id}\t{job.command}")
out = ("\n".join(lines) + "\n").encode() if lines else b""
return out, IOResult(), ExecutionNode(command=cmd_str, exit_code=0)
+215
View File
@@ -0,0 +1,215 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import tree_sitter
from mirage.commands.builtin.utils.safeguard import run_with_timeout
from mirage.io import IOResult
from mirage.io.stream import async_chain, close_quietly, merge_stdout_stderr
from mirage.io.types import ByteSource, materialize
from mirage.shell.barrier import BarrierPolicy, apply_barrier
from mirage.shell.call_stack import CallStack
from mirage.shell.types import ERREXIT_EXEMPT_TYPES
from mirage.shell.types import NodeType as NT
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
async def handle_pipe(
execute_node,
commands: list[tree_sitter.Node],
stderr_flags: list[bool],
session: Session,
stdin: ByteSource | None = None,
call_stack: CallStack | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Connect commands via pipes: stdout -> stdin."""
current_stdin = stdin
last_stdout: ByteSource | None = None
child_nodes: list[ExecutionNode] = []
ios: list[IOResult] = []
intermediate_streams: list[ByteSource] = []
try:
for i, cmd in enumerate(commands):
stdout, io, child_exec = await execute_node(
cmd, session, current_stdin, call_stack)
ios.append(io)
child_nodes.append(child_exec)
if i < len(commands) - 1:
pipe_stderr = (i < len(stderr_flags) and stderr_flags[i])
if pipe_stderr:
current_stdin = merge_stdout_stderr(stdout, io)
else:
current_stdin = stdout
if current_stdin is None:
current_stdin = b""
if not isinstance(current_stdin, bytes):
intermediate_streams.append(current_stdin)
last_stdout = stdout
if last_stdout is not None and not isinstance(last_stdout, bytes):
materialized = await run_with_timeout(
materialize(last_stdout), session.pipeline_timeout_seconds,
"pipeline")
last_stdout = materialized
finally:
# Explicitly close any intermediate generators that may still
# be holding resource resources (HTTP connections, file
# handles). Harmless on exhausted streams.
for s in intermediate_streams:
await close_quietly(s)
last_io = ios[-1]
last_io.sync_exit_code()
if session.shell_options.get("pipefail"):
for io in ios:
io.sync_exit_code()
rightmost_failure = next(
(io.exit_code for io in reversed(ios) if io.exit_code != 0), 0)
if rightmost_failure != 0:
last_io.exit_code = rightmost_failure
merged_stderr_parts: list[bytes] = []
merged_reads: dict[str, ByteSource] = {}
merged_writes: dict[str, ByteSource] = {}
merged_cache: list[str] = []
for io, child in zip(ios, child_nodes):
io.sync_exit_code()
child.exit_code = io.exit_code
stderr_bytes = await materialize(io.stderr)
if stderr_bytes:
merged_stderr_parts.append(stderr_bytes)
merged_reads.update(io.reads)
merged_writes.update(io.writes)
merged_cache.extend(io.cache)
if merged_stderr_parts:
last_io.stderr = b"".join(merged_stderr_parts)
last_io.reads = merged_reads
last_io.writes = merged_writes
last_io.cache = merged_cache
exec_node = ExecutionNode(op="|",
exit_code=last_io.exit_code,
children=child_nodes)
return last_stdout, last_io, exec_node
async def handle_connection(
execute_node,
left: tree_sitter.Node,
op: str,
right: tree_sitter.Node,
session: Session,
stdin: ByteSource | None = None,
call_stack: CallStack | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Handle &&, ||"""
left_stdout, left_io, left_exec = await execute_node(
left, session, stdin, call_stack)
children = [left_exec]
if op == NT.AND:
left_bytes = await apply_barrier(left_stdout, left_io,
BarrierPolicy.VALUE)
session.last_exit_code = left_io.exit_code
if left_io.exit_code != 0:
return left_bytes, left_io, ExecutionNode(
op="&&", exit_code=left_io.exit_code, children=children)
right_stdout, right_io, right_exec = (await execute_node(
right, session, stdin, call_stack))
children.append(right_exec)
right_bytes = await materialize(right_stdout)
merged = await left_io.merge(right_io)
combined = async_chain(left_bytes, right_bytes)
return combined, merged, ExecutionNode(op="&&",
exit_code=merged.exit_code,
children=children)
if op == NT.OR:
left_bytes = await apply_barrier(left_stdout, left_io,
BarrierPolicy.VALUE)
session.last_exit_code = left_io.exit_code
if left_io.exit_code == 0:
return left_bytes, left_io, ExecutionNode(
op="||", exit_code=left_io.exit_code, children=children)
right_stdout, right_io, right_exec = (await execute_node(
right, session, stdin, call_stack))
children.append(right_exec)
right_bytes = await materialize(right_stdout)
merged = await left_io.merge(right_io)
combined = async_chain(left_bytes, right_bytes)
return combined, merged, ExecutionNode(op="||",
exit_code=merged.exit_code,
children=children)
# semicolon or other
left_bytes = await apply_barrier(left_stdout, left_io, BarrierPolicy.VALUE)
session.last_exit_code = left_io.exit_code
right_stdout, right_io, right_exec = await execute_node(
right, session, stdin, call_stack)
children.append(right_exec)
# Materialize right side to match && and || behavior, ensuring
# lazy exit codes (e.g. from exit_on_empty) are finalized before
# the combined stream is returned to the caller.
right_bytes = await materialize(right_stdout)
merged = await left_io.merge(right_io)
combined = async_chain(left_bytes, right_bytes)
return combined, merged, ExecutionNode(op=str(op),
exit_code=merged.exit_code,
children=children)
async def handle_subshell(
execute_node,
body: list[tree_sitter.Node],
session: Session,
stdin: ByteSource | None = None,
call_stack: CallStack | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Execute body in isolated env."""
saved_cwd = session.cwd
saved_env = dict(session.env)
saved_options = dict(session.shell_options)
saved_readonly = set(session.readonly_vars)
saved_arrays = {k: list(v) for k, v in session.arrays.items()}
saved_functions = dict(session.functions)
saved_positional = list(getattr(session, "positional_args", None) or [])
try:
all_stdout: list = []
merged_io = IOResult()
last_exec = ExecutionNode(command="()", exit_code=0)
for child in body:
stdout, io, last_exec = await execute_node(child, session, stdin,
call_stack)
if stdout is not None:
all_stdout.append(stdout)
merged_io = await merged_io.merge(io)
if (io.exit_code != 0 and session.shell_options.get("errexit")
and child.type not in ERREXIT_EXEMPT_TYPES):
merged_io.exit_code = io.exit_code
break
if len(all_stdout) == 1:
return all_stdout[0], merged_io, last_exec
combined = async_chain(*all_stdout) if all_stdout else None
return combined, merged_io, last_exec
finally:
session.cwd = saved_cwd
session.env = saved_env
session.shell_options = saved_options
session.readonly_vars = saved_readonly
session.arrays = saved_arrays
session.functions = saved_functions
session.positional_args = saved_positional
@@ -0,0 +1,148 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import tree_sitter
from mirage.io import IOResult
from mirage.io.stream import materialize
from mirage.io.types import ByteSource
from mirage.shell.barrier import BarrierPolicy, apply_barrier
from mirage.shell.call_stack import CallStack
from mirage.shell.types import Redirect, RedirectKind
from mirage.types import PathSpec
from mirage.workspace.executor.builtins import _to_scope
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
async def handle_redirect(
execute_node,
dispatch,
command: tree_sitter.Node,
redirects: list[Redirect],
session: Session,
stdin: ByteSource | None = None,
call_stack: CallStack | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Handle all redirect patterns: >, >>, <, 2>, 2>&1, &>, >&2, <<<."""
cmd_stdin = stdin
for r in redirects:
if r.kind == RedirectKind.STDIN:
scope = _ensure_scope(r.target)
file_data, _ = await dispatch("read", scope)
cmd_stdin = file_data
elif r.kind == RedirectKind.HEREDOC:
cmd_stdin = r.target.encode() if isinstance(r.target,
str) else r.target
elif r.kind == RedirectKind.HERESTRING:
text = r.target
if isinstance(text, str):
if text.startswith('"') and text.endswith('"'):
text = text[1:-1]
elif text.startswith("'") and text.endswith("'"):
text = text[1:-1]
cmd_stdin = (text + "\n").encode()
else:
cmd_stdin = text
stdout, io, exec_node = await execute_node(command, session, cmd_stdin,
call_stack)
stdout_data = await apply_barrier(stdout, io, BarrierPolicy.VALUE)
if stdout_data is None:
stdout_data = b""
if isinstance(stdout_data, memoryview):
stdout_data = bytes(stdout_data)
stderr_data = await materialize(io.stderr)
result_stdout = stdout_data
result_stderr = stderr_data
for r in redirects:
stream = r.kind
append = r.append
fd = r.fd
if stream in (RedirectKind.STDIN, RedirectKind.HEREDOC,
RedirectKind.HERESTRING):
continue
# 2>&1 — merge stderr into stdout
if stream == RedirectKind.STDERR_TO_STDOUT and isinstance(
r.target, int):
result_stdout = (result_stdout or b"") + (result_stderr or b"")
result_stderr = None
continue
# >&2 or 1>&2 — stdout to stderr
if fd == 1 and isinstance(r.target, int) and r.target == 2:
result_stderr = (result_stderr or b"") + (result_stdout or b"")
result_stdout = None
continue
scope = _ensure_scope(r.target)
path = scope.virtual
# &> or &>> — both stdout+stderr to file
if fd == -1:
combined = (result_stdout or b"") + (result_stderr or b"")
if append:
combined = await _append_existing(dispatch, scope, combined)
await dispatch("write", scope, data=combined)
io.writes[path] = combined
result_stdout = None
result_stderr = None
continue
# 2> file — stderr to file
if stream == RedirectKind.STDERR:
data = result_stderr or b""
if append:
data = await _append_existing(dispatch, scope, data)
if data:
await dispatch("write", scope, data=data)
io.writes[path] = data
result_stderr = None
continue
# > or >> — stdout to file
data = result_stdout or b""
if append:
data = await _append_existing(dispatch, scope, data)
await dispatch("write", scope, data=data)
io.writes[path] = data
result_stdout = None
io.stderr = result_stderr
exec_node = ExecutionNode(command="redirect", exit_code=io.exit_code)
return result_stdout if result_stdout else None, io, exec_node
async def _append_existing(dispatch, scope, data):
try:
existing, _ = await dispatch("read", scope)
if isinstance(existing, bytes):
return existing + data
except FileNotFoundError:
pass
return data
def _ensure_scope(target):
if isinstance(target, PathSpec):
return target
if isinstance(target, str):
return _to_scope(target)
return _to_scope(str(target))
@@ -0,0 +1,27 @@
# ========= 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.expand.classify import classify_parts, classify_word
from mirage.workspace.expand.node import expand_node
from mirage.workspace.expand.parts import expand_and_classify, expand_parts
from mirage.workspace.expand.redirects import expand_redirects
__all__ = [
"classify_parts",
"classify_word",
"expand_and_classify",
"expand_node",
"expand_parts",
"expand_redirects",
]
+127
View File
@@ -0,0 +1,127 @@
# ========= 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
from collections.abc import Callable, Iterable
from dataclasses import dataclass
import tree_sitter
from mirage.commands.spec.types import OperandKind
from mirage.shell.call_stack import CallStack
from mirage.types import PathSpec, word_text
from mirage.workspace.expand.classify import classify_parts
from mirage.workspace.expand.globs import resolve_globs
from mirage.workspace.expand.parts import expand_parts
from mirage.workspace.expand.spec_hints import (spec_for_command,
spec_word_kinds)
from mirage.workspace.mount import MountRegistry
from mirage.workspace.route import WordPolicy, route, word_policy
from mirage.workspace.session import Session
@dataclass(frozen=True, slots=True)
class Argv:
"""One command's expanded argument vector.
`expand_argv` is the only place allowed to know that word zero of
an expanded command is its name; every consumer reads named views
instead of slicing word lists.
`args` and `operands` are two views of the same final word list and
always have equal length; they differ only in element type. Glob
words are resolved by whoever consumes them, exactly once: shell
consumers get shell-resolved words in both views, mount commands
keep pattern PathSpecs for backend pushdown.
Args:
name (str): expanded command name.
args (tuple[str, ...]): text view (what builtins consume).
operands (tuple[str | PathSpec, ...]): classified view (what
mount dispatch, test, and ln consume).
"""
name: str
args: tuple[str, ...]
operands: tuple[str | PathSpec, ...]
@property
def words(self) -> list[str | PathSpec]:
"""Full classified word list, name included."""
if not self.name and not self.operands:
return []
return [self.name, *self.operands]
def with_operands(self, operands: Iterable[str | PathSpec]) -> "Argv":
"""Return a copy with the classified view replaced.
Args:
operands (Iterable[str | PathSpec]): replacement operands
(e.g. after symlink rewriting).
"""
return dataclasses.replace(self, operands=tuple(operands))
async def expand_argv(
parts: list[tree_sitter.Node],
session: Session,
execute_fn: Callable,
call_stack: CallStack | None,
registry: MountRegistry,
) -> Argv:
"""Expand, classify, and glob-resolve a command's word nodes.
Uses the cwd mount's CommandSpec (when it has one for the command)
to decide which words are TEXT (skip classification) and which are
PATH (classify even bare filenames).
Args:
parts (list[tree_sitter.Node]): word nodes after env-prefix
stripping and process-substitution removal.
session (Session): shell session state.
execute_fn (Callable): evaluator for command substitutions.
call_stack (CallStack | None): shell call stack.
registry (MountRegistry): mount registry for classification.
"""
expanded = await expand_parts(parts, session, execute_fn, call_stack)
if not expanded:
return Argv(name="", args=(), operands=())
name = expanded[0]
policy = word_policy(route(name, session, registry))
word_kinds: list[OperandKind | None] | None = None
if policy is WordPolicy.MOUNT:
spec = spec_for_command(name, registry, session.cwd)
if spec:
word_kinds = spec_word_kinds(spec, expanded[1:])
classified = classify_parts(expanded,
registry,
session.cwd,
word_kinds=word_kinds)
# A glob word is resolved by whoever consumes it, exactly once:
# WordPolicy.SHELL words get matches here; mount commands keep
# patterns for backend pushdown; unknown names fail without
# touching backends.
if policy is WordPolicy.SHELL:
words = await resolve_globs(classified, registry)
else:
words = classified
# The text view renders words as typed (raw_path): bash hands
# programs their words unchanged, so `echo sub/file.txt` prints the
# relative form, not the resolved absolute path.
text_view = [word_text(p) for p in words]
return Argv(name=name,
args=tuple(text_view[1:]),
operands=tuple(words[1:]))
@@ -0,0 +1,25 @@
# ========= 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.expand.classify.heuristic import classify_word
from mirage.workspace.expand.classify.parts import classify_parts
from mirage.workspace.expand.classify.path import classify_bare_path
from mirage.workspace.expand.classify.relative import relative_spec
__all__ = [
"classify_bare_path",
"classify_parts",
"classify_word",
"relative_spec",
]
@@ -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. =========
import posixpath
import re
import shlex
from mirage.types import PathSpec
from mirage.utils.glob_walk import has_glob
from mirage.utils.key_prefix import mount_key
from mirage.workspace.expand.classify.relative import relative_spec
from mirage.workspace.mount import MountRegistry
_FILENAME_CHAR = re.compile(r"[a-zA-Z0-9_./]")
_NON_PATH_CHAR = re.compile(r"[(){}=;|&<> ]")
_RELATIVE_PATH = re.compile(
r"(?:\.?[a-zA-Z0-9_\-]*/)*[a-zA-Z0-9_\-]+\.[a-zA-Z0-9]+")
def _unescape_path(word: str) -> str:
"""Strip shell backslash escapes from a path string.
Uses shlex.split which handles all POSIX shell escaping rules:
Zecheng\\'s\\ Server -> Zecheng's Server
hello\\ world -> hello world
Args:
word (str): raw path string possibly containing backslash escapes.
"""
if "\\" not in word:
return word
try:
parts = shlex.split(word)
return parts[0] if parts else word
except ValueError:
return word
def classify_word(word: str, registry: MountRegistry,
cwd: str) -> str | PathSpec:
"""Classify an expanded word as text or PathSpec.
Rules:
- Absolute + glob chars -> PathSpec with pattern
- Absolute + no glob -> PathSpec (file or directory)
- Relative + glob chars -> resolve cwd, PathSpec
- Relative + no glob -> plain text (never a path)
- No mount match -> plain text
"""
word_has_glob = has_glob(word)
if word.startswith("/"):
# Unescape backslash-escaped paths (e.g. /data/Zecheng\'s\ Server).
# Only for absolute paths — non-path text like sed programs
# (N;s/\n/ /) also contains \ and / but must not be unescaped.
if "\\" in word:
word = _unescape_path(word)
try:
mount = registry.mount_for(word)
except ValueError:
return word
is_dir = word.endswith("/")
path = posixpath.normpath(word)
if not is_dir and path + "/" == mount.prefix:
is_dir = True
resource_path = mount_key(path, mount.prefix.rstrip("/"))
if word_has_glob:
last_slash = path.rfind("/")
return PathSpec(
virtual=path,
directory=path[:last_slash + 1],
resource_path=resource_path,
pattern=path[last_slash + 1:],
resolved=False,
)
if is_dir:
return PathSpec(virtual=path,
directory=path + "/",
resource_path=resource_path,
resolved=False)
last_slash = path.rfind("/")
return PathSpec(
virtual=path,
directory=path[:last_slash + 1],
resource_path=resource_path,
resolved=True,
)
# Relative glob: only classify if the word looks like a
# filename pattern (has alphanumeric, dot, or slash alongside
# glob chars). Bare globs like *, ?, [a-z] are command
# arguments (e.g. expr 4 * 3), not path patterns.
if word_has_glob and ("/" in word or not word.startswith(".")):
if not _FILENAME_CHAR.search(word) or _NON_PATH_CHAR.search(word):
return word
return relative_spec(word, registry, cwd)
# Relative path (no glob): resolve against cwd if the word
# contains "/" and looks like a subdirectory path (e.g. sub/file.txt).
# Bare filenames like "file.txt" are NOT classified — classify_word
# has no command context, so it can't distinguish:
# cat file.txt (file path — should resolve)
# for f in file.txt (loop value — should stay text)
# Users must use "./file.txt" or absolute paths for bare filenames.
if not word_has_glob and "/" in word and _RELATIVE_PATH.fullmatch(word):
if "\\" in word:
word = _unescape_path(word)
return relative_spec(word, registry, cwd)
return word
@@ -0,0 +1,47 @@
# ========= 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.types import OperandKind
from mirage.types import PathSpec
from mirage.workspace.expand.classify.heuristic import classify_word
from mirage.workspace.expand.classify.path import classify_bare_path
from mirage.workspace.mount import MountRegistry
def classify_parts(
parts: list[str],
registry: MountRegistry,
cwd: str,
word_kinds: list[OperandKind | None] | None = None,
) -> list[str | PathSpec]:
"""Classify a list of expanded words.
First element (command name) is never classified as a path.
word_kinds (from CommandSpec, aligned with parts[1:]) decides per
position: TEXT skips classification, PATH classifies even bare
filenames, None falls back to the shape heuristics.
"""
if not parts:
return []
result: list[str | PathSpec] = [parts[0]]
for i, w in enumerate(parts[1:]):
kind = (word_kinds[i]
if word_kinds is not None and i < len(word_kinds) else None)
if kind == OperandKind.TEXT:
result.append(w)
elif kind == OperandKind.PATH:
result.append(classify_bare_path(w, registry, cwd))
else:
result.append(classify_word(w, registry, cwd))
return result
@@ -0,0 +1,31 @@
# ========= 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
from mirage.workspace.expand.classify.heuristic import classify_word
from mirage.workspace.expand.classify.relative import relative_spec
from mirage.workspace.mount import MountRegistry
def classify_bare_path(word: str, registry: MountRegistry,
cwd: str) -> str | PathSpec:
"""Classify a bare filename as a path resolved against cwd.
Used when CommandSpec identifies an arg as PATH but classify_word
would not classify it (e.g. bare "file.txt" without "/" prefix).
"""
classified = classify_word(word, registry, cwd)
if not isinstance(classified, str):
return classified
return relative_spec(word, registry, cwd)
@@ -0,0 +1,60 @@
# ========= 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 mirage.types import PathSpec
from mirage.utils.glob_walk import has_glob
from mirage.utils.key_prefix import mount_key
from mirage.workspace.mount import MountRegistry
def relative_spec(word: str, registry: MountRegistry,
cwd: str) -> str | PathSpec:
"""Build the PathSpec for a word typed relative to cwd.
The typed word and the cwd it was typed under are two halves of one
path: ``virtual`` resolves the pair to an absolute path, ``raw_path``
keeps the typed spelling for display. Glob chars in the word make a
pattern spec (unresolved); words whose resolved path has no mount
stay plain text.
Args:
word (str): the word as typed (already unescaped).
registry (MountRegistry): mount registry.
cwd (str): working directory the word was typed under.
"""
path = posixpath.normpath(cwd.rstrip("/") + "/" + word)
try:
mount = registry.mount_for(path)
except ValueError:
return word
resource_path = mount_key(path, mount.prefix.rstrip("/"))
last_slash = path.rfind("/")
if has_glob(word):
return PathSpec(
virtual=path,
directory=path[:last_slash + 1],
resource_path=resource_path,
pattern=path[last_slash + 1:],
resolved=False,
raw_path=word,
)
return PathSpec(
virtual=path,
directory=path[:last_slash + 1],
resource_path=resource_path,
resolved=True,
raw_path=word,
)
@@ -0,0 +1,62 @@
# ========= 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. =========
# Arithmetic operator tokens from tree-sitter that pass through as-is
# when the expression text is reconstructed for the shared evaluator
# (mirage.shell.arith).
ARITH_OPERATORS = frozenset({
"+",
"-",
"*",
"/",
"%",
"**",
"==",
"!=",
"<",
">",
"<=",
">=",
"<<",
">>",
"&",
"|",
"^",
"~",
"&&",
"||",
"!",
"?",
":",
"(",
")",
",",
"=",
"+=",
"-=",
"*=",
"/=",
"%=",
"<<=",
">>=",
"&=",
"^=",
"|=",
"++",
"--",
})
# Arithmetic delimiter tokens that mark the start/end of $((...)) and
# the (( ... )) arithmetic command.
ARITH_DELIMITERS = frozenset({"$((", "((", "))"})
+153
View File
@@ -0,0 +1,153 @@
# ========= 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
from mirage.types import PathSpec
from mirage.utils.glob_walk import has_glob, spell_match
from mirage.utils.key_prefix import mount_key
from mirage.workspace.mount import MountRegistry
from mirage.workspace.mount.mount import MountEntry
async def _walk_segments(item: PathSpec, mount: MountEntry,
prefix: str) -> list[PathSpec]:
"""Expand a mid-path pattern level by level via resolve_glob.
A glob in a non-final segment (``s*/x.txt``) cannot resolve in one
listing: each glob segment is matched against its (already
expanded) parent directory, using the backend's own single-level
``resolve_glob`` per parent, so no backend needs mid-path support.
Matches are spelled the way bash expansion implies (typed head +
matched tail). An intermediate match that cannot be listed is
skipped, matching bash's directories-only descent.
Args:
item (PathSpec): the classify-shaped glob word.
mount (MountEntry): the mount owning the word.
prefix (str): the mount prefix with no trailing slash.
"""
segments = item.virtual.strip("/").split("/")
first = next(i for i, seg in enumerate(segments) if has_glob(seg))
walked = len(segments) - first
level = ["/" + "/".join(segments[:first])]
for seg in segments[first:]:
gathered: list[str] = []
for parent in level:
dir_virtual = parent.rstrip("/") + "/"
spec = PathSpec(virtual=dir_virtual,
directory=dir_virtual,
resource_path=mount_key(dir_virtual, prefix),
pattern=seg,
resolved=False)
try:
matches = await mount.resource.resolve_glob([spec],
prefix=prefix)
except OSError:
# This parent is not a listable directory; bash skips it
# during descent.
continue
for m in matches:
virtual = m.virtual if isinstance(
m, PathSpec) else (m if m.startswith(prefix) else prefix +
m)
gathered.append(virtual)
level = gathered
if not level:
return []
return [
dataclasses.replace(PathSpec.from_str_path(v, mount_key(v, prefix)),
raw_path=spell_match(item.raw_path, v, walked))
for v in level
]
def _match_raw(item: PathSpec, match: PathSpec) -> PathSpec:
"""Stamp a glob match with the spelling the user's word implies.
Bash expands `sub/*.txt` to relative matches (`sub/a.txt`), keeping
the typed prefix. The glob item's raw_path records the word as
typed; matches rebuild it by swapping the resolved directory prefix
for the typed one. Words with no distinct spelling (absolute:
raw_path == virtual) keep the resolved virtual, as do matches that
already carry one.
Args:
item (PathSpec): the glob word being resolved.
match (PathSpec): one resolved match.
"""
if item.raw_path == item.virtual or match.raw_path != match.virtual:
return match
if not match.virtual.startswith(item.directory):
return match
raw_dir = item.raw_path[:item.raw_path.rfind("/") + 1]
spelled = raw_dir + match.virtual[len(item.directory):]
return dataclasses.replace(match, raw_path=spelled)
async def resolve_globs(
classified: list[str | PathSpec],
registry: MountRegistry,
) -> list[str | PathSpec]:
"""Resolve glob patterns in PathSpec args, preserving PathSpec type.
Globs are resolved via resource.resolve_glob. Non-glob PathSpec
and plain str items pass through unchanged. Spec-TEXT words never
arrive here as PathSpec: per-position kinds keep them plain text at
classification time.
Args:
classified (list[str | PathSpec]): text arguments (str) and
paths (PathSpec).
registry (MountRegistry): mount registry.
"""
result: list[str | PathSpec] = []
for item in classified:
if isinstance(item, PathSpec) and item.pattern:
try:
mount = registry.mount_for(item.virtual)
prefix = mount.prefix.rstrip("/")
# Stamp the backend key so readdir addresses the correct
# resource-relative path.
item = dataclasses.replace(item,
resource_path=mount_key(
item.virtual, prefix))
if has_glob(item.directory):
resolved = await _walk_segments(item, mount, prefix)
else:
resolved = await mount.resource.resolve_glob([item],
prefix=prefix)
# bash with nullglob off: a zero-match glob stays the
# literal word instead of vanishing.
if not resolved:
result.append(item)
continue
for p in resolved:
if isinstance(p, PathSpec):
result.append(_match_raw(item, p))
else:
full = prefix + p if not p.startswith(prefix) else p
result.append(
_match_raw(
item,
PathSpec.from_str_path(full,
mount_key(full,
prefix))))
except (ValueError, AttributeError, TypeError):
result.append(item)
elif isinstance(item, PathSpec):
result.append(item)
else:
result.append(item)
return result
+186
View File
@@ -0,0 +1,186 @@
# ========= 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
import tree_sitter
from mirage.shell.arith import evaluate_arith
from mirage.shell.call_stack import CallStack
from mirage.shell.errors import ArithError
from mirage.shell.types import NodeType as NT
from mirage.utils.path import expand_tilde
from mirage.workspace.expand.constants import ARITH_DELIMITERS, ARITH_OPERATORS
from mirage.workspace.expand.variable import _expand_braces, _lookup_var
from mirage.workspace.session import Session
from mirage.workspace.session.shell_dirs import home_dir
def _unescape_unquoted(text: str) -> str:
if "\\" not in text:
return text
try:
parts = shlex.split(text, posix=True)
except ValueError:
return text
return parts[0] if parts else text
async def expand_arith(
ts_node: tree_sitter.Node,
session: Session,
execute_fn: Callable,
call_stack: CallStack | None,
) -> str:
"""Reconstruct arithmetic expression text for the shared evaluator.
``$``-expansions substitute textually (bash performs expansions
before arithmetic evaluation), while bare variable names stay as
names so the evaluator can resolve and assign them
(``$(( y = 3 ))`` needs ``y``, not its value).
"""
parts = []
for child in ts_node.children:
if child.type in ARITH_DELIMITERS:
continue
if child.type in (NT.BINARY_EXPRESSION, NT.UNARY_EXPRESSION,
NT.PARENTHESIZED_EXPRESSION, NT.TERNARY_EXPRESSION,
NT.POSTFIX_EXPRESSION):
parts.append(await expand_arith(child, session, execute_fn,
call_stack))
elif child.type in ARITH_OPERATORS:
parts.append(child.text.decode())
elif child.type == NT.NUMBER:
parts.append(child.text.decode())
elif child.type in (NT.SIMPLE_EXPANSION, NT.EXPANSION,
NT.COMMAND_SUBSTITUTION):
parts.append(await expand_node(child, session, execute_fn,
call_stack))
elif child.type == NT.VARIABLE_NAME:
parts.append(child.text.decode())
else:
parts.append(await expand_node(child, session, execute_fn,
call_stack))
return " ".join(parts)
async def expand_node(
ts_node: tree_sitter.Node,
session: Session,
execute_fn: Callable,
call_stack: CallStack | None = None,
) -> str:
"""Expand a tree-sitter node to a string."""
ntype = ts_node.type
if ntype == NT.WORD:
word = _unescape_unquoted(ts_node.text.decode())
return expand_tilde(word, home_dir(session))
if ntype == NT.NUMBER:
return ts_node.text.decode()
if ntype == NT.COMMAND_NAME:
# The name is a word like any other: $CMD, "quoted", $(sub) all
# expand. A bare word has one named child (or none) and falls
# through to its own expansion rule.
for child in ts_node.named_children:
return await expand_node(child, session, execute_fn, call_stack)
return ts_node.text.decode()
if ntype == NT.SIMPLE_EXPANSION:
raw = ts_node.text.decode()
dollar = raw.rfind("$")
prefix = raw[:dollar]
var = raw[dollar + 1:]
return prefix + _lookup_var(var, session, call_stack)
if ntype == NT.EXPANSION:
return _expand_braces(ts_node, session.env,
getattr(session, "arrays", {}), call_stack)
if ntype == NT.COMMAND_SUBSTITUTION:
inner_cmds = [
c for c in ts_node.named_children
if c.type in (NT.COMMAND, NT.PIPELINE, NT.LIST,
NT.REDIRECTED_STATEMENT, NT.SUBSHELL)
]
if not inner_cmds:
return ""
inner = inner_cmds[0].text.decode()
io = await execute_fn(inner, session_id=session.session_id)
return (await io.stdout_str()).rstrip("\n")
if ntype == NT.ARITHMETIC_EXPANSION:
expr = await expand_arith(ts_node, session, execute_fn, call_stack)
try:
value, updates = evaluate_arith(expr, session.env)
except ArithError:
return ts_node.text.decode()
session.env.update(updates)
return str(value)
if ntype == NT.CONCATENATION:
parts = []
for child in ts_node.children:
parts.append(await expand_node(child, session, execute_fn,
call_stack))
return "".join(parts)
if ntype == NT.STRING:
parts = []
prev_end_row = None
for child in ts_node.children:
if child.type == NT.DQUOTE:
continue
if (prev_end_row is not None
and child.start_point[0] > prev_end_row):
parts.append("\n")
parts.append(await expand_node(child, session, execute_fn,
call_stack))
prev_end_row = child.end_point[0]
return "".join(parts)
if ntype == NT.STRING_CONTENT:
# Bash double-quote escapes: \$, \`, \", \\, \<newline>.
# Everything else preserves the backslash literally.
text = ts_node.text.decode()
text = text.replace("\\\\", "\x00")
text = text.replace('\\"', '"')
text = text.replace("\\$", "$")
text = text.replace("\\`", "`")
text = text.replace("\\\n", "")
text = text.replace("\x00", "\\")
return text
if ntype == NT.RAW_STRING:
raw = ts_node.text.decode()
return raw[1:-1]
if ntype == NT.VARIABLE_ASSIGNMENT:
raw = ts_node.text.decode()
if "=" in raw:
key, _, val_part = raw.partition("=")
val_nodes = [
c for c in ts_node.named_children if c.type != NT.VARIABLE_NAME
]
if val_nodes:
expanded = await expand_node(val_nodes[0], session, execute_fn,
call_stack)
return f"{key}={expanded}"
return f"{key}={val_part}"
return raw
return ts_node.text.decode()
+165
View File
@@ -0,0 +1,165 @@
# ========= 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
import tree_sitter
from mirage.shell.call_stack import CallStack
from mirage.shell.types import NodeType as NT
from mirage.types import PathSpec
from mirage.workspace.expand.classify import classify_word
from mirage.workspace.expand.node import expand_node
from mirage.workspace.mount import MountRegistry
from mirage.workspace.session import Session
def _has_at_expansion(node: tree_sitter.Node) -> bool:
for child in node.children:
if (child.type == NT.SIMPLE_EXPANSION and child.text.decode() == "$@"):
return True
return False
def _array_at_name(child: tree_sitter.Node) -> str | None:
if child.type != NT.EXPANSION:
return None
has_length_op = any(c.type == "#" and not c.is_named
for c in child.children)
if has_length_op:
return None
sub = next((c for c in child.named_children if c.type == "subscript"),
None)
if sub is None:
return None
idx_text = ""
var_name = None
for sc in sub.named_children:
if sc.type == NT.VARIABLE_NAME:
var_name = sc.text.decode()
else:
idx_text = sc.text.decode()
if var_name and idx_text == "@":
return var_name
return None
def _string_has_array_at(node: tree_sitter.Node) -> bool:
return any(_array_at_name(c) is not None for c in node.children)
async def _expand_string_with_array(
node: tree_sitter.Node,
session: Session,
execute_fn: Callable,
call_stack: CallStack | None,
) -> list[str]:
"""Expand a string containing one or more "${a[@]}" into multiple words.
Bash semantics: "prefix${a[@]}suffix" with a=(1 2 3) produces three
words: "prefix1", "2", "3suffix". Single-element arrays merge prefix
and suffix into one word; empty arrays still produce prefix+suffix.
"""
arrays = getattr(session, "arrays", {})
fragments: list[str] = [""]
for child in node.children:
if child.type == NT.DQUOTE:
continue
arr_name = _array_at_name(child)
if arr_name is not None:
arr = arrays.get(arr_name)
if not arr:
continue
if len(arr) == 1:
fragments[-1] = fragments[-1] + arr[0]
else:
fragments[-1] = fragments[-1] + arr[0]
fragments.extend(arr[1:-1])
fragments.append(arr[-1])
continue
text = await expand_node(child, session, execute_fn, call_stack)
fragments[-1] = fragments[-1] + text
return fragments
def _get_positional_args(session: Session,
call_stack: CallStack | None) -> list[str]:
if call_stack and call_stack.get_all_positional():
return call_stack.get_all_positional()
return getattr(session, "positional_args", None) or []
_SPLIT_TYPES = frozenset({
NT.SIMPLE_EXPANSION,
NT.EXPANSION,
})
async def expand_parts(
parts: list,
session: Session,
execute_fn: Callable,
call_stack: CallStack | None = None,
) -> list[str]:
"""Expand a list of tree-sitter child nodes to strings."""
result = []
for p in parts:
if p.type == NT.STRING and _has_at_expansion(p):
positional = _get_positional_args(session, call_stack)
if positional:
result.extend(positional)
continue
if p.type == NT.STRING and _string_has_array_at(p):
words = await _expand_string_with_array(p, session, execute_fn,
call_stack)
result.extend(words)
continue
expanded = await expand_node(p, session, execute_fn, call_stack)
if p.type == NT.COMMAND_SUBSTITUTION:
for word in expanded.split():
if word:
result.append(word)
continue
elif p.type in _SPLIT_TYPES:
for word in expanded.split():
if word:
result.append(word)
elif p.type == NT.STRING:
# A quoted word stays a word even when it expands to "" (echo
# "" or "$EMPTY"), except "$@"/"${a[@]}" which yield zero words.
if expanded or not _has_at_expansion(p):
result.append(expanded)
elif p.type == NT.RAW_STRING:
result.append(expanded)
else:
if expanded:
result.append(expanded)
return result
async def expand_and_classify(
words: list,
session: Session,
execute_fn: Callable,
registry: MountRegistry,
cwd: str,
call_stack: CallStack | None = None,
) -> list[str | PathSpec]:
"""Expand words, classify as PathSpec or text.
Used by for/select where concrete values are needed
before iteration.
"""
expanded = await expand_parts(words, session, execute_fn, call_stack)
return [classify_word(w, registry, cwd) for w in expanded]
@@ -0,0 +1,96 @@
# ========= 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, Callable
from mirage.shell.call_stack import CallStack
from mirage.shell.types import Redirect, RedirectKind
from mirage.workspace.expand.classify import classify_bare_path
from mirage.workspace.expand.node import expand_node
from mirage.workspace.mount import MountRegistry
from mirage.workspace.session import Session
async def expand_redirects(
redirects: list[Redirect],
session: Session,
execute_fn: Callable,
registry: MountRegistry,
call_stack: CallStack | None = None,
) -> tuple[list[Redirect], Any]:
"""Expand redirect targets: heredoc vars, target words, pipelines.
The single expansion path for redirected statements, shared by the
executor (which then applies the redirects) and the provision
planner (which only costs them). Heredoc/herestring bodies get
session variables substituted; file targets are expanded and
classified into PathSpec or plain text; the first attached
pipeline is detached and returned separately.
Args:
redirects (list[Redirect]): parsed redirects from get_redirects.
session (Session): shell session state.
execute_fn (Callable): recursive execute (for expansions).
registry (MountRegistry): mount registry for classification.
call_stack (CallStack | None): shell call stack for expansion.
Returns:
(expanded, pipe_node): expanded redirects and the detached
pipeline node (or None).
"""
expanded: list[Redirect] = []
for r in redirects:
if r.kind in (RedirectKind.HEREDOC, RedirectKind.HERESTRING):
body = r.target
if isinstance(body, str) and r.expand_vars:
for var, val in session.env.items():
body = body.replace("$" + var, val)
expanded.append(
Redirect(fd=r.fd,
target=body,
target_node=r.target_node,
kind=r.kind,
append=r.append,
pipeline=r.pipeline,
expand_vars=r.expand_vars))
continue
if isinstance(r.target, int):
expanded.append(r)
continue
target_node = r.target_node
if target_node is not None:
target_str = await expand_node(target_node, session, execute_fn,
call_stack)
# A redirect target is a path by definition (the operator is
# the context), so force classification like a PATH-kind word;
# classify_word alone leaves extensionless relative targets as
# text. Mirrors the TS classifyBarePath call.
target_scope = classify_bare_path(target_str, registry,
session.cwd)
else:
target_scope = r.target
expanded.append(
Redirect(fd=r.fd,
target=target_scope,
target_node=r.target_node,
kind=r.kind,
append=r.append,
pipeline=r.pipeline))
pipe_node = None
for r in expanded:
if r.pipeline is not None:
pipe_node = r.pipeline
r.pipeline = None
break
return expanded, pipe_node
@@ -0,0 +1,71 @@
# ========= 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 import SPECS
from mirage.commands.spec.parser import parse_command
from mirage.commands.spec.types import CommandSpec, OperandKind
from mirage.workspace.mount import MountRegistry
def spec_for_command(
name: str,
registry: MountRegistry,
cwd: str,
) -> CommandSpec | None:
"""Find the spec that classifies a mount command's words.
The cwd mount's spec wins; the shared SPECS table fills in when
that mount does not register the command. Every absolute path has
a mount (the workspace roots an implicit RAM mount), so mount_for
never fails here; if it ever does, the registry is broken and the
error should propagate.
Args:
name (str): expanded command name.
registry (MountRegistry): mount registry.
cwd (str): current working directory.
"""
spec = registry.mount_for(cwd).spec_for(name)
if spec is not None:
return spec
return SPECS.get(name)
def spec_word_kinds(
spec: CommandSpec,
argv: list[str],
) -> list[OperandKind | None]:
"""Classify argv words into per-position operand kinds.
Delegates to parse_command so flag syntax (clusters, --flag=value,
repeatable flags, provided_by) classifies identically to dispatch.
Kinds are positional, not value sets, so the same word can be TEXT
in one slot and PATH in another (`grep '*.txt' *.txt`). None marks
flag tokens and ignored words (default classification applies).
Examples:
cat file.txt → [PATH]
grep pattern file.txt → [TEXT, PATH]
find /data -name *.txt → [PATH, None, TEXT]
Args:
spec (CommandSpec): command specification with flags/positional/rest.
argv (list[str]): command arguments (without command name).
"""
parsed = parse_command(spec, argv, cwd="/")
kinds: list[OperandKind | None] = list(parsed.word_kinds)
for i, word in enumerate(argv):
if word in spec.ignore_tokens:
kinds[i] = None
return kinds
+224
View File
@@ -0,0 +1,224 @@
# ========= 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 mirage.shell.call_stack import CallStack
from mirage.shell.types import NodeType as NT
from mirage.workspace.session import Session
from mirage.workspace.session.shell_dirs import home_dir
_PARAM_OPS = frozenset({
":-", "-", ":+", "+", ":?", "?", ":=", "=", "#", "##", "%", "%%", "/",
"//", ":", "^", "^^", ",", ",,", "!"
})
def _lookup_var(var: str, session: Session,
call_stack: CallStack | None) -> str:
env = session.env
last_exit_code = session.last_exit_code
positional = getattr(session, "positional_args", None)
if var in ("@", "*"):
if call_stack and call_stack.get_all_positional():
return " ".join(call_stack.get_all_positional())
if positional:
return " ".join(positional)
return ""
if var == "#":
if call_stack and call_stack.get_all_positional():
return str(call_stack.get_positional_count())
if positional:
return str(len(positional))
return "0"
if var == "?":
return str(last_exit_code)
if var.isdigit():
idx = int(var)
if idx == 0:
return "mirage"
if call_stack and call_stack.get_positional(idx):
return call_stack.get_positional(idx)
if positional and 0 < idx <= len(positional):
return positional[idx - 1]
return ""
if call_stack:
local_val = call_stack.get_local(var)
if local_val is not None:
return local_val
if var == "PWD":
return session.cwd
if var == "HOME":
return home_dir(session) or ""
return env.get(var, "")
def _glob_strip(value: str, pattern: str, greedy: bool, prefix: bool) -> str:
if not pattern:
return value
if prefix:
candidates = [
i for i in range(len(value) + 1)
if fnmatch.fnmatchcase(value[:i], pattern)
]
if not candidates:
return value
i = max(candidates) if greedy else min(candidates)
return value[i:]
candidates = [
i for i in range(len(value) + 1)
if fnmatch.fnmatchcase(value[i:], pattern)
]
if not candidates:
return value
i = min(candidates) if greedy else max(candidates)
return value[:i]
def _apply_op(op: str, val: str, var_in_env: bool, args: list[str]) -> str:
if op == ":-":
return val if val else (args[0] if args else "")
if op == "-":
if var_in_env:
return val
return args[0] if args else ""
if op == ":+":
return (args[0] if args else "") if val else ""
if op == "+":
return (args[0] if args else "") if var_in_env else ""
if op == "#":
return _glob_strip(val, args[0] if args else "", False, True)
if op == "##":
return _glob_strip(val, args[0] if args else "", True, True)
if op == "%":
return _glob_strip(val, args[0] if args else "", False, False)
if op == "%%":
return _glob_strip(val, args[0] if args else "", True, False)
if op == "/":
if not args:
return val
replacement = args[1] if len(args) > 1 else ""
return val.replace(args[0], replacement, 1)
if op == "//":
if not args:
return val
replacement = args[1] if len(args) > 1 else ""
return val.replace(args[0], replacement)
if op == "^^":
return val.upper()
if op == ",,":
return val.lower()
if op == "^":
return val[:1].upper() + val[1:] if val else val
if op == ",":
return val[:1].lower() + val[1:] if val else val
if op == ":" and args:
try:
offset = int(args[0])
length = int(args[1]) if len(args) > 1 else None
except ValueError:
return val
if offset < 0:
offset = max(0, len(val) + offset)
if length is None:
return val[offset:]
if length < 0:
return val[offset:max(offset, len(val) + length)]
return val[offset:offset + length]
return val
def _expand_braces(
node,
env: dict,
arrays: dict,
call_stack: CallStack | None,
) -> str:
"""Expand ${VAR}, ${VAR<op>...}, ${a[i]}, ${#a[@]}, etc."""
var_name = None
subscript_node = None
length_op = False
indirect_op = False
op = None
args: list[str] = []
seen_var = False
for c in node.children:
if c.type == "${" or c.type == "}":
continue
if c.type == "#" and not seen_var:
length_op = True
continue
if c.type == "!" and not seen_var:
indirect_op = True
continue
if c.type == NT.VARIABLE_NAME:
var_name = c.text.decode()
seen_var = True
continue
if c.type == "subscript":
subscript_node = c
for sc in c.named_children:
if sc.type == NT.VARIABLE_NAME:
var_name = sc.text.decode()
break
seen_var = True
continue
if c.type in _PARAM_OPS and op is None:
op = c.text.decode()
continue
if c.type in (NT.WORD, NT.STRING, NT.RAW_STRING, NT.STRING_CONTENT,
NT.NUMBER, "regex"):
args.append(c.text.decode())
val = ""
var_in_env = False
if subscript_node is not None and var_name is not None:
idx_text = ""
for sc in subscript_node.named_children:
if sc.type == NT.VARIABLE_NAME:
continue
idx_text = sc.text.decode()
break
arr = arrays.get(var_name)
if arr is None:
scalar = env.get(var_name, "")
arr = [scalar] if scalar else []
var_in_env = var_name in arrays or var_name in env
if idx_text in ("@", "*"):
if length_op:
return str(len(arr))
val = " ".join(arr)
else:
try:
i = int(idx_text)
val = arr[i] if 0 <= i < len(arr) else ""
except ValueError:
val = ""
elif var_name:
if call_stack:
local_val = call_stack.get_local(var_name)
if local_val is not None:
val = local_val
var_in_env = True
if not var_in_env:
var_in_env = var_name in env
val = env.get(var_name, "")
if indirect_op:
return env.get(val, "") if val else ""
if length_op:
return str(len(val))
if op is None:
return val
return _apply_op(op, val, var_in_env, args)
+35
View File
@@ -0,0 +1,35 @@
# ========= 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 MountMode
from mirage.workspace.mount import MountEntry
HELP_HINT = (
"Tip: run `man` to list every available command grouped by resource, "
"`man <cmd>` for a single entry, and `<cmd> --help` for flag details.")
def build_file_prompt(mounts: list[MountEntry]) -> str:
parts: list[str] = [HELP_HINT]
for m in mounts:
prompt = m.resource.PROMPT
if not prompt:
continue
prefix = m.prefix.rstrip("/") or "/"
section = prompt.format(prefix=prefix)
if m.mode != MountMode.READ and m.resource.WRITE_PROMPT:
section += "\n" + m.resource.WRITE_PROMPT.replace(
"{prefix}", prefix)
parts.append(section)
return "\n\n".join(parts)
+76
View File
@@ -0,0 +1,76 @@
# ========= 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 os
import subprocess
import sys
import tempfile
from threading import Thread
from mirage.fuse.mount import mount_background
from mirage.ops import Ops
class FuseManager:
def __init__(self) -> None:
self._mountpoint: str | None = None
self._thread: Thread | None = None
# True only for tempfile mountpoints Mirage created and may delete.
self._owns_mountpoint: bool = False
@property
def mountpoint(self) -> str | None:
return self._mountpoint
def setup(self,
ops: Ops,
prefix: str = "/",
mountpoint: str | None = None) -> str:
if mountpoint:
# Caller/deployment-owned mountpoints may be reused across process
# restarts, container lifecycles, or volume mounts. Mirage should
# unmount them, but must not delete the directory itself.
self._mountpoint = mountpoint
self._owns_mountpoint = False
os.makedirs(mountpoint, exist_ok=True)
else:
self._mountpoint = tempfile.mkdtemp(prefix="mirage-")
self._owns_mountpoint = True
self._thread = mount_background(ops,
self._mountpoint,
root_prefix=prefix)
return self._mountpoint
def unmount(self) -> None:
if not self._mountpoint:
return
if sys.platform == "darwin":
subprocess.run(["diskutil", "unmount", "force", self._mountpoint],
capture_output=True)
else:
subprocess.run(["fusermount", "-u", self._mountpoint],
capture_output=True)
if self._owns_mountpoint:
try:
# Empty-directory cleanup only. If the mount is still live or
# the directory has contents, leave it for the caller/admin.
os.rmdir(self._mountpoint)
except OSError:
pass
self._mountpoint = None
self._owns_mountpoint = False
def close(self) -> None:
self.unmount()
+20
View File
@@ -0,0 +1,20 @@
# ========= 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.mount.mount import MountEntry
from mirage.workspace.mount.registry import (MountCommandUnsupported,
MountRegistry)
from mirage.workspace.mount.spec import Mount
__all__ = ["Mount", "MountCommandUnsupported", "MountEntry", "MountRegistry"]
+535
View File
@@ -0,0 +1,535 @@
# ========= 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 inspect
from typing import Any, Callable
from mirage.cache.context import push_cache_manager
from mirage.cache.manager import CacheManager
from mirage.commands.builtin.utils.safeguard import (apply_op_safeguard,
run_with_timeout)
from mirage.commands.config import RegisteredCommand
from mirage.commands.resolve import get_extension
from mirage.commands.safeguard import CommandSafeguard, resolve_safeguard
from mirage.commands.spec import CommandSpec
from mirage.io.types import ByteSource, IOResult
from mirage.observe.context import (push_mount_prefix, push_revisions,
reset_revisions, with_mount_prefix,
with_revisions)
from mirage.ops.registry import RegisteredOp
from mirage.resource.base import BaseResource
from mirage.types import ConsistencyPolicy, MountMode, PathSpec
from mirage.utils.key_prefix import mount_key
def _wrap_cmd_streams(
result: tuple[ByteSource | None, IOResult],
mount_prefix: str,
revisions: dict[str, str] | None,
) -> tuple[ByteSource | None, IOResult]:
"""Wrap any async-iterator streams in ``result`` with the mount
prefix and active revisions, so ``record_stream`` and
``revision_for`` calls inside the lazy backend body see the right
context when consumed after this frame exits.
Mirrors the ``exit_on_empty`` pattern: thin async-gen wrapper that
side-effects the recorder state as bytes flow through. Same object
appearing in both the primary stream and IOResult.reads/writes is
wrapped once (dedup by identity).
Args:
result: ``(stream, io)`` as returned by a command handler.
mount_prefix: prefix to push during stream consumption.
revisions: revisions map to push during stream consumption
(None when the mount has no pins installed).
"""
stream, io = result
seen: dict[int, ByteSource] = {}
def _wrap(obj: ByteSource | None) -> ByteSource | None:
if obj is None or isinstance(obj, (bytes, bytearray)):
return obj
oid = id(obj)
if oid in seen:
return seen[oid]
wrapped = with_mount_prefix(mount_prefix, obj)
if revisions:
wrapped = with_revisions(revisions, wrapped)
seen[oid] = wrapped
return wrapped
stream = _wrap(stream)
for k, v in list(io.reads.items()):
io.reads[k] = _wrap(v)
for k, v in list(io.writes.items()):
io.writes[k] = _wrap(v)
return stream, io
class MountEntry:
"""A mounted resource with command and op dispatch.
Each mount has its own lookup tables for commands and ops.
Different mounts of the same resource type can have
different registered commands/ops.
Resolution hierarchy (same for commands and ops):
1. (name, extension) -- filetype-specific
2. (name, None) -- resource-specific
3. general[name] -- general fallback
"""
def __init__(
self,
prefix: str,
resource: BaseResource,
mode: MountMode = MountMode.READ,
consistency: ConsistencyPolicy = ConsistencyPolicy.LAZY,
) -> None:
if not prefix.startswith("/"):
raise ValueError(f"prefix must start with /: {prefix!r}")
if not prefix.endswith("/"):
raise ValueError(f"prefix must end with /: {prefix!r}")
if "//" in prefix:
raise ValueError(f"prefix must not contain //: {prefix!r}")
self.prefix = prefix
self.resource = resource
self.mode = mode
self.consistency = consistency
self.cache_manager: CacheManager | None = None
# Per-path revision pins installed at Workspace.load time. Read
# functions consult these via the ``revision_for`` contextvar
# lookup; on a hit, the backend GET pins to the recorded
# revision so replay serves the exact bytes the agent saw.
# Empty during normal runs; populated only by the snapshot
# loader.
self.revisions: dict[str, str] = {}
self._cmds: dict[tuple, RegisteredCommand] = {}
self._general_cmds: dict[str, RegisteredCommand] = {}
self._cmd_specs: dict[str, CommandSpec] = {}
self.command_safeguards: dict[str, CommandSafeguard] = {}
self._ops: dict[tuple, RegisteredOp] = {}
self._general_ops: dict[str, RegisteredOp] = {}
# key: (cmd_name, target_resource_type)
self._cross_cmds: dict[tuple, RegisteredCommand] = {}
# ── command registration ──────────────────────────
def register(self, cmd: RegisteredCommand) -> None:
"""Register a resource-specific command."""
key = (cmd.name, cmd.filetype)
self._cmds[key] = cmd
if cmd.spec is not None:
self._cmd_specs[cmd.name] = cmd.spec
def register_general(
self,
cmd: RegisteredCommand,
) -> None:
"""Register a general command (resource=None).
General commands work on any resource (e.g. echo, pwd).
They are the last fallback in resolve_command().
"""
self._general_cmds[cmd.name] = cmd
if cmd.spec is not None:
self._cmd_specs[cmd.name] = cmd.spec
def resolve_command(
self,
cmd_name: str,
extension: str | None = None,
) -> RegisteredCommand | None:
"""Resolve command with fallback hierarchy.
Lookup order:
1. (cmd_name, extension) -- filetype-specific
2. (cmd_name, None) -- resource-specific
3. general_cmds[cmd_name] -- general fallback
"""
if extension:
cmd = self._cmds.get((cmd_name, extension))
if cmd is not None:
return cmd
cmd = self._cmds.get((cmd_name, None))
if cmd is not None:
return cmd
return self._general_cmds.get(cmd_name)
def spec_for(
self,
cmd_name: str,
) -> CommandSpec | None:
"""Get the spec for a command name."""
return self._cmd_specs.get(cmd_name)
def is_general_command(self, cmd_name: str) -> bool:
"""Whether `cmd_name` is registered as a general command here."""
return cmd_name in self._general_cmds
def all_commands(self) -> list[RegisteredCommand]:
"""All registered commands (per-mount + general), deduped by name."""
seen: set[str] = set()
out: list[RegisteredCommand] = []
for rc in self._cmds.values():
if rc.name in seen:
continue
seen.add(rc.name)
out.append(rc)
for rc in self._general_cmds.values():
if rc.name in seen:
continue
seen.add(rc.name)
out.append(rc)
return out
def filetype_handlers(
self,
cmd_name: str,
) -> dict[str, Callable]:
"""Get filetype-specific command handlers.
Example::
mount.register(generic_cat) # ("cat", None)
mount.register(parquet_cat) # ("cat", ".parquet")
mount.filetype_handlers("cat")
# -> {".parquet": parquet_cat_fn}
Args:
cmd_name (str): command name, e.g. "cat".
"""
fns: dict[str, Callable] = {}
for (name, ft), rc in self._cmds.items():
if name == cmd_name and ft is not None:
if ft not in fns:
fns[ft] = rc.fn
return fns
def register_fns(self, fns: list) -> None:
"""Register commands and ops from decorated functions.
Args:
fns (list): Functions decorated with @command and/or @op.
Raises:
ValueError: If a command/op's resource doesn't match
this mount's resource.
"""
pname = self.resource.name
for fn in fns:
if hasattr(fn, "_registered_commands"):
rcs = fn._registered_commands
matching = [
rc for rc in rcs
if rc.resource is None or rc.resource == pname
]
if rcs and not matching:
resources = sorted({rc.resource for rc in rcs})
raise ValueError(
f"command {rcs[0].name!r} is for resource(s) "
f"{resources!r}, not {pname!r}")
for rc in matching:
self.register(rc)
if hasattr(fn, "_registered_ops"):
ros = fn._registered_ops
matching_ops = [
ro for ro in ros
if ro.resource is None or ro.resource == pname
]
if ros and not matching_ops:
resources = sorted({ro.resource for ro in ros})
raise ValueError(f"op {ros[0].name!r} is for resource(s) "
f"{resources!r}, not {pname!r}")
for ro in matching_ops:
self.register_op(ro)
def unregister(self, names: list[str]) -> None:
"""Remove all commands and ops with the given names.
Args:
names (list[str]): Command/op names to remove.
"""
for name in names:
keys = [k for k in self._cmds if k[0] == name]
for k in keys:
del self._cmds[k]
self._general_cmds.pop(name, None)
self._cmd_specs.pop(name, None)
op_keys = [k for k in self._ops if k[0] == name]
for k in op_keys:
del self._ops[k]
self._general_ops.pop(name, None)
def commands(self) -> dict[str, list[str | None]]:
"""List registered commands grouped by filetype variants.
Returns:
dict[str, list[str | None]]: Command name to filetype list.
"""
result: dict[str, list[str | None]] = {}
for (name, filetype) in self._cmds:
result.setdefault(name, []).append(filetype)
for name in self._general_cmds:
result.setdefault(name, [])
for name in result:
result[name] = sorted(result[name],
key=lambda x: (x is not None, x or ""))
return dict(sorted(result.items()))
def registered_ops(self) -> dict[str, list[str | None]]:
"""List registered ops grouped by filetype variants.
Returns:
dict[str, list[str | None]]: Op name to filetype list.
"""
result: dict[str, list[str | None]] = {}
for (name, filetype) in self._ops:
result.setdefault(name, []).append(filetype)
for name in self._general_ops:
result.setdefault(name, [])
for name in result:
result[name] = sorted(result[name],
key=lambda x: (x is not None, x or ""))
return dict(sorted(result.items()))
# ── cross-mount registration ─────────────────────
def register_cross(
self,
cmd: RegisteredCommand,
target_resource_type: str,
) -> None:
"""Register a cross-mount command for a target.
Example::
mount.register_cross(cp_cmd, "ram")
# This mount can now cp to ram mounts
Args:
cmd: the cross-mount command.
target_resource_type: e.g. "ram", "s3".
"""
key = (cmd.name, target_resource_type)
self._cross_cmds[key] = cmd
def resolve_cross(
self,
cmd_name: str,
target_resource_type: str,
) -> RegisteredCommand | None:
"""Find a cross-mount command for a target."""
return self._cross_cmds.get((cmd_name, target_resource_type))
# ── op registration ───────────────────────────────
def register_op(self, op: RegisteredOp) -> None:
"""Register a resource-specific VFS op."""
key = (op.name, op.filetype)
self._ops[key] = op
def _resolve_cascade(
self,
name: str,
extension: str | None,
table: dict,
general: dict,
) -> list:
"""Resolve with cascade: try filetype, resource, general.
Returns list of matching entries to try in order.
First non-None result wins.
"""
levels = []
if extension:
entry = table.get((name, extension))
if entry is not None:
levels.append(entry)
entry = table.get((name, None))
if entry is not None:
levels.append(entry)
entry = general.get(name)
if entry is not None:
levels.append(entry)
return levels
# ── execution ─────────────────────────────────────
async def execute_cmd(
self,
cmd_name: str,
paths: list[PathSpec],
texts: list[str],
flag_kwargs: dict,
*,
stdin: ByteSource | None = None,
cwd: str = "/",
dispatch: Callable | None = None,
session_id: str | None = None,
env: dict[str, str] | None = None,
exec_allowed: bool = True,
) -> tuple[ByteSource | None, IOResult]:
"""Execute a command on this mount's resource.
Pure dispatch — flag parsing is done upstream in
executor/command.py. This method just resolves the
command handler and calls it.
Args:
cmd_name (str): command name.
paths (list[PathSpec]): positional path args.
texts (list[str]): positional text args.
flag_kwargs (dict): parsed flags from upstream.
stdin (ByteSource | None): stdin data.
cwd (str): virtual cwd from session.
"""
extension = get_extension(paths[0].virtual) if paths else None
handlers = self._resolve_cascade(cmd_name, extension, self._cmds,
self._general_cmds)
if not handlers:
return None, IOResult(
exit_code=127,
stderr=(f"{cmd_name}: command not found".encode()))
mount_prefix = self.prefix.rstrip("/")
filetype_fns = self.filetype_handlers(cmd_name)
is_filetype_cmd = extension is not None and (cmd_name,
extension) in self._cmds
paths = [
dataclasses.replace(
p, resource_path=mount_key(p.virtual, mount_prefix))
if isinstance(p, PathSpec) else p for p in paths
]
# Stamp this mount's backend key onto path-shaped flag values so
# backend reads can address them: a single PathSpec (e.g. awk -f,
# single grep -f) or a list of PathSpec (repeatable grep -f).
# Everything else (bools, strings, list[str] like repeated -e) is
# not a path and passes through unchanged.
kw = {}
for k, v in flag_kwargs.items():
if isinstance(v, PathSpec):
kw[k] = dataclasses.replace(v,
resource_path=mount_key(
v.virtual, mount_prefix))
elif isinstance(v, list) and v and all(
isinstance(item, PathSpec) for item in v):
kw[k] = [
dataclasses.replace(item,
resource_path=mount_key(
item.virtual, mount_prefix))
for item in v
]
else:
kw[k] = v
kw["index"] = self.resource.index
kw["cwd"] = PathSpec(
virtual=cwd,
directory=cwd,
resolved=False,
resource_path=mount_key(cwd, mount_prefix),
)
kw["filetype_fns"] = (filetype_fns if not is_filetype_cmd else None)
if stdin is not None:
kw["stdin"] = stdin
if dispatch is not None:
kw["dispatch"] = dispatch
if session_id is not None:
kw["session_id"] = session_id
if env is not None:
kw["env"] = env
kw["exec_allowed"] = exec_allowed
prev_prefix = push_mount_prefix(mount_prefix)
revs_token = push_revisions(self.revisions or None)
prev_manager = push_cache_manager(self.cache_manager)
try:
for cmd in handlers:
if cmd.write and self.mode == MountMode.READ:
return None, IOResult(
exit_code=1,
stderr=(f"{cmd_name}: read-only mount "
f"at {self.prefix}".encode()))
result = await cmd.fn(self.resource.accessor, paths, *texts,
**kw)
if result is not None:
stream, io = _wrap_cmd_streams(result, mount_prefix,
self.revisions or None)
# TODO: hand back a finalization context separately
# instead of stamping policy onto io.safeguard.
io.safeguard = resolve_safeguard(
cmd_name, cmd.safeguard,
self.command_safeguards.get(cmd_name))
return stream, io
return None, IOResult()
finally:
reset_revisions(revs_token)
push_mount_prefix(prev_prefix)
push_cache_manager(prev_manager)
async def execute_op(
self,
op_name: str,
path: str,
*args,
**kwargs,
) -> Any:
"""Execute a VFS op on this mount's resource.
Tries filetype-specific first, then resource-specific.
First non-None result wins.
Args:
op_name (str): operation name (e.g. "read", "stat").
path (str): virtual path.
"""
filetype = get_extension(path)
levels = self._resolve_cascade(op_name, filetype, self._ops,
self._general_ops)
if not levels:
raise AttributeError(f"{self.resource.name}: "
f"no op {op_name!r}")
if self.mode == MountMode.READ and any(o.write for o in levels):
raise PermissionError(f"mount {self.prefix!r} is read-only")
mount_prefix = self.prefix.rstrip("/")
scope = PathSpec(
virtual=path,
directory=path.rsplit("/", 1)[0] or "/",
resource_path=mount_key(path, mount_prefix),
)
kwargs.setdefault("index", self.resource.index)
op_override = self.command_safeguards.get(op_name)
op_timeout = (op_override.timeout_seconds
if op_override is not None else None)
prev_prefix = push_mount_prefix(mount_prefix)
revs_token = push_revisions(self.revisions or None)
try:
for op in levels:
result = op.fn(self.resource.accessor, scope, *args, **kwargs)
if inspect.isawaitable(result):
result = await run_with_timeout(result, op_timeout,
op_name)
if result is not None:
return await apply_op_safeguard(result, op_override)
return None
finally:
reset_revisions(revs_token)
push_mount_prefix(prev_prefix)
+154
View File
@@ -0,0 +1,154 @@
# ========= 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.resource.base import BaseResource
from mirage.types import MountMode
from mirage.utils.path import resolve_symlinks
from mirage.workspace.mount.mount import MountEntry
from mirage.workspace.mount.registry import MountRegistry
@dataclass(frozen=True, slots=True)
class LinkEntry:
target: str
mtime: float
class Namespace:
"""Addressing authority: maps virtual paths to their mounts.
Owns the mount registry and the symlink table (and, in a later phase, the
attribute overlay). Pure addressing: it resolves a virtual path to its
mount and backend-relative path, following symlinks and crossing mounts.
It holds no cache and performs no backend I/O. Op execution and caching
live in the Dispatcher, which calls this layer to locate the mount.
Symlinks are stored verbatim as typed: the target string is kept exactly as
the user wrote it (relative targets are resolved lazily against the link's
own parent at resolution time), so ``readlink`` is GNU-faithful.
"""
def __init__(self, registry: MountRegistry) -> None:
self._registry = registry
self._symlinks: dict[str, LinkEntry] = {}
@property
def registry(self) -> MountRegistry:
return self._registry
@property
def symlinks(self) -> dict[str, LinkEntry]:
return self._symlinks
def replace_symlinks(self, entries: dict[str, LinkEntry]) -> None:
self._symlinks = dict(entries)
def symlink_targets(self) -> dict[str, str]:
return {link: entry.target for link, entry in self._symlinks.items()}
def is_link(self, path: str) -> bool:
return path in self._symlinks
def readlink(self, path: str) -> str | None:
entry = self._symlinks.get(path)
return entry.target if entry is not None else None
def symlink(self, link: str, target: str, mtime: float) -> None:
self._symlinks[link] = LinkEntry(target=target, mtime=mtime)
def unlink(self, path: str) -> bool:
if path in self._symlinks:
del self._symlinks[path]
return True
return False
def rename(self, src: str, dst: str) -> bool:
entry = self._symlinks.pop(src, None)
if entry is None:
return False
self._symlinks[dst] = entry
return True
def follow(self, path: str) -> str:
"""Return ``path`` with all symlink prefixes resolved.
Identity when the table is empty or nothing matches.
Args:
path (str): absolute virtual path.
Raises:
CycleError: when resolution exceeds the hop limit (ELOOP).
"""
if not self._symlinks:
return path
return resolve_symlinks(path, self.symlink_targets())
def links_under(self, directory: str) -> dict[str, str]:
"""Links living directly under a directory.
Args:
directory (str): absolute virtual directory path.
Returns:
dict[str, str]: link basename to target, for entries whose
parent is exactly ``directory``.
"""
base = directory.rstrip("/") + "/"
out: dict[str, str] = {}
for link, entry in self._symlinks.items():
if link.startswith(base) and "/" not in link[len(base):]:
out[link[len(base):]] = entry.target
return out
def purge_under(self, directory: str) -> int:
"""Drop every link entry under a directory (``rm -r`` semantics).
Args:
directory (str): absolute virtual directory path being removed.
Returns:
int: number of entries dropped.
"""
base = directory.rstrip("/") + "/"
doomed = [link for link in self._symlinks if link.startswith(base)]
for link in doomed:
del self._symlinks[link]
return len(doomed)
def resolve(self,
path: str,
*,
follow: bool = True) -> tuple[BaseResource, str, MountMode]:
"""Map a virtual path to ``(resource, resource_path, mode)``.
Args:
path (str): virtual path to resolve.
follow (bool): follow symlinks (the symlink table) before mapping
the path to its mount.
Raises:
CycleError: when symlink resolution exceeds the hop limit (ELOOP).
"""
if follow and self._symlinks:
path = resolve_symlinks(path, self.symlink_targets())
return self._registry.resolve(path)
def mount_for(self, path: str) -> MountEntry:
return self._registry.mount_for(path)
def is_mount_root(self, path: str) -> bool:
return self._registry.is_mount_root(path)
+388
View File
@@ -0,0 +1,388 @@
# ========= 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.cache.file.mixin import FileCacheMixin
from mirage.cache.manager import CacheManager
from mirage.commands.builtin.general import COMMANDS as GENERAL_COMMANDS
from mirage.ops.config import OpsMount
from mirage.resource.base import BaseResource
from mirage.resource.dev import DevResource
from mirage.types import ConsistencyPolicy, MountMode, PathSpec
from mirage.workspace.mount.mount import MountEntry
DEV_PREFIX = "/dev/"
class MountCommandUnsupported(Exception):
"""Raised when a path-bound command is unsupported by its backend.
Rendered in the GNU shape ``<cmd>: <operand>: <reason>`` with the
EOPNOTSUPP strerror, naming the offending path like coreutils does;
the backend name stays on the exception for programmatic use (#394).
"""
def __init__(self, cmd_name: str, backend: str, operand: str) -> None:
self.cmd_name = cmd_name
self.backend = backend
self.operand = operand
super().__init__(f"{cmd_name}: {operand}: Operation not supported")
class MountRegistry:
"""Longest-prefix-match router.
Given a virtual path like "/s3-prod/data/file.json",
resolves to the mount at "/s3-prod/" and returns the
stripped resource path "/data/file.json".
"""
def __init__(self) -> None:
self._mounts: list[MountEntry] = []
self._root: MountEntry | None = None
self._consistency: ConsistencyPolicy = ConsistencyPolicy.LAZY
self._file_cache: FileCacheMixin | None = None
self.mount(DEV_PREFIX, DevResource(), MountMode.WRITE)
def set_consistency(self, consistency: ConsistencyPolicy) -> None:
self._consistency = consistency
def attach_file_cache(self, cache: FileCacheMixin | None) -> None:
"""Attach the workspace file cache and build per-mount
CacheManagers.
Called once by Workspace after the cache store exists. Mounts
added later get their manager in ``mount()``.
Args:
cache (FileCacheMixin | None): Workspace file cache store.
"""
self._file_cache = cache
for m in self._mounts:
self._attach_manager(m)
def _attach_manager(self, m: MountEntry) -> None:
m.cache_manager = CacheManager(self._file_cache, m.resource.index,
m.prefix, m.resource.caches_reads)
def mount(
self,
prefix: str,
resource: BaseResource,
mode: MountMode = MountMode.READ,
consistency: ConsistencyPolicy = ConsistencyPolicy.LAZY,
) -> MountEntry:
"""Mount a resource and return the Mount object."""
stripped = prefix.strip("/")
norm_prefix = ("/" + stripped + "/" if stripped else "/")
for existing in self._mounts:
if existing.prefix == norm_prefix:
raise ValueError(f"duplicate mount prefix: "
f"{norm_prefix!r}")
m = MountEntry(norm_prefix, resource, mode, consistency)
for cmd in resource.commands():
m.register(cmd)
for cmd in GENERAL_COMMANDS:
m.register_general(cmd)
for ro in resource.ops_list():
m.register_op(ro)
if self._file_cache is not None:
self._attach_manager(m)
self._mounts.append(m)
self._mounts.sort(key=lambda x: len(x.prefix), reverse=True)
if norm_prefix == "/":
self._root = m
return m
def unmount(self, prefix: str) -> MountEntry:
"""Remove a mount by exact prefix and return it.
Per-mount commands and ops live on the Mount instance and die with
it. The /dev/ mount is reserved and cannot be removed.
Args:
prefix (str): mount prefix.
"""
stripped = prefix.strip("/")
norm_prefix = ("/" + stripped + "/" if stripped else "/")
if norm_prefix == DEV_PREFIX:
raise ValueError(f"cannot unmount reserved prefix: "
f"{norm_prefix!r}")
for i, m in enumerate(self._mounts):
if m.prefix == norm_prefix:
del self._mounts[i]
if m is self._root:
self._root = None
return m
raise ValueError(f"no mount at prefix: {norm_prefix!r}")
def resolve(
self,
path: str,
) -> tuple[BaseResource, str, MountMode]:
"""Returns (resource, resource_path, mode)."""
had_trailing = path.endswith("/")
norm = "/" + path.strip("/")
for m in self._mounts:
if (norm == m.prefix.rstrip("/") or norm.startswith(m.prefix)):
resource_path = "/" + norm[len(m.prefix):]
if (had_trailing and not resource_path.endswith("/")):
resource_path += "/"
return m.resource, resource_path, m.mode
raise ValueError(f"no mount matches path: {path!r}")
def mount_for_prefix(self, prefix: str) -> MountEntry:
for m in self._mounts:
if m.prefix == prefix:
return m
raise ValueError(f"no mount with prefix {prefix!r}")
def is_mount_root(self, path: str) -> bool:
stripped = path.strip("/")
norm = "/" + stripped + "/" if stripped else "/"
return any(m.prefix == norm for m in self._mounts)
def descendant_mounts(self, path: str) -> list[MountEntry]:
"""Mounts whose prefix is strictly under `path`.
Used by traversal commands (find, tree, du, grep -r) to fan out
across nested mounts. Excludes the mount that contains `path`
itself; callers should add that mount via `mount_for(path)`.
Args:
path (str): parent path to scan beneath.
"""
stripped = path.strip("/")
norm = "/" + stripped + "/" if stripped else "/"
out: list[MountEntry] = []
for m in self._mounts:
if m.prefix == norm:
continue
if not m.prefix.startswith(norm):
continue
out.append(m)
out.sort(key=lambda m: m.prefix)
return out
def child_mount_names(
self,
parent_path: str,
include_hidden: bool = False,
) -> list[str]:
"""Names of immediate child mounts under parent_path.
Args:
parent_path (str): directory whose child mounts to enumerate.
include_hidden (bool): include names starting with '.'.
"""
stripped = parent_path.strip("/")
norm = "/" + stripped + "/" if stripped else "/"
seen: set[str] = set()
out: list[str] = []
for m in self._mounts:
if m.prefix == norm:
continue
if not m.prefix.startswith(norm):
continue
rest = m.prefix[len(norm):]
slash = rest.find("/")
name = rest if slash == -1 else rest[:slash]
if name == "":
continue
if not include_hidden and name.startswith("."):
continue
if name in seen:
continue
seen.add(name)
out.append(name)
out.sort()
return out
def mount_for(self, path: str) -> MountEntry:
"""Find the mount that handles this path."""
norm = "/" + path.strip("/")
for m in self._mounts:
if (norm == m.prefix.rstrip("/") or norm.startswith(m.prefix)):
return m
raise ValueError(f"no mount matches path: {path!r}")
def is_exec_allowed(self) -> bool:
for m in self._mounts:
if m.prefix == DEV_PREFIX:
continue
if m.mode == MountMode.EXEC:
return True
return False
def mount_for_command(self, cmd_name: str) -> MountEntry | None:
"""Find a mount that has this command registered.
Prefers the virtual root mount, then searches other mounts.
"""
if (self._root is not None
and self._root.resolve_command(cmd_name) is not None):
return self._root
for m in self._mounts:
if m.resolve_command(cmd_name) is not None:
return m
return None
async def resolve_mount(
self,
cmd_name: str,
path_scopes: list[PathSpec],
cwd: str,
) -> MountEntry | None:
"""Resolve which mount should handle a command.
Resolution order:
1. First PathSpec path (or cwd) → mount_for(path)
2. If mount lacks the command → mount_for_command(cmd_name)
3. For a read-only command on a caching backend under ALWAYS
consistency, evict stale entries from the hidden file cache so
the in-place read-through serves fresh bytes. The command always
stays on its real mount; the cache is never a mount.
Args:
cmd_name (str): command name.
path_scopes (list[PathSpec]): path arguments.
cwd (str): current working directory.
"""
if path_scopes:
mount_path = path_scopes[0].virtual
else:
mount_path = cwd
try:
mount = self.mount_for(mount_path)
except ValueError:
mount = None
if mount is not None and mount.resolve_command(cmd_name) is None:
if path_scopes:
raise MountCommandUnsupported(cmd_name, mount.resource.name,
path_scopes[0].raw_path)
mount = self.mount_for_command(cmd_name)
elif mount is None:
mount = self.mount_for_command(cmd_name)
if mount is None:
return None
resolved = mount.resolve_command(cmd_name)
# Warm reads are served in place by with_read_cache, so a read-only
# command stays on its real mount. The cache is a hidden store (not a
# mount); under ALWAYS we evict stale entries from it here so the
# read-through serves fresh bytes.
if (self._file_cache is not None and path_scopes
and resolved is not None and not resolved.write
and mount.resource.caches_reads
and self._consistency == ConsistencyPolicy.ALWAYS):
await self._evict_stale(mount, self._file_cache, path_scopes)
return mount
async def _evict_stale(
self,
real_mount: MountEntry,
cache: FileCacheMixin,
path_scopes: list[PathSpec],
) -> None:
"""Evict cached entries whose remote fingerprint has changed.
Only used when ConsistencyPolicy.ALWAYS is active. Backends that
return stat.fingerprint=None silently fall back to LAZY behavior
(no eviction, cache serves whatever it has).
"""
for scope in path_scopes:
key = scope.virtual
if not await cache.exists(key):
continue
try:
remote_stat = await real_mount.execute_op("stat", key)
except FileNotFoundError:
await cache.remove(key)
continue
except Exception:
continue
if remote_stat is None or remote_stat.fingerprint is None:
continue
if not await cache.is_fresh(key, remote_stat.fingerprint):
await cache.remove(key)
@property
def root_mount(self) -> MountEntry | None:
return self._root
@property
def file_cache(self) -> FileCacheMixin | None:
return self._file_cache
def mounts(self) -> list[MountEntry]:
return list(self._mounts)
def ops_mounts(self) -> list[OpsMount]:
"""Build OpsMount list from registered mounts for Ops layer."""
return [
OpsMount(
prefix=m.prefix,
resource_type=m.resource.name,
accessor=m.resource.accessor,
index=m.resource.index,
mode=m.mode,
ops=m.resource.ops_list(),
) for m in self._mounts
]
def find_resource_by_name(
self,
resource_name: str | None,
) -> BaseResource | None:
"""Find a resource by its type name."""
if resource_name is None:
return None
for mount in self._mounts:
if mount.resource.name == resource_name:
return mount.resource
return None
def get_resource_type(
self,
path: str | None,
) -> str | None:
"""Get the resource type for a virtual path."""
if path is None:
return None
try:
resource, _, _ = self.resolve(path)
return resource.name
except (ValueError, KeyError):
return None
def group_by_mount(
self,
paths: list[str],
) -> list[tuple[MountEntry, list[str]]]:
"""Group virtual paths by their mount.
Returns list of (mount, resource_paths).
"""
groups: dict[int, tuple[MountEntry, list[str]]] = {}
for path in paths:
mount = self.mount_for(path)
_, resource_path, _ = self.resolve(path)
key = id(mount)
if key not in groups:
groups[key] = (mount, [])
groups[key][1].append(resource_path)
return list(groups.values())
+28
View File
@@ -0,0 +1,28 @@
# ========= 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 mirage.commands.safeguard import CommandSafeguard
from mirage.resource.base import BaseResource
from mirage.types import MountMode
@dataclass(frozen=True)
class Mount:
resource: BaseResource
mode: MountMode | None = None
fuse: bool | str = False
command_safeguards: dict[str,
CommandSafeguard] = field(default_factory=dict)
+19
View File
@@ -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}")
+102
View File
@@ -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)
+91
View File
@@ -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
+68
View File
@@ -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
@@ -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,20 @@
# ========= 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.provision import Precision, ProvisionResult
async def handle_builtin_provision() -> ProvisionResult:
"""Plan for shell builtins (cd, export, etc.): zero cost."""
return ProvisionResult(precision=Precision.EXACT)
@@ -0,0 +1,191 @@
# ========= 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
from mirage.cache.file.mixin import FileCacheMixin
from mirage.commands.builtin.generic.crossmount import is_cross_mount
from mirage.commands.resolve import get_extension
from mirage.commands.spec import parse_command, parse_to_kwargs
from mirage.provision import Precision, ProvisionResult, combine_sum
from mirage.types import PathSpec
from mirage.utils.key_prefix import mount_key
from mirage.workspace.mount import MountRegistry
from mirage.workspace.mount.namespace import Namespace
from mirage.workspace.session import Session
async def _check_cache_hits(
cache: FileCacheMixin | None,
parts: list[str | PathSpec],
) -> int:
"""Count how many path args are already cached."""
if cache is None:
return 0
hits = 0
for p in parts[1:]:
if isinstance(p, PathSpec) and await cache.exists(p.virtual):
hits += 1
return hits
def _mount_groups(registry: MountRegistry,
parts: list[str | PathSpec]) -> list[list[PathSpec]]:
"""Group path args by their own mount, in first-appearance order.
Args that resolve to no mount (glob patterns, expression operands)
ride with the first group: they scoped to the primary mount before
and must not fabricate a cross-mount split.
"""
groups: list[list[PathSpec]] = []
seen: dict[str, int] = {}
unresolved: list[PathSpec] = []
for p in parts[1:]:
if not isinstance(p, PathSpec):
continue
if p.pattern:
# Globs are not expanded during planning; a pattern operand
# (find -name, ls *.txt) must not fabricate a mount group.
unresolved.append(p)
continue
try:
prefix = registry.mount_for(p.virtual).prefix
except ValueError:
unresolved.append(p)
continue
idx = seen.get(prefix)
if idx is None:
seen[prefix] = len(groups)
groups.append([p])
else:
groups[idx].append(p)
if unresolved:
if groups:
groups[0].extend(unresolved)
else:
groups.append(unresolved)
return groups
async def handle_command_provision(
registry: MountRegistry,
parts: list[str | PathSpec],
session: Session,
namespace: Namespace | None = None,
) -> ProvisionResult:
"""Estimate cost of a simple command.
Paths are namespace-followed first (a symlinked read costs its
target, and the cache-hit check sees the entry the executor would
actually serve), then grouped by mount: a command spanning mounts
is estimated per mount against each mount's own backend and the
results summed, instead of statting foreign paths against the
first path's backend.
"""
if not parts:
return ProvisionResult(precision=Precision.EXACT)
if namespace is not None:
for i, p in enumerate(parts):
if isinstance(p, PathSpec):
followed = namespace.follow(p.virtual)
if followed != p.virtual:
parts[i] = PathSpec.from_str_path(followed)
cmd_name = str(parts[0])
cmd_str = " ".join(p.virtual if isinstance(p, PathSpec) else p
for p in parts)
groups = _mount_groups(registry, parts)
if len(groups) > 1:
path_parts = [p for p in parts[1:] if isinstance(p, PathSpec)]
if not is_cross_mount(cmd_name, path_parts, registry):
# The executor rejects this command across mounts, so an
# aggregated byte estimate would cost a run that errors.
return ProvisionResult(command=cmd_str,
precision=Precision.UNKNOWN)
texts = [p for p in parts[1:] if not isinstance(p, PathSpec)]
children = []
for group in groups:
sub: list[str | PathSpec] = [cmd_name, *texts, *group]
children.append(await
handle_command_provision(registry, sub, session))
combined = combine_sum(";", children)
combined.command = cmd_str
return combined
first_scope = None
for p in parts[1:]:
if isinstance(p, PathSpec):
first_scope = p
break
mount_path = first_scope.virtual if first_scope else session.cwd
try:
mount = registry.mount_for(mount_path)
except ValueError:
# Pathless commands (seq, date, ...) still need a mount to
# resolve their registration; any mount carries the general
# commands, so fall back to the first one.
mounts = registry.mounts()
if not mounts:
return ProvisionResult(command=cmd_str,
precision=Precision.UNKNOWN)
mount = mounts[0]
extension = get_extension(first_scope.virtual) if first_scope else None
cmd = mount.resolve_command(cmd_name, extension)
if cmd is None or cmd.provision_fn is None:
return ProvisionResult(command=cmd_str, precision=Precision.UNKNOWN)
mount_prefix = mount.prefix.rstrip("/")
resource_scopes = []
for i, p in enumerate(parts[1:], start=1):
if isinstance(p, PathSpec):
scoped = dataclasses.replace(p,
resource_path=mount_key(
p.virtual, mount_prefix))
parts[i] = scoped
resource_scopes.append(scoped)
# Parse flags so plan functions receive them as kwargs (e.g. r=True)
argv = [p.virtual if isinstance(p, PathSpec) else p for p in parts[1:]]
spec = mount.spec_for(cmd_name)
if spec is not None:
parsed = parse_command(spec, argv, cwd=session.cwd)
flag_kwargs = parse_to_kwargs(parsed)
text_args = parsed.texts()
else:
flag_kwargs = {}
text_args = [p for p in parts[1:] if not isinstance(p, PathSpec)]
result = await cmd.provision_fn(mount.resource.accessor,
resource_scopes,
*text_args,
command=cmd_str,
prefix=mount.prefix.rstrip("/"),
index=mount.resource.index,
**flag_kwargs)
if not result.command:
result.command = cmd_str
hits = await _check_cache_hits(registry.file_cache, parts)
if hits > 0:
result.cache_hits = hits
result.cache_read_low = result.network_read_low
result.cache_read_high = result.network_read_high
result.network_read_low = 0
result.network_read_high = 0
return result
@@ -0,0 +1,110 @@
# ========= 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.provision import Precision, ProvisionResult
from mirage.workspace.provision.rollup import rollup_list
from mirage.workspace.session import Session
async def _plan_body(provision_node_fn, body: list,
session) -> ProvisionResult:
"""Plan a multi-statement body."""
children = []
for cmd in body:
children.append(await provision_node_fn(cmd, session))
if not children:
return ProvisionResult(precision=Precision.EXACT)
if len(children) == 1:
return children[0]
return rollup_list(";", children)
async def handle_function_provision(
provision_node_fn,
name: str,
body: list,
planning: set[str],
session: Session,
) -> ProvisionResult:
"""Plan a shell function call: the body's cost.
Recursive functions would loop the planner, so a function already
being planned reports UNKNOWN instead of recursing again.
Args:
provision_node_fn: recursive planner.
name (str): function name.
body (list): function body statement nodes.
planning (set[str]): names currently being planned (guard).
session (Session): shell session state.
"""
if name in planning:
return ProvisionResult(command=name, precision=Precision.UNKNOWN)
planning.add(name)
try:
result = await _plan_body(provision_node_fn, body, session)
finally:
planning.discard(name)
if not result.command:
result.command = name
return result
async def handle_if_provision(
provision_node_fn,
branches: list[tuple[Any, Any]],
else_body: Any | None,
session: Session,
) -> ProvisionResult:
"""Plan an if: branches bracket as alternatives.
Taking branch i evaluates conditions 1..i plus body i, so each
alternative sums its condition ladder with its body. The else (or,
without one, the fall-through) still pays every condition.
"""
cond_costs: list[ProvisionResult] = []
children = []
for condition, body in branches:
cond_costs.append(await provision_node_fn(condition, session))
body_result = await _plan_body(provision_node_fn, body, session)
children.append(rollup_list(";", cond_costs + [body_result]))
else_result = (await _plan_body(provision_node_fn, else_body, session)
if else_body is not None else ProvisionResult(
precision=Precision.EXACT))
children.append(rollup_list(";", cond_costs + [else_result]))
return rollup_list("||", children)
async def handle_for_provision(
provision_node_fn,
body: list,
n: int,
session: Session,
) -> ProvisionResult:
"""Plan a for loop: body cost x iteration count."""
result = await _plan_body(provision_node_fn, body, session)
return result.scaled(n, command="for")
async def handle_while_provision(
provision_node_fn,
body: list,
session: Session,
) -> ProvisionResult:
"""Plan while: unknown iterations."""
result = await _plan_body(provision_node_fn, body, session)
result.precision = Precision.UNKNOWN
return result
@@ -0,0 +1,45 @@
# ========= 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.provision import ProvisionResult
from mirage.workspace.provision.rollup import rollup_list, rollup_pipe
from mirage.workspace.session import Session
async def handle_pipe_provision(
provision_node_fn,
commands: list[Any],
session: Session,
) -> ProvisionResult:
"""Plan a pipe: all commands run."""
children = []
for cmd in commands:
children.append(await provision_node_fn(cmd, session))
return rollup_pipe(children)
async def handle_connection_provision(
provision_node_fn,
left: Any,
op: str,
right: Any,
session: Session,
) -> ProvisionResult:
"""Plan &&, ||"""
children = []
children.append(await provision_node_fn(left, session))
children.append(await provision_node_fn(right, session))
return rollup_list(str(op), children)
@@ -0,0 +1,72 @@
# ========= 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.provision import Precision, ProvisionResult
from mirage.shell.types import RedirectKind
from mirage.types import PathSpec
from mirage.workspace.mount import MountRegistry
from mirage.workspace.mount.namespace import Namespace
from mirage.workspace.provision.command import handle_command_provision
from mirage.workspace.provision.rollup import rollup_list
from mirage.workspace.session import Session
async def handle_redirect_provision(
provision_node_fn,
registry: MountRegistry,
command: Any,
targets: list[tuple[RedirectKind, PathSpec]],
session: Session,
namespace: Namespace | None = None,
) -> ProvisionResult:
"""Plan a redirect: the inner command plus the redirect I/O.
A `< file` source is read fully, so it is planned as a cat of the
source (exact when the size resolves). A `>`/`>>` target writes
the inner command's stdout, whose size is only knowable when the
inner read total is: the write is bracketed 0..inner read high as
a RANGE, or UNKNOWN when the inner plan has no usable ceiling.
stderr redirects, fd duplications, /dev targets, and heredocs are
filtered out by the caller and cost nothing.
Args:
provision_node_fn: recursive planner.
registry (MountRegistry): mount registry for the source read.
command (Any): the redirected command node.
targets (list[tuple[RedirectKind, PathSpec]]): resolved
stdin/stdout redirect targets on mounts.
session (Session): shell session state.
"""
inner = await provision_node_fn(command, session)
if not targets:
return inner
children = [inner]
for kind, target in targets:
if kind == RedirectKind.STDIN:
children.append(await
handle_command_provision(registry, ["cat", target],
session, namespace))
continue
if inner.network_read_high > 0:
children.append(
ProvisionResult(
network_write_low=0,
network_write_high=inner.network_read_high,
precision=Precision.RANGE,
))
else:
children.append(ProvisionResult(precision=Precision.UNKNOWN))
return rollup_list(";", children)
@@ -0,0 +1,57 @@
# ========= 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.provision import (Precision, ProvisionResult, combine_alternative,
combine_sum)
def rollup_pipe(children: list[ProvisionResult]) -> ProvisionResult:
"""Aggregate plan results for a pipe (all stages run).
A stage downstream of an UNKNOWN stage cannot be trusted either (its
input volume is unknowable), so its precision is degraded before the
field-wise sum.
Args:
children (list[ProvisionResult]): Per-stage results.
Returns:
ProvisionResult: Field-wise sums under op "|".
"""
unknown_seen = False
for child in children:
if unknown_seen:
child.precision = Precision.UNKNOWN
elif child.precision == Precision.UNKNOWN:
unknown_seen = True
return combine_sum("|", children)
def rollup_list(
op: str,
children: list[ProvisionResult],
) -> ProvisionResult:
"""Aggregate plan results for ;, &&, ||.
Args:
op (str): List operator.
children (list[ProvisionResult]): Per-command results.
Returns:
ProvisionResult: Sums for ;/&& (every command runs); a min/max
envelope for || (only one branch runs).
"""
if op == "||":
return combine_alternative(op, children)
return combine_sum(op, children)
+32
View File
@@ -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.workspace.route.constants import (JOB_BUILTINS, NAMESPACE_COMMANDS,
NO_FOLLOW_COMMANDS,
UNSUPPORTED_BUILTINS)
from mirage.workspace.route.route import route
from mirage.workspace.route.types import (SHELL_CONSUMERS, Consumer,
WordPolicy, word_policy)
__all__ = [
"Consumer",
"JOB_BUILTINS",
"NAMESPACE_COMMANDS",
"NO_FOLLOW_COMMANDS",
"SHELL_CONSUMERS",
"UNSUPPORTED_BUILTINS",
"WordPolicy",
"route",
"word_policy",
]
@@ -0,0 +1,37 @@
# ========= 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 ShellBuiltin
# Bash builtins the parser accepts but the executor cannot honor; they
# still route to the shell layer so the error names a capability gap.
UNSUPPORTED_BUILTINS = frozenset({
"bg",
"disown",
"exec",
"complete",
"compgen",
"ulimit",
})
NAMESPACE_COMMANDS = frozenset({"ln", "readlink"})
# ShellBuiltin subset handled through the job table in the executor.
JOB_BUILTINS = frozenset({"wait", "fg", "kill", "jobs", "ps"})
# Commands with lstat semantics: they act on the symlink entry itself,
# so dispatch must not rewrite their operands through the link table.
NO_FOLLOW_COMMANDS = frozenset({"rm", "mv", "ln", "readlink", "rmdir"})
SHELL_NAMES = frozenset(str(b) for b in ShellBuiltin) | UNSUPPORTED_BUILTINS
+41
View File
@@ -0,0 +1,41 @@
# ========= 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.mount import MountRegistry
from mirage.workspace.route.constants import NAMESPACE_COMMANDS, SHELL_NAMES
from mirage.workspace.route.types import Consumer
from mirage.workspace.session import Session
def route(name: str, session: Session, registry: MountRegistry) -> Consumer:
"""Route a command name to the layer that consumes it.
Order mirrors dispatch precedence: shell builtins shadow functions,
functions shadow mount commands, and a name nobody registers is
UNKNOWN (command not found).
Args:
name (str): expanded command name.
session (Session): shell session (function table).
registry (MountRegistry): mount registry (command registration).
"""
if name in SHELL_NAMES:
return Consumer.SESSION
if name in NAMESPACE_COMMANDS:
return Consumer.NAMESPACE
if name in session.functions:
return Consumer.FUNCTION
if registry.mount_for_command(name) is not None:
return Consumer.MOUNT
return Consumer.UNKNOWN
+69
View File
@@ -0,0 +1,69 @@
# ========= 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 enum import Enum, auto
class Consumer(Enum):
"""The layer that consumes a command: a command belongs to the layer
whose state it mutates.
The verdict drives both the dispatch branch and the word policy:
SESSION / NAMESPACE / FUNCTION words are shell-resolved (bash
contract: programs receive matches, never patterns); MOUNT words
keep glob patterns intact for backend pushdown; UNKNOWN words are
never resolved (the command fails, backend I/O for it is waste).
"""
SESSION = auto()
NAMESPACE = auto()
FUNCTION = auto()
MOUNT = auto()
UNKNOWN = auto()
SHELL_CONSUMERS = frozenset({
Consumer.SESSION,
Consumer.NAMESPACE,
Consumer.FUNCTION,
})
class WordPolicy(Enum):
"""How a command's words are resolved, derived from its consumer.
SHELL: the shell resolves globs before the command runs and spec
hints are ignored; bash expands `echo /data/*.txt` no matter what
echo does with its arguments. NAMESPACE and FUNCTION consumers get
the same treatment (programs receive matches, never patterns).
MOUNT: the command's spec classifies words (TEXT stays literal,
PATH resolves) and glob PathSpecs stay intact for backend pushdown.
UNKNOWN names also land here: nothing resolves their words, the
command fails before any backend I/O.
"""
SHELL = auto()
MOUNT = auto()
def word_policy(consumer: Consumer) -> WordPolicy:
"""Map a consumer to its word policy.
Args:
consumer (Consumer): the layer that consumes the command.
"""
if consumer in SHELL_CONSUMERS:
return WordPolicy.SHELL
return WordPolicy.MOUNT
+122
View File
@@ -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 logging
import threading
from typing import Awaitable, TypeVar
from mirage.workspace.workspace import Workspace
logger = logging.getLogger(__name__)
T = TypeVar("T")
class WorkspaceRunner:
"""A Workspace pinned to its own thread and asyncio event loop.
Use this when the calling app already has its own event loop
(FastAPI, aiohttp, the Mirage daemon, etc.) and wants the
workspace to run in isolation -- so a slow / blocking call inside
the workspace cannot stall the host loop, and so multiple
workspaces hosted in one process do not interfere with each other.
The workspace's coroutines run only on the workspace loop. Callers
dispatch work via :meth:`call`, which is safe from any other
asyncio loop.
Example:
ws = Workspace({"/": (RAMResource(), MountMode.WRITE)})
runner = WorkspaceRunner(ws)
try:
result = await runner.call(runner.ws.execute("ls /"))
finally:
await runner.stop()
"""
def __init__(self, ws: Workspace) -> None:
"""Construct the runner and start its background loop.
Args:
ws (Workspace): the workspace this runner owns. The runner
takes exclusive responsibility for running the
workspace's coroutines from this point forward.
"""
self.ws = ws
self.loop = asyncio.new_event_loop()
self._ready = threading.Event()
self._thread = threading.Thread(
target=self._run,
name=f"mirage-ws-{id(ws):x}",
daemon=True,
)
self._thread.start()
self._ready.wait()
def _run(self) -> None:
asyncio.set_event_loop(self.loop)
self.loop.call_soon(self._ready.set)
self.loop.run_forever()
async def call(self, coro: Awaitable[T]) -> T:
"""Run ``coro`` on the workspace loop and await the result.
Safe to call from any other event loop. The current loop is
not blocked while the workspace loop processes ``coro``.
Args:
coro (Awaitable[T]): a coroutine produced from the
workspace's API, e.g. ``runner.ws.execute("ls /")``.
Returns:
T: whatever ``coro`` resolves to.
"""
fut = asyncio.run_coroutine_threadsafe(coro, self.loop)
return await asyncio.wrap_future(fut)
def call_sync(self, coro: Awaitable[T], timeout: float | None = None) -> T:
"""Run ``coro`` on the workspace loop and block until done.
Use from synchronous callers (tests, blocking scripts). Do
NOT use from inside another running event loop -- that will
deadlock the caller's loop. Use :meth:`call` from there.
Args:
coro (Awaitable[T]): the workspace coroutine to run.
timeout (float | None): seconds to wait, or None for no
limit.
Returns:
T: whatever ``coro`` resolves to.
"""
fut = asyncio.run_coroutine_threadsafe(coro, self.loop)
return fut.result(timeout=timeout)
async def stop(self) -> None:
"""Close the workspace and shut down the runner cleanly.
Calls ``self.ws.close()`` on the workspace loop, then stops
the loop and joins the thread. Idempotent.
"""
if not self._thread.is_alive():
return
try:
await self.call(self.ws.close())
except Exception:
logger.exception("workspace close raised during runner shutdown")
self.loop.call_soon_threadsafe(self.loop.stop)
await asyncio.to_thread(self._thread.join)
self.loop.close()
@@ -0,0 +1,27 @@
# ========= 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.runtime import (assert_mount_allowed, get_current_session,
reset_current_session, set_current_session)
from mirage.workspace.session.manager import SessionManager
from mirage.workspace.session.session import Session
__all__ = [
"Session",
"SessionManager",
"assert_mount_allowed",
"get_current_session",
"reset_current_session",
"set_current_session",
]
@@ -0,0 +1,83 @@
# ========= 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 mirage.workspace.session.session import Session
class SessionManager:
def __init__(self, default_session_id: str) -> None:
self._default_id = default_session_id
self._sessions: dict[str, Session] = {}
self._locks: dict[str, asyncio.Lock] = {}
self._sessions[default_session_id] = Session(
session_id=default_session_id)
self._locks[default_session_id] = asyncio.Lock()
@property
def default_id(self) -> str:
return self._default_id
@property
def cwd(self) -> str:
return self._sessions[self._default_id].cwd
@cwd.setter
def cwd(self, value: str) -> None:
self._sessions[self._default_id].cwd = value
@property
def env(self) -> dict[str, str]:
return self._sessions[self._default_id].env
@env.setter
def env(self, value: dict[str, str]) -> None:
self._sessions[self._default_id].env = value
def create(self,
session_id: str,
allowed_mounts: frozenset[str] | None = None) -> Session:
if session_id in self._sessions:
raise ValueError(f"Session {session_id!r} already exists")
session = Session(session_id=session_id, allowed_mounts=allowed_mounts)
self._sessions[session_id] = session
self._locks[session_id] = asyncio.Lock()
return session
def get(self, session_id: str) -> Session:
return self._sessions[session_id]
def list(self) -> list[Session]:
return list(self._sessions.values())
async def close(self, session_id: str) -> None:
if session_id == self._default_id:
raise ValueError("Cannot close the default session")
if session_id not in self._sessions:
raise KeyError(session_id)
async with self._locks[session_id]:
del self._sessions[session_id]
del self._locks[session_id]
async def close_all(self) -> None:
session_ids = [
sid for sid in self._sessions if sid != self._default_id
]
for sid in session_ids:
await self.close(sid)
def lock_for(self, session_id: str) -> asyncio.Lock:
return self._locks[session_id]
@@ -0,0 +1,77 @@
# ========= 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 time
from dataclasses import dataclass, field
from mirage.io.async_line_iterator import AsyncLineIterator
@dataclass
class Session:
session_id: str
cwd: str = "/"
env: dict[str, str] = field(default_factory=dict)
created_at: float = field(default_factory=time.time)
functions: dict[str, object] = field(default_factory=dict)
last_exit_code: int = 0
shell_options: dict[str, bool] = field(default_factory=dict)
readonly_vars: set[str] = field(default_factory=set)
arrays: dict[str, list[str]] = field(default_factory=dict)
allowed_mounts: frozenset[str] | None = None
pipeline_timeout_seconds: float | None = None
_stdin_buffer: AsyncLineIterator | None = field(default=None, repr=False)
def to_dict(self) -> dict:
return {
"session_id": self.session_id,
"cwd": self.cwd,
"env": self.env,
"created_at": self.created_at,
}
@classmethod
def from_dict(cls, data: dict) -> "Session":
return cls(**data)
def fork(self, **overrides) -> "Session":
"""Return a copy of this session with overrides applied.
Mutable containers (env, functions, readonly_vars, arrays,
shell_options) are shallow-copied so mutations on the fork do
not leak back into the source. Every field, including
capability fields like ``allowed_mounts``, is propagated, so
callers cannot accidentally forget one when adding new fields.
Args:
**overrides: Field-name kwargs to override on the copy.
"""
defaults = {
"session_id": self.session_id,
"cwd": self.cwd,
"env": dict(self.env),
"created_at": self.created_at,
"functions": dict(self.functions),
"last_exit_code": self.last_exit_code,
"shell_options": dict(self.shell_options),
"readonly_vars": set(self.readonly_vars),
"arrays": {
k: list(v)
for k, v in self.arrays.items()
},
"allowed_mounts": self.allowed_mounts,
"pipeline_timeout_seconds": self.pipeline_timeout_seconds,
}
defaults.update(overrides)
return Session(**defaults)
@@ -0,0 +1,44 @@
# ========= 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.session.session import Session
def home_dir(session: Session) -> str | None:
"""Return the session home directory used for ``~`` expansion.
Args:
session: The shell session.
Returns:
``$HOME`` from the session env, or ``None`` when unset/empty,
matching GNU bash (no implicit home; ``cd`` errors, ``~`` and
``$HOME`` do not expand).
"""
return session.env.get("HOME") or None
def change_dir(session: Session, new_cwd: str) -> None:
"""Move the session to ``new_cwd`` and record the previous cwd.
Sets ``$OLDPWD`` to the current cwd before switching, mirroring the
bash ``cd`` builtin. ``$PWD`` is resolved dynamically from
``session.cwd`` at lookup time, so it is not stored here.
Args:
session: The shell session to mutate.
new_cwd: The absolute path to switch to.
"""
session.env["OLDPWD"] = session.cwd
session.cwd = new_cwd
@@ -0,0 +1,52 @@
# ========= 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.snapshot.api import snapshot
from mirage.workspace.snapshot.config import MountArgs
from mirage.workspace.snapshot.drift import (ContentDriftError,
capture_fingerprints, check_drift,
install_fingerprints,
live_only_mount_prefixes)
from mirage.workspace.snapshot.manifest import (resolve_manifest,
split_manifest_and_blobs)
from mirage.workspace.snapshot.state import (apply_state_dict,
build_mount_args,
requires_resource_override,
to_state_dict)
from mirage.workspace.snapshot.tar_io import read_tar, write_tar
from mirage.workspace.snapshot.utils import (BLOB_REF_KEY, FORMAT_VERSION,
is_safe_blob_path,
norm_mount_prefix)
__all__ = [
"snapshot",
"to_state_dict",
"build_mount_args",
"requires_resource_override",
"apply_state_dict",
"MountArgs",
"split_manifest_and_blobs",
"resolve_manifest",
"write_tar",
"read_tar",
"BLOB_REF_KEY",
"FORMAT_VERSION",
"is_safe_blob_path",
"norm_mount_prefix",
"ContentDriftError",
"capture_fingerprints",
"check_drift",
"install_fingerprints",
"live_only_mount_prefixes",
]
+40
View File
@@ -0,0 +1,40 @@
# ========= 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.snapshot.manifest import split_manifest_and_blobs
from mirage.workspace.snapshot.state import to_state_dict
from mirage.workspace.snapshot.tar_io import write_tar
async def snapshot(ws, target, *, compress: str | None = None) -> None:
"""Serialize a Workspace to a tar archive.
Fingerprints come from ``ws._ops.records`` (each read carries the
backend's version marker captured at the moment of the read), so
no live network round-trips are needed at snapshot time. Kept as
``async def`` for API stability and future-proofing.
Workspace.load and Workspace.copy own the inverse direction
(construction). Snapshot does not construct Workspace — that
keeps the dependency direction unidirectional: workspace → snapshot.
Args:
ws: the workspace to snapshot.
target: filesystem path (str/Path) OR a writable file-like
object (BytesIO, etc.).
compress: None | "gz" | "bz2" | "xz".
"""
state = await to_state_dict(ws)
manifest, blobs = split_manifest_and_blobs(state)
write_tar(target, manifest, blobs, compress=compress)
@@ -0,0 +1,30 @@
# ========= 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.types import ConsistencyPolicy
@dataclass
class MountArgs:
"""Constructor inputs derived from a state dict.
Workspace.load uses this to instantiate a fresh Workspace; snapshot
code never constructs Workspace itself.
"""
mount_args: dict
consistency: ConsistencyPolicy
default_session_id: str
default_agent_id: str
+178
View File
@@ -0,0 +1,178 @@
# ========= 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 logging
from mirage.types import DriftPolicy, FingerprintKey
logger = logging.getLogger(__name__)
class ContentDriftError(Exception):
"""Raised at load time when a remote resource's live fingerprint
differs from what was recorded in the snapshot.
Indicates the underlying source has been modified since the snapshot
was taken, so reading current bytes would silently diverge from what
the original agent saw. Surface to the caller rather than mask.
Attributes:
path (str): Virtual path that drifted.
snapshot_fingerprint (str): Recorded marker.
live_fingerprint (str | None): Marker observed at load time.
"""
def __init__(self, path: str, snapshot_fingerprint: str,
live_fingerprint: str | None) -> None:
self.path = path
self.snapshot_fingerprint = snapshot_fingerprint
self.live_fingerprint = live_fingerprint
live_repr = repr(
live_fingerprint) if live_fingerprint is not None else "<missing>"
super().__init__(
f"{path}: snapshot fingerprint {snapshot_fingerprint!r}, "
f"live {live_repr}; data on the underlying source has changed "
"since the snapshot was taken")
def capture_fingerprints(ws) -> list[dict]:
"""Walk session ops and emit one entry per distinct read on a
``SUPPORTS_SNAPSHOT`` mount.
Pure aggregation over ``ws._ops.records``. Each read ``OpRecord``
carries the ``fingerprint`` and/or ``revision`` the backend
returned at the moment the agent read the bytes (populated from
the GET response, not a fresh stat at snapshot time). This avoids
the race where the upstream changes between read and snapshot.
Skips paths whose owning mount has ``SUPPORTS_SNAPSHOT=False``
(live-only backends like Gmail/Slack/Linear) and reads where the
backend returned neither marker.
Args:
ws: Workspace whose ops log to walk.
Returns:
list[dict]: One entry per (recorded, fingerprinted) path, with
``PATH``, ``MOUNT_PREFIX`` and at least one of ``FINGERPRINT``
or ``REVISION``. Both may be present on versioned backends that
return ETag and VersionId on every GET.
"""
seen: set[str] = set()
out: list[dict] = []
for rec in ws._ops.records:
if rec.op != "read" or rec.path in seen:
continue
if rec.fingerprint is None and rec.revision is None:
continue
seen.add(rec.path)
try:
mount = ws._registry.mount_for(rec.path)
except ValueError:
continue
if not getattr(mount.resource, "SUPPORTS_SNAPSHOT", False):
continue
entry: dict = {
FingerprintKey.PATH: rec.path,
FingerprintKey.MOUNT_PREFIX: mount.prefix,
}
if rec.fingerprint is not None:
entry[FingerprintKey.FINGERPRINT] = rec.fingerprint
if rec.revision is not None:
entry[FingerprintKey.REVISION] = rec.revision
out.append(entry)
return out
def install_fingerprints(ws, fingerprint_entries: list[dict],
drift_policy: DriftPolicy) -> None:
"""Install snapshot fingerprints/revisions onto a reconstructed ws.
Revisions pin replay reads to exact backend versions; bare
fingerprints queue an eager drift check. OFF evicts the snapshot
cache for fingerprinted paths so reads serve current state.
Args:
ws: the reconstructed workspace to install onto.
fingerprint_entries: entries from a snapshot's FINGERPRINTS.
drift_policy: STRICT queues drift checks; OFF skips and evicts.
"""
ws._drift_policy = drift_policy
if drift_policy == DriftPolicy.OFF:
if fingerprint_entries:
ws._cache.evict_paths(f[FingerprintKey.PATH]
for f in fingerprint_entries)
return
for f in fingerprint_entries:
path = f[FingerprintKey.PATH]
try:
mount = ws._registry.mount_for(path)
except ValueError:
continue
revision = f.get(FingerprintKey.REVISION)
if revision is not None:
mount.revisions[path] = revision
continue
fingerprint = f.get(FingerprintKey.FINGERPRINT)
if fingerprint is not None:
ws._pending_drift.append((mount, path, fingerprint))
ws._drift_check_pending = bool(ws._pending_drift)
def live_only_mount_prefixes(ws) -> list[str]:
"""Return mount prefixes whose resource opts out of snapshot replay.
These mounts will serve current state at load time with no drift
detection. Surfaced in the snapshot manifest so the load layer can
log them and so users can audit which paths are non-replayable.
"""
out: list[str] = []
for m in ws._registry.mounts():
if m.prefix in {"/dev/", "/.bash_history/"}:
continue
if not getattr(m.resource, "SUPPORTS_SNAPSHOT", False):
out.append(m.prefix)
return out
async def check_drift(ws, path: str, recorded: str) -> None:
"""Stat `path` against its mount and raise ContentDriftError if the
live fingerprint does not match `recorded`.
No-op if the mount cannot be resolved or the resource cannot
fingerprint (raises only on a real, observable mismatch).
Args:
ws: Workspace whose registry to consult.
path (str): Virtual path to check.
recorded (str): Fingerprint recorded at snapshot time.
Raises:
ContentDriftError: live fingerprint differs from recorded.
"""
try:
mount = ws._registry.mount_for(path)
except ValueError:
return
if not getattr(mount.resource, "SUPPORTS_SNAPSHOT", False):
return
try:
stat = await mount.execute_op("stat", path)
except FileNotFoundError as exc:
raise ContentDriftError(path, recorded, None) from exc
live = getattr(stat, "fingerprint", None)
if live is None:
return
if live != recorded:
raise ContentDriftError(path, recorded, live)
@@ -0,0 +1,180 @@
# ========= 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 (CacheKey, JobKey, MountKey, ResourceName,
ResourceStateKey, StateKey)
from mirage.workspace.snapshot.utils import BLOB_REF_KEY, is_safe_blob_path
class _BlobAllocator:
"""Mints unique blob filenames per category and collects their bytes.
`alloc("_cache") -> "_cache/0.bin"` on first call, "_cache/1.bin"
next, and so on. The category prefix is just for uniqueness; the
real tar path is decided by the caller and stored in `blobs`.
"""
def __init__(self) -> None:
self.blobs: dict[str, bytes] = {}
self._counters: dict[str, int] = {}
def alloc(self, category: str) -> str:
i = self._counters.get(category, 0)
self._counters[category] = i + 1
return f"{category}/{i}.bin"
def split_manifest_and_blobs(state: dict) -> tuple[dict, dict[str, bytes]]:
a = _BlobAllocator()
manifest: dict = {
StateKey.VERSION: state[StateKey.VERSION],
StateKey.MIRAGE_VERSION: state[StateKey.MIRAGE_VERSION],
StateKey.DEFAULT_SESSION_ID: state[StateKey.DEFAULT_SESSION_ID],
StateKey.DEFAULT_AGENT_ID: state[StateKey.DEFAULT_AGENT_ID],
StateKey.CURRENT_AGENT_ID: state[StateKey.CURRENT_AGENT_ID],
StateKey.SESSIONS: state[StateKey.SESSIONS],
StateKey.HISTORY: _history_to_manifest(state.get(StateKey.HISTORY), a),
StateKey.MOUNTS: [],
StateKey.CACHE: {
CacheKey.LIMIT:
state[StateKey.CACHE][CacheKey.LIMIT],
CacheKey.MAX_DRAIN_BYTES:
state[StateKey.CACHE][CacheKey.MAX_DRAIN_BYTES],
CacheKey.ENTRIES: [],
},
StateKey.JOBS: [],
StateKey.FINGERPRINTS: state.get(StateKey.FINGERPRINTS) or [],
StateKey.LIVE_ONLY_MOUNTS: state.get(StateKey.LIVE_ONLY_MOUNTS) or [],
StateKey.SYMLINKS: state.get(StateKey.SYMLINKS) or {},
}
for m in state[StateKey.MOUNTS]:
manifest[StateKey.MOUNTS].append(_mount_to_manifest(m, a))
for entry in state[StateKey.CACHE][CacheKey.ENTRIES]:
e = dict(entry)
if isinstance(e.get(CacheKey.DATA), bytes):
tar_path = "cache/blobs/" + a.alloc("_cache")
a.blobs[tar_path] = e[CacheKey.DATA]
e[CacheKey.DATA] = {BLOB_REF_KEY: tar_path}
manifest[StateKey.CACHE][CacheKey.ENTRIES].append(e)
for job in state.get(StateKey.JOBS, []):
j = dict(job)
for f in (JobKey.STDOUT, JobKey.STDERR):
data = j.get(f)
if isinstance(data, bytes) and data:
tar_path = "jobs/blobs/" + a.alloc("_jobs")
a.blobs[tar_path] = data
j[f] = {BLOB_REF_KEY: tar_path}
elif isinstance(data, bytes):
j[f] = "" # empty bytes -> empty string (JSON-safe)
manifest[StateKey.JOBS].append(j)
return manifest, a.blobs
def _mount_to_manifest(mount: dict, a: _BlobAllocator) -> dict:
idx = mount[MountKey.INDEX]
ps = dict(mount[MountKey.RESOURCE_STATE])
ptype = ps.get(ResourceStateKey.TYPE, "")
files = ps.get(ResourceStateKey.FILES, {})
if ptype == ResourceName.RAM:
ps[ResourceStateKey.FILES] = _stash_blobs(
files, a, f"_ram{idx}", tar_dir=f"mounts/{idx}/files")
elif ptype == ResourceName.DISK:
# tree-preserving: real files at their relative paths
new_files: dict[str, dict] = {}
for rel, data in files.items():
tar_path = f"mounts/{idx}/files/{rel}"
a.blobs[tar_path] = data
new_files[rel] = {BLOB_REF_KEY: tar_path}
ps[ResourceStateKey.FILES] = new_files
elif ptype == ResourceName.REDIS:
ps[ResourceStateKey.FILES] = _stash_blobs(files,
a,
f"_redis{idx}",
tar_dir=f"mounts/{idx}/data")
return {
**{
k: v
for k, v in mount.items() if k != MountKey.RESOURCE_STATE
}, MountKey.RESOURCE_STATE: ps
}
def _stash_blobs(files: dict, a: _BlobAllocator, category: str,
tar_dir: str) -> dict:
"""Replace each {key: bytes} with {key: {__file: tar-path}}."""
out: dict[str, dict] = {}
for k, data in files.items():
slot = a.alloc(category).split("/")[-1]
tar_path = f"{tar_dir}/{slot}"
a.blobs[tar_path] = data
out[k] = {BLOB_REF_KEY: tar_path}
return out
def _history_to_manifest(records, a: _BlobAllocator):
if records is None:
return None
out = []
for r in records:
rd = dict(r)
for f in ("stdout", "stdin", "stderr"):
data = rd.get(f)
if isinstance(data, bytes) and data:
tar_path = "history/blobs/" + a.alloc("_history")
a.blobs[tar_path] = data
rd[f] = {BLOB_REF_KEY: tar_path}
elif isinstance(data, bytes):
rd[f] = "" # empty bytes -> empty string
if "tree" in rd:
rd["tree"] = _node_to_manifest(rd["tree"], a)
out.append(rd)
return out
def _node_to_manifest(node, a: _BlobAllocator):
if not isinstance(node, dict):
return node
out = dict(node)
data = out.get("stderr")
if isinstance(data, bytes) and data:
tar_path = "history/blobs/" + a.alloc("_history")
a.blobs[tar_path] = data
out["stderr"] = {BLOB_REF_KEY: tar_path}
elif isinstance(data, bytes):
out["stderr"] = "" # empty bytes -> empty string
if "children" in out:
out["children"] = [_node_to_manifest(c, a) for c in out["children"]]
return out
def resolve_manifest(manifest: dict, blob_reader) -> dict:
return _resolve(manifest, blob_reader)
def _resolve(node, blob_reader):
if isinstance(node, dict):
if set(node.keys()) == {BLOB_REF_KEY}:
path = node[BLOB_REF_KEY]
if not is_safe_blob_path(path):
raise ValueError(f"Unsafe blob path in manifest: {path!r}")
return blob_reader(path)
return {k: _resolve(v, blob_reader) for k, v in node.items()}
if isinstance(node, list):
return [_resolve(v, blob_reader) for v in node]
return node
+319
View File
@@ -0,0 +1,319 @@
# ========= 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 importlib
import importlib.metadata
import tempfile
from mirage.observe.log_entry import EVENT_CLEAR, EVENT_COMMAND, EVENT_DELETE
from mirage.resource.history import HISTORY_PREFIX
from mirage.resource.loader import load_backend_class
from mirage.resource.registry import REGISTRY
from mirage.resource.secrets import has_redacted_secret
from mirage.shell.job_table import Job, JobStatus
from mirage.types import (CacheKey, ConsistencyPolicy, JobKey, MountKey,
MountMode, ResourceName, ResourceStateKey,
SessionKey, StateKey)
from mirage.workspace.mount.namespace import LinkEntry
from mirage.workspace.snapshot.config import MountArgs
from mirage.workspace.snapshot.drift import (capture_fingerprints,
live_only_mount_prefixes)
from mirage.workspace.snapshot.utils import FORMAT_VERSION, norm_mount_prefix
def _mirage_version() -> str:
try:
return importlib.metadata.version("mirage-ai")
except importlib.metadata.PackageNotFoundError:
return "unknown"
async def to_state_dict(ws) -> dict:
auto_prefixes = {"/dev/", norm_mount_prefix(HISTORY_PREFIX)}
mounts_state = []
for idx, m in enumerate(mt for mt in ws._registry.mounts()
if mt.prefix not in auto_prefixes):
mounts_state.append({
MountKey.INDEX: idx,
MountKey.PREFIX: m.prefix,
MountKey.MODE: m.mode.value,
MountKey.CONSISTENCY: m.consistency.value,
MountKey.RESOURCE_CLASS:
f"{type(m.resource).__module__}.{type(m.resource).__name__}",
MountKey.RESOURCE_STATE: m.resource.get_state(),
})
cache = ws._cache
cache_entries = [{
CacheKey.KEY: k,
CacheKey.DATA: cache._store.files.get(k, b""),
CacheKey.FINGERPRINT: e.fingerprint,
CacheKey.TTL: e.ttl,
CacheKey.CACHED_AT: e.cached_at,
CacheKey.SIZE: e.size,
} for k, e in cache._entries.items()]
history_events = [
e for e in await ws.observer.events()
if e.get("type") in (EVENT_COMMAND, EVENT_CLEAR, EVENT_DELETE)
]
finished_jobs = [
_job_to_dict(j) for j in ws.job_table.list_jobs()
if j.status != JobStatus.RUNNING
]
fingerprints = capture_fingerprints(ws)
live_only_mounts = live_only_mount_prefixes(ws)
return {
StateKey.VERSION: FORMAT_VERSION,
StateKey.MIRAGE_VERSION: _mirage_version(),
StateKey.MOUNTS: mounts_state,
StateKey.SESSIONS: [s.to_dict() for s in ws._session_mgr.list()],
StateKey.DEFAULT_SESSION_ID: ws._session_mgr.default_id,
StateKey.DEFAULT_AGENT_ID: ws._default_agent_id,
StateKey.CURRENT_AGENT_ID: ws._current_agent_id,
StateKey.CACHE: {
CacheKey.LIMIT: cache.cache_limit,
CacheKey.MAX_DRAIN_BYTES: cache.max_drain_bytes,
CacheKey.ENTRIES: cache_entries,
},
StateKey.HISTORY: history_events,
StateKey.JOBS: finished_jobs,
StateKey.FINGERPRINTS: fingerprints,
StateKey.LIVE_ONLY_MOUNTS: live_only_mounts,
StateKey.SYMLINKS: {
link: {
"target": entry.target,
"mtime": entry.mtime
}
for link, entry in ws._namespace.symlinks.items()
},
}
def build_mount_args(state: dict, resources: dict | None = None) -> MountArgs:
"""Translate a state dict into Workspace constructor inputs.
Validates that every mount with redacted secrets has a resource
override.
Does NOT construct a Workspace — that's the caller's job.
Raises:
ValueError: if any redacted mount lacks an override, or
if the snapshot is from an unsupported format version.
"""
saved_version = state.get(StateKey.VERSION)
if saved_version is not None and saved_version < FORMAT_VERSION:
raise ValueError(f"snapshot format v{saved_version} not supported "
f"(loader expects v{FORMAT_VERSION}); "
"regenerate via `mirage workspace snapshot`")
overrides = {norm_mount_prefix(k): v for k, v in (resources or {}).items()}
missing = [
m[MountKey.PREFIX] for m in state[StateKey.MOUNTS]
if requires_resource_override(m)
and norm_mount_prefix(m[MountKey.PREFIX]) not in overrides
]
if missing:
raise ValueError(
"Workspace.load: resources= must include overrides for: "
f"{missing}. These mounts were saved with redacted creds "
"or transient connection state and need fresh resources.")
mount_args: dict[str, tuple] = {}
for m in state[StateKey.MOUNTS]:
prefix = norm_mount_prefix(m[MountKey.PREFIX])
prov = (overrides[prefix]
if prefix in overrides else _construct_resource(m))
mount_args[m[MountKey.PREFIX]] = (prov, MountMode(m[MountKey.MODE]))
return MountArgs(
mount_args=mount_args,
consistency=ConsistencyPolicy.LAZY,
default_session_id=state.get(StateKey.DEFAULT_SESSION_ID, "default"),
default_agent_id=state.get(StateKey.DEFAULT_AGENT_ID, "default"),
)
async def apply_state_dict(ws, state: dict) -> None:
"""Restore post-construction state into an already-built Workspace.
Restores: resource load_state (content, fresh disk root, etc.),
sessions, cache entries, history, finished jobs.
Workspace must already have its mounts constructed via the args
from build_mount_args. This function is purely additive — it does
not construct anything.
"""
# load_state runs for ALL mounts (overridden too), so disk content
# is written into the new root, redis content into the new URL, etc.
# Cred-only resources (S3 et al.) define load_state as no-op.
for m in state[StateKey.MOUNTS]:
try:
mount = ws._registry.mount_for_prefix(m[MountKey.PREFIX])
except ValueError:
continue
mount.resource.load_state(m[MountKey.RESOURCE_STATE])
_restore_sessions(ws, state)
ws._current_agent_id = state.get(StateKey.CURRENT_AGENT_ID,
ws._default_agent_id)
_restore_cache(ws, state)
await _restore_history(ws, state)
_restore_jobs(ws, state)
_restore_symlinks(ws, state)
def _restore_symlinks(ws, state: dict) -> None:
entries = {
link: LinkEntry(target=d["target"], mtime=d["mtime"])
for link, d in (state.get(StateKey.SYMLINKS) or {}).items()
}
ws._namespace.replace_symlinks(entries)
def _restore_sessions(ws, state: dict) -> None:
default_sid = state.get(StateKey.DEFAULT_SESSION_ID)
for s_data in state.get(StateKey.SESSIONS, []):
sid = s_data[SessionKey.SESSION_ID]
if sid == default_sid:
session = ws._session_mgr.get(sid)
else:
try:
session = ws._session_mgr.create(sid)
except ValueError:
continue
session.cwd = s_data.get(SessionKey.CWD, "/")
session.env = s_data.get(SessionKey.ENV, {})
def _restore_cache(ws, state: dict) -> None:
cache_state = state.get(StateKey.CACHE) or {}
if hasattr(ws._cache, "max_drain_bytes"):
ws._cache.max_drain_bytes = cache_state.get(CacheKey.MAX_DRAIN_BYTES)
cache = ws._cache
if not hasattr(cache, "_entries") or not hasattr(cache, "_store"):
# Non-RAM cache backend (e.g. Redis) — skip; its content lives
# outside the workspace and isn't part of the snapshot anyway.
return
from mirage.cache.file.entry import CacheEntry
for entry in cache_state.get(CacheKey.ENTRIES, []):
key = entry[CacheKey.KEY]
data = entry[CacheKey.DATA]
cache._store.files[key] = data
cache._entries[key] = CacheEntry(
size=entry.get(CacheKey.SIZE, len(data)),
cached_at=entry.get(CacheKey.CACHED_AT, 0),
fingerprint=entry.get(CacheKey.FINGERPRINT),
ttl=entry.get(CacheKey.TTL),
)
cache._cache_size += entry.get(CacheKey.SIZE, len(data))
async def _restore_history(ws, state: dict) -> None:
# Always load (load_events clears first): a snapshot with empty
# history still rewinds the recorder, same as the cache clear.
await ws.observer.load_events(state.get(StateKey.HISTORY) or [])
def _restore_jobs(ws, state: dict) -> None:
max_id = 0
for job_d in state.get(StateKey.JOBS, []):
max_id = max(max_id, job_d.get(JobKey.ID, 0))
ws.job_table._jobs[job_d[JobKey.ID]] = _job_from_dict(job_d)
ws.job_table._next_id = max_id + 1
def _job_to_dict(job) -> dict:
return {
JobKey.ID: job.id,
JobKey.COMMAND: job.command,
JobKey.CWD: job.cwd,
JobKey.STATUS: job.status.value,
JobKey.STDOUT: job.stdout,
JobKey.STDERR: job.stderr,
JobKey.EXIT_CODE: job.exit_code,
JobKey.CREATED_AT: job.created_at,
JobKey.AGENT: job.agent,
JobKey.SESSION_ID: job.session_id,
}
def _job_from_dict(d: dict):
return Job(
id=d[JobKey.ID],
command=d[JobKey.COMMAND],
task=None,
cwd=d.get(JobKey.CWD, "/"),
status=JobStatus(d.get(JobKey.STATUS, JobStatus.COMPLETED.value)),
stdout=d.get(JobKey.STDOUT, b"") or b"",
stderr=d.get(JobKey.STDERR, b"") or b"",
exit_code=d.get(JobKey.EXIT_CODE, 0),
created_at=d.get(JobKey.CREATED_AT, 0.0),
agent=d.get(JobKey.AGENT, "unknown"),
session_id=d.get(JobKey.SESSION_ID, "default"),
)
def _construct_resource(mount_state: dict):
cls = _resource_class_for(mount_state)
resource_state = mount_state[MountKey.RESOURCE_STATE]
ptype = resource_state.get(ResourceStateKey.TYPE, "")
if ptype == ResourceName.RAM:
return cls()
if ptype == ResourceName.DISK:
return cls(root=tempfile.mkdtemp(prefix="mirage-disk-"))
if ptype == ResourceName.REDIS:
raise ValueError(
f"Redis mount at {mount_state[MountKey.PREFIX]} requires "
"resources= override")
config = resource_state.get(ResourceStateKey.CONFIG)
if config is None:
return cls()
config_cls = _config_class_for(cls)
if config_cls is not None:
return cls(config_cls(**config))
return cls()
def requires_resource_override(mount_state: dict) -> bool:
resource_state = mount_state[MountKey.RESOURCE_STATE]
config = resource_state.get(ResourceStateKey.CONFIG)
config_cls = _config_class_for(_resource_class_for(mount_state))
return has_redacted_secret(config, config_cls)
def _resource_class_for(mount_state: dict):
ptype = mount_state[MountKey.RESOURCE_STATE].get(ResourceStateKey.TYPE, "")
if ptype in REGISTRY:
return load_backend_class(REGISTRY[ptype].resource_path)
cls_path = mount_state[MountKey.RESOURCE_CLASS]
mod_name, cls_name = cls_path.rsplit(".", 1)
return getattr(importlib.import_module(mod_name), cls_name)
def _config_class_for(resource_cls):
mod = importlib.import_module(resource_cls.__module__)
for name in dir(mod):
obj = getattr(mod, name)
if isinstance(obj, type) and name.endswith("Config"):
return obj
return None
+113
View File
@@ -0,0 +1,113 @@
# ========= 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 io
import json
import tarfile
from mirage.workspace.snapshot.manifest import resolve_manifest
from mirage.workspace.snapshot.utils import is_safe_blob_path
_MANIFEST_NAME = "manifest.json"
_COMPRESS_MODES = {None: "w", "gz": "w:gz", "bz2": "w:bz2", "xz": "w:xz"}
def write_tar(target,
manifest: dict,
blobs: dict[str, bytes],
*,
compress: str | None = None) -> None:
"""Write manifest + blobs as a tar.
Args:
target: filesystem path (str/Path) OR a writable file-like
object with a `write` method (BytesIO, etc.).
manifest: JSON-serializable dict.
blobs: {tar_path: bytes} side-files.
compress: None | "gz" | "bz2" | "xz".
"""
if compress not in _COMPRESS_MODES:
raise ValueError(
f"Unknown compress mode: {compress!r}. "
f"Use one of: {sorted(k for k in _COMPRESS_MODES if k)}")
mode = _COMPRESS_MODES[compress]
if hasattr(target, "write"):
tar = tarfile.open(fileobj=target, mode=mode)
else:
tar = tarfile.open(str(target), mode)
with tar:
manifest_bytes = json.dumps(manifest, indent=2,
default=_json_default).encode("utf-8")
_add(tar, _MANIFEST_NAME, manifest_bytes)
for tar_path, data in blobs.items():
_add(tar, tar_path, data)
def read_tar(source) -> dict:
"""Read a tar produced by write_tar; return a resolved state dict.
Args:
source: filesystem path (str/Path) OR a readable file-like
object with a `read` method.
"""
if hasattr(source, "read"):
tar = tarfile.open(fileobj=source, mode="r:*")
else:
tar = tarfile.open(str(source), "r:*")
with tar:
member = tar.getmember(_MANIFEST_NAME)
f = tar.extractfile(member)
if f is None:
raise ValueError(f"{_MANIFEST_NAME} missing or unreadable")
manifest = json.loads(f.read().decode("utf-8"))
return resolve_manifest(manifest, _make_reader(tar))
def _make_reader(tar):
def reader(blob_path: str) -> bytes:
if not is_safe_blob_path(blob_path):
raise ValueError(f"Unsafe blob path: {blob_path!r}")
try:
member = tar.getmember(blob_path)
except KeyError as exc:
raise ValueError(
f"Manifest references missing blob: {blob_path!r}") from exc
f = tar.extractfile(member)
if f is None:
raise ValueError(f"Blob unreadable: {blob_path!r}")
return f.read()
return reader
def _add(tar: tarfile.TarFile, name: str, data: bytes) -> None:
info = tarfile.TarInfo(name=name)
info.size = len(data)
info.mode = 0o644
tar.addfile(info, io.BytesIO(data))
def _json_default(obj):
# StrEnum members serialize as their string value (already happens
# since StrEnum inherits from str), but bytes leftover in the
# manifest are a programmer error — they should have been split
# to blob refs earlier.
if isinstance(obj, bytes):
raise TypeError(
"Bytes leftover in manifest — split_manifest_and_blobs "
"must replace every bytes value with a blob ref")
raise TypeError(
f"Object of type {type(obj).__name__} not JSON serializable")
+47
View File
@@ -0,0 +1,47 @@
# ========= 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. =========
FORMAT_VERSION = 2
BLOB_REF_KEY = "__file"
def is_safe_blob_path(path: str) -> bool:
"""Reject path-traversal and absolute references inside the tar.
Allows spaces, unicode, and any printable character — restrictions
target structural attacks only:
- rejects empty strings
- rejects absolute paths (leading "/")
- rejects ".." segments
- rejects NUL byte (forbidden in tar)
"""
if not isinstance(path, str) or not path:
return False
if path.startswith("/"):
return False
if "\x00" in path:
return False
return ".." not in path.split("/")
def norm_mount_prefix(prefix: str) -> str:
"""Normalize mount prefix to '/x/' form.
Registry stores mounts with leading + trailing slash. Users may
pass '/m', '/m/', 'm/', or 'm' — all should match the same mount.
"""
s = prefix.strip("/")
return "/" + s + "/" if s else "/"
+57
View File
@@ -0,0 +1,57 @@
# ========= 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 asdict, dataclass, field
from mirage.observe import OpRecord
from mirage.types import PathSpec
@dataclass
class ExecutionNode:
"""A node in the execution tree capturing per-command stderr and exit code.
Args:
command (str | None): Leaf command string, None for operators.
op (str | None): Operator ("|", ";", "&&", "||"), None for leaf nodes.
stderr (bytes): This node's stderr output.
exit_code (int): This node's exit code.
children (list[ExecutionNode]): Child nodes (empty for leaf commands).
records (list[OpRecord]): I/O operation records for this node.
paths (list[PathSpec]): Classified path operands of a leaf mount
command. Transient (not serialized): lets the lazy-stream drain
respell filesystem errors as typed, like the eager chokepoint.
"""
command: str | None = None
op: str | None = None
stderr: bytes = b""
exit_code: int = 0
children: list["ExecutionNode"] = field(default_factory=list)
records: list[OpRecord] = field(default_factory=list)
paths: list[PathSpec] = field(default_factory=list)
def to_dict(self) -> dict:
d: dict = {}
if self.command is not None:
d["command"] = self.command
if self.op is not None:
d["op"] = self.op
d["stderr"] = self.stderr.decode(errors="replace")
d["exit_code"] = self.exit_code
if self.children:
d["children"] = [c.to_dict() for c in self.children]
if self.records:
d["records"] = [asdict(r) for r in self.records]
return d
+779
View File
@@ -0,0 +1,779 @@
# ========= 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 builtins
import logging
import sys
from collections.abc import AsyncIterator
from functools import partial
from typing import Any
from mirage.cache.file.config import CacheConfig, RedisCacheConfig
from mirage.cache.file.ram import RAMFileCacheStore
from mirage.cache.index import IndexConfig
from mirage.commands.builtin.utils.safeguard import (CommandTimeoutError,
run_with_timeout)
from mirage.commands.errors import FindParseError, UsageError
from mirage.commands.safeguard import resolve_safeguard
try:
from mirage.cache.file.redis import RedisFileCacheStore
except ImportError:
RedisFileCacheStore = None # type: ignore[misc, assignment]
from mirage.io import IOResult
from mirage.observe.context import RecordingScope
from mirage.observe.observer import Observer
from mirage.observe.record import OpRecord
from mirage.observe.store import ObserverStore
from mirage.ops import Ops
from mirage.ops.open import make_open
from mirage.ops.os_patch import make_os_module
from mirage.provision import ProvisionResult
from mirage.resource.base import BaseResource
from mirage.resource.history import HISTORY_PREFIX, HistoryViewResource
from mirage.resource.ram import RAMResource
from mirage.shell.job_table import JobTable
from mirage.shell.parse import find_syntax_error, parse
from mirage.types import (DEFAULT_AGENT_ID, DEFAULT_SESSION_ID,
ConsistencyPolicy, DriftPolicy, FileStat, MountMode,
PathSpec, StateKey)
from mirage.utils.errors import format_fs_error
from mirage.workspace.abort import MirageAbortError
from mirage.workspace.dispatcher import Dispatcher
from mirage.workspace.file_prompt import build_file_prompt
from mirage.workspace.fuse import FuseManager
from mirage.workspace.mount import MountEntry, MountRegistry
from mirage.workspace.mount.namespace import Namespace
from mirage.workspace.mount.spec import Mount
from mirage.workspace.node import provision_node, run_command_tree
from mirage.workspace.session import (Session, SessionManager,
reset_current_session,
set_current_session)
from mirage.workspace.snapshot import (ContentDriftError, apply_state_dict,
build_mount_args, check_drift,
install_fingerprints, norm_mount_prefix,
read_tar, requires_resource_override)
from mirage.workspace.snapshot import snapshot as _write_snapshot
from mirage.workspace.snapshot import to_state_dict
logger = logging.getLogger(__name__)
class Workspace:
"""Unified virtual filesystem over heterogeneous resources.
Manages mounts, caching, and command execution.
All ops are forwarded directly to the resolved resource.
"""
def __init__(
self,
resources: dict[str, BaseResource | tuple | Mount],
cache_limit: str | int = "512MB",
cache: CacheConfig | None = None,
index: IndexConfig | None = None,
mode: MountMode = MountMode.READ,
consistency: ConsistencyPolicy = ConsistencyPolicy.LAZY,
session_id: str = DEFAULT_SESSION_ID,
agent_id: str = DEFAULT_AGENT_ID,
observe: ObserverStore | None = None,
) -> None:
self._registry = MountRegistry()
if isinstance(cache, RedisCacheConfig):
if RedisFileCacheStore is None:
raise ImportError(
"RedisCacheConfig requires the 'redis' extra. "
"Install with: pip install mirage-ai[redis]")
self._cache = RedisFileCacheStore(
cache_limit=cache.limit,
url=cache.url,
key_prefix=cache.key_prefix,
max_drain_bytes=cache.max_drain_bytes,
)
else:
limit = cache.limit if cache is not None else cache_limit
max_drain = cache.max_drain_bytes if cache is not None else None
self._cache = RAMFileCacheStore(cache_limit=limit,
max_drain_bytes=max_drain)
self._locked_paths: set[str] = set()
self._closed = False
self._drift_policy: DriftPolicy = DriftPolicy.OFF
self._drift_check_pending: bool = False
# Queued at Workspace.load: (mount, path, expected_fingerprint).
# First dispatch/execute drains via asyncio.gather, then clears.
self._pending_drift: list[tuple[MountEntry, str, str]] = []
self.job_table = JobTable()
self._current_agent_id: str = agent_id
self._default_session_id = session_id
self._default_agent_id = agent_id
self._session_mgr = SessionManager(session_id)
self._consistency = consistency
self._registry.set_consistency(consistency)
self._registry.attach_file_cache(self._cache)
self._namespace = Namespace(self._registry)
self._dispatcher = Dispatcher(self._namespace, self._cache,
consistency)
fuse_targets: list[tuple[str, bool | str]] = []
for prefix, value in resources.items():
mount_safeguards: dict = {}
mount_fuse: bool | str = False
if isinstance(value, Mount):
prov = value.resource
mount_mode = value.mode if value.mode is not None else mode
if value.command_safeguards:
mount_safeguards = dict(value.command_safeguards)
mount_fuse = value.fuse
elif isinstance(value, tuple) and len(value) >= 2:
prov = value[0]
mount_mode = value[1]
if len(value) >= 3 and value[2]:
mount_safeguards = dict(value[2])
else:
prov = value
mount_mode = mode
if index is not None:
prov.set_index(index)
mount_obj = self._registry.mount(prefix, prov, mount_mode)
if mount_safeguards:
mount_obj.command_safeguards.update(mount_safeguards)
if mount_fuse:
fuse_targets.append((prefix, mount_fuse))
if self._registry.root_mount is None:
self._registry.mount("/", RAMResource(), mode)
self._fuse_mountpoints: dict[str, str] = {}
self._fuse_managers: dict[str, FuseManager] = {}
self.observer = Observer(store=observe)
self._registry.mount(HISTORY_PREFIX,
HistoryViewResource(self.observer),
MountMode.READ)
self._ops = Ops(self._registry.ops_mounts(),
on_write=self._invalidate_after_write_by_path,
observer=self.observer,
agent_id=agent_id,
session_id=session_id)
for prefix, fuse_target in fuse_targets:
mountpoint = fuse_target if isinstance(fuse_target, str) else None
self.add_fuse_mount(prefix, mountpoint)
async def history(self) -> list[dict]:
"""Command events recorded by the hidden recorder.
Returns:
list[dict]: All sessions' command events, timestamp order.
"""
return await self.observer.command_events()
@property
def ops(self) -> Ops:
return self._ops
@property
def namespace(self) -> Namespace:
return self._namespace
@property
def cache(self):
return self._cache
@property
def max_drain_bytes(self) -> int | None:
return self._cache.max_drain_bytes
@max_drain_bytes.setter
def max_drain_bytes(self, value: int | None) -> None:
self._cache.max_drain_bytes = value
def mounts(self) -> list:
return self._registry.mounts()
@property
def revisions(self) -> dict[str, str]:
"""Flat view of every mount's installed revision pins.
Derived (read-only) — the source of truth lives per-mount on
``mount.revisions``. Useful for tests, audit ("which paths got
pinned at load?"), and debugging. Empty until a snapshot is
loaded with revisions in its manifest.
"""
out: dict[str, str] = {}
for m in self._registry.mounts():
if m.revisions:
out.update(m.revisions)
return out
def mount(self, prefix: str):
return self._registry.mount_for(prefix)
async def unmount(self, prefix: str) -> None:
if self._closed:
raise RuntimeError("Workspace is closed")
stripped = prefix.strip("/")
norm = ("/" + stripped + "/" if stripped else "/")
if norm == "/":
raise ValueError(f"cannot unmount the virtual root: {prefix!r}")
if norm == "/dev/":
raise ValueError("cannot unmount reserved prefix: '/dev/'")
if norm == HISTORY_PREFIX + "/":
raise ValueError(f"cannot unmount history view: "
f"{HISTORY_PREFIX!r}")
removed = self._registry.unmount(prefix)
self._ops.unmount(prefix)
remaining = self._registry.mounts()
still_instance = any(m.resource is removed.resource for m in remaining)
still_kind = any(m.resource.name == removed.resource.name
for m in remaining)
if not still_kind:
self._ops._registry.unregister_resource(removed.resource.name)
if not still_instance:
close = getattr(removed.resource, "close", None)
if callable(close):
result = close()
if hasattr(result, "__await__"):
await result
def _register_fuse(self, prefix: str, mountpoint: str) -> None:
for other_prefix, other_mp in self._fuse_mountpoints.items():
if other_mp == mountpoint and other_prefix != prefix:
raise ValueError(
f"FUSE mountpoint {mountpoint!r} already used by "
f"prefix {other_prefix!r}; mounts need distinct paths")
self._fuse_mountpoints[prefix] = mountpoint
def _deregister_fuse(self, prefix: str) -> None:
self._fuse_mountpoints.pop(prefix, None)
def add_fuse_mount(self,
prefix: str,
mountpoint: str | None = None) -> str:
# Register a pinned path BEFORE mounting so a collision is rejected
# without leaving a partial mount. Each mount gets its own manager,
# so a workspace can expose any number of FUSE subtrees at once.
if mountpoint is not None:
self._register_fuse(prefix, mountpoint)
fm = FuseManager()
self._fuse_managers[prefix] = fm
try:
mp = fm.setup(self._ops, prefix, mountpoint)
except Exception:
# The mount never came up; drop the manager and any registered
# path so fuse_mountpoints does not misreport it as live.
self._fuse_managers.pop(prefix, None)
self._deregister_fuse(prefix)
raise
if mountpoint is None:
self._register_fuse(prefix, mp)
return mp
def remove_fuse_mount(self, prefix: str) -> None:
fm = self._fuse_managers.pop(prefix, None)
if fm is not None:
fm.unmount()
self._deregister_fuse(prefix)
@property
def fuse_mountpoint(self) -> str | None:
if not self._fuse_mountpoints:
return None
if len(self._fuse_mountpoints) > 1:
raise RuntimeError(
"multiple FUSE mounts active; use fuse_mountpoints to "
"select one by prefix")
return next(iter(self._fuse_mountpoints.values()))
@property
def fuse_mountpoints(self) -> dict[str, str]:
return dict(self._fuse_mountpoints)
@property
def _cwd(self) -> str:
return self._session_mgr.cwd
@_cwd.setter
def _cwd(self, value: str) -> None:
self._session_mgr.cwd = value
@property
def env(self) -> dict[str, str]:
return self._session_mgr.env
@env.setter
def env(self, value: dict[str, str]) -> None:
self._session_mgr.env = value
@property
def file_prompt(self) -> str:
return build_file_prompt(self._registry.mounts())
# ── lifecycle ───────────────────────────────────────────────────────────
def __enter__(self) -> "Workspace":
self._original_open = builtins.open
self._original_os = sys.modules["os"]
builtins.open = make_open(self._ops)
sys.modules["os"] = make_os_module(self._ops)
return self
def __exit__(self, *_: object) -> None:
builtins.open = self._original_open
sys.modules["os"] = self._original_os
self._close_parts()
def _close_parts(self) -> None:
if self._closed:
return
self._closed = True
for fm in list(self._fuse_managers.values()):
fm.unmount()
self._fuse_managers.clear()
self._fuse_mountpoints.clear()
for job in self.job_table.running_jobs():
self.job_table.kill(job.id)
for task in self._cache._drain_tasks.values():
task.cancel()
self._cache._drain_tasks.clear()
async def close(self) -> None:
drain_tasks = list(self._cache._drain_tasks.values())
self._close_parts()
for task in drain_tasks:
try:
await task
except asyncio.CancelledError:
pass
await self._cache.clear()
# ── snapshot / load / copy ─────────────────────────────────────────────
async def snapshot(self, target, *, compress: str | None = None) -> None:
"""Serialize this workspace to a tar.
Captured:
* Mount configs, sessions, history, finished jobs.
* Cache bytes for fast replay.
* One fingerprint entry per remote read (ETag-equivalent,
plus a backend-specific ``revision`` when the resource
exposes one — e.g. S3 ``VersionId``).
NOT captured:
* Live state of mounts with ``SUPPORTS_SNAPSHOT=False``
(Gmail, Slack, Linear, etc.). Load logs a warning naming
them.
* Files the agent never touched.
* Bytes of remote objects. Recovery of original bytes works
only when the resource accepts a revision pin (S3 family
today) and the recorded revision still exists on the
source.
Async because fingerprint capture stats each touched path on a
``SUPPORTS_SNAPSHOT`` mount.
Args:
target: filesystem path OR a writable file-like object.
compress: None | "gz" | "bz2" | "xz".
"""
await _write_snapshot(self, target, compress=compress)
@classmethod
async def load(
cls,
source,
*,
resources: dict | None = None,
drift_policy: DriftPolicy = DriftPolicy.STRICT) -> "Workspace":
"""Reconstruct a Workspace from a tar.
For every recorded read:
1. If the manifest entry carries a ``revision`` (e.g. S3
``VersionId``), the load installs it into the owning
``mount.revisions``. Replay reads pin to that revision via
the ``revision_for`` contextvar lookup, so the original
bytes are served. Drift check is skipped for these paths —
the pin guarantees bytes match by construction.
2. If the entry carries only a ``fingerprint`` (no stable
revision), the load queues a drift check. STRICT raises
``ContentDriftError`` on the first mismatch; OFF skips the
check entirely and evicts the snapshot cache so reads serve
current state.
Drift check is eager (fires once on the first dispatch or
execute), so downstream code can rely on consistent state.
Args:
source: filesystem path OR a readable file-like object.
resources: {prefix: Resource} overrides for mounts saved
with redacted creds.
drift_policy: STRICT (default) raises on mismatch. OFF
disables drift checking and evicts snapshot cache for
fingerprinted paths.
"""
return await cls.from_state(read_tar(source),
resources=resources,
drift_policy=drift_policy)
@classmethod
async def from_state(
cls,
state: dict,
*,
resources: dict | None = None,
drift_policy: DriftPolicy = DriftPolicy.STRICT) -> "Workspace":
"""Reconstruct a Workspace directly from a state dict (no tar).
The in-process inverse of ``to_state_dict``: build the mounts,
restore content/cache/history, then install drift fingerprints.
``load`` is this plus a tar read; callers that already hold a
state dict (e.g. a version checkout) should use this and skip the
tar round-trip.
Args:
state: a state dict from ``to_state_dict`` or a version.
resources: {prefix: Resource} overrides for mounts saved
with redacted creds.
drift_policy: STRICT (default) raises on mismatch. OFF
disables drift checking and evicts snapshot cache for
fingerprinted paths.
"""
ws = await cls._from_state(state, resources=resources)
install_fingerprints(ws,
state.get(StateKey.FINGERPRINTS) or [],
drift_policy)
live_only = state.get(StateKey.LIVE_ONLY_MOUNTS) or []
if live_only:
logger.warning(
"Workspace.from_state: %s mount(s) opt out of snapshot "
"replay; reads against them will serve current state with "
"no drift detection: %s", len(live_only), live_only)
return ws
async def copy(self) -> "Workspace":
# Reuse this process's resources so remote backends (S3, Redis,
# GDrive) stay shared between original and copy. Local backends
# (RAM, Disk) restore their content fresh into the new resources
# — see snapshot.api.snapshot docstring for the rationale.
# Only reuse resources whose state has redacted secrets or connection
# material. Local content resources (RAM, Disk) are reconstructed
# fresh so the copy's writes don't clobber the original's data.
state = await to_state_dict(self)
auto_prefixes = {"/dev/", norm_mount_prefix(HISTORY_PREFIX)}
prefix_to_resource = {
m.prefix: m.resource
for m in self._registry.mounts() if m.prefix not in auto_prefixes
}
resources = {
m["prefix"]: prefix_to_resource[m["prefix"]]
for m in state["mounts"] if requires_resource_override(m)
and m["prefix"] in prefix_to_resource
}
return await type(self)._from_state(state, resources=resources)
@classmethod
async def _from_state(cls,
state: dict,
*,
resources: dict | None = None) -> "Workspace":
args = build_mount_args(state, resources)
ws = cls(args.mount_args,
consistency=args.consistency,
session_id=args.default_session_id,
agent_id=args.default_agent_id)
await apply_state_dict(ws, state)
return ws
def __deepcopy__(self, memo) -> "Workspace":
raise NotImplementedError(
"Workspace.copy is async (it captures fingerprints for replay). "
"Call `await ws.copy()` directly instead of `copy.deepcopy(ws)`.")
def __copy__(self) -> "Workspace":
raise NotImplementedError("Workspace has no useful shallow copy — "
"use `await ws.copy()`.")
# ── session lifecycle ──────────────────────────────────────────────────
def create_session(
self,
session_id: str,
allowed_mounts: frozenset[str] | None = None) -> Session:
if allowed_mounts is not None:
normalized = {("/" + m.strip("/")) for m in allowed_mounts}
normalized.update(self._infrastructure_mount_prefixes())
allowed_mounts = frozenset(normalized)
return self._session_mgr.create(session_id,
allowed_mounts=allowed_mounts)
def _infrastructure_mount_prefixes(self) -> set[str]:
"""Mount prefixes a session is always allowed to touch.
The virtual root (where text-processing commands like ``wc``
without a path argument resolve), the device mount, and the
history view are infrastructure: they hold no user
credentials, and rejecting them would break common shell
idioms or the history builtin.
"""
prefixes = {"/dev", HISTORY_PREFIX}
root_mount = self._registry.root_mount
if root_mount is not None:
prefixes.add("/" + root_mount.prefix.strip("/"))
return prefixes
def get_session(self, session_id: str) -> Session:
return self._session_mgr.get(session_id)
def list_sessions(self) -> list[Session]:
return self._session_mgr.list()
async def close_session(self, session_id: str) -> None:
await self._session_mgr.close(session_id)
async def close_all_sessions(self) -> None:
await self._session_mgr.close_all()
# ── mount management ────────────────────────────────────────────────────
async def dispatch(self, op: str, path: PathSpec,
**kwargs: Any) -> tuple[Any, IOResult]:
if self._drift_check_pending:
await self._run_pending_drift_check()
return await self._dispatcher.dispatch(op, path, **kwargs)
async def _run_pending_drift_check(self) -> None:
"""Drain the post-load drift check.
Called once on the first async entry point (``dispatch`` or
``execute``) after ``Workspace.load`` with a non-OFF drift
policy. Stats every queued ``(mount, path, expected_fingerprint)``
triple against the live source in parallel and raises
:class:`ContentDriftError` on the first mismatch. Subsequent
calls are no-ops.
Pinned paths (those whose manifest entry carried a stable
revision) are never enqueued, because the pin guarantees bytes
match by construction.
Stats are issued with ``asyncio.gather`` so first-op latency
does not scale linearly with the number of recorded reads.
"""
self._drift_check_pending = False
if not self._pending_drift:
return
checks = [
check_drift(self, path, fingerprint)
for _, path, fingerprint in self._pending_drift
]
self._pending_drift.clear()
results = await asyncio.gather(*checks, return_exceptions=True)
for r in results:
if isinstance(r, BaseException):
raise r
async def stat(self, path: str) -> FileStat:
scope = PathSpec(virtual=path,
directory=path,
resource_path="",
resolved=True)
result, _ = await self.dispatch("stat", scope)
return result
async def readdir(self, path: str) -> list[str]:
scope = PathSpec(virtual=path,
directory=path,
resource_path="",
resolved=False)
raw, _ = await self.dispatch("readdir", scope)
return raw
# ── execution ────────────────────────────────────────────────────────────
async def apply_io(self,
io: IOResult,
records: list[OpRecord] | None = None) -> None:
await self._dispatcher.apply_io(io, records=records)
async def _invalidate_after_write_by_path(self, path: str) -> None:
await self._dispatcher.invalidate_after_write_by_path(path)
def _session_cwd(self, session_id: str) -> str | None:
try:
return self._session_mgr.get(session_id).cwd
except KeyError:
return None
async def _plan_eval_stub(self, cmd: str, **opts: Any) -> IOResult:
"""Inert evaluator for provision walks.
A dry run must never execute: a command substitution with side
effects ($(tee ...)) would otherwise run while "estimating".
Substitutions expand to empty, so affected words degrade the
plan to honest UNKNOWN instead of resolving via execution.
"""
return IOResult()
async def _exec_recursion(self, cancel: asyncio.Event | None, cmd: str,
**opts: Any) -> Any:
# The executor's internal eval ($(), source, eval, xargs, ...):
# never a typed line, so it must not record a history entry or
# open its own recording context (GNU: history is appended by
# the line reader, the evaluator can't touch it).
return await self.execute(cmd, cancel=cancel, record=False, **opts)
async def execute(
self,
command: str,
session_id: str | None = None,
stdin: AsyncIterator[bytes] | bytes | None = None,
provision: bool = False,
agent_id: str = DEFAULT_AGENT_ID,
cwd: str | None = None,
env: dict[str, str] | None = None,
cancel: asyncio.Event | None = None,
record: bool = True,
) -> IOResult | ProvisionResult:
"""Execute a shell command in the workspace.
Args:
command: The shell command string to execute.
session_id: Session whose persistent state hosts the command.
stdin: Optional stdin payload (bytes or async byte iterator).
provision: If True, return a ProvisionResult instead of running.
agent_id: Agent identifier for observability and history.
cwd: Per-call working directory override. When provided, the
command runs in an ephemeral session clone (bash subshell
semantics): the persistent session's cwd is unchanged and
any `cd` inside the command does not leak.
env: Per-call environment overrides layered on top of the
session's env. Like cwd, these apply only to an ephemeral
clone, so `export` inside the command does not leak back
to the persistent session.
cancel: Optional asyncio.Event used to abort execution
mid-flight. When set, the executor raises MirageAbortError
at the next gate (entry to each node) and races inside
blocking sleeps so cancellation is observed promptly.
record: When False, run without logging a history entry or
opening a recording context; ops emitted by the command
flow into the caller's recorder. Used by the executor's
internal evaluations and available to SDK callers that
need an unrecorded run.
"""
if cancel is not None and cancel.is_set():
raise MirageAbortError()
if self._drift_check_pending:
await self._run_pending_drift_check()
if session_id is None:
session_id = self._session_mgr.default_id
session = self._session_mgr.get(session_id)
use_override = cwd is not None or env is not None
if use_override:
overrides: dict[str, Any] = {}
if cwd is not None:
overrides["cwd"] = cwd
if env is not None:
overrides["env"] = {**session.env, **env}
effective_session = session.fork(**overrides)
else:
effective_session = session
self._current_agent_id = agent_id
io = IOResult()
# The line-reader decision (GNU: history is appended where the
# typed line is read, never inside the evaluator). Internal
# evaluations and provision runs get an inert scope.
is_line = record and not provision
scope = RecordingScope(active=is_line)
exec_recursion = partial(self._exec_recursion, cancel)
session_token = set_current_session(effective_session)
try:
ast = parse(command)
offending = find_syntax_error(ast)
if offending is not None:
snippet = offending.strip()[:40]
err = (f"mirage: syntax error near {snippet!r}\n".encode()
if snippet else b"mirage: syntax error in command\n")
io = IOResult(exit_code=2, stderr=err)
return io
if provision:
prov_name = command.strip().split()[0] if command.strip(
) else None
prov_resolved = (resolve_safeguard(prov_name)
if prov_name else None)
prov_timeout = (prov_resolved.timeout_seconds
if prov_resolved is not None else None)
return await run_with_timeout(
provision_node(self._registry, self.dispatch,
self._plan_eval_stub, self._namespace, ast,
effective_session), prov_timeout, prov_name)
io, _ = await run_command_tree(
self.dispatch,
self._registry,
self._namespace,
self.job_table,
exec_recursion,
self._current_agent_id,
ast,
effective_session,
stdin,
cancel,
)
session.last_exit_code = io.exit_code
await self.apply_io(io, records=scope.records)
return io
except CommandTimeoutError as exc:
logger.debug("command %r timed out after %ss", exc.command,
exc.seconds)
if cancel is not None:
cancel.set()
msg = (str(exc) + "\n").encode()
io = IOResult(exit_code=124, stderr=msg)
session.last_exit_code = 124
return io
except (MirageAbortError, ContentDriftError):
raise
except FindParseError as exc:
msg = f"{exc}\n".encode()
io = IOResult(exit_code=1, stderr=msg)
return io
except UsageError as exc:
msg = f"{exc}\n".encode()
io = IOResult(exit_code=exc.exit_code, stderr=msg)
return io
except OSError as exc:
cmd_name = command.split()[0] if command.split() else command
msg = format_fs_error(cmd_name, exc)
io = IOResult(exit_code=1, stderr=msg)
return io
except Exception as exc:
io = IOResult(exit_code=1, stderr=str(exc).encode())
return io
finally:
# One rule on every path: an op that happened is always
# accounted, in byte accounting (which feeds snapshot
# fingerprints/drift) and as observer op events. The
# command event's exit_code says whether the line that
# emitted them succeeded.
scope.close()
reset_current_session(session_token)
self._ops.records.extend(scope.records)
if is_line:
await self.observer.log_execution(
command, io, scope.records, agent_id, session_id,
self._session_cwd(session_id))