Files
open-terminal/open_terminal/cli.py
T
Timothy Jaeryang Baek 004ee891e6 security: harden API key handling and add startup warnings
- Increase auto-generated API key entropy from 192-bit to 384-bit
- Use hmac.compare_digest() for constant-time key comparison (HTTP + WS)
- Refuse to start without OPEN_TERMINAL_API_KEY configured
- Add prominent CORS wildcard warning at startup
- Bump version to 0.11.30
2026-03-25 17:15:15 -05:00

192 lines
6.3 KiB
Python

import os
import click
import uvicorn
@click.group()
def main():
"""open-terminal — terminal interaction API"""
pass
BANNER = r"""
____ _____ _ _
/ __ \ |_ _| (_) | |
| | | |_ __ ___ _ __ | | ___ _ __ _ __ ___ _ _ __ __ _| |
| | | | '_ \ / _ | '_ \ | |/ _ | '__| '_ ` _ \| | '_ \ / _` | |
| |__| | |_) | __| | | | | | __| | | | | | | | | | | | (_| | |
\____/| .__/ \___|_| |_| \_/\___|_| |_| |_| |_|_|_| |_|\__,_|_|
| |
|_|
"""
@main.command()
@click.option("--host", default=None, help="Bind host (default: 0.0.0.0)")
@click.option("--port", default=None, type=int, help="Bind port (default: 8000)")
@click.option(
"--config",
"config_path",
default=None,
type=click.Path(exists=True, dir_okay=False, resolve_path=True),
help="Path to a TOML config file (overrides user-level config location).",
)
@click.option(
"--cwd",
type=click.Path(exists=True, file_okay=False, resolve_path=True, path_type=str),
default=None,
help="Working directory for the server process.",
)
@click.option(
"--api-key",
default="",
envvar="OPEN_TERMINAL_API_KEY",
help="Bearer API key (or set OPEN_TERMINAL_API_KEY env var)",
)
@click.option(
"--cors-allowed-origins",
default="*",
envvar="OPEN_TERMINAL_CORS_ALLOWED_ORIGINS",
help="Allowed CORS origins, comma-separated (default: * for all)",
)
def run(
host: str | None,
port: int | None,
config_path: str | None,
cwd: str | None,
api_key: str,
cors_allowed_origins: str,
):
"""Start the sandbox API server."""
import secrets
from open_terminal import config
# Load config files before resolving other settings.
cfg = config.init(config_path)
# Resolve host/port: CLI flag > config file > built-in default
host = host or cfg.get("host", "0.0.0.0")
port = port if port is not None else cfg.get("port", 8000)
if cwd:
os.chdir(cwd)
# Support Docker secrets: load from _FILE variant if no key was given
if not api_key:
file_path = os.environ.get("OPEN_TERMINAL_API_KEY_FILE")
if file_path:
with open(file_path) as f:
api_key = f.read().strip()
# Fall back to config file value
if not api_key:
api_key = cfg.get("api_key", "")
generated = not api_key
if not api_key:
api_key = secrets.token_urlsafe(48)
os.environ["OPEN_TERMINAL_API_KEY"] = api_key
os.environ["OPEN_TERMINAL_CORS_ALLOWED_ORIGINS"] = cors_allowed_origins
click.echo(BANNER)
# -- Startup info block --
local_url = f"http://{'localhost' if host in ('0.0.0.0', '127.0.0.1') else host}:{port}"
click.echo(f" {click.style('Local:', bold=True)} {click.style(local_url, fg='cyan')}")
if host == "0.0.0.0":
import socket
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
network_ip = s.getsockname()[0]
s.close()
click.echo(f" {click.style('Network:', bold=True)} http://{network_ip}:{port}")
except Exception:
pass
click.echo()
if generated:
click.echo(f" {click.style('API Key:', bold=True)} {api_key}")
click.echo()
if host == "0.0.0.0":
click.echo(click.style(" Warning: Listening on all network interfaces.", fg="yellow"))
click.echo(click.style(" Use --host 127.0.0.1 to restrict to this machine.", dim=True))
click.echo()
if cors_allowed_origins.strip() == "*":
click.echo(click.style(" ┌─────────────────────────────────────────────────────────────┐", fg="yellow"))
click.echo(click.style(" │ ⚠ CORS is set to '*' (allow all origins) │", fg="yellow"))
click.echo(click.style(" │ │", fg="yellow"))
click.echo(click.style(" │ Any website can make requests to this server. │", fg="yellow"))
click.echo(click.style(" │ For production, restrict with: │", fg="yellow"))
click.echo(click.style(" │ --cors-allowed-origins https://your-domain.com │", fg="yellow"))
click.echo(click.style(" └─────────────────────────────────────────────────────────────┘", fg="yellow"))
click.echo()
from open_terminal.env import UVICORN_LOOP
uvicorn.run("open_terminal.main:app", host=host, port=port, loop=UVICORN_LOOP)
@main.command()
@click.option(
"--transport",
default="stdio",
type=click.Choice(["stdio", "streamable-http"]),
help="MCP transport (default: stdio)",
)
@click.option("--host", default=None, help="Bind host (streamable-http only)")
@click.option(
"--port", default=None, type=int, help="Bind port (streamable-http only)"
)
@click.option(
"--config",
"config_path",
default=None,
type=click.Path(exists=True, dir_okay=False, resolve_path=True),
help="Path to a TOML config file (overrides user-level config location).",
)
@click.option(
"--cwd",
type=click.Path(exists=True, file_okay=False, resolve_path=True, path_type=str),
default=None,
help="Working directory for the server process.",
)
def mcp(
transport: str,
host: str | None,
port: int | None,
config_path: str | None,
cwd: str | None,
):
"""Start the MCP server (requires 'pip install open-terminal[mcp]')."""
from open_terminal import config
cfg = config.init(config_path)
host = host or cfg.get("host", "0.0.0.0")
port = port if port is not None else cfg.get("port", 8000)
if cwd:
os.chdir(cwd)
try:
from open_terminal.mcp_server import mcp as mcp_server
except ImportError:
click.echo(
"Missing MCP dependencies. Install with:\n"
" pip install open-terminal[mcp]",
err=True,
)
raise SystemExit(1)
mcp_server.run(transport=transport, host=host, port=port)
if __name__ == "__main__":
main()