240 lines
9.7 KiB
Python
240 lines
9.7 KiB
Python
import asyncio
|
|
from contextlib import asynccontextmanager
|
|
from typing import Annotated
|
|
from urllib.parse import quote
|
|
|
|
import uvicorn
|
|
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
|
|
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,
|
|
WorkspaceListing,
|
|
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())
|
|
|
|
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}
|
|
|
|
@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.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:
|
|
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()
|