Add Handbook.md benchmark tasks
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
"""Proxy auth token capture for core.
|
||||
|
||||
The proxy injects ``MCP_PROXY_TOKEN`` into core's env so the viewer's
|
||||
``ProxyTokenMiddleware`` can authenticate inbound non-MCP requests.
|
||||
|
||||
This code captures the token at import time, makes it available via ``get_proxy_token()``,
|
||||
and pops it from ``os.environ`` with ``@capture_proxy_token``, so it doesn't reach the agent's bash/python.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
|
||||
_TOKEN: str = ""
|
||||
|
||||
|
||||
def capture_proxy_token[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
|
||||
"""Decorator: before running ``fn``, pop ``MCP_PROXY_TOKEN`` from
|
||||
``os.environ`` and stash it for later reads via :func:`get_proxy_token`.
|
||||
|
||||
Apply this to ``core.server.main()`` — the first thing that runs
|
||||
when core starts. The pop happens before any tool can be invoked
|
||||
so subprocesses can't recover the token from ``/proc/$PPID/environ``.
|
||||
"""
|
||||
|
||||
@wraps(fn)
|
||||
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
global _TOKEN
|
||||
_TOKEN = os.environ.pop("MCP_PROXY_TOKEN", "")
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def get_proxy_token() -> str:
|
||||
"""Return the captured proxy auth token, or ``""`` if not yet captured."""
|
||||
return _TOKEN
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Boot-time guard: every registered MCP tool must be async.
|
||||
|
||||
Sync (`def`) tools make FastMCP run pydantic argument validation in an anyio
|
||||
worker threadpool. Under concurrent calls the shared pydantic-core validator is
|
||||
re-entered across threads and panics with ``pyo3_runtime.PanicException:
|
||||
dictionary changed size during iteration``, which tears down the
|
||||
StreamableHTTP task group and turns every later request into a 500. Async
|
||||
(`async def`) tools validate on the single-threaded event loop and are safe.
|
||||
|
||||
This guard runs at server boot (and therefore during ``mcp-proxy gen``, which
|
||||
boots every server): a non-conformant package fails fast instead of shipping a
|
||||
server that can crash under load. The same check is intentionally duplicated in
|
||||
each package because the bundled prod images install only that package's own
|
||||
dependencies, so there is no shared runtime module to import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _unwrap(fn: Any) -> Any:
|
||||
while isinstance(fn, partial):
|
||||
fn = fn.func
|
||||
return fn
|
||||
|
||||
|
||||
def find_sync_tools(mcp: Any) -> list[str]:
|
||||
"""Return the names of registered tools whose function is not a coroutine."""
|
||||
# mcp.server.fastmcp.FastMCP exposes a synchronous tool manager.
|
||||
manager = getattr(mcp, "_tool_manager", None)
|
||||
if manager is not None and hasattr(manager, "list_tools"):
|
||||
return sorted(t.name for t in manager.list_tools() if not inspect.iscoroutinefunction(_unwrap(t.fn)))
|
||||
|
||||
# fastmcp.FastMCP exposes an async API.
|
||||
async def _collect() -> list[str]:
|
||||
sync: list[str] = []
|
||||
for descriptor in await mcp.list_tools():
|
||||
tool = await mcp.get_tool(descriptor.name)
|
||||
if not inspect.iscoroutinefunction(_unwrap(tool.fn)):
|
||||
sync.append(descriptor.name)
|
||||
return sorted(sync)
|
||||
|
||||
return asyncio.run(_collect())
|
||||
|
||||
|
||||
def assert_tools_async(mcp: Any) -> None:
|
||||
"""Raise if any registered tool is synchronous."""
|
||||
sync = find_sync_tools(mcp)
|
||||
if sync:
|
||||
raise RuntimeError(
|
||||
"MCP tools must be async (`async def`). Sync tools run pydantic argument "
|
||||
"validation in a worker threadpool and can trigger a pydantic-core "
|
||||
"'dictionary changed size during iteration' panic under concurrent calls, "
|
||||
"which kills the server. Make these tools async: " + ", ".join(sync)
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Workdir/ownership helpers for core's root-server + per-exec-drop model.
|
||||
|
||||
Configured via ``packages/core/mcp.json``'s ``env`` block:
|
||||
|
||||
"env": {"SETUID": "1000", "SETGID": "1000", "HOME": "/home/model"}
|
||||
|
||||
**The core server process stays root.** Sibling servers (slack, jira,
|
||||
google_mail, grading, …) also run as root because their tools just call mocked
|
||||
APIs over HTTP — no shell, no file IO, no adversarial surface. Core is
|
||||
different: it hands the agent ``bash``, so each of *those commands* is dropped
|
||||
to ``SETUID``/``SETGID`` at spawn time
|
||||
(``core.tools.sandbox._privilege_drop_kwargs``), while the server itself
|
||||
keeps root. Keeping the server root (rather than dropping the whole process to
|
||||
uid 1000, as it used to) is what lets the Dockerfile close ``/opt/venv`` to uid
|
||||
1000: the server reads its venv as root, and the agent — a *different* effective
|
||||
uid per command — cannot. ``/app`` stays sealed to ``0700 root:root`` and the
|
||||
venv lives outside ``/app`` at ``/opt/venv``. See ``docker/base/Dockerfile`` and
|
||||
the README's "Sandbox model" section.
|
||||
|
||||
This module keeps the bits that still run in the root server: provisioning
|
||||
``WORKDIR`` and restoring ownership of files copied out of the root-owned source
|
||||
tree into the model-owned workdir. The actual privilege drop now happens
|
||||
per-command in ``sandbox`` — there is intentionally no function here that drops
|
||||
the whole process.
|
||||
|
||||
When the process isn't root (local dev, CI, tests) these helpers are no-ops:
|
||||
they can't ``mkdir`` at ``/`` or ``chown``, and the same ``mcp.json`` ships
|
||||
``SETUID``/``SETGID`` everywhere harmlessly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from core.tools import sandbox
|
||||
|
||||
|
||||
def _target_ids() -> tuple[int, int] | None:
|
||||
"""Return ``(uid, gid)`` from ``SETUID``/``SETGID`` env, or ``None`` if neither is set.
|
||||
|
||||
A missing one becomes ``-1`` (the ``os.chown`` sentinel for "leave unchanged").
|
||||
"""
|
||||
setuid = os.environ.get("SETUID")
|
||||
setgid = os.environ.get("SETGID")
|
||||
if setuid is None and setgid is None:
|
||||
return None
|
||||
uid = int(setuid) if setuid is not None else -1
|
||||
gid = int(setgid) if setgid is not None else -1
|
||||
return uid, gid
|
||||
|
||||
|
||||
def ensure_workdir() -> None:
|
||||
"""Make sure WORKDIR exists with the right owner BEFORE we drop privileges.
|
||||
|
||||
Some deploy environments don't pre-provision /workdir (the Dockerfile +
|
||||
start.sh do, but bundle launchers / Modal workflows may not). The setup
|
||||
hook needs to write into it, and the server needs to chdir there — so we
|
||||
create it as root and chown to SETUID/SETGID before the drop, since uid
|
||||
1000 can't mkdir at /.
|
||||
|
||||
Non-root callers (CI, local dev, tests) can't mkdir at / and can't chown,
|
||||
so this is a no-op — WORKDIR there is either pre-provisioned (Docker, test
|
||||
fixtures pointing at tmp dirs) or genuinely absent, in which case any
|
||||
caller that actually needs the dir will fail loudly on its own.
|
||||
"""
|
||||
if os.geteuid() != 0:
|
||||
return
|
||||
workdir = sandbox.WORKDIR
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
ids = _target_ids()
|
||||
if ids is None:
|
||||
return
|
||||
os.chown(workdir, *ids)
|
||||
|
||||
|
||||
def chown_tree_to_target(path: str | os.PathLike[str]) -> None:
|
||||
"""Recursively chown ``path`` to ``SETUID``/``SETGID``. No-op when not root.
|
||||
|
||||
Used by the setup hook after copying root-owned source files into
|
||||
model-owned WORKDIR — ``shutil.copytree`` runs as root (so it can read
|
||||
locked-down sources) and the new entries inherit root ownership; this
|
||||
restores them to the unprivileged target user so the model can edit them.
|
||||
Symlinks are chowned without following.
|
||||
"""
|
||||
if os.geteuid() != 0:
|
||||
return
|
||||
ids = _target_ids()
|
||||
if ids is None:
|
||||
return
|
||||
uid, gid = ids
|
||||
root_str = os.fspath(path)
|
||||
os.chown(root_str, uid, gid, follow_symlinks=False)
|
||||
for root, dirs, files in os.walk(root_str):
|
||||
for name in dirs + files:
|
||||
os.chown(os.path.join(root, name), uid, gid, follow_symlinks=False)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Core standalone MCP server entry point.
|
||||
|
||||
This module exposes core's tools as an MCP server that can be launched as a
|
||||
subprocess and proxied by mcp_proxy or any other MCP proxy.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.tools.function_tool import FunctionTool
|
||||
|
||||
import core.tools as tools
|
||||
from core._token import capture_proxy_token
|
||||
from core.async_tool_guard import assert_tools_async
|
||||
from core.privilege import ensure_workdir
|
||||
from core.tools import sandbox
|
||||
from core.viewer import run_http_server
|
||||
|
||||
|
||||
def build_app() -> FastMCP:
|
||||
"""Build a FastMCP app exposing all core tools."""
|
||||
app = FastMCP("core")
|
||||
for tool_name in sorted(tools.__all__):
|
||||
tool_fn = getattr(tools, tool_name, None)
|
||||
if callable(tool_fn):
|
||||
# output_schema=None matches the shipped mcp-tools.generated.json,
|
||||
# which has never advertised output schemas.
|
||||
app.add_tool(FunctionTool.from_function(fn=tool_fn, name=tool_name, output_schema=None))
|
||||
return app
|
||||
|
||||
|
||||
@capture_proxy_token
|
||||
def main() -> None:
|
||||
# The server intentionally keeps running as root: it needs to read the
|
||||
# locked-down /app tree, and a root server lets us close /opt/venv to uid
|
||||
# 1000. Privilege is dropped per agent command instead — see
|
||||
# core.tools.sandbox._privilege_drop_kwargs (the chokepoint every
|
||||
# bash/file-tool subprocess flows through).
|
||||
ensure_workdir()
|
||||
# Land the running server in the agent's workdir so any tool that doesn't
|
||||
# pass cwd= explicitly resolves relative paths under /workdir. Skip when
|
||||
# the dir doesn't exist (CI / local dev where /workdir isn't provisioned).
|
||||
if os.path.isdir(sandbox.WORKDIR):
|
||||
os.chdir(sandbox.WORKDIR)
|
||||
app = build_app()
|
||||
assert_tools_async(app)
|
||||
|
||||
port = os.environ.get("PORT")
|
||||
if port:
|
||||
run_http_server(app, int(port))
|
||||
else:
|
||||
app.run(show_banner=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Copy uploaded context files into the agent's workdir.
|
||||
|
||||
Lookup order for the source directory:
|
||||
1. Unified bundle: $BUNDLEDIR/files/ (when a trajectory bundle is mounted)
|
||||
2. Task-specific: {WORLDBENCH_ROOT}/tasks/setup_data/{WORLDBENCH_TASK_ID}/
|
||||
3. Generic: {WORLDBENCH_ROOT}/setup_data/
|
||||
|
||||
BUNDLEDIR is set by the parent process (``scripts/start.sh`` for local
|
||||
dev, the production harness in production) and points at the unpacked bundle root.
|
||||
|
||||
For (2)/(3) the source files live under a ``files/`` subdirectory; for (1)
|
||||
the bundle root already has ``files/`` as a peer of ``services/``, ``memory/``,
|
||||
and ``meta/``. In all cases, the contents of ``files/`` are copied into
|
||||
``sandbox.WORKDIR`` (``/workdir``).
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from core.privilege import chown_tree_to_target, ensure_workdir
|
||||
from core.tools import sandbox
|
||||
|
||||
|
||||
def _resolve_files_dir(world_root: Path, task_id: str | None) -> Path | None:
|
||||
"""Return the source ``files/`` directory, or None if no setup data exists."""
|
||||
bundle_dir = os.environ.get("BUNDLEDIR")
|
||||
if bundle_dir:
|
||||
bundle_files = Path(bundle_dir) / "files"
|
||||
if bundle_files.is_dir():
|
||||
return bundle_files
|
||||
|
||||
task_setup_dir = world_root / "tasks" / "setup_data" / task_id if task_id else None
|
||||
if task_setup_dir and task_setup_dir.exists():
|
||||
setup_dir = task_setup_dir
|
||||
else:
|
||||
setup_dir = world_root / "setup_data"
|
||||
if not setup_dir.exists():
|
||||
return None
|
||||
|
||||
files_dir = setup_dir / "files"
|
||||
return files_dir if files_dir.is_dir() else None
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Setup runs entirely as root: the source tree (e.g. /app/setup_data/files/,
|
||||
# extracted by the production harness as root) is not readable by uid 1000, so we copy
|
||||
# before dropping privileges and then chown the result to SETUID/SETGID so
|
||||
# the model user can modify it. This script then exits — the actual MCP
|
||||
# server is a separate process invocation that does its own privilege drop.
|
||||
ensure_workdir()
|
||||
task_id = os.environ.get("WORLDBENCH_TASK_ID")
|
||||
world_root = Path(os.environ.get("WORLDBENCH_ROOT", os.getcwd()))
|
||||
|
||||
files_dir = _resolve_files_dir(world_root, task_id)
|
||||
if files_dir is None:
|
||||
print("No setup data found, skipping")
|
||||
sys.exit(0)
|
||||
|
||||
# Refuse symlinks and hardlinks anywhere in the source tree, and refuse
|
||||
# symlinks already present at the destination. We run as root here (so
|
||||
# we can read locked-down sources the model user can't see) and chown
|
||||
# the destination to uid 1000 afterwards. Three ways the protected
|
||||
# tree could be leaked back to the model otherwise:
|
||||
# 1. Source symlink `files/rubrics -> /app/tasks` — shutil.copytree's
|
||||
# default `symlinks=False` dereferences it and materializes the
|
||||
# target into /workdir.
|
||||
# 2. Source hardlink `files/foo` to `/app/packages/grading/x.py` —
|
||||
# copytree sees a regular file with the same inode and writes a
|
||||
# new copy into /workdir.
|
||||
# 3. Destination symlink `/workdir/context -> /app/packages/grading`
|
||||
# planted by a prior model run — copytree's `dirs_exist_ok=True`
|
||||
# follows it and writes bundle contents *into* the protected
|
||||
# directory as root.
|
||||
workdir = Path(sandbox.WORKDIR)
|
||||
_reject_unsafe_source(files_dir)
|
||||
workdir.mkdir(parents=True, exist_ok=True)
|
||||
_reject_dest_symlinks(workdir)
|
||||
shutil.copytree(files_dir, workdir, dirs_exist_ok=True)
|
||||
chown_tree_to_target(workdir)
|
||||
print(f"Copied files from {files_dir} to {workdir}")
|
||||
|
||||
|
||||
def _reject_unsafe_source(root: Path) -> None:
|
||||
"""Exit non-zero if any entry under ``root`` is a symlink or a hardlink.
|
||||
|
||||
Hardlinks are detected via ``st_nlink > 1`` on regular files. Files in
|
||||
the bundle should be unique copies; multi-link inodes mean the bundle
|
||||
points at something we didn't create, which might be a protected path
|
||||
on the same filesystem.
|
||||
"""
|
||||
if root.is_symlink():
|
||||
print(f"Refusing to copy symlinked setup root: {root}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
||||
for name in dirnames + filenames:
|
||||
entry = Path(dirpath) / name
|
||||
if entry.is_symlink():
|
||||
print(f"Refusing to copy symlink in setup data: {entry}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
for name in filenames:
|
||||
entry = Path(dirpath) / name
|
||||
# lstat — don't follow symlinks (already rejected above, but be defensive).
|
||||
st = entry.lstat()
|
||||
if st.st_nlink > 1:
|
||||
print(f"Refusing to copy hardlinked file in setup data: {entry}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _reject_dest_symlinks(root: Path) -> None:
|
||||
"""Exit non-zero if any entry under destination ``root`` is a symlink.
|
||||
|
||||
A symlink planted in /workdir by a prior model run would let
|
||||
``copytree(..., dirs_exist_ok=True)`` write through it as root, into
|
||||
paths the model user can't normally write — or, after chown, read.
|
||||
"""
|
||||
if root.is_symlink():
|
||||
print(f"Refusing to copy into symlinked workdir: {root}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
||||
for name in dirnames + filenames:
|
||||
entry = Path(dirpath) / name
|
||||
if entry.is_symlink():
|
||||
print(f"Refusing to copy over symlink in workdir: {entry}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,27 @@
|
||||
"""State codec for core.
|
||||
|
||||
Syntara on main is file-sandbox-backed — no database entities to snapshot.
|
||||
``export_state``/``import_state`` exist for uniformity with the other MCP
|
||||
servers (the proxy validates every server has them); round-trip is
|
||||
trivially empty.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class SyntaraState(BaseModel):
|
||||
"""Empty state — core has no DB to snapshot."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
def state_to_json() -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
def state_from_json(data: dict[str, Any]) -> None:
|
||||
_ = data
|
||||
@@ -0,0 +1,22 @@
|
||||
from .bash import bash
|
||||
from .echo import echo
|
||||
from .list_files import listFiles
|
||||
from .prepare_grading_context import prepareGradingContext
|
||||
from .read_file import readFile
|
||||
from .read_media import readMedia
|
||||
from .read_pdf import readPDF
|
||||
from .state import export_state, import_state
|
||||
from .write_file import writeFile
|
||||
|
||||
__all__ = [
|
||||
"bash",
|
||||
"echo",
|
||||
"export_state",
|
||||
"import_state",
|
||||
"listFiles",
|
||||
"prepareGradingContext",
|
||||
"readFile",
|
||||
"readMedia",
|
||||
"readPDF",
|
||||
"writeFile",
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
import asyncio
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from core.tools.sandbox import DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS, run_in_sandbox
|
||||
|
||||
|
||||
async def bash(
|
||||
command: Annotated[str, Field(description="The bash command to execute")],
|
||||
timeout_seconds: Annotated[
|
||||
int | None,
|
||||
Field(
|
||||
description=f"Timeout in seconds (default {DEFAULT_TIMEOUT_SECONDS}, max {MAX_TIMEOUT_SECONDS})",
|
||||
ge=1,
|
||||
le=MAX_TIMEOUT_SECONDS,
|
||||
),
|
||||
] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a bash command in an isolated directory."""
|
||||
timeout = timeout_seconds or DEFAULT_TIMEOUT_SECONDS
|
||||
# run_in_sandbox blocks (subprocess.run) for up to `timeout` seconds; offload
|
||||
# to a worker thread so a slow command doesn't stall the event loop and block
|
||||
# other concurrent tool calls. Validation already ran on the loop.
|
||||
return await asyncio.to_thread(run_in_sandbox, ["bash", "-c", command], timeout)
|
||||
@@ -0,0 +1,8 @@
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
|
||||
async def echo(message: Annotated[str, Field(description="The message to echo")]) -> str:
|
||||
"""Echoes a message"""
|
||||
return message + message
|
||||
@@ -0,0 +1,80 @@
|
||||
import asyncio
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from core.tools.sandbox import DEFAULT_TIMEOUT_SECONDS, run_in_sandbox
|
||||
|
||||
# Emit NUL-terminated records "d <name>\0" or "f <name>\0". NUL separation
|
||||
# preserves filenames containing newlines, which are valid POSIX. dotglob
|
||||
# includes hidden files (matching os.listdir); nullglob makes empty dirs
|
||||
# produce no output.
|
||||
_LIST_SCRIPT = (
|
||||
"shopt -s dotglob nullglob; "
|
||||
'if [ ! -e "$1" ]; then printf "No such directory: %s" "$1" >&2; exit 1; fi; '
|
||||
'if [ ! -d "$1" ]; then printf "Not a directory: %s" "$1" >&2; exit 1; fi; '
|
||||
'cd -- "$1" || exit 1; '
|
||||
"for e in *; do "
|
||||
'if [ -d "$e" ]; then printf "d %s\\0" "$e"; '
|
||||
'else printf "f %s\\0" "$e"; fi; '
|
||||
"done"
|
||||
)
|
||||
|
||||
|
||||
async def listFiles(
|
||||
directory: Annotated[
|
||||
str,
|
||||
Field(
|
||||
description=(
|
||||
"Directory to list, relative to the sandbox root. Use /workdir/ prefix or an "
|
||||
"absolute path within the sandbox to be explicit. Defaults to the sandbox root."
|
||||
),
|
||||
),
|
||||
] = ".",
|
||||
) -> dict[str, Any]:
|
||||
"""List files and subdirectories in a directory inside the sandbox."""
|
||||
# Binary mode so non-UTF-8 filenames don't raise UnicodeDecodeError before
|
||||
# we get a chance to handle them. We decode each name individually below.
|
||||
# Offload the blocking subprocess call to a worker thread so it doesn't stall
|
||||
# the event loop; the fast result parsing below stays on the loop.
|
||||
result = await asyncio.to_thread(
|
||||
run_in_sandbox,
|
||||
["bash", "-c", _LIST_SCRIPT, "--", directory],
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
text=False,
|
||||
)
|
||||
|
||||
stderr_bytes = result.get("stderr") or b""
|
||||
stderr_str = stderr_bytes.decode("utf-8", errors="replace") if isinstance(stderr_bytes, bytes) else stderr_bytes
|
||||
|
||||
if result["returncode"] != 0:
|
||||
return {
|
||||
"directory": directory,
|
||||
"files": [],
|
||||
"directories": [],
|
||||
"returncode": result["returncode"],
|
||||
"stderr": stderr_str or result.get("error", ""),
|
||||
}
|
||||
|
||||
files: list[str] = []
|
||||
directories: list[str] = []
|
||||
for entry in result["stdout"].split(b"\x00"):
|
||||
if not entry:
|
||||
continue
|
||||
kind, _, name_bytes = entry.partition(b" ")
|
||||
name = name_bytes.decode("utf-8", errors="replace")
|
||||
if kind == b"d":
|
||||
directories.append(name)
|
||||
elif kind == b"f":
|
||||
files.append(name)
|
||||
|
||||
files.sort()
|
||||
directories.sort()
|
||||
|
||||
return {
|
||||
"directory": directory,
|
||||
"files": files,
|
||||
"directories": directories,
|
||||
"returncode": 0,
|
||||
"stderr": "",
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
"""Prepare grading context for LLM-based rubric evaluation.
|
||||
|
||||
Collects file evidence from the sandbox, formats as XML, and assembles
|
||||
the full text_for_grading string by prepending file evidence to the
|
||||
agent's final output.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
|
||||
from core.tools import sandbox
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default cap on the number of files included as evidence. Sized to comfortably
|
||||
# cover a task's read-only input documents PLUS the agent's deliverables: the
|
||||
# walk is flat + alphabetical, so input files can otherwise consume every slot
|
||||
# and silently drop the agent-created files the rubric actually grades. When the
|
||||
# cap IS hit, the dropped files are surfaced both in a log line and as a
|
||||
# <files_omitted_due_to_max_files> marker inside the <workspace_files> XML, so an
|
||||
# empty or partial listing is never mistaken for "the agent created nothing."
|
||||
DEFAULT_MAX_FILES = 100
|
||||
|
||||
# Per-file content budget that ends up in the grading prompt. The ceiling on the
|
||||
# whole evidence block is DEFAULT_MAX_FILES * this, so the constraint isn't memory
|
||||
# but the grader's context window and cost — every byte here is fed to the rubric
|
||||
# LLM. 250 KB gives large multi-tab spreadsheets and multi-slide decks room to
|
||||
# show every tab/slide (the per-sheet/per-slide budgeting below distributes it),
|
||||
# while keeping the worst case bounded. Realistic tasks have a handful of
|
||||
# substantive files, so actual prompts stay far below the 100-file ceiling.
|
||||
DEFAULT_MAX_CONTENT_BYTES = 250_000
|
||||
|
||||
TEXT_EXTENSIONS = {
|
||||
"txt",
|
||||
"csv",
|
||||
"json",
|
||||
"jsonl",
|
||||
"py",
|
||||
"js",
|
||||
"ts",
|
||||
"md",
|
||||
"html",
|
||||
"xml",
|
||||
"yaml",
|
||||
"yml",
|
||||
"toml",
|
||||
"cfg",
|
||||
"ini",
|
||||
"log",
|
||||
"sh",
|
||||
"bash",
|
||||
"sql",
|
||||
"r",
|
||||
"rb",
|
||||
"java",
|
||||
"c",
|
||||
"cpp",
|
||||
"h",
|
||||
"hpp",
|
||||
"css",
|
||||
"scss",
|
||||
"tex",
|
||||
"rst",
|
||||
}
|
||||
|
||||
SPECIAL_EXTENSIONS = {"pdf", "docx", "xlsx", "pptx"}
|
||||
|
||||
ALL_SUPPORTED_EXTENSIONS = TEXT_EXTENSIONS | SPECIAL_EXTENSIONS
|
||||
|
||||
# Special formats (PDF/Office) can't be truncated without corrupting the
|
||||
# container, so extraction needs the whole file in memory — but an unbounded
|
||||
# read lets one giant agent-generated file OOM grading before the rubric runs.
|
||||
# Cap the raw read: files over this size are recorded as evidence (so the rubric
|
||||
# still knows they exist) but not extracted. Plain text is already bounded by
|
||||
# max_content_bytes, so it needs no separate cap.
|
||||
MAX_SPECIAL_FILE_BYTES = 50 * 1024 * 1024
|
||||
|
||||
DEFAULT_EXCLUDE_PATTERNS = {
|
||||
"__pycache__",
|
||||
".git",
|
||||
".venv",
|
||||
"node_modules",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
}
|
||||
|
||||
|
||||
def _is_excluded(relative_path: str, exclude_patterns: set[str]) -> bool:
|
||||
parts = relative_path.split(os.sep)
|
||||
return any(part in exclude_patterns for part in parts)
|
||||
|
||||
|
||||
def _within_workdir(path: str, workdir_root: str) -> bool:
|
||||
"""True if ``path`` resolves to ``workdir_root`` or somewhere beneath it.
|
||||
|
||||
``realpath`` collapses ``..`` and follows symlinks, so a symlink inside the
|
||||
workdir pointing at ``/app`` resolves outside ``workdir_root`` and is
|
||||
rejected. The core server runs as root, so this is what stops
|
||||
``prepareGradingContext`` from being turned into a read oracle for the
|
||||
locked-down ``/app`` tree.
|
||||
"""
|
||||
canonical = os.path.realpath(path)
|
||||
return canonical == workdir_root or canonical.startswith(workdir_root + os.sep)
|
||||
|
||||
|
||||
def _get_extension(file_path: str) -> str:
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
return ext.lstrip(".")
|
||||
|
||||
|
||||
def _extract_text(raw: bytes, file_size: int, max_bytes: int) -> tuple[str | None, str, bool]:
|
||||
try:
|
||||
truncated = file_size > max_bytes
|
||||
content = raw[:max_bytes].decode("utf-8", errors="replace")
|
||||
if truncated:
|
||||
content += f"\n\n[... truncated at {max_bytes:,} bytes, total size: {file_size:,} bytes]"
|
||||
return content, "read", truncated
|
||||
except Exception:
|
||||
return None, "read", False
|
||||
|
||||
|
||||
def _extract_pdf(raw: bytes, max_bytes: int) -> tuple[str | None, str, bool]:
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
|
||||
reader = PdfReader(io.BytesIO(raw))
|
||||
parts: list[str] = []
|
||||
total_bytes = 0
|
||||
truncated = False
|
||||
for page in reader.pages:
|
||||
text = page.extract_text() or ""
|
||||
text_bytes = len(text.encode("utf-8"))
|
||||
if total_bytes + text_bytes > max_bytes:
|
||||
remaining = max_bytes - total_bytes
|
||||
parts.append(text[:remaining])
|
||||
truncated = True
|
||||
break
|
||||
parts.append(text)
|
||||
total_bytes += text_bytes
|
||||
content = "\n".join(parts)
|
||||
if truncated:
|
||||
content += f"\n\n[... truncated at {max_bytes:,} bytes]"
|
||||
return content, "pypdf", truncated
|
||||
except Exception:
|
||||
return None, "pypdf", False
|
||||
|
||||
|
||||
def _docx_table_lines(table) -> list[str]:
|
||||
"""Return one tab-separated line per row, recursing into nested tables.
|
||||
|
||||
A cell can hold both paragraphs and further tables, so we join the cell's
|
||||
own paragraph text and then descend into any tables it nests — `cell.text`
|
||||
alone flattens only the direct paragraphs and silently drops nested tables.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
for row in table.rows:
|
||||
cells: list[str] = []
|
||||
nested: list[str] = []
|
||||
for cell in row.cells:
|
||||
cells.append("\n".join(p.text for p in cell.paragraphs))
|
||||
for inner in cell.tables:
|
||||
nested.extend(_docx_table_lines(inner))
|
||||
lines.append("\t".join(cells))
|
||||
lines.extend(nested)
|
||||
return lines
|
||||
|
||||
|
||||
def _extract_docx(raw: bytes, max_bytes: int) -> tuple[str | None, str, bool]:
|
||||
try:
|
||||
from docx import Document
|
||||
|
||||
doc = Document(io.BytesIO(raw))
|
||||
lines = [p.text for p in doc.paragraphs]
|
||||
# Paragraphs alone miss text inside tables, which agents routinely use
|
||||
# for structured output (reports, spreadsheets exported as docx, etc.).
|
||||
for table in doc.tables:
|
||||
lines.extend(_docx_table_lines(table))
|
||||
text = "\n".join(lines)
|
||||
text_bytes = len(text.encode("utf-8"))
|
||||
truncated = text_bytes > max_bytes
|
||||
if truncated:
|
||||
content = text[:max_bytes]
|
||||
content += f"\n\n[... truncated at {max_bytes:,} bytes]"
|
||||
else:
|
||||
content = text
|
||||
return content, "python-docx", truncated
|
||||
except Exception:
|
||||
return None, "python-docx", False
|
||||
|
||||
|
||||
def _extract_xlsx(raw: bytes, max_bytes: int) -> tuple[str | None, str, bool]:
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
|
||||
wb = load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
|
||||
parts: list[str] = []
|
||||
total_bytes = 0
|
||||
truncated = False
|
||||
# Budget the bytes PER SHEET instead of letting the first sheet drain the
|
||||
# whole allowance. A multi-tab workbook used to truncate mid-first-sheet
|
||||
# and never emit the later tabs at all — so a grader couldn't even tell
|
||||
# those sheets existed. We give each not-yet-visited sheet an equal slice
|
||||
# of the remaining budget, recomputed after every sheet so unused bytes
|
||||
# from small/empty sheets roll forward to later ones.
|
||||
sheet_names = wb.sheetnames
|
||||
for index, sheet_name in enumerate(sheet_names):
|
||||
ws = wb[sheet_name]
|
||||
# Always emit the header so every tab is visible, even one whose rows
|
||||
# get fully truncated away.
|
||||
header = f"--- Sheet: {sheet_name} ---"
|
||||
parts.append(header)
|
||||
total_bytes += len(header.encode("utf-8")) + 1
|
||||
|
||||
remaining_sheets = len(sheet_names) - index
|
||||
sheet_budget = max(0, (max_bytes - total_bytes) // remaining_sheets)
|
||||
sheet_used = 0
|
||||
sheet_truncated = False
|
||||
for row in ws.iter_rows(values_only=True):
|
||||
line = "\t".join(str(cell) if cell is not None else "" for cell in row)
|
||||
# Skip fully-empty rows: openpyxl's read-only dimensions can be
|
||||
# inflated, so a sheet may report thousands of blank trailing rows
|
||||
# that would otherwise burn the budget producing only tabs.
|
||||
if not line.strip():
|
||||
continue
|
||||
line_bytes = len(line.encode("utf-8")) + 1 # +1 for newline
|
||||
if sheet_used + line_bytes > sheet_budget:
|
||||
sheet_truncated = True
|
||||
truncated = True
|
||||
break
|
||||
parts.append(line)
|
||||
sheet_used += line_bytes
|
||||
total_bytes += line_bytes
|
||||
if sheet_truncated:
|
||||
parts.append(f"[... '{sheet_name}' truncated at {sheet_budget:,} bytes for this sheet]")
|
||||
wb.close()
|
||||
content = "\n".join(parts)
|
||||
if truncated:
|
||||
content += f"\n\n[... some sheets truncated; per-sheet budget of ~{max_bytes:,} total bytes reached]"
|
||||
return content, "openpyxl", truncated
|
||||
except Exception:
|
||||
return None, "openpyxl", False
|
||||
|
||||
|
||||
def _pptx_shape_lines(shapes) -> list[str]:
|
||||
"""Return text lines for a collection of shapes, recursing into groups.
|
||||
|
||||
Pulls text frames and table cells, descending into grouped shapes.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
for shape in shapes:
|
||||
if shape.shape_type == 6: # MSO_SHAPE_TYPE.GROUP
|
||||
lines.extend(_pptx_shape_lines(shape.shapes))
|
||||
continue
|
||||
if shape.has_text_frame:
|
||||
text = shape.text_frame.text
|
||||
if text.strip():
|
||||
lines.append(text)
|
||||
if shape.has_table:
|
||||
for row in shape.table.rows:
|
||||
cells = [cell.text for cell in row.cells]
|
||||
lines.append("\t".join(cells))
|
||||
return lines
|
||||
|
||||
|
||||
def _extract_pptx(raw: bytes, max_bytes: int) -> tuple[str | None, str, bool]:
|
||||
try:
|
||||
from pptx import Presentation
|
||||
|
||||
prs = Presentation(io.BytesIO(raw))
|
||||
parts: list[str] = []
|
||||
total_bytes = 0
|
||||
truncated = False
|
||||
for index, slide in enumerate(prs.slides, start=1):
|
||||
# Per-slide marker so the grader sees the slide count, which is often
|
||||
# itself part of the rubric.
|
||||
header = f"--- Slide {index} ---"
|
||||
lines = _pptx_shape_lines(slide.shapes)
|
||||
if slide.has_notes_slide:
|
||||
notes = slide.notes_slide.notes_text_frame.text
|
||||
if notes.strip():
|
||||
lines.append(f"[Notes] {notes}")
|
||||
block = "\n".join([header, *lines])
|
||||
block_bytes = len(block.encode("utf-8")) + 1
|
||||
if total_bytes + block_bytes > max_bytes:
|
||||
remaining = max_bytes - total_bytes
|
||||
if remaining > 0:
|
||||
parts.append(block[:remaining])
|
||||
truncated = True
|
||||
break
|
||||
parts.append(block)
|
||||
total_bytes += block_bytes
|
||||
content = "\n".join(parts)
|
||||
if truncated:
|
||||
content += f"\n\n[... truncated at {max_bytes:,} bytes]"
|
||||
return content, "python-pptx", truncated
|
||||
except Exception:
|
||||
return None, "python-pptx", False
|
||||
|
||||
|
||||
def _walk_dir(directory: str) -> list[str]:
|
||||
results: list[str] = []
|
||||
for root, _dirs, files in os.walk(directory):
|
||||
for name in files:
|
||||
results.append(os.path.join(root, name))
|
||||
results.sort()
|
||||
return results
|
||||
|
||||
|
||||
def _format_evidence_as_xml(
|
||||
evidence: list[dict],
|
||||
directory: str | None = None,
|
||||
dropped_paths: list[str] | None = None,
|
||||
) -> str:
|
||||
# Emit the container even with no included files when some were dropped by
|
||||
# the cap, so the grader sees the truncation notice rather than a bare
|
||||
# final_output. Only an empty listing AND nothing dropped means "no files."
|
||||
if not evidence and not dropped_paths:
|
||||
return ""
|
||||
|
||||
dir_attr = f' directory="{directory}"' if directory else ""
|
||||
truncated_attr = ""
|
||||
if dropped_paths:
|
||||
truncated_attr = f' truncated_to_max_files="true" dropped_file_count="{len(dropped_paths)}"'
|
||||
parts: list[str] = [f"<workspace_files{dir_attr}{truncated_attr}>"]
|
||||
|
||||
for item in evidence:
|
||||
attrs = f'path="{item["path"]}" type="{item["extension"]}" size_bytes="{item["size_bytes"]}"'
|
||||
if item.get("extraction_method"):
|
||||
attrs += f' extraction_method="{item["extraction_method"]}"'
|
||||
if item.get("truncated"):
|
||||
attrs += ' truncated="true"'
|
||||
|
||||
if item["content"] is not None:
|
||||
parts.append(f" <file {attrs}>")
|
||||
parts.append(item["content"])
|
||||
parts.append(" </file>")
|
||||
else:
|
||||
parts.append(f" <file {attrs} />")
|
||||
|
||||
if dropped_paths:
|
||||
# Surface the names of files that were walked but excluded by the cap.
|
||||
# An absent file here is genuinely absent; a file listed here exists but
|
||||
# its contents were not included, so a rubric must not conclude it is
|
||||
# missing from the workspace.
|
||||
parts.append(f' <files_omitted_due_to_max_files count="{len(dropped_paths)}">')
|
||||
for path in dropped_paths:
|
||||
parts.append(f' <omitted_file path="{path}" />')
|
||||
parts.append(" </files_omitted_due_to_max_files>")
|
||||
|
||||
parts.append("</workspace_files>")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
async def prepareGradingContext(
|
||||
final_output: str,
|
||||
directory: str | None = None,
|
||||
extensions: list[str] | None = None,
|
||||
exclude_patterns: list[str] | None = None,
|
||||
max_files: int = DEFAULT_MAX_FILES,
|
||||
max_content_bytes: int = DEFAULT_MAX_CONTENT_BYTES,
|
||||
) -> str:
|
||||
"""Prepare the text_for_grading string for rubric evaluation.
|
||||
|
||||
Collects file evidence from the sandbox, formats as XML,
|
||||
and prepends to the agent's final_output.
|
||||
|
||||
Args:
|
||||
final_output: The agent's final response text.
|
||||
directory: Root directory to search. Defaults to the sandbox directory.
|
||||
extensions: Only include files with these extensions (without dot).
|
||||
exclude_patterns: Directory/file name patterns to skip.
|
||||
max_files: Maximum number of files to include as evidence. Eligible files
|
||||
beyond this cap are omitted but reported (logged and listed in the
|
||||
XML under <files_omitted_due_to_max_files>) rather than dropped
|
||||
silently. Defaults to DEFAULT_MAX_FILES.
|
||||
max_content_bytes: Maximum bytes of content to read per file.
|
||||
|
||||
Returns:
|
||||
The assembled text_for_grading string.
|
||||
"""
|
||||
# Walks the sandbox tree and reads/parses files (text/PDF/docx) — all
|
||||
# blocking — so run the whole body in a worker thread to free the loop.
|
||||
return await asyncio.to_thread(
|
||||
_prepare_grading_context_sync,
|
||||
final_output,
|
||||
directory,
|
||||
extensions,
|
||||
exclude_patterns,
|
||||
max_files,
|
||||
max_content_bytes,
|
||||
)
|
||||
|
||||
|
||||
def _prepare_grading_context_sync(
|
||||
final_output: str,
|
||||
directory: str | None = None,
|
||||
extensions: list[str] | None = None,
|
||||
exclude_patterns: list[str] | None = None,
|
||||
max_files: int = DEFAULT_MAX_FILES,
|
||||
max_content_bytes: int = DEFAULT_MAX_CONTENT_BYTES,
|
||||
) -> str:
|
||||
if directory is None:
|
||||
directory = sandbox.WORKDIR
|
||||
|
||||
if not os.path.isdir(directory):
|
||||
return final_output
|
||||
|
||||
# Grading only ever needs the agent's deliverables, which live under the
|
||||
# sandbox workdir. The server runs as root, so confine the walk to the
|
||||
# workdir — anything outside it (a caller passing /app, or a symlink that
|
||||
# escapes) is refused so this can't exfiltrate the locked-down tree.
|
||||
workdir_root = os.path.realpath(sandbox.WORKDIR)
|
||||
if not _within_workdir(directory, workdir_root):
|
||||
logger.warning(
|
||||
"prepareGradingContext: directory %r is outside the sandbox workdir %r; refusing to read it",
|
||||
directory,
|
||||
sandbox.WORKDIR,
|
||||
)
|
||||
return final_output
|
||||
|
||||
excl = set(exclude_patterns) if exclude_patterns else DEFAULT_EXCLUDE_PATTERNS
|
||||
|
||||
ext_filter: set[str] | None = None
|
||||
if extensions:
|
||||
ext_filter = {e.lower().lstrip(".") for e in extensions}
|
||||
unsupported = ext_filter - ALL_SUPPORTED_EXTENSIONS
|
||||
if unsupported:
|
||||
raise ValueError(
|
||||
f"Unsupported file extension(s): {', '.join('.' + e for e in sorted(unsupported))}. "
|
||||
f"Supported extensions: {', '.join('.' + e for e in sorted(ALL_SUPPORTED_EXTENSIONS))}"
|
||||
)
|
||||
|
||||
all_files = _walk_dir(directory)
|
||||
evidence: list[dict] = []
|
||||
# Files that passed every filter (would have been included) but fell outside
|
||||
# the max_files cap. Tracked so the cap is observable rather than silent: a
|
||||
# rubric seeing one of these must not conclude the file is absent.
|
||||
dropped_paths: list[str] = []
|
||||
|
||||
for file_path in all_files:
|
||||
# Skip symlinks (and any other entry) whose real target escapes the
|
||||
# workdir — they'd otherwise let a root read reach outside the sandbox.
|
||||
if not _within_workdir(file_path, workdir_root):
|
||||
continue
|
||||
relative_path = os.path.relpath(file_path, directory)
|
||||
if _is_excluded(relative_path, excl):
|
||||
continue
|
||||
|
||||
ext = _get_extension(file_path)
|
||||
if ext not in ALL_SUPPORTED_EXTENSIONS:
|
||||
continue
|
||||
if ext_filter and ext not in ext_filter:
|
||||
continue
|
||||
|
||||
# Cap is checked AFTER filtering so it counts only eligible files, and
|
||||
# records the overflow instead of silently breaking out of the walk.
|
||||
if len(evidence) >= max_files:
|
||||
dropped_paths.append(file_path)
|
||||
continue
|
||||
|
||||
# Read bytes AS THE SANDBOX USER. The server is root, so opening the file
|
||||
# in-process would both bypass /app's permissions and be a symlink-swap
|
||||
# TOCTOU oracle; reading as uid 1000 closes both. Office/PDF formats can't
|
||||
# be truncated (zip/PDF bytes corrupt) so extraction needs the whole file;
|
||||
# plain text is bounded by max_content_bytes.
|
||||
read_limit = MAX_SPECIAL_FILE_BYTES if ext in SPECIAL_EXTENSIONS else max_content_bytes
|
||||
|
||||
# For special formats, probe the size first (limit=0 reads no bytes) so an
|
||||
# oversized file is recorded as evidence and skipped — without streaming
|
||||
# MAX_SPECIAL_FILE_BYTES through the pipe just to discard it, which would
|
||||
# let a few huge agent files waste GBs and risk OOMing grading.
|
||||
if ext in SPECIAL_EXTENSIONS:
|
||||
try:
|
||||
size_bytes, _h, _r, _s = sandbox.agent_read_window(file_path, offset=0, limit=0, sniff=0)
|
||||
except sandbox.AgentReadError:
|
||||
continue
|
||||
if size_bytes > MAX_SPECIAL_FILE_BYTES:
|
||||
evidence.append(
|
||||
{
|
||||
"path": file_path,
|
||||
"extension": ext,
|
||||
"size_bytes": size_bytes,
|
||||
"content": None,
|
||||
"truncated": True,
|
||||
"extraction_method": (
|
||||
f"skipped: {size_bytes:,} bytes exceeds the {MAX_SPECIAL_FILE_BYTES:,}-byte grading read cap"
|
||||
),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
size_bytes, _header, raw, _start = sandbox.agent_read_window(file_path, offset=0, limit=read_limit, sniff=0)
|
||||
except sandbox.AgentReadError:
|
||||
continue
|
||||
|
||||
if ext == "pdf":
|
||||
content, method, truncated = _extract_pdf(raw, max_content_bytes)
|
||||
elif ext == "docx":
|
||||
content, method, truncated = _extract_docx(raw, max_content_bytes)
|
||||
elif ext == "xlsx":
|
||||
content, method, truncated = _extract_xlsx(raw, max_content_bytes)
|
||||
elif ext == "pptx":
|
||||
content, method, truncated = _extract_pptx(raw, max_content_bytes)
|
||||
else:
|
||||
content, method, truncated = _extract_text(raw, size_bytes, max_content_bytes)
|
||||
|
||||
item: dict = {
|
||||
"path": file_path,
|
||||
"extension": ext,
|
||||
"size_bytes": size_bytes,
|
||||
"content": content,
|
||||
"truncated": truncated,
|
||||
}
|
||||
if ext in SPECIAL_EXTENSIONS:
|
||||
item["extraction_method"] = method
|
||||
|
||||
evidence.append(item)
|
||||
|
||||
if dropped_paths:
|
||||
logger.warning(
|
||||
"prepareGradingContext hit max_files=%d in %s: included %d file(s), "
|
||||
"omitted %d eligible file(s) from grading evidence: %s",
|
||||
max_files,
|
||||
directory,
|
||||
len(evidence),
|
||||
len(dropped_paths),
|
||||
dropped_paths,
|
||||
)
|
||||
|
||||
xml = _format_evidence_as_xml(evidence, directory, dropped_paths)
|
||||
|
||||
if xml:
|
||||
return f"{xml}\n\n{final_output}"
|
||||
return final_output
|
||||
@@ -0,0 +1,65 @@
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from core.tools import sandbox
|
||||
from read_file_safe import (
|
||||
DEFAULT_READ_LIMIT_BYTES,
|
||||
HEADER_SNIFF_BYTES,
|
||||
ReadFileSafeResult,
|
||||
assemble_read_result,
|
||||
error_result,
|
||||
)
|
||||
|
||||
|
||||
async def readFile(
|
||||
file_path: Annotated[
|
||||
str,
|
||||
Field(
|
||||
description="Path to the file. Use /workdir/ prefix for sandbox files, or an absolute path within the sandbox."
|
||||
),
|
||||
],
|
||||
offset: Annotated[
|
||||
int,
|
||||
Field(
|
||||
description="Byte offset to start reading from. Use 0 for the beginning or a previous call's next_offset to continue.",
|
||||
ge=0,
|
||||
),
|
||||
] = 0,
|
||||
limit: Annotated[
|
||||
int | None,
|
||||
Field(
|
||||
description=(
|
||||
f"Maximum number of bytes to read. Defaults to {DEFAULT_READ_LIMIT_BYTES}. "
|
||||
"Pass null to read from offset to end of file."
|
||||
),
|
||||
ge=1,
|
||||
),
|
||||
] = DEFAULT_READ_LIMIT_BYTES,
|
||||
) -> ReadFileSafeResult:
|
||||
"""Read a file from the sandbox, with optional offset/limit (bytes) for paginating large files."""
|
||||
workdir = os.path.realpath(sandbox.WORKDIR)
|
||||
# os.path.join discards the workdir prefix if file_path is absolute (e.g.
|
||||
# "/etc/hostname" or "/workdir/foo" in production where /workdir is real),
|
||||
# so absolute paths pass through to the host and FS permissions enforce
|
||||
# access.
|
||||
resolved_path = os.path.join(workdir, file_path)
|
||||
|
||||
# The core server runs as root, so we must NOT open() agent-supplied
|
||||
# paths in-process — that would read /app, /opt/venv, etc. Read the bytes as
|
||||
# the unprivileged sandbox user (filesystem perms gate access, TOCTOU-safe),
|
||||
# then run the normal decode/binary-sniff/pagination on what came back.
|
||||
# The read spawns a subprocess and blocks; offload to a worker thread to
|
||||
# keep the event loop free.
|
||||
def _read() -> ReadFileSafeResult:
|
||||
try:
|
||||
total_bytes, header, raw, start = sandbox.agent_read_window(
|
||||
resolved_path, offset=offset, limit=limit, sniff=HEADER_SNIFF_BYTES
|
||||
)
|
||||
except sandbox.AgentReadError as e:
|
||||
return error_result(file_path, offset, str(e))
|
||||
return assemble_read_result(file_path, total_bytes, header, raw, start, limit)
|
||||
|
||||
return await asyncio.to_thread(_read)
|
||||
@@ -0,0 +1,461 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
from typing import Annotated
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastmcp.tools import ToolResult
|
||||
from mcp.types import AudioContent, BlobResourceContents, EmbeddedResource, ImageContent, TextContent
|
||||
from PIL import Image, ImageOps
|
||||
from pydantic import AnyUrl, Field
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
|
||||
from core.tools import sandbox
|
||||
|
||||
MAX_MEDIA_FILE_BYTES = 20 * 1024 * 1024
|
||||
# PDFs pass through unmodified, and Anthropic caps the total request at 32 MB
|
||||
# base64 — beyond 10 MiB a single document plus history risks killing the run.
|
||||
MAX_PDF_FILE_BYTES = 10 * 1024 * 1024
|
||||
# Larger PDFs/page counts must be read in page ranges (mirrors Claude Code's Read tool).
|
||||
MAX_PDF_PAGES_PER_READ = 20
|
||||
# Anthropic rejects images over 2000px on either edge once a request carries
|
||||
# >20 images, and downscales to ~2576px server-side anyway.
|
||||
MAX_IMAGE_DIMENSION_PX = 2000
|
||||
# Keeps the base64 form under the strictest per-image provider cap (5 MB).
|
||||
MAX_ENCODED_IMAGE_BYTES = int(3.75 * 1024 * 1024)
|
||||
JPEG_QUALITY_LADDER = (85, 70, 55)
|
||||
|
||||
SUPPORTED_IMAGE_MIME_TYPES = {
|
||||
"image/gif",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
}
|
||||
PDF_MIME_TYPE = "application/pdf"
|
||||
# Pinned per extension to Gemini's documented inlineData mimes — Python's
|
||||
# mimetypes would guess non-canonical types like audio/x-wav, video/quicktime.
|
||||
AV_MIME_TYPES_BY_EXTENSION = {
|
||||
".wav": "audio/wav",
|
||||
".mp3": "audio/mp3",
|
||||
".aif": "audio/aiff",
|
||||
".aiff": "audio/aiff",
|
||||
".aac": "audio/aac",
|
||||
".ogg": "audio/ogg",
|
||||
".flac": "audio/flac",
|
||||
".mp4": "video/mp4",
|
||||
".mpeg": "video/mpeg",
|
||||
".mpg": "video/mpeg",
|
||||
".mov": "video/mov",
|
||||
".avi": "video/avi",
|
||||
".flv": "video/x-flv",
|
||||
".webm": "video/webm",
|
||||
".wmv": "video/wmv",
|
||||
".3gp": "video/3gpp",
|
||||
".3gpp": "video/3gpp",
|
||||
}
|
||||
SUPPORTED_AUDIO_MIME_TYPES = {m for m in AV_MIME_TYPES_BY_EXTENSION.values() if m.startswith("audio/")}
|
||||
SUPPORTED_MEDIA_MIME_TYPES = SUPPORTED_IMAGE_MIME_TYPES | {PDF_MIME_TYPE} | set(AV_MIME_TYPES_BY_EXTENSION.values())
|
||||
WORKDIR_PREFIX = "/workdir/"
|
||||
|
||||
|
||||
def _error_result(*, file_path: str, mime_type: str | None, stderr: str) -> ToolResult:
|
||||
label = mime_type or "unknown"
|
||||
return ToolResult(
|
||||
content=[
|
||||
TextContent(
|
||||
type="text",
|
||||
text=f"readMedia failed for {file_path} ({label}): {stderr}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _media_mime_type(file_path: str) -> str | None:
|
||||
_root, ext = os.path.splitext(file_path)
|
||||
av_mime = AV_MIME_TYPES_BY_EXTENSION.get(ext.lower())
|
||||
if av_mime is not None:
|
||||
return av_mime
|
||||
mime_type, _encoding = mimetypes.guess_type(file_path)
|
||||
if mime_type in SUPPORTED_MEDIA_MIME_TYPES:
|
||||
return mime_type
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_read_path(file_path: str, workdir: str) -> str:
|
||||
if file_path.startswith(WORKDIR_PREFIX):
|
||||
# lstrip("/") handles "/workdir//foo" — without it, the leading slash on
|
||||
# the remainder makes os.path.join discard workdir and escape the sandbox.
|
||||
return os.path.join(workdir, file_path.removeprefix(WORKDIR_PREFIX).lstrip("/"))
|
||||
if file_path == "/workdir":
|
||||
return workdir
|
||||
|
||||
# os.path.join discards the workdir prefix if file_path is absolute (e.g.
|
||||
# "/etc/hostname"), so absolute paths pass through to the host and FS
|
||||
# permissions enforce access.
|
||||
return os.path.join(workdir, file_path)
|
||||
|
||||
|
||||
def _workdir_relative_path(resolved_path: str, workdir: str) -> str | None:
|
||||
# realpath resolves "../" segments so paths that escape workdir don't get
|
||||
# treated as inside it via a raw commonpath check.
|
||||
canonical = os.path.realpath(resolved_path)
|
||||
try:
|
||||
if os.path.commonpath([canonical, workdir]) != workdir:
|
||||
return None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
rel_path = os.path.relpath(canonical, workdir)
|
||||
return "" if rel_path == "." else rel_path
|
||||
|
||||
|
||||
def _display_path(file_path: str, resolved_path: str, workdir: str) -> str:
|
||||
rel_path = _workdir_relative_path(resolved_path, workdir)
|
||||
if rel_path is not None:
|
||||
return "/workdir" if rel_path == "" else f"/workdir/{rel_path}"
|
||||
return file_path
|
||||
|
||||
|
||||
def _resource_uri(display_path: str) -> AnyUrl:
|
||||
return AnyUrl(f"file://{quote(display_path, safe='/')}")
|
||||
|
||||
|
||||
def _has_alpha(img: Image.Image) -> bool:
|
||||
if img.mode in ("RGBA", "LA", "PA"):
|
||||
return True
|
||||
return img.mode == "P" and "transparency" in img.info
|
||||
|
||||
|
||||
def _encode_image(img: Image.Image, output_format: str, quality: int) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
if output_format == "JPEG":
|
||||
img.save(buf, format="JPEG", quality=quality)
|
||||
else:
|
||||
img.save(buf, format="PNG", optimize=True)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _parse_page_range(pages: str) -> tuple[int, int] | None:
|
||||
"""Parse a 1-indexed page range like "3" or "1-10". Returns None if malformed."""
|
||||
match = re.fullmatch(r"(\d+)(?:-(\d+))?", pages.strip())
|
||||
if not match:
|
||||
return None
|
||||
start = int(match.group(1))
|
||||
end = int(match.group(2)) if match.group(2) else start
|
||||
if start < 1 or end < start:
|
||||
return None
|
||||
return start, end
|
||||
|
||||
|
||||
def _normalize_image(raw: bytes) -> tuple[bytes, str, list[str]] | str:
|
||||
"""Make image bytes safe to send to a model provider.
|
||||
|
||||
Detects the real format from the bytes (a mime type that mismatches the
|
||||
content is a fatal provider error), downscales anything over
|
||||
MAX_IMAGE_DIMENSION_PX on the long edge, and re-encodes oversized payloads
|
||||
under MAX_ENCODED_IMAGE_BYTES. Returns (bytes, mime_type, notes) on success
|
||||
or an error message string.
|
||||
"""
|
||||
try:
|
||||
img = Image.open(io.BytesIO(raw))
|
||||
width, height = img.size
|
||||
actual_format = img.format
|
||||
# Force a full decode now: Image.open() is lazy, so a valid header over a
|
||||
# truncated/corrupt body otherwise slips through the pass-through branch
|
||||
# below and emits ImageContent the model later rejects. load() raises here
|
||||
# on a bad body, turning it into a graceful parse error.
|
||||
img.load()
|
||||
except Exception:
|
||||
return "file content is not a parseable image (corrupt file, or extension does not match content?)"
|
||||
|
||||
actual_mime = Image.MIME.get(actual_format or "", None)
|
||||
notes: list[str] = []
|
||||
|
||||
# Phone photos carry an EXIF Orientation tag that providers honor only on the
|
||||
# original bytes; once we re-encode, that metadata is dropped, so bake the
|
||||
# rotation into the pixels. Values 2-8 mean a transform is needed (1/absent is
|
||||
# a no-op); when one applies we must re-encode to emit the rotated result
|
||||
# rather than passing the unrotated raw bytes through.
|
||||
needs_orientation_fix = img.getexif().get(0x0112, 1) not in (0, 1)
|
||||
if needs_orientation_fix:
|
||||
img = ImageOps.exif_transpose(img)
|
||||
width, height = img.size
|
||||
notes.append("applied EXIF orientation")
|
||||
|
||||
needs_resize = max(width, height) > MAX_IMAGE_DIMENSION_PX
|
||||
needs_reencode = (
|
||||
needs_resize
|
||||
or needs_orientation_fix
|
||||
or actual_mime not in SUPPORTED_IMAGE_MIME_TYPES
|
||||
or len(raw) > MAX_ENCODED_IMAGE_BYTES
|
||||
)
|
||||
if not needs_reencode:
|
||||
assert actual_mime is not None
|
||||
return raw, actual_mime, notes
|
||||
|
||||
try:
|
||||
if getattr(img, "is_animated", False):
|
||||
img.seek(0)
|
||||
img.load()
|
||||
notes.append("animated image flattened to its first frame")
|
||||
|
||||
if needs_resize:
|
||||
img.thumbnail((MAX_IMAGE_DIMENSION_PX, MAX_IMAGE_DIMENSION_PX), Image.Resampling.LANCZOS)
|
||||
notes.append(f"resized from {width}x{height} to {img.width}x{img.height} to fit model image limits")
|
||||
|
||||
# Lossless sources stay PNG when they fit the byte budget (keeps text
|
||||
# crisp for transcription); photos and anything oversized go to JPEG.
|
||||
lossless_source = actual_format in ("PNG", "GIF", "BMP", "TIFF")
|
||||
encoded: bytes | None = None
|
||||
output_mime = "image/png"
|
||||
if lossless_source:
|
||||
png_img = img
|
||||
encoded = _encode_image(png_img, "PNG", 0)
|
||||
if len(encoded) > MAX_ENCODED_IMAGE_BYTES:
|
||||
encoded = None
|
||||
|
||||
if encoded is None:
|
||||
jpeg_img = img
|
||||
if _has_alpha(jpeg_img):
|
||||
# JPEG has no alpha channel; composite onto white.
|
||||
background = Image.new("RGB", jpeg_img.size, (255, 255, 255))
|
||||
background.paste(jpeg_img.convert("RGBA"), mask=jpeg_img.convert("RGBA").split()[-1])
|
||||
jpeg_img = background
|
||||
elif jpeg_img.mode != "RGB":
|
||||
jpeg_img = jpeg_img.convert("RGB")
|
||||
|
||||
for quality in JPEG_QUALITY_LADDER:
|
||||
encoded = _encode_image(jpeg_img, "JPEG", quality)
|
||||
if len(encoded) <= MAX_ENCODED_IMAGE_BYTES:
|
||||
break
|
||||
while encoded is not None and len(encoded) > MAX_ENCODED_IMAGE_BYTES and min(jpeg_img.size) > 200:
|
||||
jpeg_img = jpeg_img.resize(
|
||||
(max(1, int(jpeg_img.width * 0.75)), max(1, int(jpeg_img.height * 0.75))),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
encoded = _encode_image(jpeg_img, "JPEG", JPEG_QUALITY_LADDER[-1])
|
||||
output_mime = "image/jpeg"
|
||||
|
||||
if encoded is None or len(encoded) > MAX_ENCODED_IMAGE_BYTES:
|
||||
return "could not compress image under the per-image size limit"
|
||||
except Exception as e:
|
||||
return f"failed to convert image for model consumption: {e}"
|
||||
|
||||
if output_mime != actual_mime:
|
||||
notes.append(f"re-encoded from {actual_mime or 'unknown format'} to {output_mime}")
|
||||
return encoded, output_mime, notes
|
||||
|
||||
|
||||
async def readMedia(
|
||||
file_path: Annotated[
|
||||
str,
|
||||
Field(
|
||||
description=(
|
||||
"Path to an image (gif/jpeg/png/webp), PDF, audio (wav/mp3/aiff/aac/ogg/flac), or video "
|
||||
"(mp4/mpeg/mov/avi/flv/webm/wmv/3gpp) file. "
|
||||
"Use /workdir/ prefix for sandbox files, or an absolute path within the sandbox. "
|
||||
"Returns multimodal MCP content; images are automatically downscaled/re-encoded to fit "
|
||||
"model limits (max 2000px on the long edge). Audio/video require a model with native "
|
||||
"audio/video support (e.g. Gemini) — other models see only a text placeholder. "
|
||||
"Use readFile for text and readPDF for PDF text extraction."
|
||||
)
|
||||
),
|
||||
],
|
||||
pages: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
description=(
|
||||
"PDF page range to read, 1-indexed, e.g. '3' or '1-10' (max 20 pages per read). "
|
||||
"Required for PDFs over 20 pages or 10 MiB. Ignored for images."
|
||||
)
|
||||
),
|
||||
] = None,
|
||||
) -> ToolResult:
|
||||
"""Read an image, PDF, audio, or video file and return it as multimodal MCP content (base64-encoded, capped at 20 MiB; images auto-resized to model limits, large PDFs read in page ranges)."""
|
||||
# File read, base64 encode, and image/PDF re-encoding all block; run in a worker thread.
|
||||
return await asyncio.to_thread(_read_media_sync, file_path, pages)
|
||||
|
||||
|
||||
def _read_pdf_sync(*, file_path: str, display_path: str, raw: bytes, file_size: int, pages: str | None) -> ToolResult:
|
||||
try:
|
||||
reader = PdfReader(io.BytesIO(raw))
|
||||
# is_encrypted is true even for owner-restricted PDFs that carry only
|
||||
# permission flags (no-print etc.) and open silently in any viewer.
|
||||
# decrypt("") succeeds for those; only a real user password fails it.
|
||||
if reader.is_encrypted and not reader.decrypt(""):
|
||||
return _error_result(
|
||||
file_path=file_path,
|
||||
mime_type=PDF_MIME_TYPE,
|
||||
stderr="PDF is password-protected (requires a password to open)",
|
||||
)
|
||||
total_pages = len(reader.pages)
|
||||
except Exception:
|
||||
return _error_result(file_path=file_path, mime_type=PDF_MIME_TYPE, stderr="file content is not a valid PDF")
|
||||
|
||||
if pages is None:
|
||||
if file_size > MAX_PDF_FILE_BYTES or total_pages > MAX_PDF_PAGES_PER_READ:
|
||||
return _error_result(
|
||||
file_path=file_path,
|
||||
mime_type=PDF_MIME_TYPE,
|
||||
stderr=(
|
||||
f"PDF is too large to attach whole ({total_pages} pages, {file_size} bytes; "
|
||||
f"limits: {MAX_PDF_PAGES_PER_READ} pages, {MAX_PDF_FILE_BYTES} bytes). "
|
||||
f"Pass pages='1-{min(total_pages, MAX_PDF_PAGES_PER_READ)}' to read a page range, "
|
||||
"or use readPDF to extract the text."
|
||||
),
|
||||
)
|
||||
blob = raw
|
||||
page_note = ""
|
||||
else:
|
||||
page_range = _parse_page_range(pages)
|
||||
if page_range is None:
|
||||
return _error_result(
|
||||
file_path=file_path,
|
||||
mime_type=PDF_MIME_TYPE,
|
||||
stderr=f"Invalid pages value {pages!r}. Use a 1-indexed page or range, e.g. '3' or '1-10'.",
|
||||
)
|
||||
start, end = page_range
|
||||
if start > total_pages:
|
||||
return _error_result(
|
||||
file_path=file_path,
|
||||
mime_type=PDF_MIME_TYPE,
|
||||
stderr=f"Page {start} is out of range: the PDF has {total_pages} pages.",
|
||||
)
|
||||
end = min(end, total_pages)
|
||||
if end - start + 1 > MAX_PDF_PAGES_PER_READ:
|
||||
return _error_result(
|
||||
file_path=file_path,
|
||||
mime_type=PDF_MIME_TYPE,
|
||||
stderr=f"Page range {start}-{end} spans more than {MAX_PDF_PAGES_PER_READ} pages. Read it in smaller chunks.",
|
||||
)
|
||||
try:
|
||||
writer = PdfWriter()
|
||||
for index in range(start - 1, end):
|
||||
writer.add_page(reader.pages[index])
|
||||
buf = io.BytesIO()
|
||||
writer.write(buf)
|
||||
blob = buf.getvalue()
|
||||
except Exception as e:
|
||||
return _error_result(
|
||||
file_path=file_path, mime_type=PDF_MIME_TYPE, stderr=f"failed to extract pages {start}-{end}: {e}"
|
||||
)
|
||||
if len(blob) > MAX_PDF_FILE_BYTES:
|
||||
return _error_result(
|
||||
file_path=file_path,
|
||||
mime_type=PDF_MIME_TYPE,
|
||||
stderr=(
|
||||
f"Pages {start}-{end} are still {len(blob)} bytes (limit {MAX_PDF_FILE_BYTES}). "
|
||||
"Read a smaller range, or use readPDF to extract the text."
|
||||
),
|
||||
)
|
||||
page_note = f" (pages {start}-{end} of {total_pages})"
|
||||
|
||||
return ToolResult(
|
||||
content=[
|
||||
TextContent(
|
||||
type="text",
|
||||
text=f"Read {display_path} as {PDF_MIME_TYPE} ({len(blob)} bytes){page_note}.",
|
||||
),
|
||||
EmbeddedResource(
|
||||
type="resource",
|
||||
resource=BlobResourceContents(
|
||||
uri=_resource_uri(display_path),
|
||||
mimeType=PDF_MIME_TYPE,
|
||||
blob=base64.b64encode(blob).decode("ascii"),
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _read_media_sync(file_path: str, pages: str | None = None) -> ToolResult:
|
||||
workdir = os.path.realpath(sandbox.WORKDIR)
|
||||
resolved_path = _resolve_read_path(file_path, workdir)
|
||||
mime_type = _media_mime_type(file_path)
|
||||
if mime_type is None:
|
||||
return _error_result(
|
||||
file_path=file_path,
|
||||
mime_type=None,
|
||||
stderr=f"Unsupported media type. Supported: {sorted(SUPPORTED_MEDIA_MIME_TYPES)}",
|
||||
)
|
||||
|
||||
# Read the bytes through the unprivileged sandbox reader so the open() runs
|
||||
# as the sandbox user (the core server itself is root). Filesystem
|
||||
# permissions — not the server's uid — gate what the agent can reach, so an
|
||||
# absolute path to /app, /opt/venv, /__modal, etc. fails with EACCES exactly
|
||||
# as it would in the agent's own shell. The reader also rejects non-regular
|
||||
# files (a FIFO/device under /workdir would otherwise block a worker until
|
||||
# the timeout) via an O_NONBLOCK + S_ISREG check on the opened fd, and
|
||||
# limit=MAX+1 bounds the read so an oversized file isn't slurped whole.
|
||||
try:
|
||||
file_size, _header, raw, _start = sandbox.agent_read_window(
|
||||
resolved_path, offset=0, limit=MAX_MEDIA_FILE_BYTES + 1, sniff=0
|
||||
)
|
||||
except sandbox.AgentReadError as e:
|
||||
return _error_result(file_path=file_path, mime_type=mime_type, stderr=str(e))
|
||||
|
||||
if file_size > MAX_MEDIA_FILE_BYTES:
|
||||
return _error_result(
|
||||
file_path=file_path,
|
||||
mime_type=mime_type,
|
||||
stderr=(
|
||||
f"File too large for media read: {file_size} bytes exceeds {MAX_MEDIA_FILE_BYTES} bytes. "
|
||||
"Shrink it first (e.g. downscale the image, or for a PDF use readPDF / render individual pages)."
|
||||
),
|
||||
)
|
||||
|
||||
display_path = _display_path(file_path, resolved_path, workdir)
|
||||
|
||||
if mime_type in SUPPORTED_IMAGE_MIME_TYPES:
|
||||
normalized = _normalize_image(raw)
|
||||
if isinstance(normalized, str):
|
||||
return _error_result(file_path=file_path, mime_type=mime_type, stderr=normalized)
|
||||
data, actual_mime, notes = normalized
|
||||
|
||||
suffix = f" ({'; '.join(notes)})" if notes else ""
|
||||
text = TextContent(
|
||||
type="text",
|
||||
text=f"Read {display_path} as {actual_mime} ({len(data)} bytes).{suffix}",
|
||||
)
|
||||
return ToolResult(
|
||||
content=[
|
||||
text,
|
||||
ImageContent(type="image", data=base64.b64encode(data).decode("ascii"), mimeType=actual_mime),
|
||||
]
|
||||
)
|
||||
|
||||
encoded = base64.b64encode(raw).decode("ascii")
|
||||
text = TextContent(
|
||||
type="text",
|
||||
text=f"Read {display_path} as {mime_type} ({file_size} bytes).",
|
||||
)
|
||||
|
||||
if mime_type in SUPPORTED_AUDIO_MIME_TYPES:
|
||||
return ToolResult(
|
||||
content=[
|
||||
text,
|
||||
AudioContent(type="audio", data=encoded, mimeType=mime_type),
|
||||
]
|
||||
)
|
||||
|
||||
if mime_type == PDF_MIME_TYPE:
|
||||
return _read_pdf_sync(file_path=file_path, display_path=display_path, raw=raw, file_size=file_size, pages=pages)
|
||||
|
||||
# Video: MCP has no dedicated content type, so embed as a blob
|
||||
# resource; runners forward supported mime types as native model content.
|
||||
return ToolResult(
|
||||
content=[
|
||||
text,
|
||||
EmbeddedResource(
|
||||
type="resource",
|
||||
resource=BlobResourceContents(
|
||||
uri=_resource_uri(display_path),
|
||||
mimeType=mime_type,
|
||||
blob=encoded,
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
import asyncio
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
from pypdf import PdfReader
|
||||
|
||||
from core.tools.sandbox import (
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
run_in_sandbox,
|
||||
)
|
||||
|
||||
|
||||
async def readPDF(file_path: str) -> dict[str, Any]:
|
||||
"""Extract text from a PDF file inside the sandbox."""
|
||||
# The subprocess read and pypdf parsing both block; run the whole body in a
|
||||
# worker thread so the event loop stays free for other tool calls.
|
||||
return await asyncio.to_thread(_read_pdf_sync, file_path)
|
||||
|
||||
|
||||
def _read_pdf_sync(file_path: str) -> dict[str, Any]:
|
||||
result = run_in_sandbox(["cat", "--", file_path], DEFAULT_TIMEOUT_SECONDS, text=False)
|
||||
if result["returncode"] != 0:
|
||||
stderr = result.get("stderr", b"")
|
||||
if isinstance(stderr, bytes):
|
||||
stderr = stderr.decode("utf-8", errors="replace")
|
||||
return {
|
||||
"pages": [],
|
||||
"page_count": 0,
|
||||
"stderr": stderr or result.get("error", ""),
|
||||
"returncode": result["returncode"],
|
||||
"file_path": file_path,
|
||||
}
|
||||
|
||||
try:
|
||||
reader = PdfReader(io.BytesIO(result["stdout"]))
|
||||
if reader.is_encrypted:
|
||||
try:
|
||||
reader.decrypt("")
|
||||
except Exception:
|
||||
return {
|
||||
"pages": [],
|
||||
"page_count": 0,
|
||||
"stderr": "Encrypted PDF: unable to decrypt",
|
||||
"returncode": 1,
|
||||
"file_path": file_path,
|
||||
}
|
||||
if reader.is_encrypted:
|
||||
return {
|
||||
"pages": [],
|
||||
"page_count": 0,
|
||||
"stderr": "Encrypted PDF: unable to decrypt",
|
||||
"returncode": 1,
|
||||
"file_path": file_path,
|
||||
}
|
||||
|
||||
pages: list[str] = []
|
||||
for page in reader.pages:
|
||||
text = page.extract_text() or ""
|
||||
pages.append(text)
|
||||
|
||||
return {
|
||||
"pages": pages,
|
||||
"page_count": len(pages),
|
||||
"stderr": "",
|
||||
"returncode": 0,
|
||||
"file_path": file_path,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"pages": [],
|
||||
"page_count": 0,
|
||||
"stderr": str(e),
|
||||
"returncode": 1,
|
||||
"file_path": file_path,
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import os
|
||||
import subprocess
|
||||
from collections.abc import Iterator
|
||||
from typing import Any, cast
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 120
|
||||
MAX_TIMEOUT_SECONDS = 1800
|
||||
|
||||
# Agent's working directory. Created in docker/base/Dockerfile and scripts/start.sh,
|
||||
# owned by the model user. Tests reassign this module attribute to point at a temp
|
||||
# dir, so the annotation is `str` rather than the inferred `Literal["/workdir"]`.
|
||||
WORKDIR: str = "/workdir"
|
||||
|
||||
# MCP_PROXY_TOKEN is injected directly into core's env by the proxy (see
|
||||
# mcp_proxy/service.py:start_service) so core's own HTTP server can
|
||||
# authenticate inbound requests on non-MCP routes. The credential-name filter
|
||||
# in mcp_proxy does NOT catch this, so we strip it here to avoid leaking it to
|
||||
# the agent's bash/python.
|
||||
#
|
||||
# FAKETIME_SHARED is exported by libfaketime when the proxy runs services under
|
||||
# the `faketime` wrapper for --current-time. It names a /dev/shm semaphore +
|
||||
# shared-memory segment that libfaketime created as root (mode 0600), used for
|
||||
# cross-process monotonic-time coordination. The agent command runs as the
|
||||
# model uid, so a child it spawns (bash) re-initializes libfaketime,
|
||||
# can't open that root-owned semaphore, and *hangs* on the very first clock read
|
||||
# (`date`, time.time(), ...). Stripping it makes libfaketime run per-process —
|
||||
# the clock is still faked (LD_PRELOAD + FAKETIME are kept); we only lose the
|
||||
# cross-process "time never goes backward" guarantee, which a sandbox doesn't
|
||||
# need.
|
||||
_PROXY_INTERNAL_ENV_KEYS = frozenset({"MCP_PROXY_TOKEN", "FAKETIME_SHARED"})
|
||||
|
||||
# Stripped from every agent-spawned subprocess so the server venv (/opt/venv)
|
||||
# and repo (/app) can't leak into the model's shell.
|
||||
_AGENT_DEFAULT_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
_AGENT_PATH_STRIP_PREFIXES = ("/opt/venv", "/app")
|
||||
_AGENT_ENV_DROP = (
|
||||
"VIRTUAL_ENV",
|
||||
"UV_PROJECT_ENVIRONMENT",
|
||||
"PYTHONPATH",
|
||||
"PYTHONHOME",
|
||||
"SETUID",
|
||||
"SETGID",
|
||||
)
|
||||
|
||||
# Harness-internal vars that point at the locked-down /app tree, name the task,
|
||||
# carry the run seed, or expose internal service ports. The agent's shell has no
|
||||
# legitimate use for them, and leaking them advertises the eval layout (paths
|
||||
# like INPUTDIR=/app/setup_data/…), the seed, and the viewer/proxy ports even
|
||||
# though those resources are themselves locked down. Matched by PREFIX so a
|
||||
# newly-added WORLDBENCH_*/BUNDLE* var is dropped automatically rather than
|
||||
# silently leaking until someone remembers to list it.
|
||||
_AGENT_ENV_DROP_PREFIXES = ("WORLDBENCH_", "BUNDLE")
|
||||
# Exact harness vars without a shared prefix.
|
||||
_AGENT_ENV_DROP_EXACT = ("INPUTDIR", "OUTPUTDIR", "PORT", "VIEWER_PORT")
|
||||
# Survives the prefix sweep: the fake clock must reach the agent's shell.
|
||||
_AGENT_ENV_KEEP = frozenset({"WORLDBENCH_CURRENT_TIME"})
|
||||
|
||||
|
||||
def _sanitize_path(path: str) -> str:
|
||||
"""Drop PATH entries under /opt/venv or /app, preserving the rest in order
|
||||
(e.g. a derived image's /opt/conda/bin). Falls back to a default if empty."""
|
||||
|
||||
def _stripped(entry: str) -> bool:
|
||||
norm = os.path.normpath(entry)
|
||||
return any(norm == p or norm.startswith(p + os.sep) for p in _AGENT_PATH_STRIP_PREFIXES)
|
||||
|
||||
kept = [entry for entry in path.split(os.pathsep) if entry and not _stripped(entry)]
|
||||
return os.pathsep.join(kept) if kept else _AGENT_DEFAULT_PATH
|
||||
|
||||
|
||||
def _agent_env() -> dict[str, str]:
|
||||
"""Inherited env with server-side venv pointers, harness-internal paths, and
|
||||
proxy-internal credentials stripped — the single chokepoint every
|
||||
agent command spawn flows through. Copies os.environ so the shell
|
||||
keeps a real /home/<user>."""
|
||||
env = os.environ.copy()
|
||||
for var in (*_AGENT_ENV_DROP, *_AGENT_ENV_DROP_EXACT, *_PROXY_INTERNAL_ENV_KEYS):
|
||||
env.pop(var, None)
|
||||
for key in list(env):
|
||||
if key not in _AGENT_ENV_KEEP and key.startswith(_AGENT_ENV_DROP_PREFIXES):
|
||||
del env[key]
|
||||
env["PATH"] = _sanitize_path(env.get("PATH", ""))
|
||||
return env
|
||||
|
||||
|
||||
def _privilege_drop_kwargs() -> dict[str, Any]:
|
||||
"""``subprocess`` kwargs that drop an agent command to the unprivileged
|
||||
sandbox user, or ``{}`` when there's nothing to drop.
|
||||
|
||||
core's server process stays **root** in production so it can read the
|
||||
locked-down ``/app`` tree and keep ``/opt/venv`` closed to uid 1000. Every
|
||||
agent-facing command (``bash``/file tools) MUST
|
||||
therefore drop to ``SETUID``/``SETGID`` itself — a command left running as
|
||||
root is a sandbox escape (it could read ``/app``, the grading data, the
|
||||
venv, ``/__modal``).
|
||||
|
||||
We use subprocess's ``user``/``group``/``extra_groups`` kwargs rather than a
|
||||
``preexec_fn``: they perform the ``setgroups``/``setgid``/``setuid`` in C
|
||||
between fork and exec, which is async-signal-safe and avoids the
|
||||
multithreaded-fork deadlock ``preexec_fn`` is prone to under the async MCP
|
||||
server.
|
||||
|
||||
* **Not root** (local dev, CI, tests): nothing to drop to — return ``{}``.
|
||||
* **Root, both env vars set**: return the drop kwargs.
|
||||
* **Root, env vars missing/partial**: raise. Fail closed rather than exec
|
||||
the agent's command as root.
|
||||
"""
|
||||
if os.geteuid() != 0:
|
||||
return {}
|
||||
setuid = os.environ.get("SETUID")
|
||||
setgid = os.environ.get("SETGID")
|
||||
if setuid is None or setgid is None:
|
||||
raise RuntimeError(
|
||||
"core is running as root but SETUID/SETGID are not both set "
|
||||
f"(SETUID={setuid!r}, SETGID={setgid!r}); refusing to run an agent command as root"
|
||||
)
|
||||
# extra_groups=[] clears supplementary groups so the dropped command can't
|
||||
# inherit any group membership root happened to carry.
|
||||
return {"user": int(setuid), "group": int(setgid), "extra_groups": []}
|
||||
|
||||
|
||||
# Stdlib-only reader run AS THE SANDBOX USER (uid 1000) via run_in_sandbox. The
|
||||
# open() happens in the dropped subprocess, so filesystem permissions — not the
|
||||
# root server — gate access: a path under /app, /opt/venv, /__modal, etc. fails
|
||||
# with EACCES exactly as in the agent's own shell, and a symlink swap can't
|
||||
# escalate a root read (the resolve+open are one atomic op as uid 1000, so this
|
||||
# is TOCTOU-safe). Emits one JSON line so the privileged caller can reuse its
|
||||
# normal in-memory processing on bytes it never opened itself.
|
||||
_AGENT_READ_WINDOW_SNIPPET = r"""
|
||||
import sys, os, stat, base64, json
|
||||
path, offset, limit, sniff = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), int(sys.argv[4])
|
||||
try:
|
||||
# O_NONBLOCK + S_ISREG on the opened fd: refuse FIFOs/devices/sockets, whose
|
||||
# open()/read() can block on a writer that never comes (TOCTOU-safe — the
|
||||
# check is on the fd, not the path).
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
|
||||
st = os.fstat(fd)
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
os.close(fd)
|
||||
raise OSError("not a regular file")
|
||||
size = st.st_size
|
||||
with os.fdopen(fd, "rb") as f:
|
||||
header = f.read(min(sniff, size)) if sniff > 0 else b""
|
||||
start = min(offset, size) if offset >= 0 else 0
|
||||
f.seek(start)
|
||||
raw = f.read() if limit < 0 else f.read(limit)
|
||||
sys.stdout.write(json.dumps({
|
||||
"ok": True, "size": size, "start": start,
|
||||
"header": base64.b64encode(header).decode(),
|
||||
"raw": base64.b64encode(raw).decode(),
|
||||
}))
|
||||
except Exception as e:
|
||||
sys.stdout.write(json.dumps({"ok": False, "error": str(e)}))
|
||||
"""
|
||||
|
||||
|
||||
class AgentReadError(Exception):
|
||||
"""A read performed as the sandbox user failed (missing file, EACCES, …)."""
|
||||
|
||||
|
||||
def agent_read_window(
|
||||
path: str,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int | None = None,
|
||||
sniff: int = 0,
|
||||
) -> tuple[int, bytes, bytes, int]:
|
||||
"""Read ``path`` AS THE SANDBOX USER and return ``(size, header, raw, start)``.
|
||||
|
||||
``raw`` is the ``limit`` bytes (all, if ``None``) at ``offset``; ``header`` is
|
||||
the first ``sniff`` bytes (for binary detection), or empty when ``sniff`` is
|
||||
0. Use this anywhere the (now root) server would otherwise ``open()`` an
|
||||
agent-supplied path in-process. Raises :class:`AgentReadError` on failure.
|
||||
"""
|
||||
lim = -1 if limit is None else int(limit)
|
||||
result = run_in_sandbox(
|
||||
["python3", "-c", _AGENT_READ_WINDOW_SNIPPET, path, str(offset), str(lim), str(sniff)],
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
)
|
||||
if result["returncode"] != 0:
|
||||
raise AgentReadError(result.get("stderr") or result.get("error") or "agent read failed")
|
||||
import json as _json
|
||||
|
||||
try:
|
||||
data = _json.loads(result["stdout"])
|
||||
except (ValueError, TypeError) as e:
|
||||
raise AgentReadError(f"agent reader returned invalid output: {e}") from e
|
||||
if not data.get("ok"):
|
||||
raise AgentReadError(data.get("error", "agent read failed"))
|
||||
import base64 as _b64
|
||||
|
||||
return data["size"], _b64.b64decode(data["header"]), _b64.b64decode(data["raw"]), data["start"]
|
||||
|
||||
|
||||
# Raw byte-copier run AS THE SANDBOX USER. Unlike _AGENT_READ_WINDOW_SNIPPET it
|
||||
# does NOT base64/JSON-wrap the payload — it copies the file straight to stdout
|
||||
# so the parent can stream it without inflating or buffering the whole thing.
|
||||
_AGENT_STREAM_SNIPPET = r"""
|
||||
import sys, os, stat
|
||||
path = sys.argv[1]
|
||||
try:
|
||||
# O_NONBLOCK so opening a FIFO/socket with no writer can't block, and reject
|
||||
# any non-regular file (FIFO, device, socket, dir): reading one could hang
|
||||
# forever waiting on a writer. The check is on the opened fd (fstat), so it's
|
||||
# TOCTOU-safe against a path swapped after open.
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
|
||||
try:
|
||||
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
||||
sys.stderr.write("not a regular file")
|
||||
sys.exit(1)
|
||||
while True:
|
||||
chunk = os.read(fd, 1 << 16)
|
||||
if not chunk:
|
||||
break
|
||||
sys.stdout.buffer.write(chunk)
|
||||
finally:
|
||||
os.close(fd)
|
||||
except Exception as e:
|
||||
sys.stderr.write(str(e))
|
||||
sys.exit(1)
|
||||
"""
|
||||
|
||||
|
||||
def agent_stream_file(
|
||||
path: str,
|
||||
*,
|
||||
chunk_size: int = 1 << 16,
|
||||
timeout: int = DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> Iterator[bytes]:
|
||||
"""Yield ``path``'s bytes in ``chunk_size`` blocks, read AS THE SANDBOX USER,
|
||||
without ever holding the whole file in the root server.
|
||||
|
||||
Same isolation model as :func:`agent_read_window` — a uid-1000 subprocess
|
||||
does the ``open()`` (filesystem perms gate access, TOCTOU-safe) — but the
|
||||
bytes are streamed straight through instead of materialized. Use for
|
||||
whole-file reads where buffering the entire payload would be wasteful (e.g.
|
||||
viewer downloads of large ``/workdir`` files).
|
||||
|
||||
Raises :class:`AgentReadError` if the read fails, whether ``open()`` fails
|
||||
before any bytes are produced or the subprocess dies mid-stream. An empty
|
||||
file yields nothing and is not an error. The subprocess is always reaped
|
||||
(killed if the consumer stops iterating early).
|
||||
"""
|
||||
os.makedirs(WORKDIR, exist_ok=True)
|
||||
# python3 (not sys.executable): the agent's subprocess runs as uid 1000 and
|
||||
# must use the system interpreter on the sanitized PATH — /opt/venv is locked
|
||||
# to root. Same partial-path resolution as run_in_sandbox.
|
||||
command = ["python3", "-c", _AGENT_STREAM_SNIPPET, path]
|
||||
# cast: the **Any privilege-drop kwargs make the type checker pick Popen's
|
||||
# text-mode overload, but we pass no text/encoding so this is binary — the
|
||||
# pipes carry bytes.
|
||||
proc = cast(
|
||||
"subprocess.Popen[bytes]",
|
||||
subprocess.Popen(
|
||||
command,
|
||||
cwd=WORKDIR,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=_agent_env(),
|
||||
**_privilege_drop_kwargs(),
|
||||
),
|
||||
)
|
||||
assert proc.stdout is not None
|
||||
assert proc.stderr is not None
|
||||
try:
|
||||
while True:
|
||||
chunk = proc.stdout.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
returncode = proc.wait(timeout=timeout)
|
||||
if returncode != 0:
|
||||
err = proc.stderr.read().decode("utf-8", errors="replace").strip()
|
||||
raise AgentReadError(err or "agent read failed")
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
|
||||
|
||||
_AGENT_LIST_DIR_SNIPPET = r"""
|
||||
import sys, os, json
|
||||
path = sys.argv[1]
|
||||
try:
|
||||
entries = []
|
||||
with os.scandir(path) as it:
|
||||
for e in it:
|
||||
try:
|
||||
is_dir = e.is_dir()
|
||||
size = None if is_dir else e.stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
entries.append({"name": e.name, "is_dir": is_dir, "size": size})
|
||||
sys.stdout.write(json.dumps({"ok": True, "entries": entries}))
|
||||
except Exception as e:
|
||||
sys.stdout.write(json.dumps({"ok": False, "error": str(e)}))
|
||||
"""
|
||||
|
||||
|
||||
def agent_list_dir(path: str) -> list[dict[str, Any]]:
|
||||
"""List ``path`` AS THE SANDBOX USER, returning ``[{name, is_dir, size}, …]``.
|
||||
|
||||
Companion to :func:`agent_read_window` for the (root) server's directory
|
||||
listings — the ``scandir`` runs as uid 1000 so it can't enumerate /app etc.
|
||||
Raises :class:`AgentReadError` on failure.
|
||||
"""
|
||||
result = run_in_sandbox(["python3", "-c", _AGENT_LIST_DIR_SNIPPET, path], DEFAULT_TIMEOUT_SECONDS)
|
||||
if result["returncode"] != 0:
|
||||
raise AgentReadError(result.get("stderr") or result.get("error") or "agent list failed")
|
||||
import json as _json
|
||||
|
||||
try:
|
||||
data = _json.loads(result["stdout"])
|
||||
except (ValueError, TypeError) as e:
|
||||
raise AgentReadError(f"agent lister returned invalid output: {e}") from e
|
||||
if not data.get("ok"):
|
||||
raise AgentReadError(data.get("error", "agent list failed"))
|
||||
return data["entries"]
|
||||
|
||||
|
||||
def run_in_sandbox(
|
||||
command: list[str],
|
||||
timeout: int,
|
||||
input: str | bytes | None = None,
|
||||
*,
|
||||
text: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Run a command in the sandbox environment.
|
||||
|
||||
Args:
|
||||
command: The command to run as a list of arguments.
|
||||
timeout: Timeout in seconds.
|
||||
input: Optional string or bytes to pass to stdin. Must be bytes when
|
||||
text=False.
|
||||
text: If True (default), stdout/stderr are decoded as UTF-8 strings.
|
||||
If False, they are returned as raw bytes — useful for binary file
|
||||
payloads.
|
||||
|
||||
Returns:
|
||||
Dict with stdout, stderr, and returncode.
|
||||
"""
|
||||
workdir = WORKDIR
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
|
||||
env = _agent_env()
|
||||
drop_kwargs = _privilege_drop_kwargs()
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
cwd=workdir,
|
||||
capture_output=True,
|
||||
text=text,
|
||||
timeout=timeout,
|
||||
input=input,
|
||||
env=env,
|
||||
**drop_kwargs,
|
||||
)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
if text:
|
||||
stdout = (
|
||||
(e.stdout or b"").decode("utf-8", errors="replace") if isinstance(e.stdout, bytes) else (e.stdout or "")
|
||||
)
|
||||
stderr = (
|
||||
(e.stderr or b"").decode("utf-8", errors="replace") if isinstance(e.stderr, bytes) else (e.stderr or "")
|
||||
)
|
||||
else:
|
||||
stdout = e.stdout or b""
|
||||
stderr = e.stderr or b""
|
||||
return {
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"returncode": -1,
|
||||
"error": f"Command timed out after {timeout} seconds",
|
||||
}
|
||||
return {
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"returncode": result.returncode,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Standard state tools: export_state and import_state.
|
||||
|
||||
Delegates to the shared state codec in ``core.state_codec`` so the MCP tools
|
||||
and any future file-based loader read/write the same canonical format.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from core import state_codec
|
||||
|
||||
|
||||
async def export_state() -> dict[str, list[dict[str, Any]]]:
|
||||
"""Export the full core state as JSON.
|
||||
|
||||
Round-trips with import_state. State is keyed by snake_case entity-class
|
||||
name (e.g. ``project``, ``employee``, ``slack_message``).
|
||||
"""
|
||||
return state_codec.state_to_json()
|
||||
|
||||
|
||||
async def import_state(state: state_codec.SyntaraState) -> dict:
|
||||
"""Replace the full core state with the provided JSON.
|
||||
|
||||
For synthetic-data injection and test setup. Round-trips with export_state.
|
||||
"""
|
||||
state_codec.state_from_json(state.model_dump(exclude_unset=True))
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,26 @@
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from core.tools.sandbox import (
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
run_in_sandbox,
|
||||
)
|
||||
|
||||
_WRITE_SCRIPT = 'd=$(dirname -- "$1"); mkdir -p -- "${d:-.}" && tee -- "$1" >/dev/null'
|
||||
|
||||
|
||||
async def writeFile(file_path: str, content: str) -> dict[str, Any]:
|
||||
"""Write content to a file in an isolated sandbox."""
|
||||
# Offload the blocking subprocess write to a worker thread.
|
||||
result = await asyncio.to_thread(
|
||||
run_in_sandbox,
|
||||
["bash", "-c", _WRITE_SCRIPT, "--", file_path],
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
input=content,
|
||||
)
|
||||
return {
|
||||
"stdout": result.get("stdout", ""),
|
||||
"stderr": result.get("stderr") or result.get("error", ""),
|
||||
"returncode": result["returncode"],
|
||||
"file_path": file_path,
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
"""Syntara viewer — read-only filesystem browser for the agent's sandbox workdir.
|
||||
|
||||
Serves:
|
||||
GET /api/tree?path=<rel> — directory listing
|
||||
GET /api/file?path=<rel> — file contents (text; binary files flagged)
|
||||
GET / — viewer HTML (single-page app)
|
||||
|
||||
All paths are relative to ``sandbox.WORKDIR`` (what readFile/writeFile see).
|
||||
Requests are rejected if the resolved path escapes that root.
|
||||
|
||||
All non-MCP routes require the X-Proxy-Token header.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
from starlette.applications import Starlette
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
from core._token import get_proxy_token
|
||||
from core.tools import sandbox
|
||||
|
||||
MAX_FILE_BYTES = 1_000_000
|
||||
BINARY_SNIFF_BYTES = 4096
|
||||
|
||||
|
||||
class ProxyTokenMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if request.url.path.startswith("/mcp"):
|
||||
return await call_next(request)
|
||||
token = get_proxy_token()
|
||||
if token and request.headers.get("x-proxy-token") != token:
|
||||
return Response("Forbidden: invalid proxy token", status_code=403)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def _get_root() -> Path:
|
||||
root = Path(sandbox.WORKDIR).resolve()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def _resolve(rel_path: str) -> Path | None:
|
||||
root = _get_root()
|
||||
candidate = (root / rel_path.lstrip("/")).resolve()
|
||||
try:
|
||||
candidate.relative_to(root)
|
||||
except ValueError:
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
def _looks_binary(data: bytes) -> bool:
|
||||
if b"\x00" in data:
|
||||
return True
|
||||
try:
|
||||
data.decode("utf-8")
|
||||
return False
|
||||
except UnicodeDecodeError:
|
||||
return True
|
||||
|
||||
|
||||
# NOTE: the core server runs as root, so every filesystem access below goes
|
||||
# through the sandbox.agent_* helpers, which perform the read/scandir as the
|
||||
# unprivileged sandbox user (uid 1000). _resolve() confines paths to WORKDIR,
|
||||
# but reading as uid 1000 is what actually prevents this (token-gated, but
|
||||
# proxied) endpoint from being a root read of /app via a symlink/TOCTOU race.
|
||||
|
||||
|
||||
async def api_tree(request: Request) -> JSONResponse:
|
||||
rel = request.query_params.get("path", "")
|
||||
target = _resolve(rel)
|
||||
if target is None:
|
||||
return JSONResponse({"error": "path outside sandbox"}, status_code=400)
|
||||
|
||||
try:
|
||||
raw_entries = sandbox.agent_list_dir(str(target))
|
||||
except sandbox.AgentReadError as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=404)
|
||||
|
||||
entries = [
|
||||
{"name": e["name"], "type": "dir" if e["is_dir"] else "file", "size": e["size"]}
|
||||
for e in sorted(raw_entries, key=lambda e: (not e["is_dir"], e["name"].lower()))
|
||||
]
|
||||
|
||||
root = _get_root()
|
||||
return JSONResponse(
|
||||
{
|
||||
"path": str(target.relative_to(root)) if target != root else "",
|
||||
"entries": entries,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def api_file(request: Request) -> JSONResponse:
|
||||
rel = request.query_params.get("path", "")
|
||||
target = _resolve(rel)
|
||||
if target is None:
|
||||
return JSONResponse({"error": "path outside sandbox"}, status_code=400)
|
||||
|
||||
try:
|
||||
size, header, raw, _start = sandbox.agent_read_window(
|
||||
str(target), offset=0, limit=MAX_FILE_BYTES, sniff=BINARY_SNIFF_BYTES
|
||||
)
|
||||
except sandbox.AgentReadError as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=404)
|
||||
|
||||
if _looks_binary(header):
|
||||
return JSONResponse({"path": rel, "size": size, "binary": True, "content": None})
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"path": rel,
|
||||
"size": size,
|
||||
"binary": False,
|
||||
"truncated": size > MAX_FILE_BYTES,
|
||||
"content": raw.decode("utf-8", errors="replace"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def api_download(request: Request) -> Response:
|
||||
rel = request.query_params.get("path", "")
|
||||
target = _resolve(rel)
|
||||
if target is None:
|
||||
return JSONResponse({"error": "path outside sandbox"}, status_code=400)
|
||||
|
||||
# Stream the file from a uid-1000 reader straight to the client so a large
|
||||
# /workdir file is never buffered whole in the (root) server. Prime the
|
||||
# first chunk up front (off the event loop) so a missing/denied file
|
||||
# surfaces as a 404 rather than a streamed 200 that aborts mid-body.
|
||||
chunks = sandbox.agent_stream_file(str(target))
|
||||
try:
|
||||
first = await run_in_threadpool(lambda: next(chunks, None))
|
||||
except sandbox.AgentReadError as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=404)
|
||||
|
||||
def body() -> Iterator[bytes]:
|
||||
if first is not None:
|
||||
yield first
|
||||
yield from chunks
|
||||
|
||||
return StreamingResponse(
|
||||
body(),
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f'attachment; filename="{target.name}"'},
|
||||
)
|
||||
|
||||
|
||||
async def viewer_html(request: Request) -> HTMLResponse:
|
||||
return HTMLResponse(VIEWER_HTML)
|
||||
|
||||
|
||||
def create_core_viewer_app() -> Starlette:
|
||||
routes = [
|
||||
Route("/", viewer_html),
|
||||
Route("/api/tree", api_tree),
|
||||
Route("/api/file", api_file),
|
||||
Route("/api/download", api_download),
|
||||
]
|
||||
return Starlette(
|
||||
routes=routes,
|
||||
middleware=[Middleware(ProxyTokenMiddleware)],
|
||||
)
|
||||
|
||||
|
||||
def run_http_server(mcp_app, port: int) -> None:
|
||||
"""Run combined MCP + viewer HTTP server."""
|
||||
fastmcp_asgi = mcp_app.http_app(
|
||||
transport="streamable-http",
|
||||
path="/mcp",
|
||||
)
|
||||
|
||||
viewer = create_core_viewer_app()
|
||||
|
||||
async def combined_app(scope, receive, send):
|
||||
if scope["type"] == "lifespan":
|
||||
await fastmcp_asgi(scope, receive, send)
|
||||
return
|
||||
path = scope.get("path", "")
|
||||
if path.startswith("/mcp"):
|
||||
await fastmcp_asgi(scope, receive, send)
|
||||
else:
|
||||
await viewer(scope, receive, send)
|
||||
|
||||
uvicorn.run(
|
||||
combined_app,
|
||||
host="127.0.0.1",
|
||||
port=port,
|
||||
log_level="warning",
|
||||
)
|
||||
|
||||
|
||||
VIEWER_HTML = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Syntara — Sandbox</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; display: flex; height: 100vh; overflow: hidden; }
|
||||
|
||||
.sidebar { width: 320px; min-width: 320px; background: #1e293b; border-right: 1px solid #334155; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.sidebar-header { padding: 16px; border-bottom: 1px solid #334155; }
|
||||
.sidebar-header h1 { font-size: 14px; font-weight: 600; color: #f8fafc; letter-spacing: -0.2px; }
|
||||
.sidebar-header .subtitle { font-size: 11px; color: #64748b; margin-top: 2px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; word-break: break-all; }
|
||||
.tree { flex: 1; padding: 8px 4px; overflow-y: auto; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; }
|
||||
.tree-node { user-select: none; }
|
||||
.tree-row { display: flex; align-items: center; gap: 4px; padding: 3px 6px; border-radius: 4px; cursor: pointer; color: #cbd5e1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tree-row:hover { background: #0f172a; }
|
||||
.tree-row.active { background: #1d4ed8; color: #fff; }
|
||||
.tree-row .chev { width: 12px; color: #64748b; flex-shrink: 0; font-size: 10px; }
|
||||
.tree-row .icon { width: 14px; flex-shrink: 0; opacity: 0.8; }
|
||||
.tree-row .name { overflow: hidden; text-overflow: ellipsis; }
|
||||
.tree-row.active .chev { color: #cbd5e1; }
|
||||
.tree-children { padding-left: 14px; display: none; }
|
||||
.tree-children.open { display: block; }
|
||||
|
||||
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.file-header { padding: 12px 20px; border-bottom: 1px solid #334155; background: #1e293b; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: #94a3b8; display: flex; justify-content: space-between; align-items: center; }
|
||||
.file-header .path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
|
||||
.file-header .meta { color: #64748b; font-size: 11px; flex-shrink: 0; margin-left: 12px; }
|
||||
.file-header .actions { flex-shrink: 0; margin-left: 12px; }
|
||||
.download-btn { background: #334155; color: #e2e8f0; border: 1px solid #475569; border-radius: 4px; padding: 4px 10px; font-size: 11px; font-family: inherit; cursor: pointer; text-decoration: none; display: inline-block; }
|
||||
.download-btn:hover { background: #475569; color: #fff; }
|
||||
.download-btn[disabled], .download-btn.hidden { display: none; }
|
||||
.file-body { flex: 1; overflow: auto; padding: 16px 20px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; line-height: 1.55; color: #e2e8f0; white-space: pre; tab-size: 4; }
|
||||
.placeholder { color: #475569; font-size: 13px; padding: 24px; font-family: -apple-system, BlinkMacSystemFont, sans-serif; white-space: normal; }
|
||||
.warn { color: #fb923c; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1>Sandbox</h1>
|
||||
<div class="subtitle" id="root-path">/workdir</div>
|
||||
</div>
|
||||
<div class="tree" id="tree"><div class="placeholder">Loading…</div></div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="file-header">
|
||||
<div class="path" id="file-path">No file selected</div>
|
||||
<div class="meta" id="file-meta"></div>
|
||||
<div class="actions">
|
||||
<a class="download-btn hidden" id="download-btn" href="#" download>⬇ Download</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-body" id="file-body"><div class="placeholder">Select a file from the tree on the left.</div></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const base = window.location.pathname.replace(/\\/$/, '');
|
||||
let activeFile = null;
|
||||
|
||||
async function fetchJSON(path) {
|
||||
const r = await fetch(base + path);
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (s === null || s === undefined) return '';
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function formatSize(n) {
|
||||
if (n === null || n === undefined) return '';
|
||||
if (n < 1024) return n + ' B';
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||||
return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function joinPath(dir, name) {
|
||||
if (!dir) return name;
|
||||
return dir.replace(/\\/$/, '') + '/' + name;
|
||||
}
|
||||
|
||||
async function renderRoot() {
|
||||
const treeEl = document.getElementById('tree');
|
||||
try {
|
||||
const data = await fetchJSON('/api/tree?path=');
|
||||
treeEl.innerHTML = '';
|
||||
const node = buildNode('', data.entries, 0);
|
||||
treeEl.appendChild(node);
|
||||
} catch (e) {
|
||||
treeEl.innerHTML = '<div class="placeholder">Failed to load: ' + esc(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function buildNode(parentPath, entries, depth) {
|
||||
const frag = document.createDocumentFragment();
|
||||
if (!entries.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'placeholder';
|
||||
empty.style.padding = '8px 12px';
|
||||
empty.textContent = '(empty)';
|
||||
frag.appendChild(empty);
|
||||
return frag;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = joinPath(parentPath, entry.name);
|
||||
const node = document.createElement('div');
|
||||
node.className = 'tree-node';
|
||||
const row = document.createElement('div');
|
||||
row.className = 'tree-row';
|
||||
row.dataset.path = fullPath;
|
||||
row.dataset.type = entry.type;
|
||||
const chev = document.createElement('span');
|
||||
chev.className = 'chev';
|
||||
chev.textContent = entry.type === 'dir' ? '▸' : '';
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'icon';
|
||||
icon.textContent = entry.type === 'dir' ? '📁' : '📄';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'name';
|
||||
name.textContent = entry.name;
|
||||
row.appendChild(chev);
|
||||
row.appendChild(icon);
|
||||
row.appendChild(name);
|
||||
node.appendChild(row);
|
||||
|
||||
if (entry.type === 'dir') {
|
||||
const children = document.createElement('div');
|
||||
children.className = 'tree-children';
|
||||
node.appendChild(children);
|
||||
row.addEventListener('click', async () => {
|
||||
const isOpen = children.classList.contains('open');
|
||||
if (isOpen) {
|
||||
children.classList.remove('open');
|
||||
chev.textContent = '▸';
|
||||
} else {
|
||||
if (!children.dataset.loaded) {
|
||||
children.innerHTML = '<div class="placeholder" style="padding:4px 12px;">…</div>';
|
||||
try {
|
||||
const data = await fetchJSON('/api/tree?path=' + encodeURIComponent(fullPath));
|
||||
children.innerHTML = '';
|
||||
children.appendChild(buildNode(fullPath, data.entries, depth + 1));
|
||||
children.dataset.loaded = '1';
|
||||
} catch (e) {
|
||||
children.innerHTML = '<div class="placeholder" style="padding:4px 12px;">Error</div>';
|
||||
}
|
||||
}
|
||||
children.classList.add('open');
|
||||
chev.textContent = '▾';
|
||||
}
|
||||
});
|
||||
} else {
|
||||
row.addEventListener('click', () => openFile(fullPath, row));
|
||||
}
|
||||
|
||||
frag.appendChild(node);
|
||||
}
|
||||
return frag;
|
||||
}
|
||||
|
||||
async function openFile(path, rowEl) {
|
||||
document.querySelectorAll('.tree-row.active').forEach(el => el.classList.remove('active'));
|
||||
if (rowEl) rowEl.classList.add('active');
|
||||
activeFile = path;
|
||||
|
||||
const pathEl = document.getElementById('file-path');
|
||||
const metaEl = document.getElementById('file-meta');
|
||||
const bodyEl = document.getElementById('file-body');
|
||||
const dlBtn = document.getElementById('download-btn');
|
||||
pathEl.textContent = path;
|
||||
metaEl.textContent = '';
|
||||
bodyEl.innerHTML = '<div class="placeholder">Loading…</div>';
|
||||
dlBtn.href = base + '/api/download?path=' + encodeURIComponent(path);
|
||||
dlBtn.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const data = await fetchJSON('/api/file?path=' + encodeURIComponent(path));
|
||||
metaEl.textContent = formatSize(data.size) + (data.truncated ? ' (truncated — use Download for full file)' : '');
|
||||
if (data.binary) {
|
||||
bodyEl.innerHTML = '<div class="placeholder warn">Binary file — not rendered. Use Download to fetch the raw bytes.</div>';
|
||||
} else {
|
||||
bodyEl.textContent = data.content;
|
||||
}
|
||||
} catch (e) {
|
||||
bodyEl.innerHTML = '<div class="placeholder">Failed to load: ' + esc(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
renderRoot();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
@@ -0,0 +1,297 @@
|
||||
{
|
||||
"schema_version": "v1",
|
||||
"tools": [
|
||||
{
|
||||
"name": "bash",
|
||||
"description": "Execute a bash command in an isolated directory.",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The bash command to execute",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout_seconds": {
|
||||
"anyOf": [
|
||||
{
|
||||
"maximum": 1800,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Timeout in seconds (default 120, max 1800)"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "echo",
|
||||
"description": "Echoes a message",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"message": {
|
||||
"description": "The message to echo",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "export_state",
|
||||
"description": "Export the full core state as JSON.\n\nRound-trips with import_state. State is keyed by snake_case entity-class\nname (e.g. ``project``, ``employee``, ``slack_message``).",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "import_state",
|
||||
"description": "Replace the full core state with the provided JSON.\n\nFor synthetic-data injection and test setup. Round-trips with export_state.",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"state": {
|
||||
"additionalProperties": true,
|
||||
"description": "Empty state \u2014 core has no DB to snapshot.",
|
||||
"properties": {},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"state"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "listFiles",
|
||||
"description": "List files and subdirectories in a directory inside the sandbox.",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"directory": {
|
||||
"default": ".",
|
||||
"description": "Directory to list, relative to the sandbox root. Use /workdir/ prefix or an absolute path within the sandbox to be explicit. Defaults to the sandbox root.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "prepareGradingContext",
|
||||
"description": "Prepare the text_for_grading string for rubric evaluation.\n\nCollects file evidence from the sandbox, formats as XML,\nand prepends to the agent's final_output.\n\nArgs:\n final_output: The agent's final response text.\n directory: Root directory to search. Defaults to the sandbox directory.\n extensions: Only include files with these extensions (without dot).\n exclude_patterns: Directory/file name patterns to skip.\n max_files: Maximum number of files to include as evidence. Eligible files\n beyond this cap are omitted but reported (logged and listed in the\n XML under <files_omitted_due_to_max_files>) rather than dropped\n silently. Defaults to DEFAULT_MAX_FILES.\n max_content_bytes: Maximum bytes of content to read per file.\n\nReturns:\n The assembled text_for_grading string.",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"final_output": {
|
||||
"type": "string"
|
||||
},
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"extensions": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"exclude_patterns": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"max_files": {
|
||||
"default": 100,
|
||||
"type": "integer"
|
||||
},
|
||||
"max_content_bytes": {
|
||||
"default": 250000,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"final_output"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "readFile",
|
||||
"description": "Read a file from the sandbox, with optional offset/limit (bytes) for paginating large files.",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"description": "Path to the file. Use /workdir/ prefix for sandbox files, or an absolute path within the sandbox.",
|
||||
"type": "string"
|
||||
},
|
||||
"offset": {
|
||||
"default": 0,
|
||||
"description": "Byte offset to start reading from. Use 0 for the beginning or a previous call's next_offset to continue.",
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"limit": {
|
||||
"anyOf": [
|
||||
{
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": 200000,
|
||||
"description": "Maximum number of bytes to read. Defaults to 200000. Pass null to read from offset to end of file."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file_path"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "readMedia",
|
||||
"description": "Read an image, PDF, audio, or video file and return it as multimodal MCP content (base64-encoded, capped at 20 MiB; images auto-resized to model limits, large PDFs read in page ranges).",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"description": "Path to an image (gif/jpeg/png/webp), PDF, audio (wav/mp3/aiff/aac/ogg/flac), or video (mp4/mpeg/mov/avi/flv/webm/wmv/3gpp) file. Use /workdir/ prefix for sandbox files, or an absolute path within the sandbox. Returns multimodal MCP content; images are automatically downscaled/re-encoded to fit model limits (max 2000px on the long edge). Audio/video require a model with native audio/video support (e.g. Gemini) \u2014 other models see only a text placeholder. Use readFile for text and readPDF for PDF text extraction.",
|
||||
"type": "string"
|
||||
},
|
||||
"pages": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "PDF page range to read, 1-indexed, e.g. '3' or '1-10' (max 20 pages per read). Required for PDFs over 20 pages or 10 MiB. Ignored for images."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file_path"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "readPDF",
|
||||
"description": "Extract text from a PDF file inside the sandbox.",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file_path"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "writeFile",
|
||||
"description": "Write content to a file in an isolated sandbox.",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file_path",
|
||||
"content"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
],
|
||||
"toolsets": {
|
||||
"read": [
|
||||
"echo",
|
||||
"listFiles",
|
||||
"readFile",
|
||||
"readPDF"
|
||||
],
|
||||
"read_multimodal": [
|
||||
"echo",
|
||||
"listFiles",
|
||||
"readFile",
|
||||
"readMedia",
|
||||
"readPDF"
|
||||
],
|
||||
"write": [
|
||||
"bash",
|
||||
"writeFile"
|
||||
],
|
||||
"debug": [
|
||||
"bash",
|
||||
"echo"
|
||||
],
|
||||
"ds_all": [
|
||||
"bash",
|
||||
"listFiles",
|
||||
"readFile",
|
||||
"readPDF",
|
||||
"writeFile"
|
||||
],
|
||||
"state": [
|
||||
"export_state",
|
||||
"import_state"
|
||||
],
|
||||
"grading": [
|
||||
"prepareGradingContext"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"run": {
|
||||
"command": "python",
|
||||
"args": ["-m", "core.server"],
|
||||
"env": { "SETUID": "1000", "SETGID": "1000", "HOME": "/home/model", "USER": "model", "LOGNAME": "model" }
|
||||
},
|
||||
"setup": {
|
||||
"command": "python",
|
||||
"args": ["-m", "core.setup"],
|
||||
"env": { "SETUID": "1000", "SETGID": "1000", "HOME": "/home/model", "USER": "model", "LOGNAME": "model" }
|
||||
},
|
||||
"namespaced": false,
|
||||
"toolsets": {
|
||||
"read": ["echo", "listFiles", "readFile", "readPDF"],
|
||||
"read_multimodal": ["echo", "listFiles", "readFile", "readMedia", "readPDF"],
|
||||
"write": ["bash", "writeFile"],
|
||||
"debug": ["bash", "echo"],
|
||||
"ds_all": [
|
||||
"bash",
|
||||
"listFiles",
|
||||
"readFile",
|
||||
"readPDF",
|
||||
"writeFile"
|
||||
],
|
||||
"state": ["export_state", "import_state"],
|
||||
"grading": ["prepareGradingContext"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
[build-system]
|
||||
build-backend = "uv_build"
|
||||
requires = [ "uv-build>=0.11,<0.12" ]
|
||||
|
||||
[project]
|
||||
name = "core"
|
||||
version = "0.0.1"
|
||||
description = "MCP Server"
|
||||
requires-python = ">=3.13"
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
]
|
||||
dependencies = [
|
||||
"fastmcp>=3.2,<4",
|
||||
"json5>=0.9",
|
||||
"mcp[cli]>=1.19",
|
||||
"openpyxl>=3.1",
|
||||
"pillow>=10",
|
||||
"pypdf>=4.2",
|
||||
"python-dateutil>=2.8",
|
||||
"python-docx>=1.1",
|
||||
"python-pptx>=0.6",
|
||||
"read-file-safe",
|
||||
]
|
||||
optional-dependencies.dev = [ "pytest>=8", "pytest-snapshot>=0.9" ]
|
||||
scripts.core-mcp = "core.server:main"
|
||||
|
||||
[tool.uv]
|
||||
environments = [
|
||||
"sys_platform == 'darwin' and platform_machine == 'arm64'",
|
||||
"sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||
]
|
||||
build-backend.module-root = ""
|
||||
|
||||
[tool.pytest]
|
||||
ini_options.pythonpath = [ "." ]
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Pin `sandbox._agent_env`: the single chokepoint that hides /opt/venv from
|
||||
agent-spawned subprocesses (bash)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.tools import sandbox
|
||||
|
||||
|
||||
def _polluted_environ() -> dict[str, str]:
|
||||
return {
|
||||
"HOME": "/home/model",
|
||||
"USER": "model",
|
||||
"LANG": "C.UTF-8",
|
||||
"PATH": "/opt/venv/bin:/app/scripts:/usr/local/bin:/usr/bin:/bin",
|
||||
"VIRTUAL_ENV": "/opt/venv",
|
||||
"UV_PROJECT_ENVIRONMENT": "/opt/venv",
|
||||
"PYTHONPATH": "/app:/opt/venv/lib/python3.13/site-packages",
|
||||
"PYTHONHOME": "/opt/venv",
|
||||
"SETUID": "1000",
|
||||
"SETGID": "1000",
|
||||
}
|
||||
|
||||
|
||||
def test_strips_opt_venv_from_path():
|
||||
with mock.patch.dict(os.environ, _polluted_environ(), clear=True):
|
||||
env = sandbox._agent_env()
|
||||
assert "/opt/venv" not in env["PATH"], env["PATH"]
|
||||
|
||||
|
||||
def test_strips_app_from_path():
|
||||
with mock.patch.dict(os.environ, _polluted_environ(), clear=True):
|
||||
env = sandbox._agent_env()
|
||||
assert "/app" not in env["PATH"], env["PATH"]
|
||||
|
||||
|
||||
def test_drops_venv_pointers():
|
||||
with mock.patch.dict(os.environ, _polluted_environ(), clear=True):
|
||||
env = sandbox._agent_env()
|
||||
for var in ("VIRTUAL_ENV", "UV_PROJECT_ENVIRONMENT", "PYTHONPATH", "PYTHONHOME"):
|
||||
assert var not in env, f"{var!r} leaked: {env.get(var)!r}"
|
||||
|
||||
|
||||
def test_drops_privilege_control_vars():
|
||||
with mock.patch.dict(os.environ, _polluted_environ(), clear=True):
|
||||
env = sandbox._agent_env()
|
||||
assert "SETUID" not in env
|
||||
assert "SETGID" not in env
|
||||
|
||||
|
||||
def test_preserves_user_facing_vars():
|
||||
with mock.patch.dict(os.environ, _polluted_environ(), clear=True):
|
||||
env = sandbox._agent_env()
|
||||
assert env["HOME"] == "/home/model"
|
||||
assert env["USER"] == "model"
|
||||
assert env["LANG"] == "C.UTF-8"
|
||||
|
||||
|
||||
def test_path_is_a_concrete_default_not_absent():
|
||||
with mock.patch.dict(os.environ, _polluted_environ(), clear=True):
|
||||
env = sandbox._agent_env()
|
||||
assert env.get("PATH"), "agent PATH unexpectedly empty"
|
||||
assert "/usr/bin" in env["PATH"]
|
||||
assert "/usr/local/bin" in env["PATH"]
|
||||
|
||||
|
||||
# Regression guard for the P1: derived images (docker/stem-*) append dirs like
|
||||
# /opt/conda/bin; sanitising must strip only /opt/venv and /app, never the rest.
|
||||
|
||||
|
||||
def _derived_image_environ() -> dict[str, str]:
|
||||
env = _polluted_environ()
|
||||
env["PATH"] = "/opt/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt/conda/bin"
|
||||
return env
|
||||
|
||||
|
||||
def test_preserves_derived_image_path_additions():
|
||||
with mock.patch.dict(os.environ, _derived_image_environ(), clear=True):
|
||||
env = sandbox._agent_env()
|
||||
entries = env["PATH"].split(os.pathsep)
|
||||
assert "/opt/conda/bin" in entries, env["PATH"]
|
||||
assert "/opt/venv/bin" not in entries, env["PATH"]
|
||||
|
||||
|
||||
def test_preserves_path_entry_order():
|
||||
with mock.patch.dict(os.environ, _derived_image_environ(), clear=True):
|
||||
env = sandbox._agent_env()
|
||||
entries = env["PATH"].split(os.pathsep)
|
||||
assert entries == ["/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin", "/opt/conda/bin"]
|
||||
|
||||
|
||||
def test_strips_only_venv_and_app_prefixes_not_lookalikes():
|
||||
polluted = _polluted_environ()
|
||||
polluted["PATH"] = "/opt/venv/bin:/opt/venvtools/bin:/application/bin:/app/scripts:/usr/bin"
|
||||
with mock.patch.dict(os.environ, polluted, clear=True):
|
||||
env = sandbox._agent_env()
|
||||
entries = env["PATH"].split(os.pathsep)
|
||||
assert entries == ["/opt/venvtools/bin", "/application/bin", "/usr/bin"], env["PATH"]
|
||||
|
||||
|
||||
def test_falls_back_to_default_when_only_server_entries():
|
||||
polluted = _polluted_environ()
|
||||
polluted["PATH"] = "/opt/venv/bin:/app/scripts"
|
||||
with mock.patch.dict(os.environ, polluted, clear=True):
|
||||
env = sandbox._agent_env()
|
||||
assert env["PATH"] == sandbox._AGENT_DEFAULT_PATH
|
||||
|
||||
|
||||
def test_falls_back_to_default_when_path_unset():
|
||||
polluted = _polluted_environ()
|
||||
del polluted["PATH"]
|
||||
with mock.patch.dict(os.environ, polluted, clear=True):
|
||||
env = sandbox._agent_env()
|
||||
assert env["PATH"] == sandbox._AGENT_DEFAULT_PATH
|
||||
|
||||
|
||||
def test_parent_env_unchanged():
|
||||
# Sanitiser must not mutate os.environ — core itself relies on those
|
||||
# vars for its own subsequent imports.
|
||||
polluted = _polluted_environ()
|
||||
with mock.patch.dict(os.environ, polluted, clear=True):
|
||||
sandbox._agent_env()
|
||||
assert os.environ["PATH"] == polluted["PATH"]
|
||||
assert os.environ["VIRTUAL_ENV"] == polluted["VIRTUAL_ENV"]
|
||||
assert os.environ["UV_PROJECT_ENVIRONMENT"] == polluted["UV_PROJECT_ENVIRONMENT"]
|
||||
assert os.environ["SETUID"] == polluted["SETUID"]
|
||||
|
||||
|
||||
def test_drops_harness_internal_vars():
|
||||
polluted = _polluted_environ()
|
||||
polluted.update(
|
||||
{
|
||||
"WORLDBENCH_ROOT": "/app",
|
||||
"WORLDBENCH_SEED": "deadbeef",
|
||||
"WORLDBENCH_TOOL_SETS": "core_debug",
|
||||
# A harness var nobody listed explicitly — the prefix sweep must catch it.
|
||||
"WORLDBENCH_SOME_FUTURE_VAR": "/app/whatever",
|
||||
"INPUTDIR": "/app/setup_data/entities/core",
|
||||
"OUTPUTDIR": "/app/output_data/core",
|
||||
"BUNDLE_OUTPUT_DIR": "/app/output_data/services",
|
||||
"BUNDLE_INPUT_DIR": "/app/bundle/services/core",
|
||||
"BUNDLEDIR": "/app/bundle",
|
||||
"PORT": "41984",
|
||||
"VIEWER_PORT": "8123",
|
||||
}
|
||||
)
|
||||
with mock.patch.dict(os.environ, polluted, clear=True):
|
||||
env = sandbox._agent_env()
|
||||
for var in (
|
||||
"WORLDBENCH_ROOT",
|
||||
"WORLDBENCH_SEED",
|
||||
"WORLDBENCH_TOOL_SETS",
|
||||
"WORLDBENCH_SOME_FUTURE_VAR",
|
||||
"INPUTDIR",
|
||||
"OUTPUTDIR",
|
||||
"BUNDLE_OUTPUT_DIR",
|
||||
"BUNDLE_INPUT_DIR",
|
||||
"BUNDLEDIR",
|
||||
"PORT",
|
||||
"VIEWER_PORT",
|
||||
):
|
||||
assert var not in env, f"{var!r} leaked to the agent: {env.get(var)!r}"
|
||||
# /app must not survive anywhere in the agent env (paths or otherwise).
|
||||
assert not any("/app" in v for v in env.values()), env
|
||||
|
||||
|
||||
def test_preserves_clock_var():
|
||||
# The fake clock must reach the agent's shell; it is NOT a harness leak.
|
||||
polluted = _polluted_environ()
|
||||
polluted["WORLDBENCH_CURRENT_TIME"] = "2026-01-02T03:04:05Z"
|
||||
with mock.patch.dict(os.environ, polluted, clear=True):
|
||||
env = sandbox._agent_env()
|
||||
assert env["WORLDBENCH_CURRENT_TIME"] == "2026-01-02T03:04:05Z"
|
||||
|
||||
|
||||
def test_privilege_drop_kwargs_noop_when_not_root():
|
||||
# Tests/CI run unprivileged: nothing to drop to, so subprocess gets no
|
||||
# user/group kwargs and runs as the invoking user.
|
||||
with mock.patch("core.tools.sandbox.os.geteuid", return_value=1000):
|
||||
assert sandbox._privilege_drop_kwargs() == {}
|
||||
|
||||
|
||||
def test_privilege_drop_kwargs_drops_when_root():
|
||||
polluted = _polluted_environ() # has SETUID/SETGID = 1000
|
||||
with (
|
||||
mock.patch("core.tools.sandbox.os.geteuid", return_value=0),
|
||||
mock.patch.dict(os.environ, polluted, clear=True),
|
||||
):
|
||||
kwargs = sandbox._privilege_drop_kwargs()
|
||||
assert kwargs == {"user": 1000, "group": 1000, "extra_groups": []}
|
||||
|
||||
|
||||
def test_privilege_drop_kwargs_fails_closed_when_root_without_ids():
|
||||
# Root but no SETUID/SETGID: refuse rather than run the agent's command as
|
||||
# root (a sandbox escape).
|
||||
env = {k: v for k, v in _polluted_environ().items() if k not in ("SETUID", "SETGID")}
|
||||
with (
|
||||
mock.patch("core.tools.sandbox.os.geteuid", return_value=0),
|
||||
mock.patch.dict(os.environ, env, clear=True),
|
||||
pytest.raises(RuntimeError, match="SETUID/SETGID are not both set"),
|
||||
):
|
||||
sandbox._privilege_drop_kwargs()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# agent_stream_file — the unprivileged whole-file reader the viewer streams from
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_agent_stream_file_streams_whole_file(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(sandbox, "WORKDIR", str(tmp_path))
|
||||
target = tmp_path / "data.bin"
|
||||
payload = bytes(range(256)) * 1000 # 256 KB, spans many read() calls
|
||||
target.write_bytes(payload)
|
||||
chunks = list(sandbox.agent_stream_file(str(target), chunk_size=4096))
|
||||
assert b"".join(chunks) == payload
|
||||
# Actually streamed in pieces rather than buffered as one blob.
|
||||
assert len(chunks) > 1
|
||||
|
||||
|
||||
def test_agent_stream_file_empty_file_yields_nothing(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(sandbox, "WORKDIR", str(tmp_path))
|
||||
target = tmp_path / "empty.bin"
|
||||
target.write_bytes(b"")
|
||||
assert list(sandbox.agent_stream_file(str(target))) == []
|
||||
|
||||
|
||||
def test_agent_stream_file_missing_file_raises(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(sandbox, "WORKDIR", str(tmp_path))
|
||||
with pytest.raises(sandbox.AgentReadError):
|
||||
list(sandbox.agent_stream_file(str(tmp_path / "nope.bin")))
|
||||
|
||||
|
||||
def test_agent_stream_file_rejects_fifo(tmp_path, monkeypatch):
|
||||
# A named pipe with no writer would block a reader forever; the helper must
|
||||
# refuse non-regular files instead of hanging. (No writer is opened here, so
|
||||
# this test would deadlock if the guard regressed.)
|
||||
monkeypatch.setattr(sandbox, "WORKDIR", str(tmp_path))
|
||||
fifo = tmp_path / "pipe"
|
||||
os.mkfifo(fifo)
|
||||
with pytest.raises(sandbox.AgentReadError):
|
||||
list(sandbox.agent_stream_file(str(fifo)))
|
||||
|
||||
|
||||
def test_agent_read_window_rejects_fifo(tmp_path, monkeypatch):
|
||||
# Same guard on the windowed reader (backs readFile / viewer / grading).
|
||||
monkeypatch.setattr(sandbox, "WORKDIR", str(tmp_path))
|
||||
fifo = tmp_path / "pipe"
|
||||
os.mkfifo(fifo)
|
||||
with pytest.raises(sandbox.AgentReadError):
|
||||
sandbox.agent_read_window(str(fifo))
|
||||
|
||||
|
||||
def test_agent_read_window_roundtrips(tmp_path):
|
||||
sandbox.WORKDIR = str(tmp_path)
|
||||
p = tmp_path / "data.bin"
|
||||
p.write_bytes(b"0123456789abcdef")
|
||||
size, header, raw, start = sandbox.agent_read_window(str(p), offset=4, limit=5, sniff=3)
|
||||
assert size == 16
|
||||
assert header == b"012"
|
||||
assert raw == b"45678"
|
||||
assert start == 4
|
||||
|
||||
|
||||
def test_agent_read_window_raises_on_missing(tmp_path):
|
||||
sandbox.WORKDIR = str(tmp_path)
|
||||
with pytest.raises(sandbox.AgentReadError):
|
||||
sandbox.agent_read_window(str(tmp_path / "nope.bin"))
|
||||
|
||||
|
||||
def test_agent_list_dir(tmp_path):
|
||||
sandbox.WORKDIR = str(tmp_path)
|
||||
(tmp_path / "a.txt").write_text("hi")
|
||||
(tmp_path / "sub").mkdir()
|
||||
entries = {e["name"]: e for e in sandbox.agent_list_dir(str(tmp_path))}
|
||||
assert entries["a.txt"]["is_dir"] is False
|
||||
assert entries["a.txt"]["size"] == 2
|
||||
assert entries["sub"]["is_dir"] is True
|
||||
|
||||
|
||||
def test_run_in_sandbox_passes_sanitised_env(tmp_path):
|
||||
# End-to-end: catches the failure mode where _agent_env exists but
|
||||
# run_in_sandbox forgot to call it.
|
||||
sandbox.WORKDIR = str(tmp_path)
|
||||
polluted = _polluted_environ()
|
||||
with mock.patch.dict(os.environ, polluted, clear=True):
|
||||
result = sandbox.run_in_sandbox(
|
||||
[
|
||||
"sh",
|
||||
"-c",
|
||||
"echo PATH=$PATH; echo VENV=${VIRTUAL_ENV:-}; echo UV=${UV_PROJECT_ENVIRONMENT:-}; echo SU=${SETUID:-}",
|
||||
],
|
||||
timeout=10,
|
||||
)
|
||||
assert result["returncode"] == 0, result
|
||||
out = result["stdout"]
|
||||
assert "/opt/venv" not in out, out
|
||||
assert "/app" not in out, out
|
||||
assert "VENV=\n" in out or out.rstrip().endswith("VENV="), out
|
||||
assert "UV=\n" in out or ("UV=" in out and "/opt/venv" not in out), out
|
||||
assert "SU=\n" in out or out.rstrip().endswith("SU="), out
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Verify HiddenToolFilter: hidden tools excluded from list_tools but still callable."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastmcp import Client, FastMCP
|
||||
from fastmcp.server.middleware import Middleware
|
||||
from fastmcp.tools.function_tool import FunctionTool
|
||||
|
||||
|
||||
class HiddenToolFilter(Middleware):
|
||||
async def on_list_tools(self, context, call_next):
|
||||
tool_list = await call_next(context)
|
||||
return [t for t in tool_list if "hidden" not in (t.tags or set())]
|
||||
|
||||
|
||||
def _build_app() -> FastMCP:
|
||||
app = FastMCP("test")
|
||||
|
||||
def visible_tool() -> str:
|
||||
"""A normal tool."""
|
||||
return "visible"
|
||||
|
||||
def hidden_tool() -> str:
|
||||
"""A hidden tool."""
|
||||
return "hidden"
|
||||
|
||||
app.add_tool(FunctionTool.from_function(fn=visible_tool, name="visible_tool"))
|
||||
app.add_tool(FunctionTool.from_function(fn=hidden_tool, name="hidden_tool", tags={"hidden"}))
|
||||
app.add_middleware(HiddenToolFilter())
|
||||
return app
|
||||
|
||||
|
||||
async def main():
|
||||
app = _build_app()
|
||||
async with Client(app) as client:
|
||||
# 1. Hidden tool should NOT appear in list_tools
|
||||
tools = await client.list_tools()
|
||||
tool_names = [t.name for t in tools]
|
||||
assert "visible_tool" in tool_names, f"visible_tool missing from {tool_names}"
|
||||
assert "hidden_tool" not in tool_names, f"hidden_tool should be hidden but found in {tool_names}"
|
||||
print("PASS: hidden tool excluded from list_tools")
|
||||
|
||||
# 2. Hidden tool should still be callable
|
||||
result = await client.call_tool("hidden_tool", {})
|
||||
assert result.content[0].text == "hidden", f"unexpected result: {result}"
|
||||
print("PASS: hidden tool still callable via call_tool")
|
||||
|
||||
print("\nAll checks passed.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,524 @@
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.tools import sandbox
|
||||
from core.tools.prepare_grading_context import (
|
||||
_extract_docx,
|
||||
_extract_pptx,
|
||||
_extract_text,
|
||||
_extract_xlsx,
|
||||
_format_evidence_as_xml,
|
||||
_get_extension,
|
||||
_is_excluded,
|
||||
_walk_dir,
|
||||
prepareGradingContext,
|
||||
)
|
||||
|
||||
|
||||
class TestHelpers(unittest.TestCase):
|
||||
def test_get_extension(self):
|
||||
assert _get_extension("/foo/bar.txt") == "txt"
|
||||
assert _get_extension("/foo/bar.CSV") == "csv"
|
||||
assert _get_extension("/foo/bar") == ""
|
||||
# Dotfiles like .hidden have no extension per os.path.splitext
|
||||
assert _get_extension("/foo/.hidden") == ""
|
||||
|
||||
def test_is_excluded(self):
|
||||
excl = {"__pycache__", ".git"}
|
||||
assert _is_excluded("__pycache__/foo.pyc", excl)
|
||||
assert _is_excluded("src/__pycache__/foo.pyc", excl)
|
||||
assert _is_excluded(".git/config", excl)
|
||||
assert not _is_excluded("src/main.py", excl)
|
||||
|
||||
def test_walk_dir_sorted(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
os.makedirs(os.path.join(d, "b"))
|
||||
open(os.path.join(d, "b", "z.txt"), "w").close()
|
||||
open(os.path.join(d, "b", "a.txt"), "w").close()
|
||||
open(os.path.join(d, "c.txt"), "w").close()
|
||||
results = _walk_dir(d)
|
||||
names = [os.path.basename(r) for r in results]
|
||||
assert names == ["a.txt", "z.txt", "c.txt"] or results == sorted(results)
|
||||
|
||||
|
||||
class TestExtractText(unittest.TestCase):
|
||||
# _extract_text now takes (raw_bytes, file_size, max_bytes) — the actual file
|
||||
# read happens earlier, as the sandbox user, in prepareGradingContext.
|
||||
def test_extract_text_normal(self):
|
||||
content, method, truncated = _extract_text(b"hello world", 11, 50_000)
|
||||
assert content == "hello world"
|
||||
assert method == "read"
|
||||
assert not truncated
|
||||
|
||||
def test_extract_text_truncated(self):
|
||||
raw = b"a" * 1000
|
||||
content, _method, truncated = _extract_text(raw, len(raw), 100)
|
||||
assert truncated
|
||||
assert content is not None
|
||||
assert "[... truncated at 100 bytes" in content
|
||||
assert "total size: 1,000 bytes]" in content
|
||||
|
||||
def test_extract_text_decodes_non_utf8_lossily(self):
|
||||
# Invalid UTF-8 bytes are replaced, never raise.
|
||||
content, _method, _truncated = _extract_text(b"\xff\xfe bad bytes", 12, 50_000)
|
||||
assert content is not None
|
||||
|
||||
|
||||
class TestFormatEvidenceAsXml(unittest.TestCase):
|
||||
def test_empty_evidence(self):
|
||||
assert _format_evidence_as_xml([]) == ""
|
||||
|
||||
def test_single_file_with_content(self):
|
||||
evidence = [
|
||||
{
|
||||
"path": "/tmp/test.txt",
|
||||
"extension": "txt",
|
||||
"size_bytes": 11,
|
||||
"content": "hello world",
|
||||
"truncated": False,
|
||||
}
|
||||
]
|
||||
xml = _format_evidence_as_xml(evidence, "/tmp")
|
||||
assert '<workspace_files directory="/tmp">' in xml
|
||||
assert 'path="/tmp/test.txt"' in xml
|
||||
assert 'type="txt"' in xml
|
||||
assert 'size_bytes="11"' in xml
|
||||
assert "hello world" in xml
|
||||
assert "</workspace_files>" in xml
|
||||
|
||||
def test_null_content_self_closing(self):
|
||||
evidence = [
|
||||
{
|
||||
"path": "/tmp/test.bin",
|
||||
"extension": "bin",
|
||||
"size_bytes": 100,
|
||||
"content": None,
|
||||
"truncated": False,
|
||||
}
|
||||
]
|
||||
xml = _format_evidence_as_xml(evidence)
|
||||
assert "/>" in xml
|
||||
assert "</file>" not in xml
|
||||
|
||||
def test_special_extension_extraction_method(self):
|
||||
evidence = [
|
||||
{
|
||||
"path": "/tmp/test.pdf",
|
||||
"extension": "pdf",
|
||||
"size_bytes": 5000,
|
||||
"content": "pdf text",
|
||||
"extraction_method": "pypdf",
|
||||
"truncated": False,
|
||||
}
|
||||
]
|
||||
xml = _format_evidence_as_xml(evidence)
|
||||
assert 'extraction_method="pypdf"' in xml
|
||||
|
||||
def test_truncated_attribute(self):
|
||||
evidence = [
|
||||
{
|
||||
"path": "/tmp/big.txt",
|
||||
"extension": "txt",
|
||||
"size_bytes": 100000,
|
||||
"content": "partial...",
|
||||
"truncated": True,
|
||||
}
|
||||
]
|
||||
xml = _format_evidence_as_xml(evidence)
|
||||
assert 'truncated="true"' in xml
|
||||
|
||||
|
||||
class TestPrepareGradingContext(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.original_workdir = sandbox.WORKDIR
|
||||
# prepareGradingContext now confines reads to the sandbox workdir, so
|
||||
# point WORKDIR at the dir under test (mirrors production, where grading
|
||||
# collects the agent's deliverables from WORKDIR).
|
||||
sandbox.WORKDIR = self.temp_dir
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
sandbox.WORKDIR = self.original_workdir
|
||||
|
||||
def _write(self, rel_path: str, content: str = "test") -> str:
|
||||
full = os.path.join(self.temp_dir, rel_path)
|
||||
os.makedirs(os.path.dirname(full), exist_ok=True)
|
||||
with open(full, "w") as f:
|
||||
f.write(content)
|
||||
return full
|
||||
|
||||
async def test_returns_xml_prepended_to_final_output(self):
|
||||
self._write("hello.txt", "hello world")
|
||||
result = await prepareGradingContext(final_output="agent response", directory=self.temp_dir)
|
||||
assert result.startswith("<workspace_files")
|
||||
assert result.endswith("agent response")
|
||||
assert "\n\nagent response" in result
|
||||
|
||||
async def test_no_files_returns_just_final_output(self):
|
||||
result = await prepareGradingContext(final_output="agent response", directory=self.temp_dir)
|
||||
assert result == "agent response"
|
||||
|
||||
async def test_nonexistent_directory_returns_final_output(self):
|
||||
result = await prepareGradingContext(final_output="agent response", directory="/nonexistent/path")
|
||||
assert result == "agent response"
|
||||
|
||||
async def test_default_workdir(self):
|
||||
workdir = tempfile.mkdtemp()
|
||||
sandbox.WORKDIR = workdir
|
||||
with open(os.path.join(workdir, "file.txt"), "w") as f:
|
||||
f.write("test")
|
||||
result = await prepareGradingContext(final_output="output")
|
||||
assert "<workspace_files" in result
|
||||
assert result.endswith("output")
|
||||
shutil.rmtree(workdir)
|
||||
|
||||
async def test_extension_filter(self):
|
||||
self._write("hello.txt", "text")
|
||||
self._write("hello.py", "print('hi')")
|
||||
self._write("hello.csv", "a,b")
|
||||
result = await prepareGradingContext(
|
||||
final_output="output",
|
||||
directory=self.temp_dir,
|
||||
extensions=["txt", "csv"],
|
||||
)
|
||||
assert 'type="txt"' in result
|
||||
assert 'type="csv"' in result
|
||||
assert 'type="py"' not in result
|
||||
|
||||
async def test_unsupported_extension_raises(self):
|
||||
with pytest.raises(ValueError) as ctx:
|
||||
await prepareGradingContext(
|
||||
final_output="output",
|
||||
directory=self.temp_dir,
|
||||
extensions=["exe"],
|
||||
)
|
||||
assert "Unsupported" in str(ctx.value)
|
||||
|
||||
async def test_exclude_patterns(self):
|
||||
self._write("src/main.py", "code")
|
||||
self._write("__pycache__/cache.py", "cached")
|
||||
self._write(".git/config", "git stuff")
|
||||
result = await prepareGradingContext(final_output="output", directory=self.temp_dir)
|
||||
assert "main.py" in result
|
||||
assert "cache.py" not in result
|
||||
assert "config" not in result
|
||||
|
||||
async def test_max_files_limit(self):
|
||||
for i in range(10):
|
||||
self._write(f"file{i:02d}.txt", f"content {i}")
|
||||
result = await prepareGradingContext(final_output="output", directory=self.temp_dir, max_files=3)
|
||||
assert result.count("<file ") == 3
|
||||
|
||||
async def test_max_files_overflow_is_reported_not_silent(self):
|
||||
# The walk is flat + alphabetical, so a low cap can drop later files.
|
||||
# Those must be surfaced (count + names), not silently dropped, so a
|
||||
# rubric does not wrongly conclude an existing file is missing.
|
||||
for i in range(5):
|
||||
self._write(f"file{i:02d}.txt", f"content {i}")
|
||||
result = await prepareGradingContext(final_output="output", directory=self.temp_dir, max_files=2)
|
||||
assert result.count("<file ") == 2
|
||||
assert 'truncated_to_max_files="true"' in result
|
||||
assert 'dropped_file_count="3"' in result
|
||||
assert "<files_omitted_due_to_max_files" in result
|
||||
# The dropped files are the alphabetically-later ones.
|
||||
assert "file04.txt" in result
|
||||
assert result.count("<omitted_file ") == 3
|
||||
|
||||
async def test_no_overflow_has_no_truncation_marker(self):
|
||||
for i in range(3):
|
||||
self._write(f"file{i:02d}.txt", f"content {i}")
|
||||
result = await prepareGradingContext(final_output="output", directory=self.temp_dir, max_files=10)
|
||||
assert "truncated_to_max_files" not in result
|
||||
assert "files_omitted_due_to_max_files" not in result
|
||||
|
||||
async def test_overflow_marker_emitted_even_when_no_files_fit(self):
|
||||
# max_files=0: nothing is included, but the dir is non-empty. The grader
|
||||
# must still see that files exist (and were omitted), not a bare output.
|
||||
self._write("only.txt", "content")
|
||||
result = await prepareGradingContext(final_output="output", directory=self.temp_dir, max_files=0)
|
||||
assert result.startswith("<workspace_files")
|
||||
assert 'dropped_file_count="1"' in result
|
||||
assert "only.txt" in result
|
||||
|
||||
async def test_unsupported_files_skipped(self):
|
||||
self._write("image.png", "binary")
|
||||
self._write("doc.txt", "text")
|
||||
result = await prepareGradingContext(final_output="output", directory=self.temp_dir)
|
||||
assert 'type="txt"' in result
|
||||
# Check for the unsupported file's marker tokens specifically — the
|
||||
# tempdir path itself can contain "png" as a random substring.
|
||||
assert "image.png" not in result
|
||||
assert 'type="png"' not in result
|
||||
|
||||
async def test_oversized_special_file_recorded_not_extracted(self):
|
||||
# A special file over the raw-read cap must be surfaced as evidence (so
|
||||
# the rubric knows it exists) but NOT extracted — extracting truncated
|
||||
# PDF/Office bytes would corrupt, and reading it whole could OOM grading.
|
||||
self._write("big.pdf", "x" * 100)
|
||||
with mock.patch("core.tools.prepare_grading_context.MAX_SPECIAL_FILE_BYTES", 10):
|
||||
result = await prepareGradingContext(final_output="output", directory=self.temp_dir)
|
||||
assert 'type="pdf"' in result
|
||||
assert 'size_bytes="100"' in result
|
||||
assert "exceeds" in result and "grading read cap" in result
|
||||
assert 'truncated="true"' in result
|
||||
# content is None -> self-closing tag, and the raw bytes never appear.
|
||||
assert "<big.pdf" not in result
|
||||
|
||||
async def test_special_file_under_cap_is_extracted(self):
|
||||
# A small docx under the cap still flows through normal extraction — the
|
||||
# cap must not block legitimate files.
|
||||
from docx import Document
|
||||
|
||||
path = os.path.join(self.temp_dir, "report.docx")
|
||||
doc = Document()
|
||||
doc.add_paragraph("under-cap evidence")
|
||||
doc.save(path)
|
||||
result = await prepareGradingContext(final_output="output", directory=self.temp_dir)
|
||||
assert 'extraction_method="python-docx"' in result
|
||||
assert "under-cap evidence" in result
|
||||
|
||||
async def test_returns_plain_string(self):
|
||||
self._write("test.txt", "content")
|
||||
result = await prepareGradingContext(final_output="my output", directory=self.temp_dir)
|
||||
assert isinstance(result, str)
|
||||
# Should NOT be JSON
|
||||
assert not result.startswith("{")
|
||||
assert not result.startswith("[")
|
||||
|
||||
async def test_empty_directory_returns_final_output(self):
|
||||
result = await prepareGradingContext(final_output="my output", directory=self.temp_dir)
|
||||
assert result == "my output"
|
||||
|
||||
|
||||
class TestExtractDocx(unittest.TestCase):
|
||||
def _make_docx(self, path: str) -> None:
|
||||
from docx import Document
|
||||
|
||||
doc = Document()
|
||||
doc.add_paragraph("Quarterly summary: revenue up 20%.")
|
||||
table = doc.add_table(rows=2, cols=2)
|
||||
table.cell(0, 0).text = "Metric"
|
||||
table.cell(0, 1).text = "Value"
|
||||
table.cell(1, 0).text = "Revenue"
|
||||
table.cell(1, 1).text = "1000"
|
||||
doc.save(path)
|
||||
|
||||
def test_extracts_paragraphs(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "report.docx")
|
||||
self._make_docx(path)
|
||||
content, method, truncated = _extract_docx(pathlib.Path(path).read_bytes(), 50_000)
|
||||
assert method == "python-docx"
|
||||
assert not truncated
|
||||
assert content is not None
|
||||
assert "revenue up 20%" in content
|
||||
|
||||
def test_includes_table_cells(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "report.docx")
|
||||
self._make_docx(path)
|
||||
content, _method, _truncated = _extract_docx(pathlib.Path(path).read_bytes(), 50_000)
|
||||
# Regression: table cell text used to be dropped (paragraphs only).
|
||||
assert content is not None
|
||||
assert "Metric" in content
|
||||
assert "Revenue" in content
|
||||
assert "1000" in content
|
||||
|
||||
def test_includes_nested_table_cells(self):
|
||||
from docx import Document
|
||||
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "nested.docx")
|
||||
doc = Document()
|
||||
outer = doc.add_table(rows=1, cols=1)
|
||||
cell = outer.cell(0, 0)
|
||||
cell.paragraphs[0].text = "OuterCell"
|
||||
inner = cell.add_table(rows=1, cols=2)
|
||||
inner.cell(0, 0).text = "InnerA"
|
||||
inner.cell(0, 1).text = "InnerB"
|
||||
doc.save(path)
|
||||
|
||||
content, _method, _truncated = _extract_docx(pathlib.Path(path).read_bytes(), 50_000)
|
||||
assert content is not None
|
||||
# cell.text flattens only direct paragraphs; nested tables must recurse.
|
||||
assert "OuterCell" in content
|
||||
assert "InnerA" in content
|
||||
assert "InnerB" in content
|
||||
|
||||
def test_missing_file_returns_none(self):
|
||||
content, method, truncated = _extract_docx(b"not a real docx", 50_000)
|
||||
assert content is None
|
||||
assert method == "python-docx"
|
||||
assert not truncated
|
||||
|
||||
|
||||
class TestExtractXlsx(unittest.TestCase):
|
||||
def _make_xlsx(self, path: str) -> None:
|
||||
from openpyxl import Workbook
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Data"
|
||||
ws.append(["Name", "Value"])
|
||||
ws.append(["foo", 42])
|
||||
wb.save(path)
|
||||
|
||||
def test_extracts_rows(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "sheet.xlsx")
|
||||
self._make_xlsx(path)
|
||||
content, method, truncated = _extract_xlsx(pathlib.Path(path).read_bytes(), 50_000)
|
||||
assert method == "openpyxl"
|
||||
assert not truncated
|
||||
assert content is not None
|
||||
assert "--- Sheet: Data ---" in content
|
||||
assert "Name\tValue" in content
|
||||
assert "foo\t42" in content
|
||||
|
||||
def test_missing_file_returns_none(self):
|
||||
content, method, truncated = _extract_xlsx(b"not a real xlsx", 50_000)
|
||||
assert content is None
|
||||
assert method == "openpyxl"
|
||||
assert not truncated
|
||||
|
||||
def test_every_sheet_appears_even_when_earlier_sheet_truncated(self):
|
||||
# Regression: a big first sheet used to drain the whole budget, so later
|
||||
# tabs never appeared. The budget is now per-sheet.
|
||||
from openpyxl import Workbook
|
||||
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "big.xlsx")
|
||||
wb = Workbook()
|
||||
ws1 = wb.active
|
||||
ws1.title = "TB Current"
|
||||
for i in range(2000):
|
||||
ws1.append([f"row{i}", "Trial Balance as of 12/31/2025", i, i * 2, "padding text"])
|
||||
ws2 = wb.create_sheet("TB Prior")
|
||||
ws2.append(["Trial Balance as of 11/30/2025"])
|
||||
ws2.append(["Prior period data", 123])
|
||||
wb.save(path)
|
||||
|
||||
content, _method, truncated = _extract_xlsx(pathlib.Path(path).read_bytes(), 2000)
|
||||
assert truncated
|
||||
assert content is not None
|
||||
assert "--- Sheet: TB Current ---" in content
|
||||
assert "--- Sheet: TB Prior ---" in content
|
||||
# The starved later sheet's data survives, and truncation is attributed.
|
||||
assert "11/30/2025" in content
|
||||
assert "Prior period data\t123" in content
|
||||
assert "'TB Current' truncated" in content
|
||||
|
||||
def test_empty_trailing_rows_do_not_consume_budget(self):
|
||||
# openpyxl read-only dimensions can be inflated; fully-empty rows must be
|
||||
# skipped so they don't burn the budget producing only blank tab output.
|
||||
from openpyxl import Workbook
|
||||
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "sparse.xlsx")
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Sparse"
|
||||
ws.append(["header", "value"])
|
||||
ws.append(["real", 1])
|
||||
# Force a large inflated dimension with empty cells far below.
|
||||
ws.cell(row=5000, column=1, value=None)
|
||||
wb.save(path)
|
||||
|
||||
content, _method, truncated = _extract_xlsx(pathlib.Path(path).read_bytes(), 50_000)
|
||||
assert content is not None
|
||||
assert not truncated
|
||||
assert "header\tvalue" in content
|
||||
assert "real\t1" in content
|
||||
# No run of empty tab-joined rows should appear.
|
||||
assert "\t\t\t" not in content
|
||||
|
||||
|
||||
class TestExtractPptx(unittest.TestCase):
|
||||
def _make_pptx(self, path: str) -> None:
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches
|
||||
|
||||
prs = Presentation()
|
||||
s1 = prs.slides.add_slide(prs.slide_layouts[5])
|
||||
s1.shapes.title.text = "Helix Pricing SLT Deck"
|
||||
# Slide 2: a textbox, a table, and speaker notes.
|
||||
s2 = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
tb = s2.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
|
||||
tb.text_frame.text = "Recommended price: $499"
|
||||
table = s2.shapes.add_table(2, 2, Inches(1), Inches(3), Inches(4), Inches(1)).table
|
||||
table.cell(0, 0).text = "Tier"
|
||||
table.cell(0, 1).text = "Price"
|
||||
table.cell(1, 0).text = "Pro"
|
||||
table.cell(1, 1).text = "499"
|
||||
s2.notes_slide.notes_text_frame.text = "Emphasize ROI in the meeting"
|
||||
prs.save(path)
|
||||
|
||||
def test_extracts_slides_tables_and_notes(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "deck.pptx")
|
||||
self._make_pptx(path)
|
||||
content, method, truncated = _extract_pptx(pathlib.Path(path).read_bytes(), 50_000)
|
||||
assert method == "python-pptx"
|
||||
assert not truncated
|
||||
assert content is not None
|
||||
assert "--- Slide 1 ---" in content
|
||||
assert "--- Slide 2 ---" in content
|
||||
assert "Helix Pricing SLT Deck" in content
|
||||
assert "Recommended price: $499" in content
|
||||
assert "Tier\tPrice" in content
|
||||
assert "Pro\t499" in content
|
||||
assert "[Notes] Emphasize ROI in the meeting" in content
|
||||
|
||||
def test_missing_file_returns_none(self):
|
||||
content, method, truncated = _extract_pptx(b"not a real pptx", 50_000)
|
||||
assert content is None
|
||||
assert method == "python-pptx"
|
||||
assert not truncated
|
||||
|
||||
|
||||
class TestPrepareGradingContextBinary(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_docx_and_xlsx_evidence_assembled(self):
|
||||
from docx import Document
|
||||
from openpyxl import Workbook
|
||||
|
||||
original_workdir = sandbox.WORKDIR
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
sandbox.WORKDIR = d # reads are confined to WORKDIR
|
||||
self.addCleanup(setattr, sandbox, "WORKDIR", original_workdir)
|
||||
doc = Document()
|
||||
doc.add_paragraph("agent wrote this in a docx")
|
||||
doc.save(os.path.join(d, "out.docx"))
|
||||
|
||||
wb = Workbook()
|
||||
wb.active.append(["col", "val"])
|
||||
wb.save(os.path.join(d, "out.xlsx"))
|
||||
|
||||
from pptx import Presentation
|
||||
|
||||
prs = Presentation()
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[5])
|
||||
slide.shapes.title.text = "agent wrote this in a pptx"
|
||||
prs.save(os.path.join(d, "out.pptx"))
|
||||
|
||||
result = await prepareGradingContext(final_output="FINAL", directory=d)
|
||||
assert 'type="docx"' in result
|
||||
assert 'extraction_method="python-docx"' in result
|
||||
assert "agent wrote this in a docx" in result
|
||||
assert 'type="xlsx"' in result
|
||||
assert 'extraction_method="openpyxl"' in result
|
||||
# Regression: a .pptx deliverable used to be silently skipped.
|
||||
assert 'type="pptx"' in result
|
||||
assert 'extraction_method="python-pptx"' in result
|
||||
assert "agent wrote this in a pptx" in result
|
||||
assert result.endswith("FINAL")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Tests for ``core.privilege`` — the workdir-ownership helpers
|
||||
(``ensure_workdir``, ``chown_tree_to_target``) used by ``core.setup``.
|
||||
|
||||
The per-command privilege drop lives in ``core.tools.sandbox`` now (the
|
||||
server stays root and drops each agent command instead of the whole process);
|
||||
its tests are in ``test_agent_env.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from core import privilege
|
||||
|
||||
|
||||
def _patch_root(is_root: bool):
|
||||
return mock.patch("core.privilege.os.geteuid", return_value=0 if is_root else 1000)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ensure_workdir
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ensure_workdir_creates_and_chowns_when_root(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("SETUID", "1000")
|
||||
monkeypatch.setenv("SETGID", "1000")
|
||||
target = tmp_path / "workdir-new"
|
||||
with (
|
||||
_patch_root(True),
|
||||
mock.patch("core.privilege.os.chown") as chown,
|
||||
mock.patch("core.privilege.sandbox.WORKDIR", str(target)),
|
||||
):
|
||||
privilege.ensure_workdir()
|
||||
assert target.is_dir()
|
||||
chown.assert_called_once_with(str(target), 1000, 1000)
|
||||
|
||||
|
||||
def test_ensure_workdir_noop_when_not_root(tmp_path):
|
||||
"""Non-root can't mkdir at / and can't chown, so the whole function is a
|
||||
no-op — WORKDIR must already exist (or the caller will fail loudly)."""
|
||||
target = tmp_path / "workdir-missing"
|
||||
with (
|
||||
_patch_root(False),
|
||||
mock.patch("core.privilege.os.chown") as chown,
|
||||
mock.patch("core.privilege.sandbox.WORKDIR", str(target)),
|
||||
):
|
||||
privilege.ensure_workdir()
|
||||
assert not target.exists()
|
||||
chown.assert_not_called()
|
||||
|
||||
|
||||
def test_ensure_workdir_skips_chown_when_env_vars_unset(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("SETUID", raising=False)
|
||||
monkeypatch.delenv("SETGID", raising=False)
|
||||
target = tmp_path / "workdir"
|
||||
with (
|
||||
_patch_root(True),
|
||||
mock.patch("core.privilege.os.chown") as chown,
|
||||
mock.patch("core.privilege.sandbox.WORKDIR", str(target)),
|
||||
):
|
||||
privilege.ensure_workdir()
|
||||
assert target.is_dir()
|
||||
chown.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# chown_tree_to_target
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_tree(root):
|
||||
"""Build a small tree: root/a.txt, root/sub/b.txt, root/sub/c.txt."""
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
(root / "a.txt").write_text("a")
|
||||
(root / "sub").mkdir()
|
||||
(root / "sub" / "b.txt").write_text("b")
|
||||
(root / "sub" / "c.txt").write_text("c")
|
||||
|
||||
|
||||
def test_chown_tree_recursively_when_root(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("SETUID", "1000")
|
||||
monkeypatch.setenv("SETGID", "1000")
|
||||
target = tmp_path / "workdir"
|
||||
_make_tree(target)
|
||||
with _patch_root(True), mock.patch("core.privilege.os.chown") as chown:
|
||||
privilege.chown_tree_to_target(target)
|
||||
paths = sorted(call.args[0] for call in chown.call_args_list)
|
||||
expected = sorted(
|
||||
[
|
||||
str(target),
|
||||
str(target / "a.txt"),
|
||||
str(target / "sub"),
|
||||
str(target / "sub" / "b.txt"),
|
||||
str(target / "sub" / "c.txt"),
|
||||
]
|
||||
)
|
||||
assert paths == expected
|
||||
for call in chown.call_args_list:
|
||||
assert call.args[1:] == (1000, 1000)
|
||||
assert call.kwargs == {"follow_symlinks": False}
|
||||
|
||||
|
||||
def test_chown_tree_noop_when_not_root(tmp_path, monkeypatch):
|
||||
"""Local dev / CI: same env vars, but no real privilege to chown — skip."""
|
||||
monkeypatch.setenv("SETUID", "1000")
|
||||
monkeypatch.setenv("SETGID", "1000")
|
||||
target = tmp_path / "workdir"
|
||||
_make_tree(target)
|
||||
with _patch_root(False), mock.patch("core.privilege.os.chown") as chown:
|
||||
privilege.chown_tree_to_target(target)
|
||||
chown.assert_not_called()
|
||||
|
||||
|
||||
def test_chown_tree_noop_when_env_vars_unset(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("SETUID", raising=False)
|
||||
monkeypatch.delenv("SETGID", raising=False)
|
||||
target = tmp_path / "workdir"
|
||||
_make_tree(target)
|
||||
with _patch_root(True), mock.patch("core.privilege.os.chown") as chown:
|
||||
privilege.chown_tree_to_target(target)
|
||||
chown.assert_not_called()
|
||||
|
||||
|
||||
def test_chown_tree_uses_minus_one_for_missing_uid_or_gid(tmp_path, monkeypatch):
|
||||
"""When only one of SETUID/SETGID is set, the other side is left unchanged
|
||||
via the chown ``-1`` sentinel."""
|
||||
monkeypatch.setenv("SETUID", "1000")
|
||||
monkeypatch.delenv("SETGID", raising=False)
|
||||
target = tmp_path / "workdir"
|
||||
target.mkdir()
|
||||
(target / "f.txt").write_text("x")
|
||||
with _patch_root(True), mock.patch("core.privilege.os.chown") as chown:
|
||||
privilege.chown_tree_to_target(target)
|
||||
for call in chown.call_args_list:
|
||||
assert call.args[1:] == (1000, -1)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
||||
"""Tests for core.setup — copies uploaded context files into the workdir."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from core import setup as setup_mod # aliased to avoid clash with pytest's xunit setup_module fixture
|
||||
from core.tools import sandbox
|
||||
|
||||
|
||||
class SetupTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = Path(tempfile.mkdtemp())
|
||||
self.world_root = self.temp_dir / "world"
|
||||
self.workdir = self.temp_dir / "workdir"
|
||||
self.world_root.mkdir()
|
||||
|
||||
self._original_workdir = sandbox.WORKDIR
|
||||
sandbox.WORKDIR = str(self.workdir)
|
||||
|
||||
def tearDown(self):
|
||||
sandbox.WORKDIR = self._original_workdir
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def _run(self, task_id: str | None = None) -> int:
|
||||
"""Invoke setup.main() in-process and return its exit code (0 on normal return)."""
|
||||
env = {"WORLDBENCH_ROOT": str(self.world_root)}
|
||||
if task_id is not None:
|
||||
env["WORLDBENCH_TASK_ID"] = task_id
|
||||
with mock.patch.dict(os.environ, env, clear=False):
|
||||
os.environ.pop("BUNDLEDIR", None)
|
||||
if task_id is None:
|
||||
os.environ.pop("WORLDBENCH_TASK_ID", None)
|
||||
try:
|
||||
setup_mod.main()
|
||||
return 0
|
||||
except SystemExit as e:
|
||||
return int(e.code or 0)
|
||||
|
||||
def test_copies_task_specific_files_into_workdir(self):
|
||||
"""Task-specific setup_data/{task_id}/files/ is copied into WORKDIR."""
|
||||
task_id = "task-abc"
|
||||
files_dir = self.world_root / "tasks" / "setup_data" / task_id / "files"
|
||||
files_dir.mkdir(parents=True)
|
||||
(files_dir / "james.txt").write_text("james is the CEO")
|
||||
(files_dir / "nested").mkdir()
|
||||
(files_dir / "nested" / "notes.md").write_text("# notes")
|
||||
|
||||
assert self._run(task_id=task_id) == 0
|
||||
|
||||
assert (self.workdir / "james.txt").read_text() == "james is the CEO"
|
||||
assert (self.workdir / "nested" / "notes.md").read_text() == "# notes"
|
||||
|
||||
def test_falls_back_to_generic_setup_data_when_task_dir_missing(self):
|
||||
"""If no task-specific dir exists, copies from {world_root}/setup_data/files/."""
|
||||
generic_files = self.world_root / "setup_data" / "files"
|
||||
generic_files.mkdir(parents=True)
|
||||
(generic_files / "shared.txt").write_text("shared context")
|
||||
|
||||
assert self._run(task_id="unknown-task") == 0
|
||||
assert (self.workdir / "shared.txt").read_text() == "shared context"
|
||||
|
||||
def test_uses_generic_when_no_task_id(self):
|
||||
"""With no WORLDBENCH_TASK_ID set, generic setup_data/files/ is used."""
|
||||
generic_files = self.world_root / "setup_data" / "files"
|
||||
generic_files.mkdir(parents=True)
|
||||
(generic_files / "default.txt").write_text("default")
|
||||
|
||||
assert self._run(task_id=None) == 0
|
||||
assert (self.workdir / "default.txt").read_text() == "default"
|
||||
|
||||
def test_merges_into_existing_workdir(self):
|
||||
"""Existing workdir contents are preserved; new files are added alongside."""
|
||||
self.workdir.mkdir(parents=True)
|
||||
(self.workdir / "preexisting.txt").write_text("keep me")
|
||||
|
||||
generic_files = self.world_root / "setup_data" / "files"
|
||||
generic_files.mkdir(parents=True)
|
||||
(generic_files / "new.txt").write_text("new file")
|
||||
|
||||
assert self._run(task_id=None) == 0
|
||||
assert (self.workdir / "preexisting.txt").read_text() == "keep me"
|
||||
assert (self.workdir / "new.txt").read_text() == "new file"
|
||||
|
||||
def test_no_setup_data_exits_cleanly(self):
|
||||
"""If neither task-specific nor generic setup_data exists, exit 0 without error."""
|
||||
assert self._run(task_id="whatever") == 0
|
||||
|
||||
def test_setup_data_without_files_subdir_is_noop(self):
|
||||
"""setup_data/ with no files/ subdirectory is a no-op (does not error).
|
||||
|
||||
Workdir IS created (ensure_workdir runs unconditionally so subsequent
|
||||
tools have something to chdir into), but no files are copied.
|
||||
"""
|
||||
(self.world_root / "setup_data").mkdir()
|
||||
|
||||
assert self._run(task_id=None) == 0
|
||||
|
||||
def test_rejects_symlink_in_setup_files(self):
|
||||
"""Symlinks in the source tree are refused — they'd let a bundle
|
||||
materialize an arbitrary host path (e.g. /app/tasks) into /workdir
|
||||
and then chown it to the model user."""
|
||||
files_dir = self.world_root / "setup_data" / "files"
|
||||
files_dir.mkdir(parents=True)
|
||||
(files_dir / "real.txt").write_text("ok")
|
||||
# Target doesn't need to exist for the check to fire.
|
||||
os.symlink("/app/tasks", str(files_dir / "rubrics"))
|
||||
|
||||
assert self._run(task_id=None) == 1
|
||||
assert not (self.workdir / "rubrics").exists()
|
||||
assert not (self.workdir / "real.txt").exists() # copytree never ran
|
||||
|
||||
def test_rejects_symlinked_files_dir_root(self):
|
||||
"""A symlinked files/ root is rejected too."""
|
||||
real_dir = self.world_root / "setup_data" / "real_files"
|
||||
real_dir.mkdir(parents=True)
|
||||
(real_dir / "x.txt").write_text("x")
|
||||
os.symlink(str(real_dir), str(self.world_root / "setup_data" / "files"))
|
||||
|
||||
assert self._run(task_id=None) == 1
|
||||
assert not (self.workdir / "x.txt").exists()
|
||||
|
||||
def test_rejects_hardlink_in_setup_files(self):
|
||||
"""Hardlinked files in the source tree are refused — they could
|
||||
point at a locked-down inode (e.g. /app/packages/grading/*) and
|
||||
slip past the symlink guard."""
|
||||
files_dir = self.world_root / "setup_data" / "files"
|
||||
files_dir.mkdir(parents=True)
|
||||
# Two paths sharing one inode: the "target" simulates a protected
|
||||
# file outside the bundle; the entry inside files/ is its hardlink.
|
||||
protected = self.temp_dir / "fake-protected.py"
|
||||
protected.write_text("rubric body")
|
||||
os.link(str(protected), str(files_dir / "rubric.py"))
|
||||
|
||||
assert self._run(task_id=None) == 1
|
||||
assert not (self.workdir / "rubric.py").exists()
|
||||
|
||||
def test_rejects_preexisting_workdir_symlink(self):
|
||||
"""A symlink planted in /workdir before setup runs is refused —
|
||||
otherwise copytree(dirs_exist_ok=True) would write through it as
|
||||
root, into paths the model can't normally write."""
|
||||
files_dir = self.world_root / "setup_data" / "files"
|
||||
files_dir.mkdir(parents=True)
|
||||
(files_dir / "context").mkdir()
|
||||
(files_dir / "context" / "doc.txt").write_text("payload")
|
||||
|
||||
# Plant a model-controlled symlink in /workdir before setup.
|
||||
self.workdir.mkdir(parents=True)
|
||||
protected_target = self.temp_dir / "fake-protected-dir"
|
||||
protected_target.mkdir()
|
||||
os.symlink(str(protected_target), str(self.workdir / "context"))
|
||||
|
||||
assert self._run(task_id=None) == 1
|
||||
# The bundle file must not have been written through the symlink.
|
||||
assert not (protected_target / "doc.txt").exists()
|
||||
|
||||
def test_chowns_workdir_to_target_uid_gid_when_root(self):
|
||||
"""Under (mocked) root, copied files are chowned back to SETUID:SETGID
|
||||
so the unprivileged model user can modify them."""
|
||||
generic_files = self.world_root / "setup_data" / "files"
|
||||
generic_files.mkdir(parents=True)
|
||||
(generic_files / "a.txt").write_text("hello")
|
||||
(generic_files / "sub").mkdir()
|
||||
(generic_files / "sub" / "b.txt").write_text("world")
|
||||
|
||||
env = {"WORLDBENCH_ROOT": str(self.world_root), "SETUID": "1000", "SETGID": "1000"}
|
||||
with (
|
||||
mock.patch.dict(os.environ, env, clear=False),
|
||||
mock.patch("core.privilege.os.geteuid", return_value=0),
|
||||
mock.patch("core.privilege.os.chown") as chown,
|
||||
):
|
||||
os.environ.pop("BUNDLEDIR", None)
|
||||
os.environ.pop("WORLDBENCH_TASK_ID", None)
|
||||
setup_mod.main()
|
||||
|
||||
chowned = sorted(call.args[0] for call in chown.call_args_list)
|
||||
# ensure_workdir chowns WORKDIR; chown_tree_to_target chowns it again plus
|
||||
# every entry under it. We only assert the post-copy entries are present.
|
||||
assert str(self.workdir / "a.txt") in chowned
|
||||
assert str(self.workdir / "sub") in chowned
|
||||
assert str(self.workdir / "sub" / "b.txt") in chowned
|
||||
for call in chown.call_args_list:
|
||||
assert call.args[1:] == (1000, 1000)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Tests for the unified-bundle layout in core.setup.
|
||||
|
||||
When ``$BUNDLEDIR/files/`` is mounted (the production harness unpacks the unified
|
||||
trajectory bundle there), core.setup should copy from that path instead
|
||||
of the legacy ``setup_data/files/`` location. We point ``BUNDLEDIR`` at a
|
||||
temp path so the test doesn't depend on the production mount path.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from core import setup as setup_mod # aliased to avoid clash with pytest's xunit setup_module fixture
|
||||
from core.tools import sandbox
|
||||
|
||||
|
||||
class BundleLayoutTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = Path(tempfile.mkdtemp())
|
||||
self.world_root = self.temp_dir / "world"
|
||||
self.workdir = self.temp_dir / "workdir"
|
||||
self.bundle_dir = self.temp_dir / "bundle"
|
||||
self.world_root.mkdir()
|
||||
|
||||
self._env_patch = mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"WORLDBENCH_ROOT": str(self.world_root),
|
||||
"BUNDLEDIR": str(self.bundle_dir),
|
||||
},
|
||||
clear=False,
|
||||
)
|
||||
self._env_patch.start()
|
||||
os.environ.pop("WORLDBENCH_TASK_ID", None)
|
||||
|
||||
self._original_workdir = sandbox.WORKDIR
|
||||
sandbox.WORKDIR = str(self.workdir)
|
||||
|
||||
def tearDown(self):
|
||||
sandbox.WORKDIR = self._original_workdir
|
||||
self._env_patch.stop()
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_bundle_files_take_precedence_over_legacy_setup_data(self):
|
||||
"""When $BUNDLEDIR/files/ exists, it's used instead of setup_data/files/."""
|
||||
bundle_files = self.bundle_dir / "files"
|
||||
bundle_files.mkdir(parents=True)
|
||||
(bundle_files / "from_bundle.txt").write_text("from bundle")
|
||||
|
||||
legacy_files = self.world_root / "setup_data" / "files"
|
||||
legacy_files.mkdir(parents=True)
|
||||
(legacy_files / "from_legacy.txt").write_text("from legacy")
|
||||
|
||||
setup_mod.main()
|
||||
|
||||
assert (self.workdir / "from_bundle.txt").read_text() == "from bundle"
|
||||
assert not (self.workdir / "from_legacy.txt").exists()
|
||||
|
||||
def test_falls_back_to_legacy_when_bundle_dir_absent(self):
|
||||
"""When BUNDLEDIR points at a path that doesn't exist on disk, the
|
||||
legacy setup_data path still works."""
|
||||
# bundle_dir intentionally not created
|
||||
legacy_files = self.world_root / "setup_data" / "files"
|
||||
legacy_files.mkdir(parents=True)
|
||||
(legacy_files / "from_legacy.txt").write_text("from legacy")
|
||||
|
||||
setup_mod.main()
|
||||
|
||||
assert (self.workdir / "from_legacy.txt").read_text() == "from legacy"
|
||||
|
||||
def test_falls_back_to_legacy_when_bundledir_unset(self):
|
||||
"""When BUNDLEDIR isn't set at all (common local-dev path), the
|
||||
legacy setup_data path is used."""
|
||||
os.environ.pop("BUNDLEDIR", None)
|
||||
legacy_files = self.world_root / "setup_data" / "files"
|
||||
legacy_files.mkdir(parents=True)
|
||||
(legacy_files / "from_legacy.txt").write_text("from legacy")
|
||||
|
||||
setup_mod.main()
|
||||
|
||||
assert (self.workdir / "from_legacy.txt").read_text() == "from legacy"
|
||||
|
||||
def test_bundle_and_legacy_produce_equivalent_workdir(self):
|
||||
"""Same files served via either layout yield the same WORKDIR contents."""
|
||||
contents = {"a.txt": "alpha", "nested/b.md": "# beta"}
|
||||
|
||||
# First run: bundle layout
|
||||
bundle_files = self.bundle_dir / "files"
|
||||
for rel, body in contents.items():
|
||||
p = bundle_files / rel
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(body)
|
||||
|
||||
setup_mod.main()
|
||||
bundle_snapshot = {
|
||||
str(p.relative_to(self.workdir)): p.read_text() for p in self.workdir.rglob("*") if p.is_file()
|
||||
}
|
||||
|
||||
# Second run: same files via legacy layout, fresh workdir
|
||||
shutil.rmtree(self.bundle_dir)
|
||||
shutil.rmtree(self.workdir, ignore_errors=True)
|
||||
legacy_files = self.world_root / "setup_data" / "files"
|
||||
for rel, body in contents.items():
|
||||
p = legacy_files / rel
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(body)
|
||||
|
||||
setup_mod.main()
|
||||
legacy_snapshot = {
|
||||
str(p.relative_to(self.workdir)): p.read_text() for p in self.workdir.rglob("*") if p.is_file()
|
||||
}
|
||||
|
||||
assert bundle_snapshot == legacy_snapshot
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Tests for core's sandbox viewer app — focused on the download path, which
|
||||
streams file bytes from a uid-1000 reader instead of buffering them whole."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from core import viewer
|
||||
from core.tools import sandbox
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
# Point WORKDIR at a temp dir so reads stay confined to it, and run the
|
||||
# viewer with no proxy token so the middleware lets requests through.
|
||||
monkeypatch.setattr(sandbox, "WORKDIR", str(tmp_path))
|
||||
monkeypatch.setattr(viewer, "get_proxy_token", lambda: None)
|
||||
return TestClient(viewer.create_core_viewer_app())
|
||||
|
||||
|
||||
def test_download_streams_file_contents(client, tmp_path):
|
||||
payload = bytes(range(256)) * 1000 # 256 KB — larger than one read() chunk
|
||||
(tmp_path / "data.bin").write_bytes(payload)
|
||||
resp = client.get("/api/download", params={"path": "data.bin"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == payload
|
||||
assert resp.headers["content-type"] == "application/octet-stream"
|
||||
assert 'filename="data.bin"' in resp.headers["content-disposition"]
|
||||
|
||||
|
||||
def test_download_empty_file(client, tmp_path):
|
||||
(tmp_path / "empty.bin").write_bytes(b"")
|
||||
resp = client.get("/api/download", params={"path": "empty.bin"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == b""
|
||||
|
||||
|
||||
def test_download_missing_file_returns_404(client):
|
||||
resp = client.get("/api/download", params={"path": "nope.bin"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_download_path_escape_rejected(client):
|
||||
resp = client.get("/api/download", params={"path": "../../etc/passwd"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_download_fifo_returns_404_not_hang(client, tmp_path):
|
||||
# An agent-created named pipe must not block a request worker waiting for a
|
||||
# writer — it's rejected as a non-regular file. (No writer is opened, so a
|
||||
# regression here would hang the test.)
|
||||
os.mkfifo(tmp_path / "pipe")
|
||||
resp = client.get("/api/download", params={"path": "pipe"})
|
||||
assert resp.status_code == 404
|
||||
Reference in New Issue
Block a user