feat: add remote workspaces and verified deliverables
This commit is contained in:
@@ -4,9 +4,12 @@ import asyncio
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import mimetypes
|
||||
import shlex
|
||||
import tarfile
|
||||
import uuid
|
||||
import zlib
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Protocol
|
||||
@@ -15,7 +18,7 @@ from docker.errors import ImageNotFound, NotFound
|
||||
|
||||
import docker
|
||||
from agent_platform.config import Settings
|
||||
from agent_platform.gateway.schemas import ToolResult, WorkspaceStatus
|
||||
from agent_platform.gateway.schemas import ToolResult, WorkspaceListing, WorkspaceStatus
|
||||
|
||||
|
||||
def normalize_workspace_path(raw_path: str) -> PurePosixPath:
|
||||
@@ -46,6 +49,14 @@ class WorkspaceRef:
|
||||
network_name: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WorkspaceDownload:
|
||||
filename: str
|
||||
media_type: str
|
||||
content: bytes | None = None
|
||||
chunks: Iterator[bytes] | None = None
|
||||
|
||||
|
||||
def workspace_ref(user_id: str) -> WorkspaceRef:
|
||||
digest = hashlib.sha256(user_id.encode("utf-8")).hexdigest()[:20]
|
||||
return WorkspaceRef(
|
||||
@@ -86,6 +97,12 @@ class ExecutionProvider(Protocol):
|
||||
|
||||
async def cancel_process(self, user_id: str, process_id: str) -> ToolResult: ...
|
||||
|
||||
async def browse_files(self, user_id: str, path: str) -> WorkspaceListing: ...
|
||||
|
||||
async def download_file(self, user_id: str, path: str) -> WorkspaceDownload: ...
|
||||
|
||||
async def archive_files(self, user_id: str, path: str) -> WorkspaceDownload: ...
|
||||
|
||||
|
||||
class DockerExecutionProvider:
|
||||
provider_name = "local-docker"
|
||||
@@ -228,6 +245,95 @@ class DockerExecutionProvider:
|
||||
["python3", "-c", script, absolute, str(max_depth), str(limit)],
|
||||
)
|
||||
|
||||
async def browse_files(self, user_id: str, path: str) -> WorkspaceListing:
|
||||
relative = normalize_workspace_path(path)
|
||||
absolute = absolute_workspace_path(path)
|
||||
script = (
|
||||
"import json,os,pathlib,sys\n"
|
||||
"root=pathlib.Path(sys.argv[1])\n"
|
||||
"if not root.is_dir(): raise SystemExit(f'Not a directory: {root}')\n"
|
||||
"items=[]\n"
|
||||
"for item in root.iterdir():\n"
|
||||
" if item.name=='.agent': continue\n"
|
||||
" try: info=item.lstat()\n"
|
||||
" except OSError: continue\n"
|
||||
" kind='symlink' if item.is_symlink() else ('directory' if item.is_dir() else 'file')\n"
|
||||
" rel=item.relative_to('/workspace').as_posix()\n"
|
||||
" items.append({'name':item.name,'path':rel,'kind':kind,'size':info.st_size,"
|
||||
"'modified_at':int(info.st_mtime)})\n"
|
||||
"items.sort(key=lambda value:(value['kind']!='directory',value['name'].casefold()))\n"
|
||||
"print(json.dumps(items,ensure_ascii=False))\n"
|
||||
)
|
||||
raw = await self._exec_argv(user_id, ["python3", "-c", script, absolute])
|
||||
if not raw.ok:
|
||||
raise FileNotFoundError(raw.output.strip() or f"Directory not found: {relative}")
|
||||
try:
|
||||
entries = json.loads(raw.output)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError("Workspace returned an invalid directory listing") from exc
|
||||
return WorkspaceListing(path="." if relative == PurePosixPath(".") else str(relative), entries=entries)
|
||||
|
||||
async def download_file(self, user_id: str, path: str) -> WorkspaceDownload:
|
||||
relative = normalize_workspace_path(path)
|
||||
if relative == PurePosixPath("."):
|
||||
raise ValueError("A file path is required")
|
||||
container = await self._container(user_id)
|
||||
absolute = absolute_workspace_path(path)
|
||||
|
||||
def collect() -> WorkspaceDownload:
|
||||
chunks, metadata = container.get_archive(absolute)
|
||||
size = int(metadata.get("size", 0))
|
||||
if size > self.settings.workspace_download_max_bytes:
|
||||
raise ValueError(
|
||||
f"File exceeds the {self.settings.workspace_download_max_bytes // (1024 * 1024)} MB download limit"
|
||||
)
|
||||
archive = io.BytesIO()
|
||||
archive_limit = self.settings.workspace_download_max_bytes + 2 * 1024 * 1024
|
||||
for chunk in chunks:
|
||||
archive.write(chunk)
|
||||
if archive.tell() > archive_limit:
|
||||
raise ValueError("File archive exceeded the download limit")
|
||||
archive.seek(0)
|
||||
with tarfile.open(fileobj=archive, mode="r:*") as tar:
|
||||
members = tar.getmembers()
|
||||
member = members[0] if members else None
|
||||
if member is None:
|
||||
raise FileNotFoundError(f"File not found: {relative}")
|
||||
if not member.isfile():
|
||||
raise IsADirectoryError(f"Not a regular file: {relative}")
|
||||
source = tar.extractfile(member)
|
||||
if source is None:
|
||||
raise FileNotFoundError(f"File not found: {relative}")
|
||||
content = source.read(self.settings.workspace_download_max_bytes + 1)
|
||||
if len(content) > self.settings.workspace_download_max_bytes:
|
||||
raise ValueError("File exceeds the download limit")
|
||||
media_type = mimetypes.guess_type(relative.name)[0] or "application/octet-stream"
|
||||
return WorkspaceDownload(filename=relative.name, media_type=media_type, content=content)
|
||||
|
||||
return await asyncio.to_thread(collect)
|
||||
|
||||
async def archive_files(self, user_id: str, path: str) -> WorkspaceDownload:
|
||||
relative = normalize_workspace_path(path)
|
||||
container = await self._container(user_id)
|
||||
chunks, _ = await asyncio.to_thread(container.get_archive, absolute_workspace_path(path))
|
||||
|
||||
def compressed_chunks() -> Iterator[bytes]:
|
||||
compressor = zlib.compressobj(level=6, method=zlib.DEFLATED, wbits=31)
|
||||
for chunk in chunks:
|
||||
compressed = compressor.compress(chunk)
|
||||
if compressed:
|
||||
yield compressed
|
||||
final = compressor.flush()
|
||||
if final:
|
||||
yield final
|
||||
|
||||
basename = "workspace" if relative == PurePosixPath(".") else relative.name
|
||||
return WorkspaceDownload(
|
||||
filename=f"{basename}.tar.gz",
|
||||
media_type="application/gzip",
|
||||
chunks=compressed_chunks(),
|
||||
)
|
||||
|
||||
async def read_file(self, user_id: str, path: str, start_line: int, max_lines: int) -> ToolResult:
|
||||
absolute = absolute_workspace_path(path)
|
||||
script = (
|
||||
|
||||
Reference in New Issue
Block a user