From 3103f34ee7699c03ec7a4c23cf41b7482bb44399 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 12 Feb 2026 13:45:59 -0600 Subject: [PATCH] refac --- .github/workflows/docker.yml | 50 +++++++++++++++++++++ Dockerfile | 41 +++++++++++++---- LICENSE | 21 +++++++++ README.md | 72 +++++++++++++++++++++++++++++ open_terminal/cli.py | 36 ++++++++++++++- open_terminal/env.py | 3 ++ open_terminal/main.py | 87 +++++++++++++++++++++++++++++------- pyproject.toml | 2 +- 8 files changed, 286 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/docker.yml create mode 100644 LICENSE create mode 100644 open_terminal/env.py diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..1d5e8ef --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,50 @@ +name: Build Docker Image + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + if: github.event_name != 'pull_request' + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/metadata-action@v5 + id: meta + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + + - uses: docker/build-push-action@v5 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index 15838aa..8e4952e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,41 @@ -FROM python:3.12-slim +FROM python:3.12 RUN apt-get update && apt-get install -y --no-install-recommends \ - curl \ - wget \ + # Core utilities + coreutils findutils grep sed gawk diffutils patch \ + less file tree bc man-db \ + # Networking + curl wget net-tools iputils-ping dnsutils netcat-openbsd socat telnet \ + openssh-client rsync \ + # Editors + vim nano \ + # Version control git \ - jq \ - vim-tiny \ - build-essential \ - ca-certificates \ - openssh-client \ - unzip \ + # Build tools + build-essential cmake make \ + # Scripting & languages + perl ruby-full lua5.4 \ + # Data processing + jq xmlstarlet sqlite3 \ + # Compression + zip unzip tar gzip bzip2 xz-utils zstd p7zip-full \ + # System + procps htop lsof strace sysstat \ + sudo tmux screen \ + ca-certificates gnupg apt-transport-https \ && rm -rf /var/lib/apt/lists/* WORKDIR /app + +RUN pip install --no-cache-dir \ + numpy pandas scipy scikit-learn \ + matplotlib seaborn plotly \ + jupyter ipython \ + requests beautifulsoup4 lxml \ + sqlalchemy psycopg2-binary \ + pyyaml toml jsonlines \ + tqdm rich + COPY . . RUN pip install --no-cache-dir . diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..16464a5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Open WebUI Inc. (Timothy Jaeryang Baek) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e69de29..1a35a96 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,72 @@ +# ⬛ Open Terminal + +A lightweight API for remote command execution with streaming support. + +## Quick Start + +### Run Locally + +```bash +pip install . +open-terminal run +``` + +### Docker + +```bash +docker build -t open-terminal . +docker run -p 8000:8000 open-terminal +``` + +An API key is auto-generated on startup if not provided. To set your own: + +```bash +# via CLI flag +open-terminal run --api-key my-secret + +# via environment variable +docker run -p 8000:8000 -e OPEN_TERMINAL_API_KEY=my-secret open-terminal +``` + +## API + +### `GET /health` + +Health check — no auth required. + +### `POST /execute` + +Execute a command and return the result. + +```bash +curl -X POST http://localhost:8000/execute \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"command": "echo hello", "timeout": 30}' +``` + +```json +{"exit_code": 0, "stdout": "hello\n", "stderr": ""} +``` + +### `POST /execute?stream=true` + +Stream output as JSONL (`application/x-ndjson`): + +```bash +curl -X POST "http://localhost:8000/execute?stream=true" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"command": "for i in 1 2 3; do echo $i; sleep 1; done"}' +``` + +```jsonl +{"type": "stdout", "data": "1\n"} +{"type": "stdout", "data": "2\n"} +{"type": "stdout", "data": "3\n"} +{"type": "exit", "data": 0} +``` + +## License + +MIT diff --git a/open_terminal/cli.py b/open_terminal/cli.py index 0304e73..7c1948a 100644 --- a/open_terminal/cli.py +++ b/open_terminal/cli.py @@ -8,11 +8,45 @@ def main(): pass +BANNER = r""" + ____ _____ _ _ + / __ \ |_ _| (_) | | + | | | |_ __ ___ _ __ | | ___ _ __ _ __ ___ _ _ __ __ _| | + | | | | '_ \ / _ | '_ \ | |/ _ | '__| '_ ` _ \| | '_ \ / _` | | + | |__| | |_) | __| | | | | | __| | | | | | | | | | | | (_| | | + \____/| .__/ \___|_| |_| \_/\___|_| |_| |_| |_|_|_| |_|\__,_|_| + | | + |_| +""" + + @main.command() @click.option("--host", default="0.0.0.0", help="Bind host") @click.option("--port", default=8000, type=int, help="Bind port") -def run(host: str, port: int): +@click.option( + "--api-key", + default="", + envvar="OPEN_TERMINAL_API_KEY", + help="Bearer API key (or set OPEN_TERMINAL_API_KEY env var)", +) +def run(host: str, port: int, api_key: str): """Start the sandbox API server.""" + import os + import secrets + + generated = not api_key + if not api_key: + api_key = secrets.token_urlsafe(24) + + os.environ["OPEN_TERMINAL_API_KEY"] = api_key + + click.echo(BANNER) + if generated: + click.echo("=" * 60) + click.echo(f" API Key: {api_key}") + click.echo("=" * 60) + click.echo() + uvicorn.run("open_terminal.main:app", host=host, port=port) diff --git a/open_terminal/env.py b/open_terminal/env.py new file mode 100644 index 0000000..f45f561 --- /dev/null +++ b/open_terminal/env.py @@ -0,0 +1,3 @@ +import os + +API_KEY = os.environ.get("OPEN_TERMINAL_API_KEY", "") diff --git a/open_terminal/main.py b/open_terminal/main.py index 758c051..8ce973a 100644 --- a/open_terminal/main.py +++ b/open_terminal/main.py @@ -2,31 +2,88 @@ import asyncio import json from typing import Optional -from fastapi import FastAPI +from fastapi import Depends, FastAPI, HTTPException, Query +from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse -from pydantic import BaseModel +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from pydantic import BaseModel, Field -app = FastAPI(title="open-terminal") +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 - timeout: Optional[float] = 30.0 + 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 - stdout: str - stderr: str + 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") +@app.get( + "/health", + summary="Health check", + description="Returns service status. No authentication required.", +) async def health(): return {"status": "ok"} -@app.post("/execute") -async def execute(req: ExecRequest, stream: bool = False): +@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) @@ -36,9 +93,7 @@ async def execute(req: ExecRequest, stream: bool = False): stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) - stdout, stderr = await asyncio.wait_for( - proc.communicate(), timeout=req.timeout - ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=req.timeout) except asyncio.TimeoutError: proc.kill() await proc.communicate() @@ -65,7 +120,9 @@ def _stream_response(req: ExecRequest): async def read_stream(s, label): async for line in s: - yield json.dumps({"type": label, "data": line.decode(errors="replace")}) + "\n" + yield json.dumps( + {"type": label, "data": line.decode(errors="replace")} + ) + "\n" async for chunk in read_stream(proc.stdout, "stdout"): yield chunk diff --git a/pyproject.toml b/pyproject.toml index 5690433..25159e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "open-terminal" -version = "0.1.0" +version = "0.1.3" description = "A barebones terminal interaction API" readme = "README.md" authors = [