feat: rebuild as multi-user web agent

This commit is contained in:
wuyang
2026-07-26 06:52:12 +08:00
parent 45792c8fd5
commit 37f83eaac7
634 changed files with 5060 additions and 139619 deletions
+1
View File
@@ -0,0 +1 @@
"""Isolated workspace execution gateway."""
+185
View File
@@ -0,0 +1,185 @@
import asyncio
from contextlib import asynccontextmanager
from typing import Annotated
import uvicorn
from fastapi import Depends, FastAPI, Header, HTTPException
from agent_platform.auth import UserIdentity, decode_openwebui_identity, verify_service_bearer
from agent_platform.config import Settings, get_settings
from agent_platform.gateway.provider import ExecutionProvider, create_execution_provider
from agent_platform.gateway.schemas import (
ApplyPatchRequest,
ExecRequest,
GitDiffRequest,
GitRequest,
ListFilesRequest,
ProcessRequest,
ReadFileRequest,
SearchFilesRequest,
StartProcessRequest,
ToolResult,
WorkspaceStatus,
WriteFileRequest,
)
def create_app(
settings: Settings | None = None,
provider: ExecutionProvider | None = None,
) -> FastAPI:
settings = settings or get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
settings.validate_gateway()
app.state.provider = provider or create_execution_provider(settings)
yield
app = FastAPI(
title="K1412 Workspace Gateway",
description="Authenticated tools for one isolated workspace per user.",
version="0.1.0",
lifespan=lifespan,
)
mutation_locks: dict[str, asyncio.Lock] = {}
mutation_locks_guard = asyncio.Lock()
def require_identity(
authorization: Annotated[
str | None,
Header(alias="Authorization", include_in_schema=False),
] = None,
user_jwt: Annotated[
str | None,
Header(alias="X-OpenWebUI-User-Jwt", include_in_schema=False),
] = None,
) -> UserIdentity:
verify_service_bearer(authorization, settings.internal_gateway_key)
return decode_openwebui_identity(user_jwt, settings.openwebui_forward_jwt_secret)
Identity = Annotated[UserIdentity, Depends(require_identity)]
def executor() -> ExecutionProvider:
return app.state.provider
def translate_value_error(exc: ValueError) -> HTTPException:
return HTTPException(status_code=400, detail=str(exc))
async def mutation_lock(user_id: str) -> asyncio.Lock:
async with mutation_locks_guard:
return mutation_locks.setdefault(user_id, asyncio.Lock())
@app.get("/health", include_in_schema=False)
async def health() -> dict:
return {"status": "ok", "provider": settings.execution_provider}
@app.post("/v1/tools/workspace_status", response_model=WorkspaceStatus, operation_id="workspace_status")
async def workspace_status(identity: Identity) -> WorkspaceStatus:
return await executor().status(identity.user_id)
@app.post("/v1/tools/list_files", response_model=ToolResult, operation_id="list_files")
async def list_files(body: ListFilesRequest, identity: Identity) -> ToolResult:
try:
return await executor().list_files(identity.user_id, body.path, body.max_depth, body.limit)
except ValueError as exc:
raise translate_value_error(exc) from exc
@app.post("/v1/tools/read_file", response_model=ToolResult, operation_id="read_file")
async def read_file(body: ReadFileRequest, identity: Identity) -> ToolResult:
try:
return await executor().read_file(identity.user_id, body.path, body.start_line, body.max_lines)
except ValueError as exc:
raise translate_value_error(exc) from exc
@app.post("/v1/tools/write_file", response_model=ToolResult, operation_id="write_file")
async def write_file(body: WriteFileRequest, identity: Identity) -> ToolResult:
try:
lock = await mutation_lock(identity.user_id)
async with lock:
return await executor().write_file(identity.user_id, body.path, body.content)
except ValueError as exc:
raise translate_value_error(exc) from exc
@app.post("/v1/tools/search_files", response_model=ToolResult, operation_id="search_files")
async def search_files(body: SearchFilesRequest, identity: Identity) -> ToolResult:
try:
return await executor().search_files(
identity.user_id,
body.query,
body.path,
body.glob,
body.limit,
)
except ValueError as exc:
raise translate_value_error(exc) from exc
@app.post("/v1/tools/exec", response_model=ToolResult, operation_id="exec")
async def exec_command(body: ExecRequest, identity: Identity) -> ToolResult:
try:
lock = await mutation_lock(identity.user_id)
async with lock:
return await executor().exec(
identity.user_id,
body.command,
body.cwd,
min(body.timeout_seconds, settings.tool_timeout_seconds),
)
except ValueError as exc:
raise translate_value_error(exc) from exc
@app.post("/v1/tools/apply_patch", response_model=ToolResult, operation_id="apply_patch")
async def apply_patch(body: ApplyPatchRequest, identity: Identity) -> ToolResult:
try:
lock = await mutation_lock(identity.user_id)
async with lock:
return await executor().apply_patch(identity.user_id, body.patch, body.cwd)
except ValueError as exc:
raise translate_value_error(exc) from exc
@app.post("/v1/tools/git_status", response_model=ToolResult, operation_id="git_status")
async def git_status(body: GitRequest, identity: Identity) -> ToolResult:
try:
return await executor().exec(identity.user_id, "git status --short --branch", body.cwd, 30)
except ValueError as exc:
raise translate_value_error(exc) from exc
@app.post("/v1/tools/git_diff", response_model=ToolResult, operation_id="git_diff")
async def git_diff(body: GitDiffRequest, identity: Identity) -> ToolResult:
command = "git diff --cached" if body.staged else "git diff"
try:
return await executor().exec(identity.user_id, command, body.cwd, 30)
except ValueError as exc:
raise translate_value_error(exc) from exc
@app.post("/v1/tools/start_process", response_model=ToolResult, operation_id="start_process")
async def start_process(body: StartProcessRequest, identity: Identity) -> ToolResult:
try:
lock = await mutation_lock(identity.user_id)
async with lock:
return await executor().start_process(identity.user_id, body.command, body.cwd)
except ValueError as exc:
raise translate_value_error(exc) from exc
@app.post("/v1/tools/poll_process", response_model=ToolResult, operation_id="poll_process")
async def poll_process(body: ProcessRequest, identity: Identity) -> ToolResult:
return await executor().poll_process(identity.user_id, body.process_id)
@app.post("/v1/tools/cancel_process", response_model=ToolResult, operation_id="cancel_process")
async def cancel_process(body: ProcessRequest, identity: Identity) -> ToolResult:
lock = await mutation_lock(identity.user_id)
async with lock:
return await executor().cancel_process(identity.user_id, body.process_id)
return app
app = create_app()
def run() -> None:
uvicorn.run("agent_platform.gateway.app:app", host="0.0.0.0", port=8001) # noqa: S104
if __name__ == "__main__":
run()
+368
View File
@@ -0,0 +1,368 @@
from __future__ import annotations
import asyncio
import hashlib
import io
import json
import shlex
import tarfile
import uuid
from dataclasses import dataclass
from pathlib import PurePosixPath
from typing import Protocol
from docker.errors import ImageNotFound, NotFound
import docker
from agent_platform.config import Settings
from agent_platform.gateway.schemas import ToolResult, WorkspaceStatus
def normalize_workspace_path(raw_path: str) -> PurePosixPath:
if "\x00" in raw_path:
raise ValueError("Path contains a null byte")
raw = raw_path.strip() or "."
path = PurePosixPath(raw)
if path.is_absolute():
try:
path = path.relative_to("/workspace")
except ValueError as exc:
raise ValueError("Absolute paths must be under /workspace") from exc
if any(part in {"..", ""} for part in path.parts):
raise ValueError("Path traversal is not allowed")
return PurePosixPath(".") if str(path) in {"", "."} else path
def absolute_workspace_path(raw_path: str) -> str:
relative = normalize_workspace_path(raw_path)
return "/workspace" if relative == PurePosixPath(".") else f"/workspace/{relative}"
@dataclass(frozen=True, slots=True)
class WorkspaceRef:
workspace_id: str
container_name: str
volume_name: str
network_name: str
def workspace_ref(user_id: str) -> WorkspaceRef:
digest = hashlib.sha256(user_id.encode("utf-8")).hexdigest()[:20]
return WorkspaceRef(
workspace_id=digest,
container_name=f"k1412-ws-{digest}",
volume_name=f"k1412-ws-data-{digest}",
network_name=f"k1412-ws-net-{digest}",
)
class ExecutionProvider(Protocol):
provider_name: str
async def status(self, user_id: str) -> WorkspaceStatus: ...
async def exec(self, user_id: str, command: str, cwd: str, timeout_seconds: int) -> ToolResult: ...
async def list_files(self, user_id: str, path: str, max_depth: int, limit: int) -> ToolResult: ...
async def read_file(self, user_id: str, path: str, start_line: int, max_lines: int) -> ToolResult: ...
async def write_file(self, user_id: str, path: str, content: str) -> ToolResult: ...
async def search_files(
self,
user_id: str,
query: str,
path: str,
glob: str | None,
limit: int,
) -> ToolResult: ...
async def apply_patch(self, user_id: str, patch: str, cwd: str) -> ToolResult: ...
async def start_process(self, user_id: str, command: str, cwd: str) -> ToolResult: ...
async def poll_process(self, user_id: str, process_id: str) -> ToolResult: ...
async def cancel_process(self, user_id: str, process_id: str) -> ToolResult: ...
class DockerExecutionProvider:
provider_name = "local-docker"
def __init__(self, settings: Settings, client: docker.DockerClient | None = None) -> None:
self.settings = settings
self.client = client or docker.from_env()
self._locks: dict[str, asyncio.Lock] = {}
self._lock_guard = asyncio.Lock()
async def _user_lock(self, user_id: str) -> asyncio.Lock:
async with self._lock_guard:
return self._locks.setdefault(user_id, asyncio.Lock())
async def _container(self, user_id: str):
ref = workspace_ref(user_id)
def ensure():
try:
container = self.client.containers.get(ref.container_name)
except NotFound:
try:
self.client.images.get(self.settings.workspace_image)
except ImageNotFound as exc:
raise RuntimeError(f"Workspace image {self.settings.workspace_image!r} is not installed") from exc
if self.settings.workspace_network_enabled:
try:
self.client.networks.get(ref.network_name)
except NotFound:
self.client.networks.create(
ref.network_name,
driver="bridge",
check_duplicate=True,
labels={
"app.k1412.component": "user-workspace-network",
"app.k1412.workspace-id": ref.workspace_id,
},
)
container = self.client.containers.create(
image=self.settings.workspace_image,
name=ref.container_name,
hostname=f"workspace-{ref.workspace_id}",
command=["sleep", "infinity"],
user="1000:1000",
working_dir="/workspace",
volumes={ref.volume_name: {"bind": "/workspace", "mode": "rw"}},
labels={
"app.k1412.component": "user-workspace",
"app.k1412.workspace-id": ref.workspace_id,
},
detach=True,
read_only=True,
tmpfs={
"/tmp": "rw,noexec,nosuid,size=256m", # noqa: S108 - isolated container tmpfs
"/run": "rw,noexec,nosuid,size=16m",
},
cap_drop=["ALL"],
security_opt=["no-new-privileges:true"],
mem_limit=self.settings.workspace_memory_limit,
nano_cpus=int(self.settings.workspace_cpu_limit * 1_000_000_000),
pids_limit=self.settings.workspace_pids_limit,
network_mode=ref.network_name if self.settings.workspace_network_enabled else "none",
)
container.reload()
if container.status != "running":
container.start()
container.reload()
return container
lock = await self._user_lock(user_id)
async with lock:
return await asyncio.to_thread(ensure)
async def status(self, user_id: str) -> WorkspaceStatus:
container = await self._container(user_id)
ref = workspace_ref(user_id)
return WorkspaceStatus(
workspace_id=ref.workspace_id,
provider=self.provider_name,
container_name=ref.container_name,
state=container.status,
)
async def _exec_argv(
self,
user_id: str,
argv: list[str],
*,
cwd: str = ".",
max_output: int = 128_000,
) -> ToolResult:
container = await self._container(user_id)
workdir = absolute_workspace_path(cwd)
def execute() -> ToolResult:
result = container.exec_run(
argv,
workdir=workdir,
user="1000:1000",
demux=True,
)
stdout, stderr = result.output if isinstance(result.output, tuple) else (result.output, b"")
output = ((stdout or b"") + (stderr or b"")).decode("utf-8", errors="replace")
truncated = len(output) > max_output
if truncated:
output = output[:max_output] + "\n… output truncated …"
return ToolResult(
ok=result.exit_code == 0,
output=output,
exit_code=result.exit_code,
truncated=truncated,
)
return await asyncio.to_thread(execute)
async def exec(self, user_id: str, command: str, cwd: str, timeout_seconds: int) -> ToolResult:
normalize_workspace_path(cwd)
bounded_timeout = max(1, min(timeout_seconds, 1800))
shell = f"timeout --signal=TERM {bounded_timeout}s sh -lc {shlex.quote(command)}"
return await self._exec_argv(user_id, ["sh", "-lc", shell], cwd=cwd)
async def list_files(self, user_id: str, path: str, max_depth: int, limit: int) -> ToolResult:
absolute = absolute_workspace_path(path)
script = (
"import os,sys\n"
"root=sys.argv[1]; max_depth=int(sys.argv[2]); limit=int(sys.argv[3]); out=[]\n"
"base_depth=root.rstrip('/').count('/')\n"
"for current, dirs, files in os.walk(root):\n"
" depth=current.rstrip('/').count('/')-base_depth\n"
" dirs[:]=sorted(d for d in dirs if d not in {'.git','node_modules','.venv','__pycache__'})\n"
" if depth>=max_depth: dirs[:]=[]\n"
" rel=os.path.relpath(current,'/workspace')\n"
" for name in sorted(dirs): out.append((os.path.join(rel,name) if rel!='.' else name)+'/')\n"
" for name in sorted(files): out.append(os.path.join(rel,name) if rel!='.' else name)\n"
" if len(out)>=limit: break\n"
"print('\\n'.join(out[:limit]))\n"
)
return await self._exec_argv(
user_id,
["python3", "-c", script, absolute, str(max_depth), str(limit)],
)
async def read_file(self, user_id: str, path: str, start_line: int, max_lines: int) -> ToolResult:
absolute = absolute_workspace_path(path)
script = (
"import pathlib,sys\n"
"p=pathlib.Path(sys.argv[1]); start=int(sys.argv[2]); count=int(sys.argv[3])\n"
"if not p.is_file(): raise SystemExit(f'Not a file: {p}')\n"
"with p.open('r',encoding='utf-8',errors='replace') as f:\n"
" lines=f.readlines()\n"
"for i,line in enumerate(lines[start-1:start-1+count],start): print(f'{i:>6} {line}',end='')\n"
)
return await self._exec_argv(
user_id,
["python3", "-c", script, absolute, str(start_line), str(max_lines)],
)
async def write_file(self, user_id: str, path: str, content: str) -> ToolResult:
relative = normalize_workspace_path(path)
if relative == PurePosixPath("."):
raise ValueError("A file path is required")
parent = str(relative.parent)
container = await self._container(user_id)
if parent not in {"", "."}:
await self._exec_argv(user_id, ["mkdir", "-p", absolute_workspace_path(parent)])
archive = io.BytesIO()
encoded = content.encode("utf-8")
with tarfile.open(fileobj=archive, mode="w") as tar:
info = tarfile.TarInfo(name=relative.name)
info.size = len(encoded)
info.mode = 0o644
info.uid = 1000
info.gid = 1000
tar.addfile(info, io.BytesIO(encoded))
archive.seek(0)
destination = "/workspace" if parent in {"", "."} else absolute_workspace_path(parent)
await asyncio.to_thread(container.put_archive, destination, archive.getvalue())
return ToolResult(ok=True, output=f"Wrote {len(encoded)} bytes to {relative}")
async def search_files(
self,
user_id: str,
query: str,
path: str,
glob: str | None,
limit: int,
) -> ToolResult:
absolute = absolute_workspace_path(path)
argv = ["rg", "--line-number", "--color=never", "--max-count", str(limit), "--", query, absolute]
if glob:
argv[1:1] = ["--glob", glob]
result = await self._exec_argv(user_id, argv)
if result.exit_code == 1:
return ToolResult(ok=True, output="No matches.", exit_code=0)
return result
async def apply_patch(self, user_id: str, patch: str, cwd: str) -> ToolResult:
process_id = uuid.uuid4().hex
patch_path = f".agent/tmp/{process_id}.patch"
await self.write_file(user_id, patch_path, patch)
result = await self.exec(
user_id,
f"git apply --whitespace=nowarn {shlex.quote(absolute_workspace_path(patch_path))}",
cwd,
120,
)
await self._exec_argv(user_id, ["rm", "-f", absolute_workspace_path(patch_path)])
return result
async def start_process(self, user_id: str, command: str, cwd: str) -> ToolResult:
normalize_workspace_path(cwd)
process_id = uuid.uuid4().hex
process_dir = absolute_workspace_path(f".agent/processes/{process_id}")
worker = (
f"sh -lc {shlex.quote(command)}; "
"code=$?; "
f'echo "$code" > {shlex.quote(process_dir + "/exit_code")}; '
'exit "$code"'
)
wrapped = (
f"mkdir -p {shlex.quote(process_dir)}; "
f"setsid sh -lc {shlex.quote(worker)} "
f"> {shlex.quote(process_dir + '/output.log')} 2>&1 & "
f'pid=$!; echo "$pid" > {shlex.quote(process_dir + "/pid")}; '
f"echo {shlex.quote(json.dumps({'process_id': process_id}))}"
)
result = await self._exec_argv(user_id, ["sh", "-lc", wrapped], cwd=cwd)
result.metadata = {"process_id": process_id}
return result
async def poll_process(self, user_id: str, process_id: str) -> ToolResult:
process_dir = absolute_workspace_path(f".agent/processes/{process_id}")
script = (
"import json,os,pathlib,sys\n"
"d=pathlib.Path(sys.argv[1]); pid=(d/'pid').read_text().strip() if (d/'pid').exists() else ''\n"
"code=(d/'exit_code').read_text().strip() if (d/'exit_code').exists() else None\n"
"log=(d/'output.log').read_text(errors='replace')[-50000:] if (d/'output.log').exists() else ''\n"
"print(json.dumps({'running': code is None and bool(pid), 'pid': pid, 'exit_code': code, 'output': log}))\n"
)
raw = await self._exec_argv(user_id, ["python3", "-c", script, process_dir])
if not raw.ok:
return raw
try:
data = json.loads(raw.output)
except json.JSONDecodeError:
return ToolResult(ok=False, output="Invalid process state", exit_code=1)
exit_code = int(data["exit_code"]) if data["exit_code"] is not None else None
return ToolResult(
ok=exit_code in {None, 0},
output=data["output"],
exit_code=exit_code,
metadata={"process_id": process_id, "running": data["running"], "pid": data["pid"]},
)
async def cancel_process(self, user_id: str, process_id: str) -> ToolResult:
process_dir = absolute_workspace_path(f".agent/processes/{process_id}")
command = (
f"pid=$(cat {shlex.quote(process_dir + '/pid')} 2>/dev/null) || exit 1; "
'kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true; '
f"echo 143 > {shlex.quote(process_dir + '/exit_code')}"
)
return await self._exec_argv(user_id, ["sh", "-lc", command])
class SSHDockerExecutionProvider(DockerExecutionProvider):
provider_name = "ssh-docker"
def __init__(self, settings: Settings) -> None:
client = docker.DockerClient(
base_url=f"ssh://{settings.workspace_ssh_host}",
use_ssh_client=True,
)
super().__init__(settings, client=client)
def create_execution_provider(settings: Settings) -> ExecutionProvider:
if settings.execution_provider == "ssh-docker":
return SSHDockerExecutionProvider(settings)
return DockerExecutionProvider(settings)
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field, field_validator
class PathRequest(BaseModel):
path: str = Field(default=".", max_length=4096, description="Path relative to /workspace.")
class ListFilesRequest(PathRequest):
max_depth: int = Field(default=4, ge=1, le=12)
limit: int = Field(default=500, ge=1, le=5000)
class ReadFileRequest(PathRequest):
start_line: int = Field(default=1, ge=1)
max_lines: int = Field(default=1000, ge=1, le=5000)
class WriteFileRequest(PathRequest):
content: str = Field(max_length=2_000_000, description="Complete UTF-8 file content.")
class SearchFilesRequest(BaseModel):
query: str = Field(min_length=1, max_length=500)
path: str = Field(default=".", description="Directory relative to /workspace.")
glob: str | None = Field(default=None, max_length=200)
limit: int = Field(default=200, ge=1, le=2000)
class ExecRequest(BaseModel):
command: str = Field(min_length=1, max_length=32_000)
cwd: str = Field(default=".", max_length=4096, description="Working directory relative to /workspace.")
timeout_seconds: int = Field(default=120, ge=1, le=1800)
class ApplyPatchRequest(BaseModel):
patch: str = Field(min_length=1, max_length=512_000)
cwd: str = Field(default=".", max_length=4096, description="Repository directory relative to /workspace.")
class GitRequest(BaseModel):
cwd: str = Field(default=".", max_length=4096, description="Repository directory relative to /workspace.")
class GitDiffRequest(GitRequest):
staged: bool = False
class StartProcessRequest(BaseModel):
command: str = Field(min_length=1, max_length=32_000)
cwd: str = Field(default=".", max_length=4096, description="Working directory relative to /workspace.")
class ProcessRequest(BaseModel):
process_id: str = Field(pattern=r"^[a-f0-9]{32}$")
class ToolResult(BaseModel):
ok: bool
output: str = ""
exit_code: int | None = None
truncated: bool = False
metadata: dict = Field(default_factory=dict)
class WorkspaceStatus(BaseModel):
workspace_id: str
provider: Literal["local-docker", "ssh-docker"]
container_name: str
state: str
class PlanItem(BaseModel):
step: str = Field(min_length=1, max_length=1000)
status: Literal["pending", "in_progress", "completed"]
class UpdatePlanRequest(BaseModel):
explanation: str | None = Field(default=None, max_length=4000)
items: list[PlanItem] = Field(min_length=1, max_length=50)
@field_validator("items")
@classmethod
def one_in_progress(cls, value: list[PlanItem]) -> list[PlanItem]:
if sum(item.status == "in_progress" for item in value) > 1:
raise ValueError("At most one plan item may be in progress")
return value