This commit is contained in:
Timothy Jaeryang Baek 2026-02-12 13:45:59 -06:00
parent 3f7c544835
commit 3103f34ee7
8 changed files with 286 additions and 26 deletions

50
.github/workflows/docker.yml vendored Normal file
View file

@ -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

View file

@ -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 .

21
LICENSE Normal file
View file

@ -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.

View file

@ -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 <api-key>" \
-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 <api-key>" \
-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

View file

@ -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)

3
open_terminal/env.py Normal file
View file

@ -0,0 +1,3 @@
import os
API_KEY = os.environ.get("OPEN_TERMINAL_API_KEY", "")

View file

@ -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

View file

@ -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 = [