feat: add remote workspaces and verified deliverables

This commit is contained in:
wuyang
2026-07-26 12:58:04 +08:00
parent 37f83eaac7
commit 0b4d799216
30 changed files with 1595 additions and 55 deletions
+55 -1
View File
@@ -1,9 +1,11 @@
import asyncio
from contextlib import asynccontextmanager
from typing import Annotated
from urllib.parse import quote
import uvicorn
from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi import Depends, FastAPI, Header, HTTPException, Query
from fastapi.responses import Response, StreamingResponse
from agent_platform.auth import UserIdentity, decode_openwebui_identity, verify_service_bearer
from agent_platform.config import Settings, get_settings
@@ -19,6 +21,7 @@ from agent_platform.gateway.schemas import (
SearchFilesRequest,
StartProcessRequest,
ToolResult,
WorkspaceListing,
WorkspaceStatus,
WriteFileRequest,
)
@@ -70,6 +73,11 @@ def create_app(
async with mutation_locks_guard:
return mutation_locks.setdefault(user_id, asyncio.Lock())
def attachment_header(filename: str) -> str:
fallback = "".join(character for character in filename if character.isascii() and character.isalnum())
fallback = fallback[:80] or "download"
return f"attachment; filename={fallback}; filename*=UTF-8''{quote(filename)}"
@app.get("/health", include_in_schema=False)
async def health() -> dict:
return {"status": "ok", "provider": settings.execution_provider}
@@ -85,6 +93,52 @@ def create_app(
except ValueError as exc:
raise translate_value_error(exc) from exc
@app.get("/v1/files", response_model=WorkspaceListing, operation_id="browse_workspace_files")
async def browse_workspace_files(
identity: Identity,
path: Annotated[str, Query(max_length=4096)] = ".",
) -> WorkspaceListing:
try:
return await executor().browse_files(identity.user_id, path)
except ValueError as exc:
raise translate_value_error(exc) from exc
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.get("/v1/files/download", operation_id="download_workspace_file")
async def download_workspace_file(
identity: Identity,
path: Annotated[str, Query(max_length=4096)],
) -> Response:
try:
download = await executor().download_file(identity.user_id, path)
except ValueError as exc:
raise translate_value_error(exc) from exc
except (FileNotFoundError, IsADirectoryError) as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return Response(
content=download.content or b"",
media_type=download.media_type,
headers={"Content-Disposition": attachment_header(download.filename)},
)
@app.get("/v1/files/archive", operation_id="archive_workspace_files")
async def archive_workspace_files(
identity: Identity,
path: Annotated[str, Query(max_length=4096)] = ".",
) -> StreamingResponse:
try:
download = await executor().archive_files(identity.user_id, path)
except ValueError as exc:
raise translate_value_error(exc) from exc
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return StreamingResponse(
download.chunks or iter(()),
media_type=download.media_type,
headers={"Content-Disposition": attachment_header(download.filename)},
)
@app.post("/v1/tools/read_file", response_model=ToolResult, operation_id="read_file")
async def read_file(body: ReadFileRequest, identity: Identity) -> ToolResult:
try:
+107 -1
View File
@@ -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 = (
+13
View File
@@ -73,6 +73,19 @@ class WorkspaceStatus(BaseModel):
state: str
class WorkspaceEntry(BaseModel):
name: str
path: str
kind: Literal["file", "directory", "symlink"]
size: int = Field(ge=0)
modified_at: int = Field(ge=0)
class WorkspaceListing(BaseModel):
path: str
entries: list[WorkspaceEntry]
class PlanItem(BaseModel):
step: str = Field(min_length=1, max_length=1000)
status: Literal["pending", "in_progress", "completed"]