feat: add TOML configuration file support

Load settings from /etc/open-terminal/config.toml (system) and
$XDG_CONFIG_HOME/open-terminal/config.toml (user). Supports host, port,
api_key, cors_allowed_origins, log_dir, and binary_mime_prefixes.

CLI flags and environment variables still take precedence. A --config
flag allows pointing to a custom config file.

Closes #11
This commit is contained in:
Timothy Jaeryang Baek committed 2026-03-02 12:52:33 -06:00
1 parent c8d3285abb
commit f94fa3cec6
6 files changed
+203 -16

No files matched your search

+6
View File
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.6.0] - 2026-03-02
### Added
- 📄 **Configuration file support** — settings can now be loaded from TOML config files at /etc/open-terminal/config.toml (system-wide) and $XDG_CONFIG_HOME/open-terminal/config.toml (per-user, defaults to ~/.config/open-terminal/config.toml). Supports host, port, api_key, cors_allowed_origins, log_dir, and binary_mime_prefixes. CLI flags and environment variables still take precedence. Use --config to point to a custom config file. This keeps the API key out of ps / htop output.
## [0.5.0] - 2026-03-02
### Changed
+30
View File
@@ -50,6 +50,36 @@ open-terminal run --host 0.0.0.0 --port 8000 --api-key your-secret-key
> On bare metal, commands run directly on your machine with your user's permissions. Use Docker if you want sandboxed execution.
## Configuration
Open Terminal can be configured via a TOML config file, environment variables, and CLI flags. Settings are resolved in this order (highest priority wins):
1. **CLI flags** (`--host`, `--port`, `--api-key`, etc.)
2. **Environment variables** (`OPEN_TERMINAL_API_KEY`, etc.)
3. **User config** — `$XDG_CONFIG_HOME/open-terminal/config.toml` (defaults to `~/.config/open-terminal/config.toml`)
4. **System config** — `/etc/open-terminal/config.toml`
5. **Built-in defaults**
Create a config file at either location with any of these keys (all optional):
```toml
host = "0.0.0.0"
port = 8000
api_key = "sk-my-secret-key"
cors_allowed_origins = "*"
log_dir = "/var/log/open-terminal"
binary_mime_prefixes = "image,audio"
```
> [!TIP]
> Use the system config at `/etc/open-terminal/config.toml` to set site-wide defaults for host and port, and the user config for personal settings like the API key — this keeps the key out of `ps` / `htop`.
You can also point to a specific config file:
```bash
open-terminal run --config /path/to/my-config.toml
```
## Using with Open WebUI
Open Terminal integrates with [Open WebUI](https://github.com/open-webui/open-webui), giving your AI assistants the ability to run commands, manage files, and interact with a terminal right from the AI interface. Make sure to add it under **Open Terminal** in the integrations settings, not as a tool server. Adding it as an Open Terminal connection gives you a built-in file navigation sidebar where you can browse directories, upload, download, and edit files. There are two ways to connect:
+55 -6
View File
@@ -23,8 +23,15 @@ 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")
@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),
@@ -43,10 +50,26 @@ BANNER = r"""
envvar="OPEN_TERMINAL_CORS_ALLOWED_ORIGINS",
help="Allowed CORS origins, comma-separated (default: * for all)",
)
def run(host: str, port: int, cwd: str | None, api_key: str, cors_allowed_origins: str):
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)
@@ -57,6 +80,10 @@ def run(host: str, port: int, cwd: str | None, api_key: str, cors_allowed_origin
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(24)
@@ -81,16 +108,38 @@ def run(host: str, port: int, cwd: str | None, api_key: str, cors_allowed_origin
type=click.Choice(["stdio", "streamable-http"]),
help="MCP transport (default: stdio)",
)
@click.option("--host", default="0.0.0.0", help="Bind host (streamable-http only)")
@click.option("--port", default=8000, type=int, help="Bind port (streamable-http only)")
@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, port: int, cwd: str | None):
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)
+91
View File
@@ -0,0 +1,91 @@
"""TOML configuration file support.
Settings are resolved with this precedence (highest wins):
1. CLI flags
2. Environment variables / Docker-secrets ``_FILE`` variants
3. User config — ``$XDG_CONFIG_HOME/open-terminal/config.toml``
(defaults to ``~/.config/open-terminal/config.toml``)
4. System config — ``/etc/open-terminal/config.toml``
5. Built-in defaults
"""
import os
import sys
import tomllib
from pathlib import Path
def _default_user_config_path() -> Path:
"""Return the XDG-compliant user config file path."""
xdg = os.environ.get("XDG_CONFIG_HOME") or os.path.join(
os.path.expanduser("~"), ".config"
)
return Path(xdg) / "open-terminal" / "config.toml"
_SYSTEM_CONFIG_PATH = Path("/etc/open-terminal/config.toml")
def load_config(explicit_path: str | None = None) -> dict:
"""Load and merge TOML configuration files.
Parameters
----------
explicit_path:
If given, this file replaces the *user-level* lookup. The
system-level config is still loaded underneath.
Returns
-------
dict
Merged configuration dictionary. System values are overridden
by user (or explicit) values.
"""
merged: dict = {}
# 1. System config (lowest priority of the two files)
if _SYSTEM_CONFIG_PATH.is_file():
try:
merged.update(tomllib.loads(_SYSTEM_CONFIG_PATH.read_text("utf-8")))
except Exception as exc:
print(
f"Warning: failed to read {_SYSTEM_CONFIG_PATH}: {exc}",
file=sys.stderr,
)
# 2. User / explicit config (overrides system)
user_path = Path(explicit_path) if explicit_path else _default_user_config_path()
if user_path.is_file():
try:
merged.update(tomllib.loads(user_path.read_text("utf-8")))
except Exception as exc:
# If the user explicitly asked for this file, treat errors as fatal.
if explicit_path:
raise SystemExit(f"Error: failed to read {user_path}: {exc}") from exc
print(
f"Warning: failed to read {user_path}: {exc}",
file=sys.stderr,
)
return merged
# Module-level merged config, lazily populated by ``init()``.
_config: dict = {}
def init(explicit_path: str | None = None) -> dict:
"""Load config files and cache the result module-wide.
This should be called once, early in startup (e.g. from the CLI
entry-point), *before* ``env.py`` constants are evaluated.
"""
global _config
_config = load_config(explicit_path)
return _config
def get(key: str, default=None):
"""Look up a value from the loaded config."""
return _config.get(key, default)
+20 -9
View File
@@ -1,5 +1,7 @@
import os
from open_terminal import config
def _resolve_file_env(var: str, default: str = "") -> str:
"""Resolve an environment variable with Docker-secrets ``_FILE`` support.
@@ -26,17 +28,23 @@ def _resolve_file_env(var: str, default: str = "") -> str:
return value or default
API_KEY = _resolve_file_env("OPEN_TERMINAL_API_KEY")
CORS_ALLOWED_ORIGINS = os.environ.get("OPEN_TERMINAL_CORS_ALLOWED_ORIGINS", "*")
API_KEY = _resolve_file_env("OPEN_TERMINAL_API_KEY", config.get("api_key", ""))
CORS_ALLOWED_ORIGINS = os.environ.get(
"OPEN_TERMINAL_CORS_ALLOWED_ORIGINS",
config.get("cors_allowed_origins", "*"),
)
LOG_DIR = os.environ.get(
"OPEN_TERMINAL_LOG_DIR",
os.path.join(
os.environ.get(
"XDG_STATE_HOME",
os.path.join(os.path.expanduser("~"), ".local", "state"),
config.get(
"log_dir",
os.path.join(
os.environ.get(
"XDG_STATE_HOME",
os.path.join(os.path.expanduser("~"), ".local", "state"),
),
"open-terminal",
"logs",
),
"open-terminal",
"logs",
),
)
@@ -44,6 +52,9 @@ LOG_DIR = os.environ.get(
# as raw binary responses (e.g. "image,audio" or "image/png,image/jpeg").
BINARY_FILE_MIME_PREFIXES = [
p.strip()
for p in os.environ.get("OPEN_TERMINAL_BINARY_MIME_PREFIXES", "image").split(",")
for p in os.environ.get(
"OPEN_TERMINAL_BINARY_MIME_PREFIXES",
config.get("binary_mime_prefixes", "image"),
).split(",")
if p.strip()
]
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "open-terminal"
version = "0.5.0"
version = "0.6.0"
description = "A remote terminal API."
readme = "README.md"
authors = [