chore: import upstream snapshot with attribution
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,759 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import functools
|
||||
from collections.abc import Callable
|
||||
from typing import NamedTuple
|
||||
|
||||
from mirage.commands.builtin.find_parse import (FindParseError, find_expr_tail,
|
||||
parse_find_expression)
|
||||
from mirage.commands.builtin.generic.crossmount import (handle_cross_mount,
|
||||
is_cross_mount)
|
||||
from mirage.commands.builtin.utils.safeguard import maybe_with_timeout
|
||||
from mirage.commands.errors import UsageError
|
||||
from mirage.commands.safeguard import resolve_across_mounts, resolve_safeguard
|
||||
from mirage.commands.spec import (SPECS, CommandSpec, OperandKind,
|
||||
flag_kwarg_name, parse_command,
|
||||
parse_to_kwargs)
|
||||
from mirage.commands.spec.usage import (missing_value_error,
|
||||
unknown_option_error)
|
||||
from mirage.io import IOResult
|
||||
from mirage.io.stream import async_chain, materialize, wrap_cachable_streams
|
||||
from mirage.io.types import ByteSource
|
||||
from mirage.shell.call_stack import CallStack
|
||||
from mirage.shell.job_table import JobTable
|
||||
from mirage.shell.types import ERREXIT_EXEMPT_TYPES
|
||||
from mirage.types import PathSpec, word_text
|
||||
from mirage.utils.errors import FS_ERRORS, format_fs_error
|
||||
from mirage.workspace.executor.control import ReturnSignal
|
||||
from mirage.workspace.executor.fanout import (_fan_out_traversal,
|
||||
_should_fan_out)
|
||||
from mirage.workspace.executor.find_action_dispatch import _apply_find_actions
|
||||
from mirage.workspace.executor.jobs import (handle_jobs, handle_kill,
|
||||
handle_ps, handle_wait)
|
||||
from mirage.workspace.mount import (MountCommandUnsupported, MountEntry,
|
||||
MountRegistry)
|
||||
from mirage.workspace.mount.namespace import Namespace
|
||||
from mirage.workspace.route import JOB_BUILTINS, Consumer, route
|
||||
from mirage.workspace.session import Session, assert_mount_allowed
|
||||
from mirage.workspace.types import ExecutionNode
|
||||
|
||||
_FIND_ACTION_FLAGS = frozenset({"delete", "print0", "ls"})
|
||||
|
||||
|
||||
async def _exec_node(cmd_str: str, io: IOResult,
|
||||
paths: list[PathSpec]) -> ExecutionNode:
|
||||
"""Build the recorded execution node, materializing any streamed stderr.
|
||||
|
||||
Args:
|
||||
cmd_str (str): Original command text for the record.
|
||||
io (IOResult): Command result whose stderr/exit_code the node carries.
|
||||
paths (list[PathSpec]): Classified path operands, carried so the
|
||||
lazy-stream drain can respell filesystem errors as typed.
|
||||
"""
|
||||
# The node is a recorded artifact (compared by value, serialized via a
|
||||
# sync to_dict, sometimes read twice), so the live lazy io.stderr is
|
||||
# materialized to concrete bytes here. On the cross-mount path it is bytes.
|
||||
return ExecutionNode(command=cmd_str,
|
||||
stderr=await materialize(io.stderr),
|
||||
exit_code=io.exit_code,
|
||||
paths=paths)
|
||||
|
||||
|
||||
def _check_mount_root_guard_raw(
|
||||
cmd_name: str,
|
||||
paths: list[PathSpec],
|
||||
registry: MountRegistry,
|
||||
argv: list[str],
|
||||
) -> tuple[str, int] | None:
|
||||
"""Refuse destructive/conflicting ops targeting a mount root.
|
||||
|
||||
Fires before mount resolution / cross-mount routing so a refusal
|
||||
message is consistent regardless of whether the operands span mounts.
|
||||
Returns (stderr_message, exit_code) when the guard fires, else None.
|
||||
|
||||
Args:
|
||||
cmd_name (str): command name (rm/mv/mkdir/touch/ln/...).
|
||||
paths (list[PathSpec]): raw positional path arguments.
|
||||
registry (MountRegistry): mount registry for is_mount_root checks.
|
||||
argv (list[str]): raw argv after the command name (used to spot
|
||||
shorthand flags like `mkdir -p` before _parse_flags runs).
|
||||
"""
|
||||
if not paths:
|
||||
return None
|
||||
|
||||
def _is_root(p: PathSpec) -> bool:
|
||||
return registry.is_mount_root(p.virtual)
|
||||
|
||||
if cmd_name in ("rm", "rmdir"):
|
||||
for p in paths:
|
||||
if _is_root(p):
|
||||
if cmd_name == "rmdir":
|
||||
msg = (f"rmdir: failed to remove '{p.virtual}': "
|
||||
f"Device or resource busy\n")
|
||||
else:
|
||||
msg = (f"rm: cannot remove '{p.virtual}': "
|
||||
f"Device or resource busy\n")
|
||||
return msg, 1
|
||||
elif cmd_name == "mv":
|
||||
if _is_root(paths[0]):
|
||||
dst = paths[1].virtual if len(paths) > 1 else "?"
|
||||
msg = (f"mv: cannot move '{paths[0].virtual}' to '{dst}': "
|
||||
f"Device or resource busy\n")
|
||||
return msg, 1
|
||||
elif cmd_name == "mkdir":
|
||||
# GNU mkdir -p makes "already exists" a no-op.
|
||||
for tok in argv:
|
||||
if isinstance(tok,
|
||||
str) and (tok == "-p" or tok == "--parents" or
|
||||
(tok.startswith("-") and "p" in tok[1:]
|
||||
and not tok.startswith("--"))):
|
||||
return None
|
||||
for p in paths:
|
||||
if _is_root(p):
|
||||
msg = (f"mkdir: cannot create directory '{p.virtual}': "
|
||||
f"File exists\n")
|
||||
return msg, 1
|
||||
elif cmd_name == "touch":
|
||||
for p in paths:
|
||||
if _is_root(p):
|
||||
msg = (f"touch: cannot touch '{p.virtual}': "
|
||||
f"Is a directory\n")
|
||||
return msg, 1
|
||||
elif cmd_name == "ln":
|
||||
if _is_root(paths[-1]):
|
||||
msg = (f"ln: failed to create link '{paths[-1].virtual}': "
|
||||
f"File exists\n")
|
||||
return msg, 1
|
||||
return None
|
||||
|
||||
|
||||
def _scalar_find_flags(flag_kwargs: dict) -> dict:
|
||||
# `repeatable=True` on find value-flags makes parse_to_kwargs emit
|
||||
# lists; bespoke backend wrappers read these as scalars. Migrated
|
||||
# backends read the expression from `texts` and ignore flag_kwargs.
|
||||
return {
|
||||
k: (v[-1] if isinstance(v, list) and v else v)
|
||||
for k, v in flag_kwargs.items()
|
||||
}
|
||||
|
||||
|
||||
async def run_on_mount(
|
||||
registry: MountRegistry,
|
||||
session: Session,
|
||||
dispatch: Callable,
|
||||
namespace: Namespace | None,
|
||||
cmd_name: str,
|
||||
paths: list[PathSpec],
|
||||
texts: list[str],
|
||||
flag_kwargs: dict,
|
||||
stdin: ByteSource | None = None,
|
||||
resolve_hint: PathSpec | None = None,
|
||||
mount: MountEntry | None = None,
|
||||
) -> tuple[ByteSource | None, IOResult]:
|
||||
"""Run one already-parsed command on the mount that owns its paths.
|
||||
|
||||
The shared single-mount execution tail: mount resolution, grant checks,
|
||||
``execute_cmd``, filesystem-error formatting, ls/find post-processing,
|
||||
and read/write key prefixing. ``handle_command`` uses it for the normal
|
||||
path, and passes it (bound) to the cross-mount runners so each operand
|
||||
executes natively on its owning mount.
|
||||
|
||||
Args:
|
||||
registry (MountRegistry): Mount registry.
|
||||
session (Session): Session providing cwd/env/session_id.
|
||||
dispatch (Callable): Workspace operation dispatcher.
|
||||
namespace (Namespace | None): Addressing authority for ls symlinks.
|
||||
cmd_name (str): Command name.
|
||||
paths (list[PathSpec]): Positional path operands (may hold globs;
|
||||
the mount wrapper expands them natively).
|
||||
texts (list[str]): Positional text operands.
|
||||
flag_kwargs (dict): Parsed flags forwarded to the mount command.
|
||||
stdin (ByteSource | None): Standard input for the command.
|
||||
resolve_hint (PathSpec | None): Mount-resolution path when ``paths``
|
||||
is empty (a stream command running in stdin mode).
|
||||
mount: Pre-resolved mount; skips resolution and grant checks, which
|
||||
the caller already performed.
|
||||
"""
|
||||
if mount is None:
|
||||
resolve_paths = paths or ([resolve_hint] if resolve_hint else [])
|
||||
try:
|
||||
mount = await registry.resolve_mount(cmd_name, resolve_paths,
|
||||
session.cwd)
|
||||
except MountCommandUnsupported as exc:
|
||||
return None, IOResult(exit_code=1, stderr=f"{exc}\n".encode())
|
||||
if mount is None:
|
||||
return None, IOResult(
|
||||
exit_code=127,
|
||||
stderr=f"{cmd_name}: command not found".encode())
|
||||
try:
|
||||
assert_mount_allowed(mount.prefix)
|
||||
for ps in paths:
|
||||
target = registry.mount_for(ps.virtual)
|
||||
assert_mount_allowed(target.prefix)
|
||||
except PermissionError as exc:
|
||||
return None, IOResult(exit_code=1, stderr=f"{exc}\n".encode())
|
||||
|
||||
if cmd_name == "find":
|
||||
flag_kwargs = _scalar_find_flags(flag_kwargs)
|
||||
|
||||
try:
|
||||
stdout, io = await mount.execute_cmd(
|
||||
cmd_name,
|
||||
paths,
|
||||
texts,
|
||||
flag_kwargs,
|
||||
stdin=stdin,
|
||||
cwd=session.cwd,
|
||||
dispatch=dispatch,
|
||||
session_id=session.session_id,
|
||||
env=session.env,
|
||||
exec_allowed=registry.is_exec_allowed(),
|
||||
)
|
||||
except UsageError as exc:
|
||||
# Command-owned usage errors (extra operands, missing patterns)
|
||||
# become this command's IOResult so the rest of the line keeps
|
||||
# running, like a real shell (#452).
|
||||
return None, IOResult(exit_code=exc.exit_code,
|
||||
stderr=f"{exc}\n".encode())
|
||||
except FS_ERRORS as exc:
|
||||
err = format_fs_error(cmd_name, exc, paths)
|
||||
return None, IOResult(exit_code=1, stderr=err)
|
||||
|
||||
if cmd_name == "ls" and io.exit_code == 0:
|
||||
stdout = await _inject_child_mounts(stdout, registry, paths,
|
||||
flag_kwargs, session.cwd)
|
||||
if namespace is not None and namespace.symlinks:
|
||||
stdout = await _inject_links(stdout, namespace, paths, flag_kwargs,
|
||||
session.cwd)
|
||||
|
||||
if cmd_name == "find":
|
||||
stdout, action_err = await _apply_find_actions(stdout, flag_kwargs,
|
||||
registry, session.cwd)
|
||||
if action_err:
|
||||
existing = await materialize(io.stderr) if io.stderr else b""
|
||||
io.stderr = existing + action_err
|
||||
if io.exit_code == 0:
|
||||
io.exit_code = 1
|
||||
|
||||
prefix = mount.prefix.rstrip("/")
|
||||
if prefix:
|
||||
io.reads = {prefix + k: v for k, v in io.reads.items()}
|
||||
io.writes = {prefix + k: v for k, v in io.writes.items()}
|
||||
io.cache = [prefix + p for p in io.cache]
|
||||
return wrap_cachable_streams(stdout, io)
|
||||
|
||||
|
||||
class _ParsedCommand(NamedTuple):
|
||||
paths: list[PathSpec]
|
||||
texts: list[str]
|
||||
flag_kwargs: dict[str, object]
|
||||
warnings: list[str]
|
||||
invalid_options: list[str]
|
||||
needs_value_options: list[str]
|
||||
|
||||
|
||||
def _parse_flags(
|
||||
parts: list[str | PathSpec],
|
||||
spec: CommandSpec | None,
|
||||
cmd_name: str,
|
||||
cwd: str,
|
||||
str_flag_paths: bool = False,
|
||||
) -> _ParsedCommand:
|
||||
"""Parse flags from classified parts, recovering PathSpec for PATH values.
|
||||
|
||||
Single-mount dispatch and cross-mount dispatch both parse through
|
||||
here, so flags, texts, and parser warnings cannot drift between the
|
||||
two paths (a cross-mount `grep --bogus` used to lose its warning).
|
||||
|
||||
Args:
|
||||
parts (list[str | PathSpec]): expanded command words after the
|
||||
command name; path-classified words arrive as PathSpec.
|
||||
spec (CommandSpec | None): command spec, from the owning mount on
|
||||
the single-mount path or the shared SPECS registry on the
|
||||
cross-mount path; None falls back to type separation.
|
||||
cmd_name (str): command name used in warnings.
|
||||
cwd (str): current working directory for relative path resolution.
|
||||
str_flag_paths (bool): keep PATH flag values as their resolved
|
||||
virtual-path strings instead of PathSpec. Cross-mount
|
||||
strategies read flags through FlagView, which type-checks
|
||||
str, so they get the string view.
|
||||
|
||||
Returns:
|
||||
_ParsedCommand: positional paths, positional texts, parsed flag dict
|
||||
(PATH flag values recovered to PathSpec, repeatable PATH flags to
|
||||
list[PathSpec]), and parser warnings (e.g. ignored unknown options).
|
||||
"""
|
||||
# Build string argv and PathSpec lookup
|
||||
argv = [
|
||||
item.virtual if isinstance(item, PathSpec) else item for item in parts
|
||||
]
|
||||
scope_map: dict[str, PathSpec] = {}
|
||||
for item in parts:
|
||||
if isinstance(item, PathSpec):
|
||||
scope_map[item.virtual] = item
|
||||
stripped = item.virtual.rstrip("/")
|
||||
if stripped and stripped != item.virtual:
|
||||
scope_map[stripped] = item
|
||||
|
||||
if spec is not None:
|
||||
parsed = parse_command(spec, argv, cwd=cwd)
|
||||
flag_kwargs = parse_to_kwargs(parsed)
|
||||
|
||||
# Recover PathSpec for PATH flag values; repeatable PATH flags
|
||||
# arrive as a list of resolved paths and become list[PathSpec].
|
||||
# A relative PATH flag value cwd-resolved by parse_command (e.g.
|
||||
# csplit -f part -> /data/part) is absent from scope_map, so build a
|
||||
# PathSpec for it just like positional paths do, otherwise it never
|
||||
# gets the mount prefix stripped.
|
||||
repeat_path_keys = {
|
||||
flag_kwarg_name(name)
|
||||
for opt in spec.options
|
||||
if opt.value_kind == OperandKind.PATH and opt.repeatable
|
||||
for name in (opt.short, opt.long) if name
|
||||
}
|
||||
single_path_keys = {
|
||||
flag_kwarg_name(name)
|
||||
for opt in spec.options
|
||||
if opt.value_kind == OperandKind.PATH and not opt.repeatable
|
||||
for name in (opt.short, opt.long) if name
|
||||
}
|
||||
if not str_flag_paths:
|
||||
for key, value in flag_kwargs.items():
|
||||
if key in repeat_path_keys and isinstance(value, list):
|
||||
flag_kwargs[key] = [
|
||||
scope_map.get(
|
||||
part,
|
||||
PathSpec(virtual=part,
|
||||
directory=part[:part.rfind("/") + 1]
|
||||
or "/",
|
||||
resource_path="",
|
||||
resolved=True)) for part in value
|
||||
]
|
||||
elif key in single_path_keys and isinstance(value, str):
|
||||
flag_kwargs[key] = scope_map.get(
|
||||
value,
|
||||
PathSpec(virtual=value,
|
||||
directory=value[:value.rfind("/") + 1] or "/",
|
||||
resource_path="",
|
||||
resolved=True))
|
||||
elif isinstance(value, str) and value in scope_map:
|
||||
flag_kwargs[key] = scope_map[value]
|
||||
|
||||
# Classify positional args
|
||||
paths: list[PathSpec] = []
|
||||
texts: list[str] = []
|
||||
for value, kind in parsed.args:
|
||||
if kind == OperandKind.PATH:
|
||||
scope = scope_map.get(value)
|
||||
if scope is None:
|
||||
scope = PathSpec(
|
||||
virtual=value,
|
||||
directory=value[:value.rfind("/") + 1] or "/",
|
||||
resource_path="",
|
||||
resolved=True,
|
||||
)
|
||||
paths.append(scope)
|
||||
else:
|
||||
texts.append(value)
|
||||
return _ParsedCommand(paths, texts, flag_kwargs, parsed.warnings,
|
||||
parsed.invalid_options,
|
||||
parsed.needs_value_options)
|
||||
|
||||
# No spec: separate by type
|
||||
paths = [item for item in parts if isinstance(item, PathSpec)]
|
||||
texts = [item for item in parts if not isinstance(item, PathSpec)]
|
||||
return _ParsedCommand(paths, texts, {}, [], [], [])
|
||||
|
||||
|
||||
def _option_error(cmd_name: str,
|
||||
parsed: _ParsedCommand) -> tuple[bytes, int] | None:
|
||||
"""GNU-shaped refusal for option errors the parser reported.
|
||||
|
||||
find is exempt: its expression tokens are validated by
|
||||
parse_find_expression, which raises the GNU predicate error itself.
|
||||
|
||||
Args:
|
||||
cmd_name (str): command name for message shape and exit code.
|
||||
parsed (_ParsedCommand): parse result carrying the reports.
|
||||
"""
|
||||
if cmd_name == "find":
|
||||
return None
|
||||
if parsed.invalid_options:
|
||||
return unknown_option_error(cmd_name, parsed.invalid_options[0])
|
||||
if parsed.needs_value_options:
|
||||
return missing_value_error(cmd_name, parsed.needs_value_options[0])
|
||||
return None
|
||||
|
||||
|
||||
async def handle_command(
|
||||
execute_node: Callable,
|
||||
dispatch: Callable,
|
||||
registry: MountRegistry,
|
||||
parts: list[str | PathSpec],
|
||||
session: Session,
|
||||
stdin: ByteSource | None = None,
|
||||
call_stack: CallStack | None = None,
|
||||
job_table: JobTable | None = None,
|
||||
namespace: Namespace | None = None,
|
||||
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
||||
"""Execute a simple command.
|
||||
|
||||
Parts are already classified: strings for text,
|
||||
PathSpec for paths. Dispatches to mount.execute_cmd.
|
||||
"""
|
||||
if not parts:
|
||||
return None, IOResult(), ExecutionNode(command="", exit_code=0)
|
||||
|
||||
cmd_name = str(parts[0])
|
||||
cmd_str = " ".join(p.virtual if isinstance(p, PathSpec) else p
|
||||
for p in parts)
|
||||
|
||||
# Job builtins
|
||||
if cmd_name in JOB_BUILTINS and job_table is not None:
|
||||
text_parts = [
|
||||
p.virtual if isinstance(p, PathSpec) else p for p in parts
|
||||
]
|
||||
if cmd_name in ("wait", "fg"):
|
||||
return await handle_wait(job_table, text_parts)
|
||||
if cmd_name == "kill":
|
||||
return await handle_kill(job_table, text_parts)
|
||||
if cmd_name == "jobs":
|
||||
return await handle_jobs(job_table, text_parts)
|
||||
if cmd_name == "ps":
|
||||
return await handle_ps(job_table, text_parts)
|
||||
|
||||
# Shell functions
|
||||
if cmd_name in session.functions:
|
||||
func_body = session.functions[cmd_name]
|
||||
cs = call_stack or CallStack()
|
||||
# Positional args carry the word as typed ($1 stays sub/a.txt).
|
||||
text_args = [word_text(p) for p in parts[1:]]
|
||||
cs.push(text_args, function_name=cmd_name)
|
||||
saved_locals: dict[str, str | None] = {}
|
||||
session._local_vars = saved_locals
|
||||
try:
|
||||
all_stdout: list = []
|
||||
merged_io = IOResult()
|
||||
last_exec = ExecutionNode(command=cmd_name, exit_code=0)
|
||||
for cmd in func_body:
|
||||
try:
|
||||
stdout, io, last_exec = await execute_node(
|
||||
cmd, session, stdin, cs)
|
||||
except ReturnSignal as sig:
|
||||
if sig.stderr:
|
||||
merged_io = await merged_io.merge(
|
||||
IOResult(stderr=sig.stderr))
|
||||
merged_io.exit_code = sig.exit_code
|
||||
break
|
||||
if stdout is not None:
|
||||
all_stdout.append(stdout)
|
||||
merged_io = await merged_io.merge(io)
|
||||
if (io.exit_code != 0 and session.shell_options.get("errexit")
|
||||
and cmd.type not in ERREXIT_EXEMPT_TYPES):
|
||||
merged_io.exit_code = io.exit_code
|
||||
break
|
||||
combined = async_chain(*all_stdout) if all_stdout else None
|
||||
last_exec.exit_code = merged_io.exit_code
|
||||
return combined, merged_io, last_exec
|
||||
finally:
|
||||
cs.pop()
|
||||
for key, old_val in saved_locals.items():
|
||||
if old_val is None:
|
||||
session.env.pop(key, None)
|
||||
else:
|
||||
session.env[key] = old_val
|
||||
session._local_vars = None
|
||||
|
||||
# Cross-mount: paths span different mounts (e.g. cp /ram/a /disk/b).
|
||||
# Use dispatch to read/write across mounts directly.
|
||||
path_scopes = [p for p in parts[1:] if isinstance(p, PathSpec)]
|
||||
raw_argv = [p.virtual if isinstance(p, PathSpec) else p for p in parts[1:]]
|
||||
early_guard = _check_mount_root_guard_raw(cmd_name, path_scopes, registry,
|
||||
raw_argv)
|
||||
if early_guard is not None:
|
||||
msg, code = early_guard
|
||||
return None, IOResult(exit_code=code,
|
||||
stderr=msg.encode()), ExecutionNode(
|
||||
command=cmd_str,
|
||||
exit_code=code,
|
||||
stderr=msg.encode())
|
||||
|
||||
# Unknown name: nobody registers it; fail like bash before any
|
||||
# backend work. The mount-root guard stays ahead of this so
|
||||
# protective refusals keep their specific messages.
|
||||
if route(cmd_name, session, registry) is Consumer.UNKNOWN:
|
||||
err = f"{cmd_name}: command not found\n".encode()
|
||||
return None, IOResult(exit_code=127,
|
||||
stderr=err), ExecutionNode(command=cmd_str,
|
||||
exit_code=127,
|
||||
stderr=err)
|
||||
|
||||
find_expr_tokens: list[str] | None = None
|
||||
if cmd_name == "find":
|
||||
find_expr_tokens = find_expr_tail(raw_argv)
|
||||
try:
|
||||
parse_find_expression(find_expr_tokens)
|
||||
except FindParseError as exc:
|
||||
msg = f"{exc}\n"
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=msg.encode()), ExecutionNode(
|
||||
command=cmd_str,
|
||||
exit_code=1,
|
||||
stderr=msg.encode())
|
||||
|
||||
if is_cross_mount(cmd_name, path_scopes, registry):
|
||||
# Cross-mount execution bypasses a resource command handler. Parse
|
||||
# against the shared spec so flags and text operands do not depend on
|
||||
# the source mount. The bound single-mount runner lets the strategy
|
||||
# runners execute each operand natively on its owning mount.
|
||||
cross_parsed = _parse_flags(parts[1:],
|
||||
SPECS.get(cmd_name),
|
||||
cmd_name,
|
||||
session.cwd,
|
||||
str_flag_paths=True)
|
||||
cross_texts = (find_expr_tokens
|
||||
if find_expr_tokens is not None else cross_parsed.texts)
|
||||
cross_refusal = _option_error(cmd_name, cross_parsed)
|
||||
if cross_refusal is not None:
|
||||
msg, code = cross_refusal
|
||||
return None, IOResult(exit_code=code,
|
||||
stderr=msg), ExecutionNode(command=cmd_str,
|
||||
exit_code=code,
|
||||
stderr=msg)
|
||||
run_single = functools.partial(run_on_mount, registry, session,
|
||||
dispatch, namespace)
|
||||
stdout, io = await handle_cross_mount(cmd_name,
|
||||
path_scopes,
|
||||
cross_texts,
|
||||
cross_parsed.flag_kwargs,
|
||||
dispatch,
|
||||
run_single,
|
||||
stdin=stdin)
|
||||
if cross_parsed.warnings:
|
||||
warn = "".join(f"{cmd_name}: {w}\n"
|
||||
for w in cross_parsed.warnings).encode()
|
||||
existing = await materialize(io.stderr) if io.stderr else b""
|
||||
io.stderr = warn + existing
|
||||
# The native sub-runs carry their own mount's safeguard; the
|
||||
# cross-mount command as a whole uses the strictest one across the
|
||||
# operand mounts, regardless of which sub-run merged last.
|
||||
mounts = []
|
||||
for s in path_scopes:
|
||||
try:
|
||||
mounts.append(registry.mount_for(s.virtual))
|
||||
except ValueError:
|
||||
pass
|
||||
io.safeguard = (resolve_across_mounts(cmd_name, mounts)
|
||||
if mounts else resolve_safeguard(cmd_name))
|
||||
stdout = maybe_with_timeout(stdout, io.safeguard, cmd_name)
|
||||
return stdout, io, await _exec_node(cmd_str, io, path_scopes)
|
||||
|
||||
# Reject unsupported cross-mount commands
|
||||
if len(path_scopes) >= 2:
|
||||
mount_prefixes = set()
|
||||
for s in path_scopes:
|
||||
try:
|
||||
mount_prefixes.add(registry.mount_for(s.virtual).prefix)
|
||||
except ValueError:
|
||||
pass
|
||||
if len(mount_prefixes) > 1:
|
||||
prefixes_str = ", ".join(sorted(mount_prefixes))
|
||||
err = (f"{cmd_name}: paths span multiple mounts "
|
||||
f"({prefixes_str}), cross-mount not supported\n")
|
||||
return None, IOResult(
|
||||
exit_code=1,
|
||||
stderr=err.encode(),
|
||||
), ExecutionNode(command=cmd_str, exit_code=1)
|
||||
|
||||
try:
|
||||
mount = await registry.resolve_mount(cmd_name, path_scopes,
|
||||
session.cwd)
|
||||
except MountCommandUnsupported as exc:
|
||||
err = f"{exc}\n".encode()
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command=cmd_str,
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
if mount is None:
|
||||
return None, IOResult(
|
||||
exit_code=127,
|
||||
stderr=f"{cmd_name}: command not found".encode(),
|
||||
), ExecutionNode(command=cmd_str, exit_code=127)
|
||||
|
||||
try:
|
||||
assert_mount_allowed(mount.prefix)
|
||||
for ps in path_scopes:
|
||||
target = registry.mount_for(ps.virtual)
|
||||
assert_mount_allowed(target.prefix)
|
||||
except PermissionError as exc:
|
||||
err = f"{exc}\n".encode()
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command=cmd_str,
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
|
||||
# Parse flags upstream — mount receives clean args
|
||||
single_parsed = _parse_flags(parts[1:], mount.spec_for(cmd_name), cmd_name,
|
||||
session.cwd)
|
||||
paths, texts, flag_kwargs, parse_warnings = (single_parsed.paths,
|
||||
single_parsed.texts,
|
||||
single_parsed.flag_kwargs,
|
||||
single_parsed.warnings)
|
||||
refusal = _option_error(cmd_name, single_parsed)
|
||||
if refusal is not None:
|
||||
msg, code = refusal
|
||||
return None, IOResult(exit_code=code,
|
||||
stderr=msg), ExecutionNode(command=cmd_str,
|
||||
exit_code=code,
|
||||
stderr=msg)
|
||||
|
||||
if find_expr_tokens is not None:
|
||||
texts = find_expr_tokens
|
||||
flag_kwargs = _scalar_find_flags(flag_kwargs)
|
||||
|
||||
warn_bytes = ("".join(
|
||||
f"{cmd_name}: {w}\n"
|
||||
for w in parse_warnings).encode() if parse_warnings else b"")
|
||||
|
||||
if _should_fan_out(cmd_name, paths, flag_kwargs, registry):
|
||||
stdout, io, node = await _fan_out_traversal(cmd_name, paths, texts,
|
||||
flag_kwargs, registry,
|
||||
mount, session.cwd,
|
||||
cmd_str, stdin)
|
||||
if warn_bytes:
|
||||
existing = await materialize(io.stderr) if io.stderr else b""
|
||||
io.stderr = warn_bytes + existing
|
||||
node.stderr = warn_bytes + (node.stderr or b"")
|
||||
return stdout, io, node
|
||||
|
||||
stdout, io = await run_on_mount(registry,
|
||||
session,
|
||||
dispatch,
|
||||
namespace,
|
||||
cmd_name,
|
||||
paths,
|
||||
texts,
|
||||
flag_kwargs,
|
||||
stdin=stdin,
|
||||
mount=mount)
|
||||
|
||||
if warn_bytes:
|
||||
existing = await materialize(io.stderr) if io.stderr else b""
|
||||
io.stderr = warn_bytes + existing
|
||||
|
||||
stdout = maybe_with_timeout(stdout, io.safeguard, cmd_name)
|
||||
io.stderr = maybe_with_timeout(io.stderr, io.safeguard, cmd_name)
|
||||
|
||||
return stdout, io, await _exec_node(cmd_str, io, paths)
|
||||
|
||||
|
||||
async def _inject_links(
|
||||
stdout: ByteSource | None,
|
||||
namespace: Namespace,
|
||||
paths: list[PathSpec],
|
||||
flag_kwargs: dict,
|
||||
cwd: str,
|
||||
) -> ByteSource | None:
|
||||
"""Append symlink entries living under the listed directory.
|
||||
|
||||
Links are namespace state, invisible to backend readdir, so ``ls``
|
||||
surfaces them the same way child mounts are surfaced. Long form
|
||||
renders GNU-style ``name -> target``.
|
||||
|
||||
Args:
|
||||
stdout (ByteSource | None): backend ls output.
|
||||
namespace (Namespace): addressing authority holding the link table.
|
||||
paths (list[PathSpec]): positional ls operands.
|
||||
flag_kwargs (dict): parsed ls flags.
|
||||
cwd (str): current working directory fallback operand.
|
||||
"""
|
||||
if flag_kwargs.get("d") is True or flag_kwargs.get("R") is True:
|
||||
return stdout
|
||||
if len(paths) > 1:
|
||||
return stdout
|
||||
listed = paths[0].virtual if paths else cwd
|
||||
links = namespace.links_under(listed)
|
||||
if not links:
|
||||
return stdout
|
||||
|
||||
existing_bytes = await materialize(stdout) if stdout is not None else b""
|
||||
existing = existing_bytes.decode("utf-8")
|
||||
long_form = flag_kwargs.get("args_l") is True
|
||||
classify = flag_kwargs.get("F") is True
|
||||
present: set[str] = set()
|
||||
for line in existing.split("\n"):
|
||||
if line == "":
|
||||
continue
|
||||
name = line.split("\t")[-1] if long_form else line.rstrip("/*@|=")
|
||||
if name:
|
||||
present.add(name)
|
||||
extras: list[str] = []
|
||||
for name in sorted(links):
|
||||
if name in present:
|
||||
continue
|
||||
if long_form:
|
||||
extras.append(f"l\t-\t-\t{name} -> {links[name]}")
|
||||
else:
|
||||
extras.append(f"{name}@" if classify else name)
|
||||
if not extras:
|
||||
return stdout
|
||||
sep = "" if existing == "" or existing.endswith("\n") else "\n"
|
||||
return (existing + sep + "\n".join(extras) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
async def _inject_child_mounts(
|
||||
stdout: ByteSource | None,
|
||||
registry: MountRegistry,
|
||||
paths: list[PathSpec],
|
||||
flag_kwargs: dict,
|
||||
cwd: str,
|
||||
) -> ByteSource | None:
|
||||
if flag_kwargs.get("d") is True or flag_kwargs.get("R") is True:
|
||||
return stdout
|
||||
if len(paths) > 1:
|
||||
return stdout
|
||||
listed = paths[0].virtual if paths else cwd
|
||||
include_hidden = (flag_kwargs.get("a") is True
|
||||
or flag_kwargs.get("A") is True)
|
||||
child_names = registry.child_mount_names(listed, include_hidden)
|
||||
if not child_names:
|
||||
return stdout
|
||||
|
||||
existing_bytes = await materialize(stdout) if stdout is not None else b""
|
||||
existing = existing_bytes.decode("utf-8")
|
||||
long_form = flag_kwargs.get("args_l") is True
|
||||
classify = flag_kwargs.get("F") is True
|
||||
present: set[str] = set()
|
||||
for line in existing.split("\n"):
|
||||
if line == "":
|
||||
continue
|
||||
if long_form:
|
||||
name = line.split("\t")[-1]
|
||||
else:
|
||||
name = line.rstrip("/*@|=")
|
||||
if name:
|
||||
present.add(name)
|
||||
extras: list[str] = []
|
||||
for name in child_names:
|
||||
if name in present:
|
||||
continue
|
||||
if long_form:
|
||||
extras.append(f"d\t-\t-\t{name}")
|
||||
else:
|
||||
extras.append(f"{name}/" if classify else name)
|
||||
if not extras:
|
||||
return stdout
|
||||
sep = "" if existing == "" or existing.endswith("\n") else "\n"
|
||||
return (existing + sep + "\n".join(extras)).encode("utf-8")
|
||||
@@ -0,0 +1,358 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import fnmatch
|
||||
from collections.abc import Callable
|
||||
|
||||
import tree_sitter
|
||||
|
||||
from mirage.io import IOResult
|
||||
from mirage.io.async_line_iterator import AsyncLineIterator
|
||||
from mirage.io.stream import async_chain
|
||||
from mirage.io.types import ByteSource
|
||||
from mirage.shell.barrier import BarrierPolicy, apply_barrier
|
||||
from mirage.shell.call_stack import CallStack
|
||||
from mirage.shell.types import ERREXIT_EXEMPT_TYPES
|
||||
from mirage.types import PathSpec, word_text
|
||||
from mirage.workspace.session import Session
|
||||
from mirage.workspace.types import ExecutionNode
|
||||
|
||||
# Safety cap on while/until iterations. Independent of stdin size:
|
||||
# even with lazy stdin (Step 15), a `while read` over a stream longer
|
||||
# than this cap stops here. Cap-hit emits a stderr warning so callers
|
||||
# notice silent truncation. Bump if agents process larger streams.
|
||||
_MAX_WHILE = 10000
|
||||
|
||||
|
||||
def _line_buffer(stdin: ByteSource) -> AsyncLineIterator:
|
||||
"""Wrap a ByteSource (bytes or chunked async iter) as a line iterator."""
|
||||
if isinstance(stdin, bytes):
|
||||
return AsyncLineIterator(async_chain(stdin))
|
||||
return AsyncLineIterator(stdin)
|
||||
|
||||
|
||||
async def _execute_body(
|
||||
execute_node: Callable,
|
||||
body: list[tree_sitter.Node],
|
||||
session: Session,
|
||||
stdin: ByteSource | None,
|
||||
call_stack: CallStack | None,
|
||||
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
||||
"""Execute a list of body commands sequentially."""
|
||||
all_stdout: list[ByteSource | None] = []
|
||||
merged_io = IOResult()
|
||||
last_exec = ExecutionNode(command="", exit_code=0)
|
||||
for cmd in body:
|
||||
try:
|
||||
stdout, io, last_exec = await execute_node(cmd, session, stdin,
|
||||
call_stack)
|
||||
except BreakSignal as sig:
|
||||
if sig.stdout is not None:
|
||||
all_stdout.append(sig.stdout)
|
||||
merged_io = await merged_io.merge(sig.io)
|
||||
combined = async_chain(*[s for s in all_stdout
|
||||
if s is not None]) if any(
|
||||
s is not None
|
||||
for s in all_stdout) else None
|
||||
raise BreakSignal(stdout=combined, io=merged_io)
|
||||
except ContinueSignal as sig:
|
||||
if sig.stdout is not None:
|
||||
all_stdout.append(sig.stdout)
|
||||
merged_io = await merged_io.merge(sig.io)
|
||||
combined = async_chain(*[s for s in all_stdout
|
||||
if s is not None]) if any(
|
||||
s is not None
|
||||
for s in all_stdout) else None
|
||||
raise ContinueSignal(stdout=combined, io=merged_io)
|
||||
all_stdout.append(stdout)
|
||||
merged_io = await merged_io.merge(io)
|
||||
if (io.exit_code != 0 and session.shell_options.get("errexit")
|
||||
and cmd.type not in ERREXIT_EXEMPT_TYPES):
|
||||
merged_io.exit_code = io.exit_code
|
||||
break
|
||||
non_empty = [s for s in all_stdout if s is not None]
|
||||
combined = async_chain(*non_empty) if non_empty else None
|
||||
return combined, merged_io, last_exec
|
||||
|
||||
|
||||
class BreakSignal(Exception):
|
||||
|
||||
def __init__(self, stdout=None, io=None):
|
||||
self.stdout = stdout
|
||||
self.io = io or IOResult()
|
||||
|
||||
|
||||
class ContinueSignal(Exception):
|
||||
|
||||
def __init__(self, stdout=None, io=None):
|
||||
self.stdout = stdout
|
||||
self.io = io or IOResult()
|
||||
|
||||
|
||||
class ReturnSignal(Exception):
|
||||
|
||||
def __init__(self, exit_code: int = 0, stderr: bytes = b"") -> None:
|
||||
self.exit_code = exit_code
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
def _collect_loop_result(
|
||||
all_stdout: list[ByteSource | None],
|
||||
merged_io: IOResult,
|
||||
label: str,
|
||||
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
||||
exec_node = ExecutionNode(command=label, exit_code=merged_io.exit_code)
|
||||
non_empty = [s for s in all_stdout if s is not None]
|
||||
if not non_empty:
|
||||
return None, merged_io, exec_node
|
||||
return async_chain(*non_empty), merged_io, exec_node
|
||||
|
||||
|
||||
async def handle_if(
|
||||
execute_node: Callable,
|
||||
branches: list[tuple[tree_sitter.Node, list[tree_sitter.Node]]],
|
||||
else_body: list[tree_sitter.Node] | None,
|
||||
session: Session,
|
||||
stdin: ByteSource | None = None,
|
||||
call_stack: CallStack | None = None,
|
||||
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
||||
for condition, body in branches:
|
||||
cond_stdout, cond_io, _ = await execute_node(condition, session, stdin,
|
||||
call_stack)
|
||||
await apply_barrier(cond_stdout, cond_io, BarrierPolicy.STATUS)
|
||||
session.last_exit_code = cond_io.exit_code
|
||||
if cond_io.exit_code == 0:
|
||||
return await _execute_body(execute_node, body, session, stdin,
|
||||
call_stack)
|
||||
if else_body is not None:
|
||||
return await _execute_body(execute_node, else_body, session, stdin,
|
||||
call_stack)
|
||||
return None, IOResult(), ExecutionNode(exit_code=0)
|
||||
|
||||
|
||||
async def handle_for(
|
||||
execute_node: Callable,
|
||||
variable: str,
|
||||
values: list[str | PathSpec],
|
||||
body: list[tree_sitter.Node],
|
||||
session: Session,
|
||||
stdin: ByteSource | None = None,
|
||||
call_stack: CallStack | None = None,
|
||||
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
||||
merged_io = IOResult()
|
||||
all_stdout: list[ByteSource | None] = []
|
||||
saved = session.env.get(variable)
|
||||
|
||||
# Save and materialize stdin for re-reading across iterations
|
||||
prev_buffer = session._stdin_buffer
|
||||
if stdin is not None:
|
||||
session._stdin_buffer = _line_buffer(stdin)
|
||||
stdin = None
|
||||
|
||||
try:
|
||||
for val in values:
|
||||
# env stores strings only; bash keeps `for f in sub/*.txt`
|
||||
# matches relative, so the loop variable takes the typed form
|
||||
session.env[variable] = word_text(val)
|
||||
try:
|
||||
stdout, io, _ = await _execute_body(execute_node, body,
|
||||
session, stdin, call_stack)
|
||||
except BreakSignal as sig:
|
||||
if sig.stdout is not None:
|
||||
all_stdout.append(sig.stdout)
|
||||
merged_io = await merged_io.merge(sig.io)
|
||||
break
|
||||
except ContinueSignal as sig:
|
||||
if sig.stdout is not None:
|
||||
all_stdout.append(sig.stdout)
|
||||
merged_io = await merged_io.merge(sig.io)
|
||||
continue
|
||||
merged_io = await merged_io.merge(io)
|
||||
all_stdout.append(stdout)
|
||||
finally:
|
||||
session._stdin_buffer = prev_buffer
|
||||
if saved is not None:
|
||||
session.env[variable] = saved
|
||||
else:
|
||||
session.env.pop(variable, None)
|
||||
return _collect_loop_result(all_stdout, merged_io, "for")
|
||||
|
||||
|
||||
async def _condition_loop(
|
||||
execute_node: Callable,
|
||||
condition: tree_sitter.Node,
|
||||
body: list[tree_sitter.Node],
|
||||
session: Session,
|
||||
stdin: ByteSource | None,
|
||||
call_stack: CallStack | None,
|
||||
label: str,
|
||||
break_on_zero: bool,
|
||||
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
||||
merged_io = IOResult()
|
||||
all_stdout: list[ByteSource | None] = []
|
||||
prev_buffer = session._stdin_buffer
|
||||
if stdin is not None:
|
||||
session._stdin_buffer = _line_buffer(stdin)
|
||||
stdin = None
|
||||
try:
|
||||
hit_limit = True
|
||||
for _ in range(_MAX_WHILE):
|
||||
cond_stdout, cond_io, _ = await execute_node(
|
||||
condition, session, stdin, call_stack)
|
||||
await apply_barrier(cond_stdout, cond_io, BarrierPolicy.STATUS)
|
||||
session.last_exit_code = cond_io.exit_code
|
||||
if break_on_zero and cond_io.exit_code == 0:
|
||||
hit_limit = False
|
||||
break
|
||||
if (not break_on_zero and cond_io.exit_code != 0):
|
||||
hit_limit = False
|
||||
break
|
||||
try:
|
||||
stdout, io, _ = await _execute_body(execute_node, body,
|
||||
session, stdin, call_stack)
|
||||
except BreakSignal as sig:
|
||||
hit_limit = False
|
||||
if sig.stdout is not None:
|
||||
all_stdout.append(sig.stdout)
|
||||
merged_io = await merged_io.merge(sig.io)
|
||||
break
|
||||
except ContinueSignal as sig:
|
||||
if sig.stdout is not None:
|
||||
all_stdout.append(sig.stdout)
|
||||
merged_io = await merged_io.merge(sig.io)
|
||||
continue
|
||||
merged_io = await merged_io.merge(io)
|
||||
all_stdout.append(stdout)
|
||||
if hit_limit:
|
||||
warn = (f"warning: {label} loop terminated after "
|
||||
f"{_MAX_WHILE} iterations\n").encode()
|
||||
existing = merged_io.stderr
|
||||
if isinstance(existing, bytes) and existing:
|
||||
merged_io.stderr = existing + warn
|
||||
else:
|
||||
merged_io.stderr = warn
|
||||
finally:
|
||||
session._stdin_buffer = prev_buffer
|
||||
return _collect_loop_result(all_stdout, merged_io, label)
|
||||
|
||||
|
||||
async def handle_while(
|
||||
execute_node: Callable,
|
||||
condition: tree_sitter.Node,
|
||||
body: list[tree_sitter.Node],
|
||||
session: Session,
|
||||
stdin: ByteSource | None = None,
|
||||
call_stack: CallStack | None = None,
|
||||
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
||||
return await _condition_loop(execute_node,
|
||||
condition,
|
||||
body,
|
||||
session,
|
||||
stdin,
|
||||
call_stack,
|
||||
"while",
|
||||
break_on_zero=False)
|
||||
|
||||
|
||||
async def handle_until(
|
||||
execute_node: Callable,
|
||||
condition: tree_sitter.Node,
|
||||
body: list[tree_sitter.Node],
|
||||
session: Session,
|
||||
stdin: ByteSource | None = None,
|
||||
call_stack: CallStack | None = None,
|
||||
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
||||
return await _condition_loop(execute_node,
|
||||
condition,
|
||||
body,
|
||||
session,
|
||||
stdin,
|
||||
call_stack,
|
||||
"until",
|
||||
break_on_zero=True)
|
||||
|
||||
|
||||
async def handle_case(
|
||||
execute_node: Callable,
|
||||
word: str,
|
||||
items: list[tuple[list[str], list[tree_sitter.Node]]],
|
||||
session: Session,
|
||||
stdin: ByteSource | None = None,
|
||||
call_stack: CallStack | None = None,
|
||||
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
||||
for patterns, body in items:
|
||||
if any(fnmatch.fnmatch(word, p.strip()) for p in patterns):
|
||||
all_stdout: list[ByteSource] = []
|
||||
merged_io = IOResult()
|
||||
last_exec = ExecutionNode(command="case", exit_code=0)
|
||||
for stmt in body:
|
||||
stdout, io, last_exec = await execute_node(
|
||||
stmt, session, stdin, call_stack)
|
||||
stdin = None
|
||||
if stdout is not None:
|
||||
all_stdout.append(stdout)
|
||||
merged_io = await merged_io.merge(io)
|
||||
if len(all_stdout) == 1:
|
||||
return all_stdout[0], merged_io, last_exec
|
||||
combined = async_chain(*all_stdout) if all_stdout else None
|
||||
return combined, merged_io, last_exec
|
||||
return None, IOResult(), ExecutionNode(command="case", exit_code=0)
|
||||
|
||||
|
||||
async def handle_select(
|
||||
execute_node: Callable,
|
||||
variable: str,
|
||||
values: list[str | PathSpec],
|
||||
body: list[tree_sitter.Node],
|
||||
session: Session,
|
||||
stdin: ByteSource | None = None,
|
||||
call_stack: CallStack | None = None,
|
||||
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
||||
merged_io = IOResult()
|
||||
all_stdout: list[ByteSource | None] = []
|
||||
saved = session.env.get(variable)
|
||||
|
||||
# Save and materialize stdin for re-reading across iterations
|
||||
prev_buffer = session._stdin_buffer
|
||||
if stdin is not None:
|
||||
session._stdin_buffer = _line_buffer(stdin)
|
||||
stdin = None
|
||||
|
||||
try:
|
||||
for val in values:
|
||||
# env stores strings only; bash keeps `for f in sub/*.txt`
|
||||
# matches relative, so the loop variable takes the typed form
|
||||
session.env[variable] = word_text(val)
|
||||
try:
|
||||
stdout, io, _ = await _execute_body(execute_node, body,
|
||||
session, stdin, call_stack)
|
||||
except BreakSignal as sig:
|
||||
if sig.stdout is not None:
|
||||
all_stdout.append(sig.stdout)
|
||||
merged_io = await merged_io.merge(sig.io)
|
||||
break
|
||||
except ContinueSignal as sig:
|
||||
if sig.stdout is not None:
|
||||
all_stdout.append(sig.stdout)
|
||||
merged_io = await merged_io.merge(sig.io)
|
||||
continue
|
||||
merged_io = await merged_io.merge(io)
|
||||
all_stdout.append(stdout)
|
||||
finally:
|
||||
session._stdin_buffer = prev_buffer
|
||||
if saved is not None:
|
||||
session.env[variable] = saved
|
||||
else:
|
||||
session.env.pop(variable, None)
|
||||
return _collect_loop_result(all_stdout, merged_io, "select")
|
||||
@@ -0,0 +1,347 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.commands.builtin.find_eval import FindEntry, keep
|
||||
from mirage.commands.builtin.find_parse import parse_find_expression
|
||||
from mirage.commands.errors import FindParseError
|
||||
from mirage.commands.safeguard import resolve_across_mounts
|
||||
from mirage.io import IOResult
|
||||
from mirage.io.stream import materialize
|
||||
from mirage.io.types import ByteSource
|
||||
from mirage.types import PathSpec
|
||||
from mirage.workspace.executor.find_action_dispatch import _apply_find_actions
|
||||
from mirage.workspace.mount import MountRegistry
|
||||
from mirage.workspace.types import ExecutionNode
|
||||
|
||||
_TRAVERSAL_CMDS = frozenset({"find", "tree", "du"})
|
||||
|
||||
|
||||
def _path_segments(path: str) -> list[str]:
|
||||
return [s for s in path.strip("/").split("/") if s]
|
||||
|
||||
|
||||
def _should_fan_out(
|
||||
cmd_name: str,
|
||||
paths: list[PathSpec],
|
||||
flag_kwargs: dict,
|
||||
registry: MountRegistry,
|
||||
) -> bool:
|
||||
"""Whether `cmd` on this path should run across multiple mounts.
|
||||
|
||||
True when the command is in the traversal whitelist (find/tree/du)
|
||||
and the path has at least one descendant mount; or for grep with
|
||||
-r/-R; or for ls -R. Returns False when there's no descendant
|
||||
mount under the path (single-mount dispatch is correct).
|
||||
"""
|
||||
if not paths:
|
||||
return False
|
||||
target = paths[0].virtual
|
||||
if not registry.descendant_mounts(target):
|
||||
return False
|
||||
if cmd_name in _TRAVERSAL_CMDS:
|
||||
return True
|
||||
if cmd_name == "grep":
|
||||
return (flag_kwargs.get("r") is True or flag_kwargs.get("R") is True
|
||||
or flag_kwargs.get("recursive") is True)
|
||||
if cmd_name == "ls":
|
||||
return flag_kwargs.get("R") is True
|
||||
return False
|
||||
|
||||
|
||||
def _adjust_depth_flags(
|
||||
flag_kwargs: dict,
|
||||
parent_path: str,
|
||||
mount_prefix: str,
|
||||
) -> dict | None:
|
||||
"""Adjust find's -maxdepth/-mindepth for a fan-out into a child mount.
|
||||
|
||||
Returns the new kwargs dict, or None if the child mount falls
|
||||
outside the depth budget (caller should skip it).
|
||||
"""
|
||||
parent_depth = len(_path_segments(parent_path))
|
||||
mount_depth = len(_path_segments(mount_prefix))
|
||||
delta = mount_depth - parent_depth
|
||||
new = dict(flag_kwargs)
|
||||
if "maxdepth" in new:
|
||||
raw_md = new["maxdepth"]
|
||||
if isinstance(raw_md, list):
|
||||
raw_md = raw_md[0] if raw_md else None
|
||||
try:
|
||||
md = int(raw_md) - delta
|
||||
except (TypeError, ValueError):
|
||||
md = None
|
||||
if md is not None:
|
||||
if md < 0:
|
||||
return None
|
||||
new["maxdepth"] = str(md)
|
||||
if "mindepth" in new:
|
||||
raw_mn = new["mindepth"]
|
||||
if isinstance(raw_mn, list):
|
||||
raw_mn = raw_mn[0] if raw_mn else None
|
||||
try:
|
||||
mn = max(0, int(raw_mn) - delta)
|
||||
new["mindepth"] = str(mn)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return new
|
||||
|
||||
|
||||
def _adjust_depth_texts(
|
||||
texts: list[str],
|
||||
parent_path: str,
|
||||
mount_prefix: str,
|
||||
) -> list[str]:
|
||||
"""Adjust -maxdepth/-mindepth values inside a find expression.
|
||||
|
||||
The generic find parses depth from the expression tokens (`texts`),
|
||||
not from flag kwargs, so a fan-out into a deeper child mount must
|
||||
rewrite the depth values by the parent-to-mount delta. Mirrors
|
||||
`_adjust_depth_flags`.
|
||||
|
||||
Args:
|
||||
texts (list[str]): the find expression tokens.
|
||||
parent_path (str): the find start path the fan-out runs from.
|
||||
mount_prefix (str): the child mount prefix being descended into.
|
||||
"""
|
||||
delta = len(_path_segments(mount_prefix)) - len(
|
||||
_path_segments(parent_path))
|
||||
if delta == 0:
|
||||
return list(texts)
|
||||
out = list(texts)
|
||||
i = 0
|
||||
while i < len(out) - 1:
|
||||
tok = out[i]
|
||||
if tok in ("-maxdepth", "-mindepth"):
|
||||
try:
|
||||
val = int(out[i + 1])
|
||||
except ValueError:
|
||||
i += 2
|
||||
continue
|
||||
if tok == "-maxdepth":
|
||||
out[i + 1] = str(val - delta)
|
||||
else:
|
||||
out[i + 1] = str(max(0, val - delta))
|
||||
i += 2
|
||||
continue
|
||||
i += 1
|
||||
return out
|
||||
|
||||
|
||||
def _synthesize_find_mount_entries(
|
||||
target_path: str,
|
||||
descendants: list,
|
||||
texts: list[str],
|
||||
) -> str:
|
||||
"""Return synthetic find lines for descendant mount roots.
|
||||
|
||||
`find /` and friends should list mount prefixes as directory
|
||||
entries even though no per-mount find emits its own root. The find
|
||||
expression is parsed into a predicate tree and evaluated per mount
|
||||
root (kind "d"), mirroring the per-backend cores, so -not / -o /
|
||||
-path / -type and the -maxdepth / -mindepth window all apply.
|
||||
|
||||
Args:
|
||||
target_path (str): the find start path the fan-out runs from.
|
||||
descendants (list): descendant mounts to inject as entries.
|
||||
texts (list[str]): the find expression tokens.
|
||||
"""
|
||||
try:
|
||||
expr = parse_find_expression(list(texts))
|
||||
except FindParseError:
|
||||
return ""
|
||||
tree = expr.tree
|
||||
max_depth = expr.maxdepth
|
||||
min_depth = expr.mindepth if expr.mindepth is not None else 0
|
||||
parent_depth = len(_path_segments(target_path))
|
||||
out: list[str] = []
|
||||
for m in descendants:
|
||||
prefix_no_slash = m.prefix.rstrip("/")
|
||||
depth = len(_path_segments(prefix_no_slash)) - parent_depth
|
||||
if max_depth is not None and depth > max_depth:
|
||||
continue
|
||||
base = prefix_no_slash.rsplit("/", 1)[-1] or prefix_no_slash
|
||||
entry = FindEntry(key=prefix_no_slash,
|
||||
name=base,
|
||||
kind="d",
|
||||
depth=depth)
|
||||
if not keep(entry, tree, min_depth):
|
||||
continue
|
||||
out.append(prefix_no_slash)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
async def _filter_under_prefixes(
|
||||
stdout: ByteSource,
|
||||
descendant_prefixes: list[str],
|
||||
) -> bytes:
|
||||
"""Drop lines whose path falls under any descendant mount prefix.
|
||||
|
||||
Path is taken from the start of the line up to the first tab,
|
||||
colon, or whitespace (handles find / du / grep output formats).
|
||||
Lines that do not start with `/` are passed through.
|
||||
"""
|
||||
data = await materialize(stdout)
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
out_lines: list[str] = []
|
||||
for line in text.split("\n"):
|
||||
if line == "":
|
||||
continue
|
||||
path = line
|
||||
for sep in ("\t", ":"):
|
||||
if sep in path:
|
||||
path = path.split(sep, 1)[0]
|
||||
break
|
||||
if path.startswith("/"):
|
||||
shadowed = False
|
||||
for pre in descendant_prefixes:
|
||||
if path == pre or path.startswith(pre + "/"):
|
||||
shadowed = True
|
||||
break
|
||||
if shadowed:
|
||||
continue
|
||||
out_lines.append(line)
|
||||
return ("\n".join(out_lines) + "\n").encode("utf-8") if out_lines else b""
|
||||
|
||||
|
||||
async def _drop_mount_root_line(stdout: ByteSource, mount_root: str) -> bytes:
|
||||
"""Drop a descendant find's own mount-root line.
|
||||
|
||||
A per-mount find now emits its start directory, so a descendant
|
||||
mount's find yields its mount-root path. The mount-point entry is
|
||||
instead synthesized centrally (with the mount's display name and the
|
||||
full predicate tree applied), so the raw root line is dropped here to
|
||||
avoid a duplicate that skips the name filter.
|
||||
|
||||
Args:
|
||||
stdout (ByteSource): descendant find output.
|
||||
mount_root (str): the descendant mount root path.
|
||||
"""
|
||||
data = await materialize(stdout)
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
out_lines = [
|
||||
line for line in text.split("\n") if line != "" and line != mount_root
|
||||
]
|
||||
return ("\n".join(out_lines) + "\n").encode("utf-8") if out_lines else b""
|
||||
|
||||
|
||||
async def _fan_out_traversal(
|
||||
cmd_name: str,
|
||||
paths: list[PathSpec],
|
||||
texts: list[str],
|
||||
flag_kwargs: dict,
|
||||
registry: MountRegistry,
|
||||
primary_mount: object,
|
||||
cwd: str,
|
||||
cmd_str: str,
|
||||
stdin: ByteSource | None,
|
||||
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
||||
"""Run a traversal command across the parent mount + descendant mounts.
|
||||
|
||||
Each mount runs the command with its own root as the path argument
|
||||
(depth flags adjusted for find/tree). Outputs are concatenated in
|
||||
mount-prefix-sorted order. The parent mount's output is filtered to
|
||||
drop lines that fall under any descendant mount (avoids duplicates
|
||||
when the parent's resource has shadowed keys).
|
||||
|
||||
For `find`, mount-prefix paths themselves are injected as synthetic
|
||||
directory entries (subject to depth and -type filters) because
|
||||
mirage's per-mount find doesn't emit the path argument itself.
|
||||
"""
|
||||
target_path = paths[0].virtual
|
||||
descendants = registry.descendant_mounts(target_path)
|
||||
descendant_prefixes = [m.prefix.rstrip("/") for m in descendants]
|
||||
|
||||
all_stdout: list[bytes] = []
|
||||
merged_io = IOResult()
|
||||
final_exit = 0
|
||||
success_seen = False
|
||||
|
||||
for mount in [primary_mount] + list(descendants):
|
||||
if mount is primary_mount:
|
||||
sub_paths = list(paths)
|
||||
sub_flags = dict(flag_kwargs)
|
||||
sub_texts = list(texts)
|
||||
else:
|
||||
mount_root = mount.prefix.rstrip("/") or "/"
|
||||
sub_flags = _adjust_depth_flags(flag_kwargs, target_path,
|
||||
mount.prefix)
|
||||
if sub_flags is None:
|
||||
continue
|
||||
sub_texts = _adjust_depth_texts(texts, target_path, mount.prefix)
|
||||
sub_paths = [
|
||||
PathSpec(virtual=mount_root,
|
||||
directory=mount_root,
|
||||
resource_path="",
|
||||
resolved=True)
|
||||
]
|
||||
try:
|
||||
stdout, io = await mount.execute_cmd(cmd_name,
|
||||
sub_paths,
|
||||
sub_texts,
|
||||
sub_flags,
|
||||
stdin=stdin,
|
||||
cwd=cwd)
|
||||
except FindParseError:
|
||||
# A bad numeric/size/mtime argument is a usage error that
|
||||
# applies to every mount identically; fail the whole command
|
||||
# instead of silently skipping mounts and exiting 0.
|
||||
raise
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if mount is primary_mount and descendant_prefixes and stdout:
|
||||
stdout = await _filter_under_prefixes(stdout, descendant_prefixes)
|
||||
elif mount is not primary_mount and cmd_name == "find" and stdout:
|
||||
stdout = await _drop_mount_root_line(stdout, mount_root)
|
||||
|
||||
if stdout is not None:
|
||||
data = await materialize(stdout)
|
||||
if data:
|
||||
all_stdout.append(data)
|
||||
if io.exit_code == 0:
|
||||
success_seen = True
|
||||
elif io.exit_code != 0 and final_exit == 0:
|
||||
final_exit = io.exit_code
|
||||
merged_io = await merged_io.merge(io)
|
||||
|
||||
if cmd_name == "find":
|
||||
synthetic = _synthesize_find_mount_entries(target_path, descendants,
|
||||
texts)
|
||||
if synthetic:
|
||||
all_stdout.append(synthetic.encode("utf-8"))
|
||||
|
||||
combined: ByteSource | None
|
||||
if all_stdout:
|
||||
combined = b"\n".join(b.rstrip(b"\n") for b in all_stdout) + b"\n"
|
||||
else:
|
||||
combined = None
|
||||
final_io_exit = 0 if success_seen else final_exit
|
||||
|
||||
if cmd_name == "find":
|
||||
combined, action_err = await _apply_find_actions(
|
||||
combined, flag_kwargs, registry, cwd)
|
||||
if action_err:
|
||||
existing = (await materialize(merged_io.stderr)
|
||||
if merged_io.stderr else b"")
|
||||
merged_io.stderr = existing + action_err
|
||||
if final_io_exit == 0:
|
||||
final_io_exit = 1
|
||||
|
||||
merged_io.exit_code = final_io_exit
|
||||
merged_io.safeguard = resolve_across_mounts(cmd_name,
|
||||
[primary_mount, *descendants])
|
||||
exec_node = ExecutionNode(command=cmd_str,
|
||||
exit_code=final_io_exit,
|
||||
stderr=merged_io.stderr)
|
||||
return combined, merged_io, exec_node
|
||||
@@ -0,0 +1,131 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.io.stream import materialize
|
||||
from mirage.io.types import ByteSource
|
||||
from mirage.types import PathSpec
|
||||
from mirage.workspace.mount import MountRegistry
|
||||
|
||||
|
||||
async def _apply_find_actions(
|
||||
stdout: ByteSource | None,
|
||||
flag_kwargs: dict,
|
||||
registry: MountRegistry,
|
||||
cwd: str,
|
||||
) -> tuple[ByteSource | None, bytes]:
|
||||
"""Apply find action flags (-delete / -print0 / -ls) to find output.
|
||||
|
||||
Per-resource find handlers only emit matched paths. This dispatcher
|
||||
layer reads action flags and dispatches the side effect (rm for
|
||||
-delete, ls -ld for -ls) per match through the appropriate mount,
|
||||
then re-formats the output.
|
||||
|
||||
Args:
|
||||
stdout (ByteSource | None): newline-joined match list from find.
|
||||
flag_kwargs (dict): parsed flag dict; action flags read here.
|
||||
registry (MountRegistry): used to route per-match dispatch.
|
||||
cwd (str): cwd forwarded to per-match sub-dispatch.
|
||||
"""
|
||||
has_delete = flag_kwargs.get("delete") is True
|
||||
has_print0 = flag_kwargs.get("print0") is True
|
||||
has_ls = flag_kwargs.get("ls") is True
|
||||
has_print = flag_kwargs.get("print") is True
|
||||
|
||||
if not (has_delete or has_print0 or has_ls):
|
||||
return stdout, b""
|
||||
if stdout is None:
|
||||
return stdout, b""
|
||||
|
||||
text = (await materialize(stdout)).decode("utf-8", errors="replace")
|
||||
matches = [p for p in text.split("\n") if p]
|
||||
errors: list[bytes] = []
|
||||
|
||||
if has_delete:
|
||||
# Deepest-first so children are removed before parents.
|
||||
# Skip mount roots: mount points are structural, not
|
||||
# unlinkable entries — refusing matches Unix semantics.
|
||||
deletable = [p for p in matches if not registry.is_mount_root(p)]
|
||||
ordered = sorted(deletable, key=lambda p: p.count("/"), reverse=True)
|
||||
for path in ordered:
|
||||
try:
|
||||
mount = registry.mount_for(path)
|
||||
except ValueError:
|
||||
msg = f"find: cannot delete '{path}': no mount\n"
|
||||
errors.append(msg.encode())
|
||||
continue
|
||||
ps = PathSpec(
|
||||
virtual=path,
|
||||
directory=path[:path.rfind("/") + 1] or "/",
|
||||
resource_path="",
|
||||
resolved=True,
|
||||
)
|
||||
try:
|
||||
_, rm_io = await mount.execute_cmd("rm", [ps], [], {},
|
||||
stdin=None,
|
||||
cwd=cwd)
|
||||
except (FileNotFoundError, NotADirectoryError, PermissionError,
|
||||
ValueError) as exc:
|
||||
errors.append(
|
||||
f"find: cannot delete '{path}': {exc}\n".encode())
|
||||
continue
|
||||
if rm_io.exit_code != 0:
|
||||
err = await materialize(rm_io.stderr) if rm_io.stderr else b""
|
||||
if not err:
|
||||
err = f"find: cannot delete '{path}'\n".encode()
|
||||
errors.append(err)
|
||||
# GNU find: -delete suppresses default print unless -print also set.
|
||||
output_matches = matches if has_print else []
|
||||
elif has_ls:
|
||||
output_matches = []
|
||||
for path in matches:
|
||||
try:
|
||||
mount = registry.mount_for(path)
|
||||
except ValueError:
|
||||
errors.append(f"find: cannot ls '{path}': no mount\n".encode())
|
||||
continue
|
||||
ps = PathSpec(
|
||||
virtual=path,
|
||||
directory=path[:path.rfind("/") + 1] or "/",
|
||||
resource_path="",
|
||||
resolved=True,
|
||||
)
|
||||
try:
|
||||
ls_out, _ = await mount.execute_cmd("ls", [ps], [], {
|
||||
"args_l": True,
|
||||
"d": True
|
||||
},
|
||||
stdin=None,
|
||||
cwd=cwd)
|
||||
except (FileNotFoundError, NotADirectoryError, PermissionError,
|
||||
ValueError) as exc:
|
||||
errors.append(f"find: cannot ls '{path}': {exc}\n".encode())
|
||||
continue
|
||||
if ls_out is not None:
|
||||
line = (await materialize(ls_out)).decode(
|
||||
"utf-8", errors="replace").rstrip("\n")
|
||||
if line:
|
||||
output_matches.append(line)
|
||||
else:
|
||||
output_matches = matches
|
||||
|
||||
err_blob = b"".join(errors)
|
||||
if not output_matches:
|
||||
return None, err_blob
|
||||
|
||||
if has_print0:
|
||||
body = b"\x00".join(m.encode("utf-8")
|
||||
for m in output_matches) + b"\x00"
|
||||
else:
|
||||
body = ("\n".join(output_matches) + "\n").encode("utf-8")
|
||||
return body, err_blob
|
||||
@@ -0,0 +1,179 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
|
||||
import tree_sitter
|
||||
|
||||
from mirage.commands.builtin.utils.safeguard import CommandTimeoutError
|
||||
from mirage.io import IOResult
|
||||
from mirage.io.types import ByteSource, materialize
|
||||
from mirage.shell.helpers import get_text
|
||||
from mirage.shell.job_table import JobTable
|
||||
from mirage.workspace.session import Session
|
||||
from mirage.workspace.types import ExecutionNode
|
||||
|
||||
|
||||
async def handle_background(
|
||||
execute_node,
|
||||
left: tree_sitter.Node,
|
||||
right: tree_sitter.Node | None,
|
||||
session: Session,
|
||||
job_table: JobTable | None,
|
||||
agent_id: str | None,
|
||||
stdin: ByteSource | None = None,
|
||||
call_stack=None,
|
||||
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
||||
"""Run left side in background."""
|
||||
bg_session = session.fork()
|
||||
|
||||
async def _run_bg():
|
||||
# Background jobs don't receive stdin, matching real shell
|
||||
# behavior where bg processes get /dev/null. This prevents
|
||||
# race conditions when stdin is an async iterator.
|
||||
cmd_str_inner = get_text(left) if hasattr(left, "text") else str(left)
|
||||
try:
|
||||
stdout, io, exec_node = await execute_node(left, bg_session, None,
|
||||
call_stack)
|
||||
except CommandTimeoutError as exc:
|
||||
msg = (str(exc) + "\n").encode()
|
||||
io = IOResult(exit_code=124, stderr=msg)
|
||||
exec_node = ExecutionNode(command=cmd_str_inner,
|
||||
stderr=msg,
|
||||
exit_code=124)
|
||||
return b"", io, exec_node
|
||||
stdout = await materialize(stdout)
|
||||
# Eagerly materialize stderr too so JobTable._refresh can read
|
||||
# the task result synchronously without an async hop.
|
||||
await io.materialize_stderr()
|
||||
io.sync_exit_code()
|
||||
return stdout, io, exec_node
|
||||
|
||||
task = asyncio.create_task(_run_bg())
|
||||
cmd_str = get_text(left) if hasattr(left, 'text') else str(left)
|
||||
|
||||
if job_table is not None:
|
||||
job = job_table.submit(command=cmd_str,
|
||||
task=task,
|
||||
cwd=bg_session.cwd,
|
||||
agent=agent_id or "",
|
||||
session_id=session.session_id)
|
||||
job_line = f"[{job.id}]\n".encode()
|
||||
else:
|
||||
job_line = b"[bg]\n"
|
||||
|
||||
if right is None:
|
||||
return None, IOResult(stderr=job_line), ExecutionNode(
|
||||
op="&",
|
||||
exit_code=0,
|
||||
children=[ExecutionNode(command=cmd_str, exit_code=0)])
|
||||
|
||||
right_stdout, right_io, right_exec = await execute_node(
|
||||
right, session, stdin, call_stack)
|
||||
left_stderr = await materialize(right_io.stderr)
|
||||
right_io.stderr = (job_line + left_stderr if left_stderr else job_line)
|
||||
children = [
|
||||
ExecutionNode(command=cmd_str, exit_code=0),
|
||||
right_exec,
|
||||
]
|
||||
return right_stdout, right_io, ExecutionNode(op="&",
|
||||
exit_code=right_io.exit_code,
|
||||
children=children)
|
||||
|
||||
|
||||
async def handle_wait(
|
||||
job_table: JobTable,
|
||||
parts: list[str],
|
||||
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
||||
cmd_str = " ".join(parts)
|
||||
if len(parts) <= 1:
|
||||
await job_table.wait_all()
|
||||
return None, IOResult(), ExecutionNode(command=cmd_str, exit_code=0)
|
||||
raw = parts[1].lstrip("%")
|
||||
try:
|
||||
job_id = int(raw)
|
||||
except ValueError:
|
||||
err = f"wait: invalid job id: {parts[1]}\n".encode()
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command=cmd_str,
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
job = job_table.get(job_id)
|
||||
if job is None:
|
||||
err = f"wait: no such job: {job_id}\n".encode()
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command=cmd_str,
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
job = await job_table.wait(job_id)
|
||||
return job.stdout, IOResult(
|
||||
exit_code=job.exit_code,
|
||||
stderr=job.stderr or None,
|
||||
), ExecutionNode(command=cmd_str, exit_code=job.exit_code)
|
||||
|
||||
|
||||
async def handle_kill(
|
||||
job_table: JobTable,
|
||||
parts: list[str],
|
||||
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
||||
cmd_str = " ".join(parts)
|
||||
if len(parts) < 2:
|
||||
err = b"kill: usage: kill <job_id>\n"
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command=cmd_str,
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
raw = parts[1].lstrip("%")
|
||||
try:
|
||||
job_id = int(raw)
|
||||
except ValueError:
|
||||
err = f"kill: invalid job id: {parts[1]}\n".encode()
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command=cmd_str,
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
killed = job_table.kill(job_id)
|
||||
if not killed:
|
||||
err = f"kill: no such job: {job_id}\n".encode()
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command=cmd_str,
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
return None, IOResult(), ExecutionNode(command=cmd_str, exit_code=0)
|
||||
|
||||
|
||||
async def handle_jobs(
|
||||
job_table: JobTable,
|
||||
parts: list[str],
|
||||
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
||||
cmd_str = " ".join(parts)
|
||||
lines = []
|
||||
for job in job_table.list_jobs():
|
||||
lines.append(f"[{job.id}] {job.status.value} {job.command}")
|
||||
job_table.pop_completed()
|
||||
out = ("\n".join(lines) + "\n").encode() if lines else b""
|
||||
return out, IOResult(), ExecutionNode(command=cmd_str, exit_code=0)
|
||||
|
||||
|
||||
async def handle_ps(
|
||||
job_table: JobTable,
|
||||
parts: list[str],
|
||||
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
||||
cmd_str = " ".join(parts)
|
||||
running = job_table.running_jobs()
|
||||
lines = []
|
||||
for job in running:
|
||||
lines.append(f"{job.id}\t{job.command}")
|
||||
out = ("\n".join(lines) + "\n").encode() if lines else b""
|
||||
return out, IOResult(), ExecutionNode(command=cmd_str, exit_code=0)
|
||||
@@ -0,0 +1,215 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import tree_sitter
|
||||
|
||||
from mirage.commands.builtin.utils.safeguard import run_with_timeout
|
||||
from mirage.io import IOResult
|
||||
from mirage.io.stream import async_chain, close_quietly, merge_stdout_stderr
|
||||
from mirage.io.types import ByteSource, materialize
|
||||
from mirage.shell.barrier import BarrierPolicy, apply_barrier
|
||||
from mirage.shell.call_stack import CallStack
|
||||
from mirage.shell.types import ERREXIT_EXEMPT_TYPES
|
||||
from mirage.shell.types import NodeType as NT
|
||||
from mirage.workspace.session import Session
|
||||
from mirage.workspace.types import ExecutionNode
|
||||
|
||||
|
||||
async def handle_pipe(
|
||||
execute_node,
|
||||
commands: list[tree_sitter.Node],
|
||||
stderr_flags: list[bool],
|
||||
session: Session,
|
||||
stdin: ByteSource | None = None,
|
||||
call_stack: CallStack | None = None,
|
||||
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
||||
"""Connect commands via pipes: stdout -> stdin."""
|
||||
current_stdin = stdin
|
||||
last_stdout: ByteSource | None = None
|
||||
child_nodes: list[ExecutionNode] = []
|
||||
ios: list[IOResult] = []
|
||||
intermediate_streams: list[ByteSource] = []
|
||||
|
||||
try:
|
||||
for i, cmd in enumerate(commands):
|
||||
stdout, io, child_exec = await execute_node(
|
||||
cmd, session, current_stdin, call_stack)
|
||||
ios.append(io)
|
||||
child_nodes.append(child_exec)
|
||||
|
||||
if i < len(commands) - 1:
|
||||
pipe_stderr = (i < len(stderr_flags) and stderr_flags[i])
|
||||
if pipe_stderr:
|
||||
current_stdin = merge_stdout_stderr(stdout, io)
|
||||
else:
|
||||
current_stdin = stdout
|
||||
if current_stdin is None:
|
||||
current_stdin = b""
|
||||
if not isinstance(current_stdin, bytes):
|
||||
intermediate_streams.append(current_stdin)
|
||||
last_stdout = stdout
|
||||
|
||||
if last_stdout is not None and not isinstance(last_stdout, bytes):
|
||||
materialized = await run_with_timeout(
|
||||
materialize(last_stdout), session.pipeline_timeout_seconds,
|
||||
"pipeline")
|
||||
last_stdout = materialized
|
||||
finally:
|
||||
# Explicitly close any intermediate generators that may still
|
||||
# be holding resource resources (HTTP connections, file
|
||||
# handles). Harmless on exhausted streams.
|
||||
for s in intermediate_streams:
|
||||
await close_quietly(s)
|
||||
|
||||
last_io = ios[-1]
|
||||
last_io.sync_exit_code()
|
||||
if session.shell_options.get("pipefail"):
|
||||
for io in ios:
|
||||
io.sync_exit_code()
|
||||
rightmost_failure = next(
|
||||
(io.exit_code for io in reversed(ios) if io.exit_code != 0), 0)
|
||||
if rightmost_failure != 0:
|
||||
last_io.exit_code = rightmost_failure
|
||||
merged_stderr_parts: list[bytes] = []
|
||||
merged_reads: dict[str, ByteSource] = {}
|
||||
merged_writes: dict[str, ByteSource] = {}
|
||||
merged_cache: list[str] = []
|
||||
for io, child in zip(ios, child_nodes):
|
||||
io.sync_exit_code()
|
||||
child.exit_code = io.exit_code
|
||||
stderr_bytes = await materialize(io.stderr)
|
||||
if stderr_bytes:
|
||||
merged_stderr_parts.append(stderr_bytes)
|
||||
merged_reads.update(io.reads)
|
||||
merged_writes.update(io.writes)
|
||||
merged_cache.extend(io.cache)
|
||||
|
||||
if merged_stderr_parts:
|
||||
last_io.stderr = b"".join(merged_stderr_parts)
|
||||
last_io.reads = merged_reads
|
||||
last_io.writes = merged_writes
|
||||
last_io.cache = merged_cache
|
||||
|
||||
exec_node = ExecutionNode(op="|",
|
||||
exit_code=last_io.exit_code,
|
||||
children=child_nodes)
|
||||
return last_stdout, last_io, exec_node
|
||||
|
||||
|
||||
async def handle_connection(
|
||||
execute_node,
|
||||
left: tree_sitter.Node,
|
||||
op: str,
|
||||
right: tree_sitter.Node,
|
||||
session: Session,
|
||||
stdin: ByteSource | None = None,
|
||||
call_stack: CallStack | None = None,
|
||||
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
||||
"""Handle &&, ||"""
|
||||
left_stdout, left_io, left_exec = await execute_node(
|
||||
left, session, stdin, call_stack)
|
||||
children = [left_exec]
|
||||
|
||||
if op == NT.AND:
|
||||
left_bytes = await apply_barrier(left_stdout, left_io,
|
||||
BarrierPolicy.VALUE)
|
||||
session.last_exit_code = left_io.exit_code
|
||||
if left_io.exit_code != 0:
|
||||
return left_bytes, left_io, ExecutionNode(
|
||||
op="&&", exit_code=left_io.exit_code, children=children)
|
||||
right_stdout, right_io, right_exec = (await execute_node(
|
||||
right, session, stdin, call_stack))
|
||||
children.append(right_exec)
|
||||
right_bytes = await materialize(right_stdout)
|
||||
merged = await left_io.merge(right_io)
|
||||
combined = async_chain(left_bytes, right_bytes)
|
||||
return combined, merged, ExecutionNode(op="&&",
|
||||
exit_code=merged.exit_code,
|
||||
children=children)
|
||||
|
||||
if op == NT.OR:
|
||||
left_bytes = await apply_barrier(left_stdout, left_io,
|
||||
BarrierPolicy.VALUE)
|
||||
session.last_exit_code = left_io.exit_code
|
||||
if left_io.exit_code == 0:
|
||||
return left_bytes, left_io, ExecutionNode(
|
||||
op="||", exit_code=left_io.exit_code, children=children)
|
||||
right_stdout, right_io, right_exec = (await execute_node(
|
||||
right, session, stdin, call_stack))
|
||||
children.append(right_exec)
|
||||
right_bytes = await materialize(right_stdout)
|
||||
merged = await left_io.merge(right_io)
|
||||
combined = async_chain(left_bytes, right_bytes)
|
||||
return combined, merged, ExecutionNode(op="||",
|
||||
exit_code=merged.exit_code,
|
||||
children=children)
|
||||
|
||||
# semicolon or other
|
||||
left_bytes = await apply_barrier(left_stdout, left_io, BarrierPolicy.VALUE)
|
||||
session.last_exit_code = left_io.exit_code
|
||||
right_stdout, right_io, right_exec = await execute_node(
|
||||
right, session, stdin, call_stack)
|
||||
children.append(right_exec)
|
||||
# Materialize right side to match && and || behavior, ensuring
|
||||
# lazy exit codes (e.g. from exit_on_empty) are finalized before
|
||||
# the combined stream is returned to the caller.
|
||||
right_bytes = await materialize(right_stdout)
|
||||
merged = await left_io.merge(right_io)
|
||||
combined = async_chain(left_bytes, right_bytes)
|
||||
return combined, merged, ExecutionNode(op=str(op),
|
||||
exit_code=merged.exit_code,
|
||||
children=children)
|
||||
|
||||
|
||||
async def handle_subshell(
|
||||
execute_node,
|
||||
body: list[tree_sitter.Node],
|
||||
session: Session,
|
||||
stdin: ByteSource | None = None,
|
||||
call_stack: CallStack | None = None,
|
||||
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
||||
"""Execute body in isolated env."""
|
||||
saved_cwd = session.cwd
|
||||
saved_env = dict(session.env)
|
||||
saved_options = dict(session.shell_options)
|
||||
saved_readonly = set(session.readonly_vars)
|
||||
saved_arrays = {k: list(v) for k, v in session.arrays.items()}
|
||||
saved_functions = dict(session.functions)
|
||||
saved_positional = list(getattr(session, "positional_args", None) or [])
|
||||
try:
|
||||
all_stdout: list = []
|
||||
merged_io = IOResult()
|
||||
last_exec = ExecutionNode(command="()", exit_code=0)
|
||||
for child in body:
|
||||
stdout, io, last_exec = await execute_node(child, session, stdin,
|
||||
call_stack)
|
||||
if stdout is not None:
|
||||
all_stdout.append(stdout)
|
||||
merged_io = await merged_io.merge(io)
|
||||
if (io.exit_code != 0 and session.shell_options.get("errexit")
|
||||
and child.type not in ERREXIT_EXEMPT_TYPES):
|
||||
merged_io.exit_code = io.exit_code
|
||||
break
|
||||
if len(all_stdout) == 1:
|
||||
return all_stdout[0], merged_io, last_exec
|
||||
combined = async_chain(*all_stdout) if all_stdout else None
|
||||
return combined, merged_io, last_exec
|
||||
finally:
|
||||
session.cwd = saved_cwd
|
||||
session.env = saved_env
|
||||
session.shell_options = saved_options
|
||||
session.readonly_vars = saved_readonly
|
||||
session.arrays = saved_arrays
|
||||
session.functions = saved_functions
|
||||
session.positional_args = saved_positional
|
||||
@@ -0,0 +1,148 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import tree_sitter
|
||||
|
||||
from mirage.io import IOResult
|
||||
from mirage.io.stream import materialize
|
||||
from mirage.io.types import ByteSource
|
||||
from mirage.shell.barrier import BarrierPolicy, apply_barrier
|
||||
from mirage.shell.call_stack import CallStack
|
||||
from mirage.shell.types import Redirect, RedirectKind
|
||||
from mirage.types import PathSpec
|
||||
from mirage.workspace.executor.builtins import _to_scope
|
||||
from mirage.workspace.session import Session
|
||||
from mirage.workspace.types import ExecutionNode
|
||||
|
||||
|
||||
async def handle_redirect(
|
||||
execute_node,
|
||||
dispatch,
|
||||
command: tree_sitter.Node,
|
||||
redirects: list[Redirect],
|
||||
session: Session,
|
||||
stdin: ByteSource | None = None,
|
||||
call_stack: CallStack | None = None,
|
||||
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
||||
"""Handle all redirect patterns: >, >>, <, 2>, 2>&1, &>, >&2, <<<."""
|
||||
cmd_stdin = stdin
|
||||
for r in redirects:
|
||||
if r.kind == RedirectKind.STDIN:
|
||||
scope = _ensure_scope(r.target)
|
||||
file_data, _ = await dispatch("read", scope)
|
||||
cmd_stdin = file_data
|
||||
elif r.kind == RedirectKind.HEREDOC:
|
||||
cmd_stdin = r.target.encode() if isinstance(r.target,
|
||||
str) else r.target
|
||||
elif r.kind == RedirectKind.HERESTRING:
|
||||
text = r.target
|
||||
if isinstance(text, str):
|
||||
if text.startswith('"') and text.endswith('"'):
|
||||
text = text[1:-1]
|
||||
elif text.startswith("'") and text.endswith("'"):
|
||||
text = text[1:-1]
|
||||
cmd_stdin = (text + "\n").encode()
|
||||
else:
|
||||
cmd_stdin = text
|
||||
|
||||
stdout, io, exec_node = await execute_node(command, session, cmd_stdin,
|
||||
call_stack)
|
||||
|
||||
stdout_data = await apply_barrier(stdout, io, BarrierPolicy.VALUE)
|
||||
if stdout_data is None:
|
||||
stdout_data = b""
|
||||
if isinstance(stdout_data, memoryview):
|
||||
stdout_data = bytes(stdout_data)
|
||||
|
||||
stderr_data = await materialize(io.stderr)
|
||||
|
||||
result_stdout = stdout_data
|
||||
result_stderr = stderr_data
|
||||
|
||||
for r in redirects:
|
||||
stream = r.kind
|
||||
append = r.append
|
||||
fd = r.fd
|
||||
|
||||
if stream in (RedirectKind.STDIN, RedirectKind.HEREDOC,
|
||||
RedirectKind.HERESTRING):
|
||||
continue
|
||||
|
||||
# 2>&1 — merge stderr into stdout
|
||||
if stream == RedirectKind.STDERR_TO_STDOUT and isinstance(
|
||||
r.target, int):
|
||||
result_stdout = (result_stdout or b"") + (result_stderr or b"")
|
||||
result_stderr = None
|
||||
continue
|
||||
|
||||
# >&2 or 1>&2 — stdout to stderr
|
||||
if fd == 1 and isinstance(r.target, int) and r.target == 2:
|
||||
result_stderr = (result_stderr or b"") + (result_stdout or b"")
|
||||
result_stdout = None
|
||||
continue
|
||||
|
||||
scope = _ensure_scope(r.target)
|
||||
path = scope.virtual
|
||||
|
||||
# &> or &>> — both stdout+stderr to file
|
||||
if fd == -1:
|
||||
combined = (result_stdout or b"") + (result_stderr or b"")
|
||||
if append:
|
||||
combined = await _append_existing(dispatch, scope, combined)
|
||||
await dispatch("write", scope, data=combined)
|
||||
io.writes[path] = combined
|
||||
result_stdout = None
|
||||
result_stderr = None
|
||||
continue
|
||||
|
||||
# 2> file — stderr to file
|
||||
if stream == RedirectKind.STDERR:
|
||||
data = result_stderr or b""
|
||||
if append:
|
||||
data = await _append_existing(dispatch, scope, data)
|
||||
if data:
|
||||
await dispatch("write", scope, data=data)
|
||||
io.writes[path] = data
|
||||
result_stderr = None
|
||||
continue
|
||||
|
||||
# > or >> — stdout to file
|
||||
data = result_stdout or b""
|
||||
if append:
|
||||
data = await _append_existing(dispatch, scope, data)
|
||||
await dispatch("write", scope, data=data)
|
||||
io.writes[path] = data
|
||||
result_stdout = None
|
||||
|
||||
io.stderr = result_stderr
|
||||
exec_node = ExecutionNode(command="redirect", exit_code=io.exit_code)
|
||||
return result_stdout if result_stdout else None, io, exec_node
|
||||
|
||||
|
||||
async def _append_existing(dispatch, scope, data):
|
||||
try:
|
||||
existing, _ = await dispatch("read", scope)
|
||||
if isinstance(existing, bytes):
|
||||
return existing + data
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return data
|
||||
|
||||
|
||||
def _ensure_scope(target):
|
||||
if isinstance(target, PathSpec):
|
||||
return target
|
||||
if isinstance(target, str):
|
||||
return _to_scope(target)
|
||||
return _to_scope(str(target))
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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({"$((", "((", "))"})
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -0,0 +1,19 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.workspace.node.execute_node import execute_node
|
||||
from mirage.workspace.node.provision_node import provision_node
|
||||
from mirage.workspace.node.run_tree import run_command_tree
|
||||
|
||||
__all__ = ["execute_node", "provision_node", "run_command_tree"]
|
||||
@@ -0,0 +1,437 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from mirage.commands.builtin.utils.safeguard import run_with_timeout
|
||||
from mirage.commands.safeguard import resolve_safeguard
|
||||
from mirage.io import IOResult
|
||||
from mirage.shell.types import NodeType as NT
|
||||
from mirage.shell.types import ShellBuiltin as SB
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.path import CycleError
|
||||
from mirage.workspace.executor.command import handle_command
|
||||
from mirage.workspace.executor.control import BreakSignal, ContinueSignal
|
||||
from mirage.workspace.expand import expand_node
|
||||
from mirage.workspace.expand.argv import Argv, expand_argv
|
||||
from mirage.workspace.expand.classify import classify_bare_path
|
||||
from mirage.workspace.route import NO_FOLLOW_COMMANDS, UNSUPPORTED_BUILTINS
|
||||
from mirage.workspace.session.shell_dirs import home_dir
|
||||
from mirage.workspace.types import ExecutionNode
|
||||
|
||||
from mirage.shell.helpers import ( # isort: skip
|
||||
ProcessSubDirection, get_command_name, get_parts,
|
||||
get_process_sub_direction, get_text, split_env_prefix)
|
||||
from mirage.workspace.executor.builtins import ( # isort: skip
|
||||
follow_paths, handle_bash, handle_cd, handle_echo, handle_eval,
|
||||
handle_export, handle_history, handle_ln, handle_local, handle_man,
|
||||
handle_printenv, handle_printf, handle_read, handle_readlink,
|
||||
handle_return, handle_set, handle_shift, handle_sleep, handle_source,
|
||||
handle_test, handle_timeout, handle_trap, handle_unset, handle_whoami,
|
||||
handle_xargs, link_flags, prepare_mv, strip_link_operands)
|
||||
|
||||
_CdArgs = list[str | PathSpec]
|
||||
|
||||
|
||||
def _split_cd_options(args: _CdArgs) -> tuple[_CdArgs, str | None, bool]:
|
||||
"""Split leading ``cd`` option flags from the directory operand.
|
||||
|
||||
Accepts the GNU ``cd`` options ``-L -P -e -@`` (and clusters such as
|
||||
``-LP``) plus a ``--`` end-of-options marker; a bare ``-`` is the
|
||||
OLDPWD operand, not an option.
|
||||
|
||||
Args:
|
||||
args: The classified arguments after the ``cd`` command name.
|
||||
|
||||
Returns:
|
||||
``(operands, bad, physical)`` where ``operands`` are the non-option
|
||||
args, ``bad`` is the first unknown option character (or ``None``),
|
||||
and ``physical`` is True when ``-P`` is the effective (last-wins)
|
||||
mode.
|
||||
"""
|
||||
operands: _CdArgs = []
|
||||
parsing = True
|
||||
physical = False
|
||||
for arg in args:
|
||||
s = arg.virtual if isinstance(arg, PathSpec) else str(arg)
|
||||
if parsing:
|
||||
if s == "--":
|
||||
parsing = False
|
||||
continue
|
||||
if s != "-" and len(s) >= 2 and s.startswith("-"):
|
||||
bad = next((c for c in s[1:] if c not in "LPe@"), None)
|
||||
if bad is None:
|
||||
for c in s[1:]:
|
||||
if c == "P":
|
||||
physical = True
|
||||
elif c == "L":
|
||||
physical = False
|
||||
continue
|
||||
return operands, bad, physical
|
||||
parsing = False
|
||||
operands.append(arg)
|
||||
return operands, None, physical
|
||||
|
||||
|
||||
async def execute_command(
|
||||
recurse,
|
||||
dispatch,
|
||||
registry,
|
||||
namespace,
|
||||
execute_fn,
|
||||
node,
|
||||
session,
|
||||
stdin,
|
||||
call_stack,
|
||||
job_table,
|
||||
cancel: asyncio.Event | None = None,
|
||||
) -> tuple[Any, IOResult, ExecutionNode]:
|
||||
"""Dispatch a command node by name."""
|
||||
name = get_command_name(node)
|
||||
assignment_nodes, parts = split_env_prefix(get_parts(node))
|
||||
|
||||
prefix_assignments: list[tuple[str, str]] = []
|
||||
for p in assignment_nodes:
|
||||
atext = get_text(p)
|
||||
if "=" not in atext:
|
||||
continue
|
||||
key, _, raw_val = atext.partition("=")
|
||||
val_nodes = [c for c in p.named_children if c.type != NT.VARIABLE_NAME]
|
||||
if val_nodes:
|
||||
v = await expand_node(val_nodes[0], session, execute_fn,
|
||||
call_stack)
|
||||
else:
|
||||
v = raw_val
|
||||
prefix_assignments.append((key, v))
|
||||
|
||||
for k, _ in prefix_assignments:
|
||||
if k in session.readonly_vars:
|
||||
err = f"bash: {k}: readonly variable\n".encode()
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command=name or k,
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
|
||||
if prefix_assignments and not name:
|
||||
for k, v in prefix_assignments:
|
||||
session.env[k] = v
|
||||
return None, IOResult(), ExecutionNode(command=" ".join(
|
||||
f"{k}={v}" for k, v in prefix_assignments),
|
||||
exit_code=0)
|
||||
|
||||
is_function_call = name in session.functions
|
||||
saved_env_overrides: dict[str, str | None] = {}
|
||||
for k, v in prefix_assignments:
|
||||
if not is_function_call:
|
||||
saved_env_overrides[k] = session.env.get(k)
|
||||
session.env[k] = v
|
||||
|
||||
try:
|
||||
return await _dispatch_command_body(recurse, dispatch, registry,
|
||||
namespace, execute_fn, node, parts,
|
||||
name, session, stdin, call_stack,
|
||||
job_table, cancel)
|
||||
finally:
|
||||
for k, prev in saved_env_overrides.items():
|
||||
if prev is None:
|
||||
session.env.pop(k, None)
|
||||
else:
|
||||
session.env[k] = prev
|
||||
|
||||
|
||||
async def _dispatch_command_body(
|
||||
recurse,
|
||||
dispatch,
|
||||
registry,
|
||||
namespace,
|
||||
execute_fn,
|
||||
node,
|
||||
parts,
|
||||
name,
|
||||
session,
|
||||
stdin,
|
||||
call_stack,
|
||||
job_table,
|
||||
cancel: asyncio.Event | None = None,
|
||||
) -> tuple[Any, IOResult, ExecutionNode]:
|
||||
for child in node.named_children:
|
||||
if child.type == NT.HERESTRING_REDIRECT:
|
||||
for sc in child.named_children:
|
||||
content = await expand_node(sc, session, execute_fn,
|
||||
call_stack)
|
||||
stdin = content.encode() + b"\n"
|
||||
break
|
||||
|
||||
# Process substitution: <(cmd) feeds inner stdout as stdin.
|
||||
# Output direction >(cmd) is unsupported; reject early so the
|
||||
# caller sees a capability gap rather than a silent no-op.
|
||||
proc_sub_parts = []
|
||||
clean_parts = []
|
||||
for p in parts:
|
||||
if hasattr(p, "type") and p.type == NT.PROCESS_SUBSTITUTION:
|
||||
if get_process_sub_direction(p) == ProcessSubDirection.OUTPUT:
|
||||
err = b"mirage: unsupported: process substitution >(...)\n"
|
||||
return None, IOResult(exit_code=2, stderr=err), ExecutionNode(
|
||||
command=name or "process_sub", exit_code=2, stderr=err)
|
||||
inner_cmds = [c for c in p.named_children if c.type == NT.COMMAND]
|
||||
if inner_cmds:
|
||||
io_ps = await execute_fn(get_text(inner_cmds[0]),
|
||||
session_id=session.session_id)
|
||||
proc_sub_parts.append(io_ps.stdout or b"")
|
||||
else:
|
||||
clean_parts.append(p)
|
||||
if proc_sub_parts and stdin is None:
|
||||
stdin = b"".join(proc_sub_parts)
|
||||
parts = clean_parts
|
||||
|
||||
argv = await expand_argv(parts, session, execute_fn, call_stack, registry)
|
||||
|
||||
# Safeguards resolve against the expanded name, so `$CMD`-style
|
||||
# invocations get their real command's policy.
|
||||
resolved = resolve_safeguard(argv.name) if argv.name else None
|
||||
timeout = (resolved.timeout_seconds if resolved is not None else None)
|
||||
body = _run_argv(recurse, dispatch, registry, namespace, execute_fn, argv,
|
||||
session, stdin, call_stack, job_table, cancel)
|
||||
return await run_with_timeout(body, timeout, argv.name or "?")
|
||||
|
||||
|
||||
async def _run_argv(
|
||||
recurse,
|
||||
dispatch,
|
||||
registry,
|
||||
namespace,
|
||||
execute_fn,
|
||||
argv: Argv,
|
||||
session,
|
||||
stdin,
|
||||
call_stack,
|
||||
job_table,
|
||||
cancel: asyncio.Event | None = None,
|
||||
) -> tuple[Any, IOResult, ExecutionNode]:
|
||||
"""Route one expanded command to its builtin or mount handler."""
|
||||
name = argv.name
|
||||
args = list(argv.args)
|
||||
operands = list(argv.operands)
|
||||
|
||||
# ── unsupported bash builtins ──────────────
|
||||
# Constructs the parser accepts but the executor cannot honor.
|
||||
# Returning a clear error lets LLMs detect a capability gap instead
|
||||
# of treating it as a missing binary or a silent no-op.
|
||||
if name in UNSUPPORTED_BUILTINS:
|
||||
err = f"mirage: unsupported builtin: {name}\n".encode()
|
||||
return None, IOResult(exit_code=2,
|
||||
stderr=err), ExecutionNode(command=name,
|
||||
exit_code=2,
|
||||
stderr=err)
|
||||
|
||||
# ── shell builtins ──────────────────────────
|
||||
if name == SB.PWD:
|
||||
out = (session.cwd + "\n").encode()
|
||||
return out, IOResult(), ExecutionNode(command="pwd", exit_code=0)
|
||||
|
||||
if name == SB.CD:
|
||||
cd_operands, bad_opt, physical = _split_cd_options(operands)
|
||||
if bad_opt is not None:
|
||||
err = (f"cd: -{bad_opt}: invalid option\n"
|
||||
f"cd: usage: cd [-L|[-P [-e]] [-@]] [dir]\n").encode()
|
||||
return None, IOResult(exit_code=2,
|
||||
stderr=err), ExecutionNode(command="cd",
|
||||
exit_code=2,
|
||||
stderr=err)
|
||||
if len(cd_operands) > 1:
|
||||
err = b"cd: too many arguments\n"
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command="cd",
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
if not cd_operands:
|
||||
home = home_dir(session)
|
||||
if home is None:
|
||||
err = b"cd: HOME not set\n"
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command="cd",
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
return await handle_cd(dispatch,
|
||||
registry.is_mount_root,
|
||||
home,
|
||||
session,
|
||||
links=namespace.symlink_targets(),
|
||||
physical=physical)
|
||||
raw = cd_operands[0]
|
||||
raw_str = raw.virtual if isinstance(raw, PathSpec) else str(raw)
|
||||
if raw_str == "-":
|
||||
old = session.env.get("OLDPWD")
|
||||
if not old:
|
||||
err = b"cd: OLDPWD not set\n"
|
||||
return None, IOResult(exit_code=1, stderr=err), ExecutionNode(
|
||||
command="cd -", exit_code=1, stderr=err)
|
||||
return await handle_cd(dispatch,
|
||||
registry.is_mount_root,
|
||||
old,
|
||||
session,
|
||||
print_path=True,
|
||||
links=namespace.symlink_targets(),
|
||||
physical=physical)
|
||||
if isinstance(raw, PathSpec):
|
||||
path = raw
|
||||
cdpath_target = raw.raw_path
|
||||
elif raw_str.startswith("/"):
|
||||
path = raw_str
|
||||
cdpath_target = raw_str
|
||||
else:
|
||||
path = classify_bare_path(raw_str, registry, session.cwd)
|
||||
cdpath_target = raw_str
|
||||
return await handle_cd(dispatch,
|
||||
registry.is_mount_root,
|
||||
path,
|
||||
session,
|
||||
cdpath_target=cdpath_target,
|
||||
links=namespace.symlink_targets(),
|
||||
physical=physical)
|
||||
|
||||
if name == SB.HISTORY:
|
||||
return await handle_history(registry, args, session)
|
||||
|
||||
if name == SB.TRUE:
|
||||
return None, IOResult(), ExecutionNode(command="true", exit_code=0)
|
||||
|
||||
if name == SB.FALSE:
|
||||
return None, IOResult(exit_code=1), ExecutionNode(command="false",
|
||||
exit_code=1)
|
||||
|
||||
if name in (SB.SOURCE, SB.DOT):
|
||||
path = operands[0] if operands else ""
|
||||
return await handle_source(dispatch, execute_fn, path, session)
|
||||
|
||||
if name == SB.EVAL:
|
||||
return await handle_eval(execute_fn, args, session)
|
||||
|
||||
if name in (SB.BASH, SB.SH):
|
||||
return await handle_bash(execute_fn, args, session, stdin)
|
||||
|
||||
if name == SB.EXPORT:
|
||||
return await handle_export(args, session)
|
||||
|
||||
if name == SB.UNSET:
|
||||
return await handle_unset(args, session)
|
||||
|
||||
if name == SB.LOCAL:
|
||||
return await handle_local(args, session)
|
||||
|
||||
if name == SB.PRINTENV:
|
||||
var_name = args[0] if args else None
|
||||
return await handle_printenv(var_name, session)
|
||||
|
||||
if name == SB.WHOAMI:
|
||||
return await handle_whoami(session)
|
||||
|
||||
if name == SB.MAN:
|
||||
return await handle_man(args, session, registry)
|
||||
|
||||
if name == SB.READ:
|
||||
return await handle_read(args, session, stdin)
|
||||
|
||||
if name == SB.SET:
|
||||
return await handle_set(args, session, call_stack=call_stack)
|
||||
|
||||
if name == SB.SHIFT:
|
||||
return await handle_shift(args, call_stack, session=session)
|
||||
|
||||
if name == SB.TRAP:
|
||||
return await handle_trap(session)
|
||||
|
||||
if name in (SB.TEST, SB.BRACKET, SB.DOUBLE_BRACKET):
|
||||
return await handle_test(dispatch, operands, session)
|
||||
|
||||
if name == SB.ECHO:
|
||||
return await handle_echo(args)
|
||||
|
||||
if name == SB.PRINTF:
|
||||
return await handle_printf(args)
|
||||
|
||||
if name == SB.SLEEP:
|
||||
return await handle_sleep(args, cancel=cancel)
|
||||
|
||||
if name == SB.RETURN:
|
||||
return await handle_return(args)
|
||||
|
||||
if name == SB.XARGS:
|
||||
return await handle_xargs(execute_fn, args, session, stdin)
|
||||
|
||||
if name == SB.TIMEOUT:
|
||||
return await handle_timeout(execute_fn, args, session)
|
||||
|
||||
if name == SB.BREAK:
|
||||
raise BreakSignal()
|
||||
|
||||
if name == SB.CONTINUE:
|
||||
raise ContinueSignal()
|
||||
|
||||
# ── symlinks (namespace-backed; not bash builtins, not mount
|
||||
# commands: they mutate the addressing layer) ──
|
||||
if name == "ln" and "s" in link_flags(operands, "sfnv"):
|
||||
return handle_ln(namespace, session, operands)
|
||||
|
||||
if name == "readlink" and not (link_flags(operands, "fenm")
|
||||
& {"f", "e", "m"}):
|
||||
return handle_readlink(namespace, session, operands)
|
||||
|
||||
# ── symlink-aware dispatch: reads follow links (open(2)); rm/mv act
|
||||
# on the link entry itself (lstat semantics) ──
|
||||
post_unlink: str | None = None
|
||||
if namespace.symlinks:
|
||||
try:
|
||||
if name == "rm":
|
||||
operands, removed = strip_link_operands(namespace, operands)
|
||||
if removed and not any(
|
||||
isinstance(a, PathSpec) for a in operands):
|
||||
return None, IOResult(), ExecutionNode(command=name,
|
||||
exit_code=0)
|
||||
elif name == "mv":
|
||||
operands, post_unlink, early = await prepare_mv(
|
||||
namespace, dispatch, operands)
|
||||
if early is not None:
|
||||
return early
|
||||
elif name not in NO_FOLLOW_COMMANDS:
|
||||
operands = follow_paths(namespace, operands)
|
||||
except CycleError as exc:
|
||||
err = (f"{name}: {exc}: "
|
||||
f"Too many levels of symbolic links\n").encode()
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command=name,
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
argv = argv.with_operands(operands)
|
||||
|
||||
# ── mount command (default) ─────────────────
|
||||
stdout, io, exec_node = await handle_command(recurse,
|
||||
dispatch,
|
||||
registry,
|
||||
argv.words,
|
||||
session,
|
||||
stdin,
|
||||
call_stack,
|
||||
job_table=job_table,
|
||||
namespace=namespace)
|
||||
|
||||
if io.exit_code == 0 and namespace.symlinks:
|
||||
if name == "rm":
|
||||
for item in operands:
|
||||
if isinstance(item, PathSpec):
|
||||
namespace.purge_under(item.virtual)
|
||||
if post_unlink is not None:
|
||||
namespace.unlink(post_unlink)
|
||||
return stdout, io, exec_node
|
||||
@@ -0,0 +1,392 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
from functools import partial
|
||||
from typing import Any, Callable
|
||||
|
||||
from mirage.io import IOResult
|
||||
from mirage.io.stream import async_chain
|
||||
from mirage.shell.arith import evaluate_arith
|
||||
from mirage.shell.call_stack import CallStack
|
||||
from mirage.shell.errors import ArithError
|
||||
from mirage.shell.job_table import JobTable
|
||||
from mirage.shell.node_kind import NodeKind, node_kind
|
||||
from mirage.shell.types import ERREXIT_EXEMPT_TYPES
|
||||
from mirage.shell.types import NodeType as NT
|
||||
from mirage.workspace.abort import MirageAbortError
|
||||
from mirage.workspace.executor.control import (handle_case, handle_for,
|
||||
handle_if, handle_select,
|
||||
handle_until, handle_while)
|
||||
from mirage.workspace.executor.pipes import (handle_connection, handle_pipe,
|
||||
handle_subshell)
|
||||
from mirage.workspace.executor.redirect import handle_redirect
|
||||
from mirage.workspace.expand import (expand_and_classify, expand_node,
|
||||
expand_redirects)
|
||||
from mirage.workspace.expand.globs import resolve_globs
|
||||
from mirage.workspace.expand.node import expand_arith
|
||||
from mirage.workspace.mount import MountRegistry
|
||||
from mirage.workspace.mount.namespace import Namespace
|
||||
from mirage.workspace.node.command_dispatch import execute_command
|
||||
from mirage.workspace.node.program import execute_program
|
||||
from mirage.workspace.node.test_expr import expand_test_expr
|
||||
from mirage.workspace.session import Session
|
||||
from mirage.workspace.types import ExecutionNode
|
||||
|
||||
from mirage.shell.helpers import ( # isort: skip
|
||||
get_case_items, get_case_word, get_declaration_keyword, get_for_parts,
|
||||
get_function_body, get_function_name, get_if_branches, get_list_parts,
|
||||
get_negated_command, get_pipeline_commands, get_redirects,
|
||||
get_subshell_body, get_text, get_unset_names, get_while_parts)
|
||||
from mirage.workspace.executor.builtins import ( # isort: skip
|
||||
handle_export, handle_local, handle_readonly, handle_test, handle_unset)
|
||||
|
||||
|
||||
async def _recurse_reassociated(
|
||||
recurse: Callable,
|
||||
dispatch: Callable,
|
||||
execute_fn: Callable,
|
||||
registry: MountRegistry,
|
||||
redirects: list,
|
||||
right: Any,
|
||||
node: Any,
|
||||
session: Session,
|
||||
stdin: Any = None,
|
||||
call_stack: CallStack | None = None,
|
||||
) -> tuple[Any, IOResult, ExecutionNode]:
|
||||
"""Recurse wrapper for a re-associated trailing redirect.
|
||||
|
||||
Executes the list's last command with the hoisted redirects,
|
||||
expanding targets only at that point (after the left side ran, so
|
||||
cwd changes apply); every other node recurses normally.
|
||||
|
||||
Args:
|
||||
recurse (Callable): the plain execute_node recursion.
|
||||
dispatch (Callable): VFS op dispatcher.
|
||||
execute_fn (Callable): recursive execute (for expansions).
|
||||
registry (MountRegistry): mount registry.
|
||||
redirects (list): parsed redirects hoisted off the list.
|
||||
right (Any): the list's last command node.
|
||||
node (Any): node being executed by handle_connection.
|
||||
session (Session): shell session state.
|
||||
stdin (Any): input stream.
|
||||
call_stack (CallStack | None): shell call stack.
|
||||
"""
|
||||
if node is not right:
|
||||
return await recurse(node, session, stdin, call_stack)
|
||||
expanded, pipe_node = await expand_redirects(redirects, session,
|
||||
execute_fn, registry,
|
||||
call_stack)
|
||||
stdout, io, exec_node = await handle_redirect(recurse, dispatch, right,
|
||||
expanded, session, stdin,
|
||||
call_stack)
|
||||
if pipe_node is not None and stdout is not None:
|
||||
stdout, io2, exec_node2 = await recurse(pipe_node, session, stdout,
|
||||
call_stack)
|
||||
io = await io.merge(io2)
|
||||
exec_node = exec_node2
|
||||
return stdout, io, exec_node
|
||||
|
||||
|
||||
async def execute_node(
|
||||
dispatch: Callable,
|
||||
registry: MountRegistry,
|
||||
namespace: Namespace,
|
||||
job_table: JobTable,
|
||||
execute_fn: Callable,
|
||||
agent_id: str,
|
||||
node: Any,
|
||||
session: Session,
|
||||
stdin: Any = None,
|
||||
call_stack: CallStack | None = None,
|
||||
cancel: asyncio.Event | None = None,
|
||||
) -> tuple[Any, IOResult, ExecutionNode]:
|
||||
"""Walk tree-sitter AST and dispatch each node.
|
||||
|
||||
Args:
|
||||
dispatch (Callable): VFS op dispatcher (op, path, **kw).
|
||||
registry (MountRegistry): mount registry for path resolution.
|
||||
namespace (Namespace): addressing authority for symlink ops.
|
||||
job_table (JobTable): background job management.
|
||||
execute_fn (Callable): recursive execute (for source/eval).
|
||||
agent_id (str): current agent ID for jobs.
|
||||
node (Any): tree-sitter node to execute.
|
||||
session (Session): shell session state.
|
||||
stdin (Any): input stream.
|
||||
call_stack (CallStack): shell call stack.
|
||||
cancel (asyncio.Event | None): event used to abort mid-flight.
|
||||
"""
|
||||
if cancel is not None and cancel.is_set():
|
||||
raise MirageAbortError()
|
||||
cs = call_stack or CallStack()
|
||||
|
||||
recurse = partial(execute_node,
|
||||
dispatch,
|
||||
registry,
|
||||
namespace,
|
||||
job_table,
|
||||
execute_fn,
|
||||
agent_id,
|
||||
cancel=cancel)
|
||||
|
||||
kind = node_kind(node)
|
||||
|
||||
if kind == NodeKind.COMMENT:
|
||||
return None, IOResult(), ExecutionNode(command="", exit_code=0)
|
||||
|
||||
# ── program (root / semicolons) ─────────────
|
||||
if kind == NodeKind.PROGRAM:
|
||||
return await execute_program(recurse, node, session, stdin, cs,
|
||||
job_table, agent_id)
|
||||
|
||||
# ── command ─────────────────────────────────
|
||||
if kind == NodeKind.COMMAND:
|
||||
return await execute_command(recurse,
|
||||
dispatch,
|
||||
registry,
|
||||
namespace,
|
||||
execute_fn,
|
||||
node,
|
||||
session,
|
||||
stdin,
|
||||
cs,
|
||||
job_table,
|
||||
cancel=cancel)
|
||||
|
||||
# ── pipeline ────────────────────────────────
|
||||
if kind == NodeKind.PIPELINE:
|
||||
commands, stderr_flags = get_pipeline_commands(node)
|
||||
return await handle_pipe(recurse, commands, stderr_flags, session,
|
||||
stdin, cs)
|
||||
|
||||
# ── list (&&, ||) ───────────────────────────
|
||||
if kind == NodeKind.LIST:
|
||||
left, op, right = get_list_parts(node)
|
||||
return await handle_connection(recurse, left, op, right, session,
|
||||
stdin, cs)
|
||||
|
||||
# ── redirected statement ────────────────────
|
||||
if kind == NodeKind.REDIRECT:
|
||||
command, redirects = get_redirects(node)
|
||||
if command.type == NT.LIST:
|
||||
# tree-sitter hoists a trailing redirect over the whole
|
||||
# &&/|| list; bash binds it to the last command:
|
||||
# redirected(list(L, op, R), r) == list(L, op, redirected(R, r))
|
||||
# Re-associate and defer target expansion until R runs, so
|
||||
# `cd /x && echo hi > f` writes under /x. Compound and
|
||||
# subshell bodies keep the whole-body redirect (bash group
|
||||
# semantics).
|
||||
left, op, right = get_list_parts(command)
|
||||
wrapped = partial(_recurse_reassociated, recurse, dispatch,
|
||||
execute_fn, registry, redirects, right)
|
||||
return await handle_connection(wrapped, left, op, right, session,
|
||||
stdin, cs)
|
||||
expanded_redirects, pipe_node = await expand_redirects(
|
||||
redirects, session, execute_fn, registry, cs)
|
||||
stdout, io, exec_node = await handle_redirect(recurse, dispatch,
|
||||
command,
|
||||
expanded_redirects,
|
||||
session, stdin, cs)
|
||||
if pipe_node is not None and stdout is not None:
|
||||
stdout, io2, exec_node2 = await recurse(pipe_node, session, stdout,
|
||||
cs)
|
||||
io = await io.merge(io2)
|
||||
exec_node = exec_node2
|
||||
return stdout, io, exec_node
|
||||
|
||||
# ── subshell ────────────────────────────────
|
||||
if kind == NodeKind.SUBSHELL:
|
||||
body = get_subshell_body(node)
|
||||
return await handle_subshell(recurse, body, session, stdin, cs)
|
||||
|
||||
# ── arithmetic command ((( ... ))) ──────────
|
||||
if (kind == NodeKind.COMPOUND and node.children
|
||||
and node.children[0].type == NT.ARITH_OPEN):
|
||||
text = get_text(node)
|
||||
expr = await expand_arith(node, session, execute_fn, cs)
|
||||
try:
|
||||
value, updates = evaluate_arith(expr, session.env)
|
||||
except ArithError as exc:
|
||||
err = f"bash: ((: {expr}: {exc}\n".encode()
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command=text,
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
for name in updates:
|
||||
if name in session.readonly_vars:
|
||||
err = f"bash: {name}: readonly variable\n".encode()
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command=text,
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
session.env.update(updates)
|
||||
code = 0 if value != 0 else 1
|
||||
return None, IOResult(exit_code=code), ExecutionNode(command=text,
|
||||
exit_code=code)
|
||||
|
||||
# ── compound statement ({ ... }) ───────────
|
||||
if kind == NodeKind.COMPOUND:
|
||||
all_stdout: list = []
|
||||
merged_io = IOResult()
|
||||
last_exec = ExecutionNode(command="{}", exit_code=0)
|
||||
for child in node.named_children:
|
||||
if child.type == NT.COMMENT:
|
||||
continue
|
||||
stdout, io, last_exec = await recurse(child, session, stdin, cs)
|
||||
if stdout is not None:
|
||||
all_stdout.append(stdout)
|
||||
merged_io = await merged_io.merge(io)
|
||||
if (io.exit_code != 0 and session.shell_options.get("errexit")
|
||||
and child.type not in ERREXIT_EXEMPT_TYPES):
|
||||
merged_io.exit_code = io.exit_code
|
||||
break
|
||||
if len(all_stdout) == 1:
|
||||
return all_stdout[0], merged_io, last_exec
|
||||
combined = async_chain(*all_stdout) if all_stdout else None
|
||||
return combined, merged_io, last_exec
|
||||
|
||||
# ── if ──────────────────────────────────────
|
||||
if kind == NodeKind.IF:
|
||||
branches, else_body = get_if_branches(node)
|
||||
return await handle_if(recurse, branches, else_body, session, stdin,
|
||||
cs)
|
||||
|
||||
# ── for / select ────────────────────────────
|
||||
if kind in (NodeKind.FOR, NodeKind.SELECT):
|
||||
var, values, body = get_for_parts(node)
|
||||
classified = await expand_and_classify(values, session, execute_fn,
|
||||
registry, session.cwd, cs)
|
||||
# The loop word list is consumed by the shell (WordPolicy.SHELL):
|
||||
# globs resolve to matches before iteration starts.
|
||||
classified = await resolve_globs(classified, registry)
|
||||
if kind == NodeKind.SELECT:
|
||||
return await handle_select(recurse, var, classified, body, session,
|
||||
stdin, cs)
|
||||
return await handle_for(recurse, var, classified, body, session, stdin,
|
||||
cs)
|
||||
|
||||
# ── while / until ───────────────────────────
|
||||
if kind in (NodeKind.WHILE, NodeKind.UNTIL):
|
||||
condition, body = get_while_parts(node)
|
||||
if kind == NodeKind.UNTIL:
|
||||
return await handle_until(recurse, condition, body, session, stdin,
|
||||
cs)
|
||||
return await handle_while(recurse, condition, body, session, stdin, cs)
|
||||
|
||||
# ── case ────────────────────────────────────
|
||||
if kind == NodeKind.CASE:
|
||||
word_node = get_case_word(node)
|
||||
word = await expand_node(word_node, session, execute_fn, cs)
|
||||
items = get_case_items(node)
|
||||
return await handle_case(recurse, word, items, session, stdin, cs)
|
||||
|
||||
# ── function definition ─────────────────────
|
||||
if kind == NodeKind.FUNCTION_DEF:
|
||||
name = get_function_name(node)
|
||||
body = get_function_body(node)
|
||||
session.functions[name] = body
|
||||
return None, IOResult(), ExecutionNode(command=f"function {name}",
|
||||
exit_code=0)
|
||||
|
||||
# ── declaration (export/local/declare/readonly) ──
|
||||
if kind == NodeKind.DECLARATION:
|
||||
keyword = get_declaration_keyword(node)
|
||||
assignments = []
|
||||
flag_chars: set[str] = set()
|
||||
for child in node.named_children:
|
||||
if child.type == NT.VARIABLE_ASSIGNMENT:
|
||||
val_nodes = [
|
||||
c for c in child.named_children
|
||||
if c.type != NT.VARIABLE_NAME
|
||||
]
|
||||
if val_nodes and val_nodes[0].type == NT.ARRAY:
|
||||
key = get_text(child).partition("=")[0]
|
||||
items = [
|
||||
await expand_node(ac, session, execute_fn, cs)
|
||||
for ac in val_nodes[0].named_children
|
||||
]
|
||||
session.arrays[key] = items
|
||||
continue
|
||||
expanded = await expand_node(child, session, execute_fn, cs)
|
||||
assignments.append(expanded)
|
||||
elif child.type in (NT.SIMPLE_EXPANSION, NT.EXPANSION,
|
||||
NT.CONCATENATION, NT.WORD):
|
||||
expanded = await expand_node(child, session, execute_fn, cs)
|
||||
if not expanded:
|
||||
continue
|
||||
if expanded.startswith("-") and len(expanded) > 1:
|
||||
flag_chars.update(expanded[1:])
|
||||
else:
|
||||
assignments.append(expanded)
|
||||
if keyword == NT.LOCAL:
|
||||
return await handle_local(assignments, session)
|
||||
if keyword == "readonly" or "r" in flag_chars:
|
||||
return await handle_readonly(assignments, session)
|
||||
return await handle_export(assignments, session)
|
||||
|
||||
# ── unset ───────────────────────────────────
|
||||
if kind == NodeKind.UNSET:
|
||||
names = get_unset_names(node)
|
||||
return await handle_unset(names, session)
|
||||
|
||||
# ── test ([ ] or [[ ]]) ─────────────────────
|
||||
if kind == NodeKind.TEST:
|
||||
expanded = await expand_test_expr(node, session, execute_fn, cs,
|
||||
registry)
|
||||
return await handle_test(dispatch, expanded, session)
|
||||
|
||||
# ── negated command ─────────────────────────
|
||||
if kind == NodeKind.NEGATED:
|
||||
inner = get_negated_command(node)
|
||||
stdout, io, exec_node = await recurse(inner, session, stdin, cs)
|
||||
io = IOResult(
|
||||
exit_code=0 if io.exit_code != 0 else 1,
|
||||
stderr=io.stderr,
|
||||
reads=io.reads,
|
||||
writes=io.writes,
|
||||
cache=io.cache,
|
||||
)
|
||||
exec_node.exit_code = io.exit_code
|
||||
return stdout, io, exec_node
|
||||
|
||||
# ── variable assignment at top level ────────
|
||||
if kind == NodeKind.VAR_ASSIGN:
|
||||
text = get_text(node)
|
||||
if "=" in text:
|
||||
key, _, val = text.partition("=")
|
||||
if key in session.readonly_vars:
|
||||
err = f"bash: {key}: readonly variable\n".encode()
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command=text,
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
val_nodes = [
|
||||
c for c in node.named_children if c.type != NT.VARIABLE_NAME
|
||||
]
|
||||
if val_nodes and val_nodes[0].type == NT.ARRAY:
|
||||
items = []
|
||||
for ac in val_nodes[0].named_children:
|
||||
items.append(await expand_node(ac, session, execute_fn,
|
||||
cs))
|
||||
session.arrays[key] = items
|
||||
session.env.pop(key, None)
|
||||
return None, IOResult(), ExecutionNode(command=text,
|
||||
exit_code=0)
|
||||
if val_nodes:
|
||||
val = await expand_node(val_nodes[0], session, execute_fn, cs)
|
||||
session.env[key] = val
|
||||
session.arrays.pop(key, None)
|
||||
return None, IOResult(), ExecutionNode(command=text, exit_code=0)
|
||||
|
||||
raise TypeError(f"unsupported tree-sitter node type: {node.type}")
|
||||
@@ -0,0 +1,102 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from typing import Any
|
||||
|
||||
from mirage.io import IOResult
|
||||
from mirage.io.stream import async_chain, materialize
|
||||
from mirage.shell.types import ERREXIT_EXEMPT_TYPES
|
||||
from mirage.shell.types import NodeType as NT
|
||||
from mirage.utils.errors import format_fs_error
|
||||
from mirage.workspace.executor.jobs import handle_background
|
||||
from mirage.workspace.types import ExecutionNode
|
||||
|
||||
|
||||
async def execute_program(
|
||||
recurse,
|
||||
node,
|
||||
session,
|
||||
stdin,
|
||||
call_stack,
|
||||
job_table,
|
||||
agent_id,
|
||||
) -> tuple[Any, IOResult, ExecutionNode]:
|
||||
"""Execute program node (root / semicolon-separated)."""
|
||||
children = node.children
|
||||
all_stdout: list = []
|
||||
merged_io = IOResult()
|
||||
last_exec = ExecutionNode(command="", exit_code=0)
|
||||
|
||||
i = 0
|
||||
while i < len(children):
|
||||
child = children[i]
|
||||
|
||||
if (not child.is_named or child.type == NT.ERROR
|
||||
or child.type == NT.COMMENT):
|
||||
if child.type == NT.SEMI:
|
||||
i += 1
|
||||
continue
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Check for background: named node followed by & token
|
||||
is_bg = (i + 1 < len(children)
|
||||
and children[i + 1].type == NT.BACKGROUND)
|
||||
|
||||
if is_bg:
|
||||
stdout, io, last_exec = await handle_background(
|
||||
recurse, child, None, session, job_table, agent_id, stdin,
|
||||
call_stack)
|
||||
i += 2
|
||||
else:
|
||||
stdout, io, last_exec = await recurse(child, session, stdin,
|
||||
call_stack)
|
||||
# Materialize stdout so lazy exit codes (e.g. from
|
||||
# exit_on_empty in grep) are finalized before $? is set.
|
||||
drain_err: bytes | None = None
|
||||
try:
|
||||
stdout = await materialize(stdout)
|
||||
except OSError as exc:
|
||||
# Lazy reads (head/tail opening the stream mid-pipeline) can
|
||||
# fail on the first pull; format as a GNU coreutils line,
|
||||
# respelling the path as typed via the operands the leaf
|
||||
# node carries, mirroring the eager executor chokepoint.
|
||||
cmd_name = (last_exec.command.split()[0]
|
||||
if last_exec.command else "")
|
||||
drain_err = format_fs_error(cmd_name, exc, last_exec.paths)
|
||||
stdout = None
|
||||
except Exception as exc:
|
||||
drain_err = f"{exc}\n".encode()
|
||||
stdout = None
|
||||
io.sync_exit_code()
|
||||
if drain_err is not None:
|
||||
existing = await materialize(io.stderr) or b""
|
||||
io.stderr = existing + drain_err
|
||||
io.exit_code = 1
|
||||
session.last_exit_code = io.exit_code
|
||||
i += 1
|
||||
|
||||
if stdout is not None:
|
||||
all_stdout.append(stdout)
|
||||
merged_io = await merged_io.merge(io)
|
||||
|
||||
if (io.exit_code != 0 and session.shell_options.get("errexit")
|
||||
and not is_bg and child.type not in ERREXIT_EXEMPT_TYPES):
|
||||
merged_io.exit_code = io.exit_code
|
||||
break
|
||||
|
||||
if len(all_stdout) == 1:
|
||||
return all_stdout[0], merged_io, last_exec
|
||||
combined = async_chain(*all_stdout) if all_stdout else None
|
||||
return combined, merged_io, last_exec
|
||||
@@ -0,0 +1,315 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from functools import partial
|
||||
from typing import Any, Callable
|
||||
|
||||
from mirage.provision import Precision, ProvisionResult
|
||||
from mirage.shell.node_kind import NodeKind, node_kind
|
||||
from mirage.shell.types import NodeType as NT
|
||||
from mirage.shell.types import RedirectKind
|
||||
from mirage.shell.types import ShellBuiltin as SB
|
||||
from mirage.types import PathSpec
|
||||
from mirage.workspace.expand import (classify_parts, expand_and_classify,
|
||||
expand_parts, expand_redirects)
|
||||
from mirage.workspace.mount import MountRegistry
|
||||
from mirage.workspace.mount.namespace import Namespace
|
||||
from mirage.workspace.provision.builtins import handle_builtin_provision
|
||||
from mirage.workspace.provision.command import handle_command_provision
|
||||
from mirage.workspace.provision.control import (handle_for_provision,
|
||||
handle_function_provision,
|
||||
handle_if_provision,
|
||||
handle_while_provision)
|
||||
from mirage.workspace.provision.pipes import (handle_connection_provision,
|
||||
handle_pipe_provision)
|
||||
from mirage.workspace.provision.redirect import handle_redirect_provision
|
||||
from mirage.workspace.provision.rollup import rollup_list, rollup_pipe
|
||||
from mirage.workspace.session import Session
|
||||
|
||||
from mirage.shell.helpers import ( # isort: skip
|
||||
get_case_items, get_command_name, get_for_parts, get_function_body,
|
||||
get_function_name, get_if_branches, get_list_parts, get_negated_command,
|
||||
get_parts, get_pipeline_commands, get_redirects, get_subshell_body,
|
||||
get_text, get_while_parts, has_command_substitution, split_env_prefix)
|
||||
|
||||
# eval / source execute their payload, so they are NOT free builtins:
|
||||
# leaving them out lets them fall through to command resolution, which
|
||||
# honestly reports UNKNOWN instead of a zero-cost EXACT.
|
||||
_BUILTIN_NAMES = frozenset({
|
||||
SB.CD,
|
||||
SB.TRUE,
|
||||
SB.FALSE,
|
||||
SB.EXPORT,
|
||||
SB.UNSET,
|
||||
SB.LOCAL,
|
||||
SB.PRINTENV,
|
||||
SB.READ,
|
||||
SB.SET,
|
||||
SB.SHIFT,
|
||||
SB.TRAP,
|
||||
SB.TEST,
|
||||
SB.BRACKET,
|
||||
SB.DOUBLE_BRACKET,
|
||||
SB.WAIT,
|
||||
SB.FG,
|
||||
SB.KILL,
|
||||
SB.JOBS,
|
||||
SB.PS,
|
||||
SB.ECHO,
|
||||
SB.PRINTF,
|
||||
SB.SLEEP,
|
||||
"return",
|
||||
"break",
|
||||
"continue",
|
||||
})
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanScope:
|
||||
"""Walk-local planner state.
|
||||
|
||||
Function definitions seen during this plan are recorded here (not
|
||||
on the session: planning must not mutate shell state), and
|
||||
`planning` guards recursive functions from looping the planner.
|
||||
"""
|
||||
functions: dict[str, list] = field(default_factory=dict)
|
||||
planning: set[str] = field(default_factory=set)
|
||||
|
||||
|
||||
async def _provision_redirected(
|
||||
recurse: Callable,
|
||||
registry: MountRegistry,
|
||||
namespace: Namespace | None,
|
||||
execute_fn: Callable,
|
||||
command: Any,
|
||||
redirects: list,
|
||||
session: Session,
|
||||
) -> ProvisionResult:
|
||||
"""Plan one redirected command: expand targets, cost, degrade.
|
||||
|
||||
Args:
|
||||
recurse (Callable): the provision recursion.
|
||||
registry (MountRegistry): mount registry.
|
||||
namespace (Namespace | None): addressing authority.
|
||||
execute_fn (Callable): recursive execute (for expansions).
|
||||
command (Any): the redirected command node.
|
||||
redirects (list): parsed redirects.
|
||||
session (Session): shell session state.
|
||||
"""
|
||||
expanded, pipe_node = await expand_redirects(redirects, session,
|
||||
execute_fn, registry)
|
||||
# A cmdsub target expands empty under provision, so its
|
||||
# classification is garbage; the precision degrade below keeps
|
||||
# the plan honest without costing a phantom write (mirrors TS).
|
||||
targets = [
|
||||
(r.kind, r.target) for r in expanded
|
||||
if r.kind in (RedirectKind.STDIN, RedirectKind.STDOUT) and isinstance(
|
||||
r.target, PathSpec) and not r.target.virtual.startswith("/dev/")
|
||||
and not (r.target_node is not None
|
||||
and has_command_substitution(r.target_node))
|
||||
]
|
||||
result = await handle_redirect_provision(recurse, registry, command,
|
||||
targets, session, namespace)
|
||||
if any(r.target_node is not None
|
||||
and has_command_substitution(r.target_node) for r in redirects):
|
||||
# A suppressed substitution hid the real redirect target.
|
||||
result.precision = Precision.UNKNOWN
|
||||
if pipe_node is not None:
|
||||
return rollup_pipe([result, await recurse(pipe_node, session)])
|
||||
return result
|
||||
|
||||
|
||||
async def _provision_reassociated(
|
||||
recurse: Callable,
|
||||
registry: MountRegistry,
|
||||
namespace: Namespace | None,
|
||||
execute_fn: Callable,
|
||||
redirects: list,
|
||||
right: Any,
|
||||
node: Any,
|
||||
session: Session,
|
||||
) -> ProvisionResult:
|
||||
"""Provision recurse wrapper for a re-associated trailing redirect.
|
||||
|
||||
Mirrors the executor: the list's last command carries the hoisted
|
||||
redirects; every other node provisions normally.
|
||||
|
||||
Args:
|
||||
recurse (Callable): the provision recursion.
|
||||
registry (MountRegistry): mount registry.
|
||||
namespace (Namespace | None): addressing authority.
|
||||
execute_fn (Callable): recursive execute (for expansions).
|
||||
redirects (list): parsed redirects hoisted off the list.
|
||||
right (Any): the list's last command node.
|
||||
node (Any): node being provisioned by the connection handler.
|
||||
session (Session): shell session state.
|
||||
"""
|
||||
if node is not right:
|
||||
return await recurse(node, session)
|
||||
return await _provision_redirected(recurse, registry, namespace,
|
||||
execute_fn, right, redirects, session)
|
||||
|
||||
|
||||
async def provision_node(
|
||||
registry: MountRegistry,
|
||||
dispatch: Callable,
|
||||
execute_fn: Callable,
|
||||
namespace: Namespace | None,
|
||||
node: Any,
|
||||
session: Session,
|
||||
scope: PlanScope | None = None,
|
||||
) -> ProvisionResult:
|
||||
"""Walk tree-sitter AST and estimate execution cost.
|
||||
|
||||
Dispatches on the same NodeKind classification as the executor
|
||||
(`mirage.shell.classify`), so every construct the executor runs
|
||||
has a planner branch; kinds neither walker supports fall through
|
||||
to an honest UNKNOWN.
|
||||
|
||||
Args:
|
||||
registry (MountRegistry): mount registry for path resolution.
|
||||
dispatch (Callable): VFS op dispatcher (op, path, **kw).
|
||||
execute_fn (Callable): recursive execute (for expansions).
|
||||
node (Any): tree-sitter node to plan.
|
||||
session (Session): shell session state.
|
||||
scope (PlanScope | None): walk-local planner state; created at
|
||||
the root and threaded through recursion.
|
||||
"""
|
||||
plan_scope = scope if scope is not None else PlanScope()
|
||||
recurse = partial(provision_node,
|
||||
registry,
|
||||
dispatch,
|
||||
execute_fn,
|
||||
namespace,
|
||||
scope=plan_scope)
|
||||
kind = node_kind(node)
|
||||
|
||||
if kind == NodeKind.COMMENT:
|
||||
return ProvisionResult(precision=Precision.EXACT)
|
||||
|
||||
if kind in (NodeKind.PROGRAM, NodeKind.SUBSHELL, NodeKind.COMPOUND):
|
||||
if kind == NodeKind.SUBSHELL:
|
||||
body = get_subshell_body(node)
|
||||
else:
|
||||
body = [c for c in node.named_children if c.type != NT.COMMENT]
|
||||
children = []
|
||||
for child in body:
|
||||
children.append(await recurse(child, session))
|
||||
if not children:
|
||||
return ProvisionResult(precision=Precision.EXACT)
|
||||
return rollup_list(";", children)
|
||||
|
||||
if kind == NodeKind.COMMAND:
|
||||
name = get_command_name(node)
|
||||
func_body = plan_scope.functions.get(name)
|
||||
if func_body is None:
|
||||
func_body = session.functions.get(name)
|
||||
if func_body is not None:
|
||||
return await handle_function_provision(recurse, name, func_body,
|
||||
plan_scope.planning,
|
||||
session)
|
||||
if name in _BUILTIN_NAMES:
|
||||
return await handle_builtin_provision()
|
||||
_, parts = split_env_prefix(get_parts(node))
|
||||
if not parts:
|
||||
return ProvisionResult(precision=Precision.EXACT)
|
||||
expanded = await expand_parts(parts, session, execute_fn)
|
||||
classified = classify_parts(expanded, registry, session.cwd)
|
||||
result = await handle_command_provision(registry, classified, session,
|
||||
namespace)
|
||||
if any(has_command_substitution(p) for p in parts):
|
||||
# The plan walk suppressed the substitution, so the operand
|
||||
# list is incomplete: the totals are floors, not answers.
|
||||
result.precision = Precision.UNKNOWN
|
||||
return result
|
||||
|
||||
if kind == NodeKind.PIPELINE:
|
||||
commands, _ = get_pipeline_commands(node)
|
||||
return await handle_pipe_provision(recurse, commands, session)
|
||||
|
||||
if kind == NodeKind.LIST:
|
||||
left, op, right = get_list_parts(node)
|
||||
return await handle_connection_provision(recurse, left, op, right,
|
||||
session)
|
||||
|
||||
if kind == NodeKind.REDIRECT:
|
||||
command, redirects = get_redirects(node)
|
||||
if command.type == NT.LIST:
|
||||
# Mirror the executor: a trailing redirect hoisted over an
|
||||
# &&/|| list binds to the last command.
|
||||
left, op, right = get_list_parts(command)
|
||||
wrapped = partial(_provision_reassociated, recurse, registry,
|
||||
namespace, execute_fn, redirects, right)
|
||||
return await handle_connection_provision(wrapped, left, op, right,
|
||||
session)
|
||||
return await _provision_redirected(recurse, registry, namespace,
|
||||
execute_fn, command, redirects,
|
||||
session)
|
||||
|
||||
if kind == NodeKind.IF:
|
||||
branches, else_body = get_if_branches(node)
|
||||
return await handle_if_provision(recurse, branches, else_body, session)
|
||||
|
||||
if kind == NodeKind.FOR:
|
||||
_, values, body = get_for_parts(node)
|
||||
if any(has_command_substitution(v) for v in values):
|
||||
# The iteration count comes from a suppressed substitution:
|
||||
# plan one pass as a floor and degrade.
|
||||
result = await handle_for_provision(recurse, body, 1, session)
|
||||
result.precision = Precision.UNKNOWN
|
||||
return result
|
||||
classified = await expand_and_classify(values, session, execute_fn,
|
||||
registry, session.cwd)
|
||||
n = len(classified) or 1
|
||||
return await handle_for_provision(recurse, body, n, session)
|
||||
|
||||
if kind == NodeKind.SELECT:
|
||||
# select re-prompts until break: unbounded like while.
|
||||
_, _, body = get_for_parts(node)
|
||||
return await handle_while_provision(recurse, body, session)
|
||||
|
||||
if kind in (NodeKind.WHILE, NodeKind.UNTIL):
|
||||
_, body = get_while_parts(node)
|
||||
return await handle_while_provision(recurse, body, session)
|
||||
|
||||
if kind == NodeKind.CASE:
|
||||
items = get_case_items(node)
|
||||
children = []
|
||||
for _, body in items:
|
||||
if not body:
|
||||
continue
|
||||
stmts = [await recurse(stmt, session) for stmt in body]
|
||||
children.append(stmts[0] if len(stmts) ==
|
||||
1 else rollup_list(";", stmts))
|
||||
if children:
|
||||
return rollup_list("||", children)
|
||||
return ProvisionResult(precision=Precision.EXACT)
|
||||
|
||||
if kind == NodeKind.FUNCTION_DEF:
|
||||
name = get_function_name(node)
|
||||
body = get_function_body(node)
|
||||
if name and body is not None:
|
||||
plan_scope.functions[name] = body
|
||||
return await handle_builtin_provision()
|
||||
|
||||
if kind in (NodeKind.DECLARATION, NodeKind.UNSET, NodeKind.TEST,
|
||||
NodeKind.VAR_ASSIGN):
|
||||
return await handle_builtin_provision()
|
||||
|
||||
if kind == NodeKind.NEGATED:
|
||||
inner = get_negated_command(node)
|
||||
return await recurse(inner, session)
|
||||
|
||||
return ProvisionResult(command=get_text(node), precision=Precision.UNKNOWN)
|
||||
@@ -0,0 +1,91 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Callable
|
||||
|
||||
from mirage.commands.builtin.utils.safeguard import apply_safeguard
|
||||
from mirage.io import IOResult
|
||||
from mirage.io.types import materialize
|
||||
from mirage.shell.barrier import BarrierPolicy, apply_barrier
|
||||
from mirage.shell.job_table import JobTable
|
||||
from mirage.workspace.mount import MountRegistry
|
||||
from mirage.workspace.mount.namespace import Namespace
|
||||
from mirage.workspace.node.execute_node import execute_node
|
||||
from mirage.workspace.session import Session
|
||||
from mirage.workspace.types import ExecutionNode
|
||||
|
||||
|
||||
async def run_command_tree(
|
||||
dispatch: Callable,
|
||||
registry: MountRegistry,
|
||||
namespace: Namespace,
|
||||
job_table: JobTable,
|
||||
execute_fn: Callable,
|
||||
agent_id: str,
|
||||
ast: Any,
|
||||
session: Session,
|
||||
stdin: Any,
|
||||
cancel: asyncio.Event | None,
|
||||
) -> tuple[IOResult, ExecutionNode]:
|
||||
"""Run a parsed command tree and finalize its output stream.
|
||||
|
||||
Executes the AST root, then applies the value barrier and the
|
||||
command safeguard, folding the safeguard's stderr and exit code
|
||||
into the result. This is the seam between the Workspace shell
|
||||
(sessions, drift, recording) and the command executor: a caller
|
||||
hands in a parsed tree plus its dependencies and gets back the
|
||||
resolved result. Byte recording is the caller's responsibility, so
|
||||
the active recorder spans the stream consumption that happens
|
||||
inside the barrier here.
|
||||
|
||||
Args:
|
||||
dispatch (Callable): VFS op dispatcher (op, path, **kw).
|
||||
registry (MountRegistry): mount registry for path resolution.
|
||||
namespace (Namespace): addressing authority for symlink ops.
|
||||
job_table (JobTable): background job management.
|
||||
execute_fn (Callable): recursive execute (for source/eval).
|
||||
agent_id (str): current agent ID for jobs.
|
||||
ast (Any): parsed tree-sitter root node.
|
||||
session (Session): shell session state.
|
||||
stdin (Any): input stream.
|
||||
cancel (asyncio.Event | None): event used to abort mid-flight.
|
||||
|
||||
Returns:
|
||||
tuple[IOResult, ExecutionNode]: the finalized result (with
|
||||
``io.stdout`` set to the barrier-resolved value) and the
|
||||
execution node.
|
||||
"""
|
||||
stdout, io, exec_node = await execute_node(
|
||||
dispatch,
|
||||
registry,
|
||||
namespace,
|
||||
job_table,
|
||||
execute_fn,
|
||||
agent_id,
|
||||
ast,
|
||||
session,
|
||||
stdin,
|
||||
cancel=cancel,
|
||||
)
|
||||
stdout = await apply_barrier(stdout, io, BarrierPolicy.VALUE)
|
||||
if io.safeguard is not None:
|
||||
stdout, sg_io = await apply_safeguard(stdout, io.safeguard)
|
||||
if sg_io.stderr is not None:
|
||||
existing = await materialize(io.stderr)
|
||||
io.stderr = existing + await materialize(sg_io.stderr)
|
||||
if sg_io.exit_code != 0:
|
||||
io.exit_code = sg_io.exit_code
|
||||
io.stdout = stdout
|
||||
return io, exec_node
|
||||
@@ -0,0 +1,68 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.shell.types import NodeType as NT
|
||||
from mirage.workspace.expand import expand_node
|
||||
|
||||
|
||||
async def expand_test_expr(node, session, execute_fn, cs, registry):
|
||||
"""Expand test_command [ ... ] into a flat list of strings."""
|
||||
result = []
|
||||
for child in node.named_children:
|
||||
if child.type == NT.BINARY_EXPRESSION:
|
||||
for part in child.children:
|
||||
if part.type in ("=", "!=", "=="):
|
||||
result.append(part.text.decode())
|
||||
elif part.is_named:
|
||||
exp = await expand_node(part, session, execute_fn, cs)
|
||||
result.append(exp)
|
||||
elif child.type == NT.UNARY_EXPRESSION:
|
||||
for part in child.children:
|
||||
if part.type == NT.TEST_OPERATOR:
|
||||
result.append(part.text.decode())
|
||||
elif part.is_named:
|
||||
exp = await expand_node(part, session, execute_fn, cs)
|
||||
result.append(exp)
|
||||
elif child.type == NT.NEGATION_EXPRESSION:
|
||||
result.append("!")
|
||||
for part in child.named_children:
|
||||
sub = await _expand_inner(part, session, execute_fn, cs)
|
||||
result.extend(sub)
|
||||
else:
|
||||
exp = await expand_node(child, session, execute_fn, cs)
|
||||
result.append(exp)
|
||||
return result
|
||||
|
||||
|
||||
async def _expand_inner(node, session, execute_fn, cs):
|
||||
"""Expand a single test expression node into args."""
|
||||
result = []
|
||||
if node.type == NT.BINARY_EXPRESSION:
|
||||
for part in node.children:
|
||||
if part.type in ("=", "!=", "=="):
|
||||
result.append(part.text.decode())
|
||||
elif part.is_named:
|
||||
exp = await expand_node(part, session, execute_fn, cs)
|
||||
result.append(exp)
|
||||
elif node.type == NT.UNARY_EXPRESSION:
|
||||
for part in node.children:
|
||||
if part.type == NT.TEST_OPERATOR:
|
||||
result.append(part.text.decode())
|
||||
elif part.is_named:
|
||||
exp = await expand_node(part, session, execute_fn, cs)
|
||||
result.append(exp)
|
||||
else:
|
||||
exp = await expand_node(node, session, execute_fn, cs)
|
||||
result.append(exp)
|
||||
return result
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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 "/"
|
||||
@@ -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
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user