bcbd1bdb22
Test (Python) / test-python-gate (push) Blocked by required conditions
Test (TypeScript) / test-typescript-gate (push) Blocked by required conditions
CLI exit codes / cli-gate (push) Blocked by required conditions
Test (Install) / test-install-gate (push) Blocked by required conditions
Integ / integ-gate (push) Blocked by required conditions
CLI exit codes / Python CLI (push) Waiting to run
Integ / changes (push) Has been skipped
Test (Install) / python-minimal (3.11) (push) Waiting to run
Test (Install) / python-minimal (3.12) (push) Waiting to run
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Waiting to run
Integ / integ-ts (push) Waiting to run
Integ / integ (push) Waiting to run
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Waiting to run
Integ / integ-database (push) Waiting to run
Test (Install) / python-extra (email, mirage.resource.email) (push) Waiting to run
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Waiting to run
Integ / integ-ssh (push) Waiting to run
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
CLI exit codes / TypeScript CLI (push) Waiting to run
CLI exit codes / Cross-language snapshot interop (push) Waiting to run
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Waiting to run
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Waiting to run
Test (Python) / changes (push) Has been skipped
Integ / integ-database-ts (push) Waiting to run
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Waiting to run
Integ / integ-data (push) Waiting to run
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Waiting to run
Test (Python) / test (push) Waiting to run
Integ / integ-fuse (push) Waiting to run
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Waiting to run
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Waiting to run
Test (Python) / audit (push) Waiting to run
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Waiting to run
Integ / integ-ssh-ts (push) Waiting to run
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Waiting to run
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Waiting to run
Test (TypeScript) / changes (push) Has been skipped
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Waiting to run
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Waiting to run
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Waiting to run
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Waiting to run
Test (TypeScript) / test (push) Waiting to run
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Waiting to run
Test (TypeScript) / python-fs-shim (push) Waiting to run
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Waiting to run
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Waiting to run
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Waiting to run
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Waiting to run
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Waiting to run
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Waiting to run
Test (Install) / ts-minimal (push) Waiting to run
332 lines
11 KiB
Python
332 lines
11 KiB
Python
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
|
|
|
import asyncio
|
|
import logging
|
|
|
|
import pytest
|
|
|
|
from mirage import MountMode, Workspace
|
|
from mirage.commands import safeguard as sg
|
|
from mirage.resource.ram import RAMResource
|
|
from mirage.types import CommandSafeguard, OnExceed
|
|
|
|
|
|
async def _slow_provision(*args, **kwargs):
|
|
await asyncio.sleep(5)
|
|
|
|
|
|
@pytest.fixture
|
|
def restore_defaults():
|
|
snapshot = dict(sg.DEFAULT_COMMAND_SAFEGUARDS)
|
|
yield
|
|
sg.DEFAULT_COMMAND_SAFEGUARDS.clear()
|
|
sg.DEFAULT_COMMAND_SAFEGUARDS.update(snapshot)
|
|
|
|
|
|
def _ws(safeguards: dict | None = None) -> Workspace:
|
|
if safeguards:
|
|
return Workspace(
|
|
{"/data": (RAMResource(), MountMode.WRITE, safeguards)},
|
|
mode=MountMode.WRITE)
|
|
return Workspace({"/data": RAMResource()}, mode=MountMode.WRITE)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quick_builtin_under_default_does_not_fire():
|
|
ws = _ws()
|
|
r = await ws.execute("echo hi")
|
|
assert r.exit_code == 0
|
|
assert (await r.stdout_str()) == "hi\n"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_builtin_default_safeguard_fires(restore_defaults):
|
|
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
|
|
timeout_seconds=0.1)
|
|
ws = _ws()
|
|
r = await ws.execute("sleep 2")
|
|
assert r.exit_code == 124
|
|
assert "sleep: timed out after 0.1s" in (await r.stderr_str())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fallback_safeguard_applies_to_unknown_command(restore_defaults):
|
|
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
|
|
timeout_seconds=0.05)
|
|
ws = _ws()
|
|
r = await ws.execute("sleep 1")
|
|
assert r.exit_code == 124
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pipeline_first_stage_to_trip_wins(restore_defaults):
|
|
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
|
|
timeout_seconds=0.1)
|
|
ws = _ws()
|
|
r = await ws.execute("sleep 2 | echo done")
|
|
assert r.exit_code == 124
|
|
assert "sleep: timed out" in (await r.stderr_str())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_timeout_zero_disables(restore_defaults):
|
|
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
|
|
timeout_seconds=0)
|
|
ws = _ws()
|
|
r = await ws.execute("sleep 0.1")
|
|
assert r.exit_code == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mount_override_threaded_via_constructor():
|
|
overrides = {"cat": CommandSafeguard(timeout_seconds=42.0, max_lines=99)}
|
|
ws = _ws(safeguards=overrides)
|
|
mount = next(m for m in ws._registry._mounts if m.prefix == "/data/")
|
|
assert mount.command_safeguards["cat"].timeout_seconds == 42.0
|
|
assert mount.command_safeguards["cat"].max_lines == 99
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mount_override_beats_command_default():
|
|
overrides = {"cat": CommandSafeguard(timeout_seconds=999.0)}
|
|
ws = _ws(safeguards=overrides)
|
|
mount = next(m for m in ws._registry._mounts if m.prefix == "/data/")
|
|
from mirage.commands.safeguard import resolve_safeguard
|
|
resolved = resolve_safeguard("cat", None,
|
|
mount.command_safeguards.get("cat"))
|
|
assert resolved.timeout_seconds == 999.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_timeout_sets_shared_cancel_event(restore_defaults):
|
|
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
|
|
timeout_seconds=0.05)
|
|
ws = _ws()
|
|
cancel = asyncio.Event()
|
|
r = await ws.execute("sleep 1", cancel=cancel)
|
|
assert r.exit_code == 124
|
|
assert cancel.is_set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cross_mount_cat_honors_command_default_safeguard(
|
|
restore_defaults):
|
|
sg.DEFAULT_COMMAND_SAFEGUARDS["cat"] = CommandSafeguard(max_lines=4)
|
|
a = RAMResource()
|
|
b = RAMResource()
|
|
a._store.dirs.add("/")
|
|
b._store.dirs.add("/")
|
|
a._store.files["/x.txt"] = b"a\n" * 20
|
|
b._store.files["/y.txt"] = b"b\n" * 20
|
|
ws = Workspace({"/a/": a, "/b/": b}, mode=MountMode.WRITE)
|
|
r = await ws.execute("cat /a/x.txt /b/y.txt")
|
|
out = await r.stdout_str()
|
|
err = await r.stderr_str()
|
|
assert out.count("\n") == 4
|
|
assert "output truncated" in err
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fan_out_find_has_safeguard_set():
|
|
a = RAMResource()
|
|
a._store.dirs.add("/")
|
|
a._store.files["/x.txt"] = b"hi\n"
|
|
ws = Workspace({"/a/": a}, mode=MountMode.WRITE)
|
|
r = await ws.execute("find /")
|
|
assert r.safeguard is not None
|
|
assert r.safeguard.timeout_seconds is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cross_mount_honors_per_mount_timeout_override():
|
|
a = RAMResource()
|
|
b = RAMResource()
|
|
a._store.dirs.add("/")
|
|
b._store.dirs.add("/")
|
|
a._store.files["/x.txt"] = b"hi\n"
|
|
b._store.files["/y.txt"] = b"yo\n"
|
|
ws = Workspace(
|
|
{
|
|
"/a/": (a, MountMode.WRITE, {
|
|
"cat": CommandSafeguard(timeout_seconds=3.0)
|
|
}),
|
|
"/b/":
|
|
b,
|
|
},
|
|
mode=MountMode.WRITE)
|
|
r = await ws.execute("cat /a/x.txt /b/y.txt")
|
|
assert r.safeguard is not None
|
|
assert r.safeguard.timeout_seconds == 3.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fan_out_uses_tightest_timeout_among_mounts():
|
|
parent = RAMResource()
|
|
child = RAMResource()
|
|
parent._store.dirs.add("/")
|
|
parent._store.files["/a.txt"] = b"hi\n"
|
|
child._store.dirs.add("/")
|
|
child._store.files["/b.txt"] = b"yo\n"
|
|
ws = Workspace(
|
|
{
|
|
"/p/":
|
|
parent,
|
|
"/p/sub/": (child, MountMode.WRITE, {
|
|
"find": CommandSafeguard(timeout_seconds=2.0)
|
|
}),
|
|
},
|
|
mode=MountMode.WRITE)
|
|
r = await ws.execute("find /p")
|
|
assert r.safeguard is not None
|
|
assert r.safeguard.timeout_seconds == 2.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fan_out_tightest_when_parent_is_tighter():
|
|
parent = RAMResource()
|
|
child = RAMResource()
|
|
parent._store.dirs.add("/")
|
|
parent._store.files["/a.txt"] = b"hi\n"
|
|
child._store.dirs.add("/")
|
|
child._store.files["/b.txt"] = b"yo\n"
|
|
ws = Workspace(
|
|
{
|
|
"/p/": (parent, MountMode.WRITE, {
|
|
"find": CommandSafeguard(timeout_seconds=2.0)
|
|
}),
|
|
"/p/sub/": (child, MountMode.WRITE, {
|
|
"find": CommandSafeguard(timeout_seconds=9.0)
|
|
}),
|
|
},
|
|
mode=MountMode.WRITE)
|
|
r = await ws.execute("find /p")
|
|
assert r.safeguard is not None
|
|
assert r.safeguard.timeout_seconds == 2.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_background_job_propagates_timeout(restore_defaults):
|
|
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
|
|
timeout_seconds=0.05)
|
|
ws = _ws()
|
|
r1 = await ws.execute("sleep 2 &")
|
|
assert r1.exit_code == 0
|
|
r2 = await ws.execute("wait %1")
|
|
assert r2.exit_code == 124
|
|
err = await r2.stderr_str()
|
|
assert "sleep: timed out" in err
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stderr_redirect_does_not_swallow_timeout_exit(restore_defaults):
|
|
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
|
|
timeout_seconds=0.05)
|
|
ws = _ws()
|
|
r = await ws.execute("sleep 2 2>&1")
|
|
assert r.exit_code == 124
|
|
r = await ws.execute("sleep 2 2>&1 | cat")
|
|
assert r.exit_code == 124
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_job_table_reports_completed_bg_without_wait(restore_defaults):
|
|
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
|
|
timeout_seconds=0.05)
|
|
ws = _ws()
|
|
await ws.execute("sleep 5 &")
|
|
await asyncio.sleep(0.2)
|
|
jobs = ws.job_table.list_jobs()
|
|
assert len(jobs) == 1
|
|
assert jobs[0].status.value == "completed"
|
|
assert jobs[0].exit_code == 124
|
|
assert b"sleep: timed out" in jobs[0].stderr
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_timeout_wrap_preserves_lazy_nonzero_exit_on_no_match():
|
|
ws = _ws()
|
|
await ws.execute("echo hello > /data/f.txt")
|
|
r = await ws.execute("grep zzz /data/f.txt")
|
|
assert r.exit_code == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_timeout_wrap_preserves_lazy_zero_exit_on_match():
|
|
ws = _ws()
|
|
await ws.execute("echo hello > /data/f.txt")
|
|
r = await ws.execute("grep hello /data/f.txt")
|
|
assert r.exit_code == 0
|
|
assert (await r.stdout_str()) == "hello\n"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_truncation_keeps_lazy_exit_zero_on_match():
|
|
ws = _ws({"grep": CommandSafeguard(max_lines=2, timeout_seconds=600)})
|
|
await ws.execute("printf 'a\\na\\na\\na\\n' > /data/f.txt")
|
|
r = await ws.execute("grep a /data/f.txt")
|
|
assert r.exit_code == 0
|
|
assert (await r.stdout_str()) == "a\na\n"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_provision_dry_run_honors_timeout(monkeypatch, restore_defaults):
|
|
sg.DEFAULT_COMMAND_SAFEGUARDS["cat"] = CommandSafeguard(
|
|
timeout_seconds=0.1)
|
|
monkeypatch.setattr("mirage.workspace.workspace.provision_node",
|
|
_slow_provision)
|
|
ws = _ws()
|
|
r = await ws.execute("cat /data/f.txt", provision=True)
|
|
assert r.exit_code == 124
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_timeout_preserves_partial_records_and_logs(
|
|
caplog, restore_defaults):
|
|
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
|
|
timeout_seconds=0.1)
|
|
ws = _ws()
|
|
await ws.execute("echo hello > /data/f.txt")
|
|
before = len(ws._ops.records)
|
|
with caplog.at_level(logging.DEBUG, logger="mirage.workspace.workspace"):
|
|
r = await ws.execute("cat /data/f.txt; sleep 5")
|
|
assert r.exit_code == 124
|
|
assert len(ws._ops.records) > before
|
|
assert any("timed out" in m for m in caplog.messages)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cross_mount_cat_aggregates_tightest_safeguard():
|
|
a = RAMResource()
|
|
b = RAMResource()
|
|
a._store.dirs.add("/")
|
|
b._store.dirs.add("/")
|
|
a._store.files["/x.txt"] = b"1\n2\n3\n"
|
|
b._store.files["/y.txt"] = b"4\n5\n6\n"
|
|
ws = Workspace(
|
|
{
|
|
"/a/": (a, MountMode.WRITE, {
|
|
"cat":
|
|
CommandSafeguard(max_lines=100, on_exceed=OnExceed.TRUNCATE)
|
|
}),
|
|
"/b/":
|
|
(b, MountMode.WRITE, {
|
|
"cat": CommandSafeguard(max_lines=1, on_exceed=OnExceed.ERROR)
|
|
}),
|
|
},
|
|
mode=MountMode.WRITE)
|
|
r = await ws.execute("cat /a/x.txt /b/y.txt")
|
|
assert r.safeguard.max_lines == 1
|
|
assert r.safeguard.on_exceed is OnExceed.ERROR
|