Files
open-terminal/open_terminal/main.py
T
2026-02-12 13:45:59 -06:00

136 lines
3.9 KiB
Python

import asyncio
import json
from typing import Optional
from fastapi import Depends, FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field
from open_terminal.env import API_KEY
bearer_scheme = HTTPBearer(auto_error=False)
async def verify_api_key(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
):
if not API_KEY:
return
if not credentials or credentials.credentials != API_KEY:
raise HTTPException(status_code=401, detail="Invalid API key")
app = FastAPI(
title="Open Terminal",
description="Shell command execution API with synchronous and streaming support.",
version="0.1.3",
dependencies=[Depends(verify_api_key)],
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class ExecRequest(BaseModel):
command: str = Field(
...,
description="Shell command to execute. Supports chaining (&&, ||, ;), pipes (|), and redirections.",
json_schema_extra={"examples": ["echo hello", "ls -la && whoami"]},
)
timeout: Optional[float] = Field(
30.0,
description="Max execution time in seconds. Process is killed if exceeded (exit_code: -1). Null to disable.",
ge=0,
)
class ExecResponse(BaseModel):
exit_code: int = Field(
...,
description="Process exit code. 0 = success, non-zero = error, -1 = timeout.",
)
stdout: str = Field(..., description="Captured standard output.")
stderr: str = Field(..., description="Captured standard error.")
@app.get(
"/health",
summary="Health check",
description="Returns service status. No authentication required.",
)
async def health():
return {"status": "ok"}
@app.post(
"/execute",
summary="Execute a command",
description="Run a shell command and return the result.",
response_model=ExecResponse,
responses={
401: {"description": "Invalid or missing API key."},
},
)
async def execute(
req: ExecRequest,
stream: bool = Query(
False,
description="Stream output as JSONL (application/x-ndjson) instead of waiting for completion.",
),
):
if stream:
return _stream_response(req)
try:
proc = await asyncio.create_subprocess_shell(
req.command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=req.timeout)
except asyncio.TimeoutError:
proc.kill()
await proc.communicate()
return ExecResponse(
exit_code=-1,
stdout="",
stderr=f"Command timed out after {req.timeout}s",
)
return ExecResponse(
exit_code=proc.returncode or 0,
stdout=stdout.decode(errors="replace"),
stderr=stderr.decode(errors="replace"),
)
def _stream_response(req: ExecRequest):
async def generate():
proc = await asyncio.create_subprocess_shell(
req.command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
async def read_stream(s, label):
async for line in s:
yield json.dumps(
{"type": label, "data": line.decode(errors="replace")}
) + "\n"
async for chunk in read_stream(proc.stdout, "stdout"):
yield chunk
async for chunk in read_stream(proc.stderr, "stderr"):
yield chunk
await proc.wait()
yield json.dumps({"type": "exit", "data": proc.returncode}) + "\n"
return StreamingResponse(generate(), media_type="application/x-ndjson")