From 5c8611fba64d65f6e74dc4f4fb30256e89bbcaa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o?= Date: Wed, 30 Sep 2026 15:11:19 +0200 Subject: [PATCH] docs: generate the ODYSSEUS_* configuration reference from the source The configuration surface was undiscoverable. .env.example has three active lines, and of the ODYSSEUS_* variables the code actually reads, most appear nowhere in .env.example, docs/, website/ or README.md - including several that change security-relevant behaviour (ODYSSEUS_BROWSER_NO_SANDBOX, ODYSSEUS_ALLOW_PRIVATE_CALDAV, ODYSSEUS_ENABLE_HOST_DOCKER, ODYSSEUS_MCP_ALLOWED_COMMANDS). Every question about one of them lands in the issue tracker. A hand-written page would drift within a month, so the page is generated: - scripts/generate_env_reference.py walks the Python sources, collects each read with the default it falls back to and the file it is read in, groups by area, and marks the internal variables rather than omitting them. - website/configuration-reference.md is the generated output, wired into the Pages layout and linked from setup.md and .env.example. .env.example stays a short deployment-level example and links onward rather than growing. - tests/test_env_reference.py regenerates and compares, so adding a variable without documenting it fails the suite. That is the point: the current state happened because nothing objected. Finding the reads needs more than one pattern. Two families - the upload caps in src/upload_limits.py and the media-ingress overrides in src/media_ingress.py - are read through helper functions, so the generator detects env-reader helpers rather than hardcoding a list. Others span two lines, hold the variable name in a module constant, or read through a mapping passed in as an argument. One lives inside a string literal, in the Ollama probe script routes/cookbook_helpers.py builds line by line. A line-based grep for os.environ.get("ODYSSEUS_ finds 70 of the 98 the generator finds; the page reports that gap and recomputes it on every run so the claim cannot go stale. No application behaviour changes. --- .env.example | 5 + scripts/generate_env_reference.py | 1084 +++++++++++++++++++++++++++ tests/test_docs_no_orphan_images.py | 1 + tests/test_env_reference.py | 223 ++++++ website/configuration-reference.md | 267 +++++++ website/setup.md | 6 + 6 files changed, 1586 insertions(+) create mode 100644 scripts/generate_env_reference.py create mode 100644 tests/test_env_reference.py create mode 100644 website/configuration-reference.md diff --git a/.env.example b/.env.example index a7d1074a7..edb147aaf 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,10 @@ # Odysseus UI — Environment Configuration # Copy this file to .env and fill in your values. +# +# This file stays deliberately short: it is for deployment-level overrides, and +# most runtime configuration belongs in Settings inside the app. For the complete +# list of ODYSSEUS_* variables the code reads, with the default each one falls +# back to, see website/configuration-reference.md (generated from the source). # ============================================================ # LLM Configuration diff --git a/scripts/generate_env_reference.py b/scripts/generate_env_reference.py new file mode 100644 index 000000000..766cb244e --- /dev/null +++ b/scripts/generate_env_reference.py @@ -0,0 +1,1084 @@ +#!/usr/bin/env python3 +"""Generate the ODYSSEUS_* configuration reference from the source tree. + +The page at website/configuration-reference.md is generated, never hand-edited. +This script walks the Python sources, collects every ODYSSEUS_* environment read +with the default it falls back to and the file it is read in, merges the +hand-written area/audience notes in VARIABLE_NOTES, and writes the Markdown. + + python3 scripts/generate_env_reference.py # rewrite the page + python3 scripts/generate_env_reference.py --check # fail if it is stale + python3 scripts/generate_env_reference.py --stdout # print, write nothing + python3 scripts/generate_env_reference.py --list # one name per line + +Reads are found three ways, because one pattern is not enough: + +1. Direct reads: `os.getenv(...)`, and any `.get` / `.setdefault` / `.pop` call + or subscript load keyed by an `ODYSSEUS_*` literal. The receiver is + deliberately not required to be `os.environ` - `src/tool_index.py` reads + through `source = os.environ if environ is None else environ`, and + `src/agent_tools/web_tools.py` reads from an env mapping passed in as an + argument. The `ODYSSEUS_` prefix is specific enough that keying anything else + by one of these names would itself be the bug. +2. Calls to env-reader helpers - any function that passes one of its own + parameters to an environment read. This is detected, not hardcoded, so a new + helper is picked up without editing this script. It is what finds the + read_byte_limit_env family in src/upload_limits.py and the media-ingress + overrides in src/media_ingress.py, both of which a grep for `os.environ.get(` + misses entirely. +3. A regex sweep of the raw file text, to catch reads that the AST cannot see - + notably a read inside a Python snippet that is itself a string literal + (routes/cookbook_helpers.py builds an Ollama probe script that way). +""" +import argparse +import ast +import re +import sys +from collections import defaultdict +from dataclasses import dataclass, field +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +OUTPUT_PATH = REPO_ROOT / "website" / "configuration-reference.md" +PREFIX = "ODYSSEUS_" + +# Source roots walked for reads. Order is irrelevant; results are sorted. +SOURCE_ROOTS = ( + "app.py", + "launcher.py", + "setup.py", + "companion", + "config", + "core", + "integrations", + "mcp_servers", + "routes", + "scripts", + "services", + "src", + "tests", +) + +# Mapping methods that read a variable out of an environment-like mapping. +ENVIRON_READERS = ("get", "setdefault", "pop") + +# Files whose ODYSSEUS_* text is deliberately not a real read. The generator's +# own test builds a synthetic source tree out of string literals, and the text +# sweep below would otherwise document its fixtures as configuration. +EXCLUDED_FILES = ("tests/test_env_reference.py",) + +# Reads the AST walk cannot reach (strings holding generated code) are found by +# this. It allows any quoting and any whitespace, and the lookahead keeps a +# subscript ASSIGNMENT - `os.environ["ODYSSEUS_X"] = "1"` - from counting as a +# read, which the AST pass already excludes by checking the expression context. +TEXT_READ_RE = re.compile( + r"""(?:os\.)?(?:environ\.(?:get|setdefault|pop)|getenv)\(\s*['"](ODYSSEUS_[A-Z0-9_]+)['"]""" + r"""|environ\[\s*['"](ODYSSEUS_[A-Z0-9_]+)['"]\s*\](?!\s*=[^=])""" +) + +# Markdown table order. A variable whose area is missing from here is a bug in +# VARIABLE_NOTES, and check_notes() reports it. +AREA_ORDER = ( + "Deployment and first run", + "Data directories and paths", + "Model routing and providers", + "Agent loop and tool execution", + "Browser automation", + "Container and workspace mounts", + "Email", + "Calendar, notes and single-user mode", + "Upload and media limits", + "Search", + "Memory and skills", + "Speech and vision models", + "Auth and internal API", + "Integrations (Claude, Codex)", + "Testing, capture and development tooling", + "Build and release metadata", +) + +USER = "user" +INTERNAL = "internal" + + +@dataclass +class Read: + """One place a variable is read.""" + + path: str + lineno: int + how: str + default: str | None + + @property + def location(self) -> str: + return f"{self.path}:{self.lineno}" + + +@dataclass +class Variable: + name: str + reads: list[Read] = field(default_factory=list) + + @property + def primary(self) -> Read: + """The read to quote: prefer application code over tooling and tests.""" + return min(self.reads, key=_read_rank) + + @property + def defaults(self) -> list[str]: + seen = [] + for read in sorted(self.reads, key=_read_rank): + shown = read.default if read.default is not None else "unset" + if shown not in seen: + seen.append(shown) + return seen + + @property + def test_only(self) -> bool: + return all(read.path.startswith("tests/") for read in self.reads) + + +def _read_rank(read: "Read") -> tuple[int, int, str, int]: + """Sort key picking the most informative read first. + + Application code beats tooling beats tests, and within one tier a read that + carries an explicit default beats one that does not - otherwise the Default + column quotes a call site that simply has no fallback to report. + """ + if read.path.startswith("tests/"): + tier = 3 + elif read.path.startswith("scripts/"): + tier = 2 + elif read.path.startswith("integrations/"): + tier = 1 + else: + tier = 0 + return (tier, 0 if read.default is not None else 1, read.path, read.lineno) + + +def iter_source_files(repo_root: Path): + for entry in SOURCE_ROOTS: + target = repo_root / entry + if target.is_file(): + candidates = [target] + elif target.is_dir(): + candidates = sorted(target.rglob("*.py")) + else: + continue + for path in candidates: + if "__pycache__" in path.parts: + continue + if path.relative_to(repo_root).as_posix() in EXCLUDED_FILES: + continue + yield path + + +def _fold(node: ast.AST, constants: dict[str, ast.AST]) -> str: + """Render a default expression, resolving one level of indirection. + + Names bound to a module-level literal and attributes of a locally + constructed dataclass are resolved to the literal in the source. Anything + else is rendered as written, which keeps the table honest about defaults + that are computed at import time. + """ + if isinstance(node, ast.Name) and node.id in constants: + return ast.unparse(constants[node.id]) + if isinstance(node, ast.Attribute): + resolved = constants.get(f".{node.attr}") + if resolved is not None: + return ast.unparse(resolved) + return ast.unparse(node) + + +def _collect_constants(tree: ast.Module) -> dict[str, ast.AST]: + """Module-level `NAME = ` bindings, plus dataclass field defaults. + + Dataclass fields are keyed as `.field_name` so an attribute read on any + local instance resolves. That is loose, but every collision would have to be + two dataclasses in one module using one field name with different defaults, + and check_notes() makes a wrong default visible rather than silent. + """ + constants: dict[str, ast.AST] = {} + for node in tree.body: + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name): + constants[target.id] = node.value + elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + if node.value is not None: + constants[node.target.id] = node.value + elif isinstance(node, ast.ClassDef): + for stmt in node.body: + if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name): + if stmt.value is not None: + constants.setdefault(f".{stmt.target.id}", stmt.value) + return constants + + +def _env_name(node: ast.AST, constants: dict[str, ast.AST]) -> str | None: + """The variable name a call/subscript argument refers to, if any.""" + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + if isinstance(node, ast.Name): + bound = constants.get(node.id) + if isinstance(bound, ast.Constant) and isinstance(bound.value, str): + return bound.value + return None + + +def find_env_reader_helpers(tree: ast.Module) -> dict[str, int]: + """Functions in this module that read `os.environ` from a parameter. + + Returns helper name -> index of the parameter holding the variable name. + Nested functions count: src/media_ingress.py defines its reader inside + limits_from_env(). + """ + helpers: dict[str, int] = {} + for func in ast.walk(tree): + if not isinstance(func, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + params = [arg.arg for arg in func.args.posonlyargs + func.args.args] + if not params: + continue + for node in ast.walk(func): + arg = None + if isinstance(node, ast.Call): + attr = getattr(node.func, "attr", None) + base = getattr(node.func, "value", None) + if node.args and (attr in ENVIRON_READERS or attr == "getenv"): + arg = node.args[0] + elif isinstance(node, ast.Subscript) and isinstance(node.ctx, ast.Load): + arg = node.slice + if isinstance(arg, ast.Name) and arg.id in params: + helpers[func.name] = params.index(arg.id) + break + return helpers + + +def scan_file(path: Path, repo_root: Path) -> list[tuple[str, Read]]: + """Every ODYSSEUS_* read in one file.""" + rel = path.relative_to(repo_root).as_posix() + text = path.read_text(encoding="utf-8", errors="replace") + if PREFIX not in text: + return [] + try: + tree = ast.parse(text) + except SyntaxError: + return [] + + constants = _collect_constants(tree) + helpers = find_env_reader_helpers(tree) + results: list[tuple[str, Read]] = [] + seen_by_ast: set[str] = set() + + for node in ast.walk(tree): + name = None + how = None + default_node = None + + if isinstance(node, ast.Call): + func = node.func + attr = getattr(func, "attr", None) + base = getattr(func, "value", None) + if node.args and attr in ENVIRON_READERS and base is not None: + name = _env_name(node.args[0], constants) + how = f"environ.{attr}" + default_node = node.args[1] if len(node.args) > 1 else None + elif node.args and attr == "getenv" and base is not None: + name = _env_name(node.args[0], constants) + how = "os.getenv" + default_node = node.args[1] if len(node.args) > 1 else None + elif isinstance(func, ast.Name) and func.id in helpers: + index = helpers[func.id] + if len(node.args) > index: + name = _env_name(node.args[index], constants) + how = f"{func.id}()" + if len(node.args) > index + 1: + default_node = node.args[index + 1] + elif isinstance(func, ast.Name) and func.id == "getenv" and node.args: + name = _env_name(node.args[0], constants) + how = "os.getenv" + default_node = node.args[1] if len(node.args) > 1 else None + elif isinstance(node, ast.Subscript) and isinstance(node.ctx, ast.Load): + name = _env_name(node.slice, constants) + how = "environ[...]" + + if not name or not name.startswith(PREFIX): + continue + default = _fold(default_node, constants) if default_node is not None else None + results.append((name, Read(rel, node.lineno, how, default))) + seen_by_ast.add(name) + + # Pass 3: reads the AST cannot reach, e.g. inside generated-code strings. + for lineno, line in enumerate(text.splitlines(), start=1): + for match in TEXT_READ_RE.finditer(line): + name = match.group(1) or match.group(2) + if name in seen_by_ast: + continue + results.append((name, Read(rel, lineno, "read in generated code", None))) + return results + + +def collect(repo_root: Path = REPO_ROOT) -> dict[str, Variable]: + variables: dict[str, Variable] = {} + for path in iter_source_files(repo_root): + for name, read in scan_file(path, repo_root): + variables.setdefault(name, Variable(name)).reads.append(read) + for variable in variables.values(): + variable.reads.sort(key=_read_rank) + return dict(sorted(variables.items())) + + +# Hand-written notes, one entry per variable: (area, audience, summary). +# +# This is the only part of the page that is not derived from the source, and it +# is why the test is worth having: check_notes() fails when a variable is read +# without an entry here, so a new ODYSSEUS_* knob cannot land undocumented. +# Every default in the table comes from the source, not from this table. +# +# audience is USER for something an operator may reasonably set on a real +# install, and INTERNAL for sentinels, fixture switches, capture hooks and +# development tooling. Internal variables are listed on the page too, in their +# own section, rather than hidden. +VARIABLE_NOTES: dict[str, tuple[str, str, str]] = { + # -- Deployment and first run ------------------------------------------ + "ODYSSEUS_ADMIN_USER": ( + "Deployment and first run", USER, + "Username for the admin account created on first run. Setup uses env vars " + "first, then an interactive prompt, then a random password.", + ), + "ODYSSEUS_ADMIN_PASSWORD": ( + "Deployment and first run", USER, + "Password for the admin account created on first run. Setup refuses a value " + "shorter than its minimum length rather than silently falling back.", + ), + "ODYSSEUS_SKIP_ADMIN_PROMPT": ( + "Deployment and first run", USER, + "Any non-empty value suppresses the interactive admin-credential prompt even " + "on a TTY, for unattended installs.", + ), + "ODYSSEUS_INPROCESS_TASKS": ( + "Deployment and first run", USER, + "Set to 0, false, no or off to stop the in-process scheduled-task runner, for " + "deployments where an external worker drives task firing.", + ), + "ODYSSEUS_INPROCESS_POLLERS": ( + "Deployment and first run", USER, + "The same off switch for the in-process email pollers, when " + "`odysseus-mail poll-scheduled` is the sole external driver.", + ), + "ODYSSEUS_STARTUP_WARMUPS": ( + "Deployment and first run", USER, + "Opt-in startup pings of the configured model endpoints. Off by default " + "because they compete with the first seconds of UI use.", + ), + "ODYSSEUS_MODEL_KEEPALIVE": ( + "Deployment and first run", USER, + "Opt-in periodic model keep-alive pings. Off by default: the ping path runs " + "model discovery, so stale LAN endpoints add background pressure.", + ), + "ODYSSEUS_SLOW_REQUEST_LOG_SECONDS": ( + "Deployment and first run", USER, + "Request duration in seconds above which the middleware logs a slow-request " + "warning.", + ), + "ODYSSEUS_REQUIRE_TOOL_INDEX_READY": ( + "Deployment and first run", USER, + "Set truthy to make semantic tool-index readiness gate startup. Off by " + "default so an install stays available on deterministic tool selection.", + ), + "ODYSSEUS_TOOL_INDEX_PREWARM": ( + "Deployment and first run", USER, + "Set to 0, false, no or off to skip background initialization of semantic " + "tool retrieval at startup.", + ), + "ODYSSEUS_ENABLE_HOST_DOCKER": ( + "Deployment and first run", USER, + "Security-relevant. Must be exactly `true` before tools may use a mounted " + "host Docker socket, and the socket itself must exist.", + ), + "ODYSSEUS_CONTAINER_NETWORK_MODE": ( + "Deployment and first run", USER, + "Declares the container's Docker network mode. Set to `host` to skip " + "host-gateway probing when discovering local model endpoints.", + ), + "ODYSSEUS_ALLOW_OLLAMA_CLI_SCAN": ( + "Deployment and first run", USER, + "On Windows only, set truthy to let the Cookbook dependency probe shell out " + "to `ollama list`. Ignored on other platforms, where the scan always runs.", + ), + + # -- Data directories and paths ---------------------------------------- + "ODYSSEUS_DATA_DIR": ( + "Data directories and paths", USER, + "Root directory for every persisted file. Prefer this over the per-path " + "overrides; the rest of `src/constants.py` derives from it.", + ), + "ODYSSEUS_MAIL_ATTACHMENTS_DIR": ( + "Data directories and paths", USER, + "Dedicated override for the mail attachment store, which otherwise lives " + "under the data directory.", + ), + "ODYSSEUS_INTERNAL_BASE": ( + "Auth and internal API", USER, + "Base URL the in-app tool layer uses for loopback HTTP calls. Set it when " + "the app is not reachable at the port it thinks it is bound to.", + ), + + # -- Model routing and providers --------------------------------------- + "ODYSSEUS_LOCAL_MODEL_GATE": ( + "Model routing and providers", USER, + "On by default. Set 0, false, no or off to drop the gate that checks a local " + "endpoint before routing a request to it.", + ), + "ODYSSEUS_FIRST_TOKEN_TIMEOUT": ( + "Model routing and providers", USER, + "Seconds to wait for the first streamed token from a local endpoint before " + "failing. Unset keeps the generous read timeout, which makes a stalled " + "backend look like a hung agent.", + ), + "ODYSSEUS_MISTRAL_REASONING_EFFORT": ( + "Model routing and providers", USER, + "Reasoning effort sent to Mistral thinking-capable models. The API accepts " + "high, medium, low and none.", + ), + "ODYSSEUS_DEEPSEEK_REASONING_EFFORT": ( + "Model routing and providers", USER, + "Reasoning effort for DeepSeek. Only `high` and `max` are accepted; any " + "other value falls back to the default.", + ), + "ODYSSEUS_QWEN_ROUTE_THINKING": ( + "Model routing and providers", USER, + "Thinking policy for the Qwen routing step. An unrecognized value falls back " + "to `auto`.", + ), + "ODYSSEUS_COPILOT_CLIENT_ID": ( + "Model routing and providers", USER, + "GitHub OAuth client id for the Copilot device flow. The default is the " + "public VS Code client id; override it only with your own allow-listed app.", + ), + "ODYSSEUS_COPILOT_API_VERSION": ( + "Model routing and providers", USER, + "Dated API-version header the Copilot models and chat endpoints require.", + ), + "ODYSSEUS_MLX_IMAGE_VLM_MODEL": ( + "Model routing and providers", USER, + "Vision-language model id for the MLX image server script. Required unless " + "`--vlm-model` is passed on the command line.", + ), + "ODYSSEUS_COPILOT_USER_AGENT": ( + "Model routing and providers", INTERNAL, + "Editor-like User-Agent presented to the Copilot API. Kept stable on purpose.", + ), + "ODYSSEUS_COPILOT_INTEGRATION_ID": ( + "Model routing and providers", INTERNAL, + "Integration id presented to the Copilot API. Kept stable on purpose.", + ), + "ODYSSEUS_COPILOT_EDITOR_VERSION": ( + "Model routing and providers", INTERNAL, + "Editor-version header presented to the Copilot API. Kept stable on purpose.", + ), + "ODYSSEUS_DEBUG_LLM_SHAPE": ( + "Model routing and providers", INTERNAL, + "Truthy logs the shape of streamed provider chunks. Debugging aid for " + "provider response parsing.", + ), + + # -- Agent loop and tool execution ------------------------------------- + "ODYSSEUS_TOOL_APPROVAL_GATE": ( + "Agent loop and tool execution", USER, + "Security-relevant. Truthy makes tool calls pass through the approval gate. " + "Off by default.", + ), + "ODYSSEUS_MCP_ALLOWED_COMMANDS": ( + "Agent loop and tool execution", USER, + "Security-relevant. Comma-separated allowlist of MCP launcher basenames the " + "agent may start. Empty by default, and the deny list still wins.", + ), + "ODYSSEUS_PYTHON_TOOL_SITE_PACKAGES": ( + "Agent loop and tool execution", USER, + "Security-relevant. Absolute package roots, separated by the platform path " + "separator, exposed to the sandboxed Python tool. Empty exposes none.", + ), + "ODYSSEUS_DISABLE_MCP": ( + "Agent loop and tool execution", USER, + "Truthy disables MCP entirely, as an escape hatch for compatibility " + "problems with a server.", + ), + "ODYSSEUS_SCRIPT_HOST": ( + "Agent loop and tool execution", USER, + "Default host for the run-script action. `localhost`, `127.0.0.1`, `local` " + "and empty run locally; any other value runs over SSH.", + ), + "ODYSSEUS_MAX_VISUAL_EVIDENCE_IMAGES": ( + "Agent loop and tool execution", USER, + "How many images one tool result may contribute to the model turn. Clamped " + "to 1-8.", + ), + "ODYSSEUS_MAX_VISUAL_EVIDENCE_FRAMES": ( + "Agent loop and tool execution", USER, + "How many video frames one tool result may contribute. Clamped to 1-8.", + ), + "ODYSSEUS_CAPTURE_MODEL_REQUESTS": ( + "Agent loop and tool execution", INTERNAL, + "Truthy writes model-request snapshots for local debugging. The marker file " + "`/tmp/odysseus_capture_model_requests` enables the same thing.", + ), + "ODYSSEUS_EXPOSE_RAW_BROWSER_MCP": ( + "Agent loop and tool execution", INTERNAL, + "Truthy stops hiding the raw Playwright MCP tools from agent prompts when " + "the private-browser tool is available.", + ), + "ODYSSEUS_TOOL_CONTRACT_ROOT": ( + "Agent loop and tool execution", INTERNAL, + "Directory holding the tool-contract scripts the clean-agent preview loads. " + "The default is a path on the maintainer's own machine.", + ), + + # -- Browser automation ------------------------------------------------- + "ODYSSEUS_BROWSER_EXECUTABLE": ( + "Browser automation", USER, + "Absolute path to the Chrome or Chromium binary. Empty searches the usual " + "names, then lets Playwright MCP pick its own browser.", + ), + "ODYSSEUS_BROWSER_ISOLATED": ( + "Browser automation", USER, + "Security-relevant. On by default, adding `--isolated` so each browser " + "session starts clean. Set 0, false or no to keep a persistent profile.", + ), + "ODYSSEUS_BROWSER_NO_SANDBOX": ( + "Browser automation", USER, + "Security-relevant. On by default, adding `--no-sandbox` because the Docker " + "image cannot use the Chromium sandbox. Set 0, false or no to keep it.", + ), + "ODYSSEUS_BROWSER_MCP_CACHE": ( + "Browser automation", USER, + "Cache directory handed to the browser MCP server, so its npm download " + "survives a container rebuild.", + ), + "ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE": ( + "Browser automation", USER, + "Truthy refuses to start the browser MCP server unless its npm package is " + "already in the npx cache, instead of installing it at startup.", + ), + "ODYSSEUS_BROWSER_NAMESPACE": ( + "Browser automation", USER, + "Namespace for the detached agent-browser daemon's pid files, so two " + "runtimes on one machine do not terminate each other's browsers.", + ), + "ODYSSEUS_BROWSER_SCREENSHOT_DIR": ( + "Browser automation", USER, + "Where private-browser screenshots are written. Falls back to the container " + "path, then the system temp directory.", + ), + + # -- Container and workspace mounts ------------------------------------ + "ODYSSEUS_WORKSPACE_MOUNTS": ( + "Container and workspace mounts", USER, + "`host=container` path pairs separated by commas or semicolons, so the agent " + "can translate a container path back to the host path a user typed.", + ), + "ODYSSEUS_WORKSPACE_HOST_ROOT": ( + "Container and workspace mounts", USER, + "Single host-side root, paired with the container root below. Simpler than " + "the explicit mount list when there is only one mount.", + ), + "ODYSSEUS_WORKSPACE_CONTAINER_ROOT": ( + "Container and workspace mounts", USER, + "Container-side root that the host root maps onto. An `or` fallback, not a " + "read default, supplies `/workspace` when it is unset.", + ), + "ODYSSEUS_WORKSPACE_DEFAULT": ( + "Container and workspace mounts", USER, + "Default workspace path the admin-only workspace route reports. Empty means " + "no default is configured.", + ), + + # -- Email -------------------------------------------------------------- + "ODYSSEUS_IMAP_TIMEOUT_SECONDS": ( + "Email", USER, + "IMAP socket timeout in seconds, clamped to 5-300. A non-numeric value falls " + "back to 30 rather than failing.", + ), + "ODYSSEUS_DOCUMENT_OWNER": ( + "Email", USER, + "Owner stamped on documents the email MCP server creates. Stdio MCP tools " + "get no authenticated user, so without this a draft is invisible.", + ), + "ODYSSEUS_EMAIL_FIXTURE": ( + "Email", INTERNAL, + "Exactly `1`, plus a fixture file on disk, makes the email MCP server serve " + "fixtures instead of a real mailbox.", + ), + + # -- Calendar, notes and single-user mode ------------------------------- + "ODYSSEUS_SINGLE_USER": ( + "Calendar, notes and single-user mode", USER, + "Security-relevant. On by default. Set to 0 on a real multi-user install so " + "unauthenticated calendar writes are rejected rather than absorbed.", + ), + "ODYSSEUS_FALLBACK_OWNER": ( + "Calendar, notes and single-user mode", USER, + "Owner address that single-user mode attributes an unauthenticated request " + "to. Only reachable while single-user mode is on.", + ), + "ODYSSEUS_ALLOW_PRIVATE_CALDAV": ( + "Calendar, notes and single-user mode", USER, + "Security-relevant. Truthy lets CalDAV sync reach private and link-local " + "addresses. Off by default; this is an SSRF guard.", + ), + + # -- Upload and media limits ------------------------------------------- + "ODYSSEUS_CHAT_UPLOAD_MAX_BYTES": ( + "Upload and media limits", USER, "Maximum bytes accepted for a chat attachment.", + ), + "ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES": ( + "Upload and media limits", USER, "Maximum bytes accepted for a gallery upload.", + ), + "ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES": ( + "Upload and media limits", USER, + "Maximum bytes accepted for an image handed to a gallery transform.", + ), + "ODYSSEUS_EDITOR_DRAFT_MAX_BYTES": ( + "Upload and media limits", USER, "Maximum bytes accepted for a saved editor draft.", + ), + "ODYSSEUS_MEMORY_IMPORT_MAX_BYTES": ( + "Upload and media limits", USER, "Maximum bytes accepted for a memory import file.", + ), + "ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES": ( + "Upload and media limits", USER, + "Maximum bytes accepted for a personal-documents upload.", + ), + "ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES": ( + "Upload and media limits", USER, + "Maximum bytes accepted for an attachment added while composing mail.", + ), + "ODYSSEUS_STT_MAX_AUDIO_BYTES": ( + "Upload and media limits", USER, + "Maximum bytes accepted for an audio file submitted for transcription.", + ), + "ODYSSEUS_ICS_MAX_BYTES": ( + "Upload and media limits", USER, "Maximum bytes accepted for an imported ICS file.", + ), + "ODYSSEUS_MEDIA_MAX_FILES": ( + "Upload and media limits", USER, + "How many local media files one agent turn may ingest.", + ), + "ODYSSEUS_MEDIA_MAX_IMAGE_BYTES": ( + "Upload and media limits", USER, "Largest source image the media pipeline will read.", + ), + "ODYSSEUS_MEDIA_MAX_VIDEO_BYTES": ( + "Upload and media limits", USER, "Largest source video the media pipeline will read.", + ), + "ODYSSEUS_MEDIA_MAX_DOCUMENT_BYTES": ( + "Upload and media limits", USER, "Largest source document the media pipeline will read.", + ), + "ODYSSEUS_MEDIA_MAX_AUDIO_BYTES": ( + "Upload and media limits", USER, "Largest source audio file the media pipeline will read.", + ), + "ODYSSEUS_MEDIA_MAX_ENCODED_BYTES": ( + "Upload and media limits", USER, + "Budget for the encoded payload handed to the model, counted cumulatively " + "across one turn's attachments rather than per file.", + ), + "ODYSSEUS_MEDIA_MAX_DOCUMENT_CHARS": ( + "Upload and media limits", USER, + "How many characters of an ingested document are inlined into the turn.", + ), + "ODYSSEUS_MEDIA_MAX_DIMENSION": ( + "Upload and media limits", USER, + "Longest edge in pixels an image is resized down to before encoding.", + ), + "ODYSSEUS_MEDIA_MAX_PIXELS": ( + "Upload and media limits", USER, + "Total pixel budget for a source image, as a decompression-bomb guard.", + ), + "ODYSSEUS_MEDIA_MAX_VIDEO_FRAMES": ( + "Upload and media limits", USER, "How many frames are sampled from a video.", + ), + "ODYSSEUS_MEDIA_PROBE_TIMEOUT": ( + "Upload and media limits", USER, + "Seconds allowed for probing a video's metadata before giving up.", + ), + "ODYSSEUS_MEDIA_FRAME_TIMEOUT": ( + "Upload and media limits", USER, + "Seconds allowed for extracting frames from a video before giving up.", + ), + + # -- Search ------------------------------------------------------------- + "ODYSSEUS_SEARCH_PROVIDER": ( + "Search", USER, + "Forces the search provider, overriding the Settings value. Empty keeps the " + "UI authoritative, which is what a normal install wants.", + ), + + # -- Memory and skills -------------------------------------------------- + "ODYSSEUS_SKILL_SEMANTIC_RETRIEVAL": ( + "Memory and skills", USER, + "On by default. Set 0, false, no or off to fall back to keyword-only skill " + "retrieval when no vector store is reachable.", + ), + "ODYSSEUS_SKILL_SEMANTIC_THRESHOLD": ( + "Memory and skills", USER, + "Minimum semantic score a skill needs to be retrieved. A non-numeric value " + "falls back to the default.", + ), + + # -- Speech and vision models ------------------------------------------- + "ODYSSEUS_STT_MODEL": ( + "Speech and vision models", USER, + "Default speech-to-text model for media transcription when the tool call " + "does not name one.", + ), + "ODYSSEUS_TTS_CACHE_MAX_BYTES": ( + "Speech and vision models", USER, + "Cap on the synthesized-speech cache. A non-numeric value falls back to the " + "default.", + ), + "ODYSSEUS_SAM_MODEL": ( + "Speech and vision models", USER, + "Segmentation model id the gallery loads for subject selection.", + ), + "ODYSSEUS_GROUNDING_MODEL": ( + "Speech and vision models", USER, + "Object-grounding model id the gallery loads for text-driven selection.", + ), + + # -- Auth and internal API ---------------------------------------------- + "ODYSSEUS_INTERNAL_TOKEN": ( + "Auth and internal API", USER, + "Security-relevant. Token that lets the in-app tool layer reach admin-gated " + "routes over loopback. Unset generates a fresh per-process token, which is " + "what you want unless something outside the process needs the same value.", + ), + + # -- Integrations (Claude, Codex) --------------------------------------- + "ODYSSEUS_URL": ( + "Integrations (Claude, Codex)", USER, + "Base URL of the Odysseus instance the bundled integration scripts call.", + ), + "ODYSSEUS_API_TOKEN": ( + "Integrations (Claude, Codex)", USER, + "API token those scripts authenticate with. Both this and the URL are " + "required; the scripts name whichever is missing.", + ), + + # -- Testing, capture and development tooling --------------------------- + "ODYSSEUS_SKIP_RUN_HINT": ( + "Testing, capture and development tooling", INTERNAL, + "Any non-empty value suppresses the `start the server with` hint at the end " + "of setup. `start-macos.sh` sets it because it starts the server itself.", + ), + "ODYSSEUS_TEST_STATIC_ORIGIN": ( + "Testing, capture and development tooling", INTERNAL, + "Origin an already-running static server is serving the repository from, so " + "snapshot tooling reuses it instead of starting its own.", + ), + "ODYSSEUS_TEST_STATIC_PORT": ( + "Testing, capture and development tooling", INTERNAL, + "Fixed port for the test suite's static server. Unset takes an ephemeral " + "port, which is what keeps parallel runs from colliding.", + ), + "ODYSSEUS_SFT_TRACE_CAPTURE": ( + "Testing, capture and development tooling", INTERNAL, + "On by default, but only for owners whose name starts with `sft_`. Set 0, " + "false, no or off to stop writing training traces.", + ), + "ODYSSEUS_SFT_TRACE_DIR": ( + "Testing, capture and development tooling", INTERNAL, + "Directory the SFT trace JSONL files are written to. Defaults to " + "`sft_traces` under the data directory.", + ), + "ODYSSEUS_SFT_FORCE_UTC_TIMEZONE": ( + "Testing, capture and development tooling", INTERNAL, + "Truthy forces `sft_` accounts to UTC for deterministic batch generation. " + "Interactive accounts still follow the browser timezone.", + ), + "ODYSSEUS_SFT_DISABLE_WORKSPACE_TOOLS": ( + "Testing, capture and development tooling", INTERNAL, + "On by default. Keeps synthetic personal-assistant fixtures out of workspace " + "mode; set 0, false, no or off to let them through.", + ), + "ODYSSEUS_RUNTIME_REVISION": ( + "Testing, capture and development tooling", INTERNAL, + "Revision string stamped into each captured SFT trace record, so a trace can " + "be tied back to the build that produced it.", + ), + "ODYSSEUS_QA_TEACHER_ATTEMPTS": ( + "Testing, capture and development tooling", INTERNAL, + "Retry budget for the conversation-QA teacher model call. Clamped to 1-3.", + ), + "ODYSSEUS_QA_TEACHER_TIMEOUT": ( + "Testing, capture and development tooling", INTERNAL, + "Timeout in seconds for that call. Clamped to 15-120.", + ), + + # -- Build and release metadata ---------------------------------------- + "ODYSSEUS_BUILD_VERSION": ( + "Build and release metadata", INTERNAL, + "Overrides the build-version string the API and UI report, without touching " + "the public application version.", + ), + "ODYSSEUS_SOURCE_COMMIT": ( + "Build and release metadata", INTERNAL, + "Overrides the source commit reported for runtime provenance, for builds " + "that ship without a git directory.", + ), +} + + +GENERATED_WARNING = ( + "This page is generated from the source tree by " + "`scripts/generate_env_reference.py`. Do not edit it by hand: run the script " + "instead. `tests/test_env_reference.py` fails when the committed page and the " + "source disagree, or when a variable is read without an entry in the script's " + "notes table." +) + +INTRO = """Odysseus reads its runtime configuration from the Settings UI. The +environment variables below are the deployment-level escape hatches underneath +that: they are read directly from the process environment, mostly at import or +startup, and most installs never need any of them. + +`.env.example` stays a short, deployment-level example on purpose - `APP_BIND`, +`APP_PORT`, `AUTH_ENABLED`, `DATABASE_URL` and a pre-seeded admin password. This +page is the complete list, which is a different job. + +Truthiness is not uniform across the codebase. Where a variable is described as +"truthy" the read accepts `1`, `true`, `yes` and usually `on`; where it is +described as a switch that turns something off, the read rejects `0`, `false`, +`no` and `off` and treats everything else as on. The `Default` column is the +value the code falls back to when the variable is unset, quoted from the source.""" + +HOW_SECTION = """The generator walks the Python sources under {roots} and finds +reads three ways, because no single pattern covers the codebase: + +- Direct reads: `os.getenv(...)`, and any `.get` / `.setdefault` / `.pop` call or + subscript keyed by an `ODYSSEUS_*` literal, including names held in a + module-level constant. The receiver is not required to be `os.environ`, because + several call sites read through a mapping passed in as an argument + (`src/tool_index.py`, `src/host_docker_access.py`). +- Calls to env-reader helpers - any function that forwards one of its own + parameters to an environment read. This is detected rather than hardcoded, so a + new helper needs no change here. It is what finds the upload caps in + `src/upload_limits.py` and the media-ingress overrides in + `src/media_ingress.py`. +- A regex sweep of the raw file text, for reads the AST cannot see. + `routes/cookbook_helpers.py` builds an Ollama probe script as a list of source + lines, so one read lives inside a string literal. + +The three passes are not redundancy. A line-based grep for a direct +`os.environ.get("ODYSSEUS_...` call finds {naive} of the {total} variables on this +page. What it misses is reads through an env-reader helper, reads whose call +spans more than one line, reads whose variable name is held in a module +constant, and reads through a mapping passed in as an argument - which is the +whole reason this page is generated rather than maintained. + +The `Default` column shows the expression as written, with one level of +indirection resolved: a module-level constant and a dataclass field default are +replaced by the literal they hold, so `defaults.max_media_files` shows as `4`. +Anything computed at import time - `get_default_data_dir()` - is shown as +written, because that is the honest answer. A few call sites supply their +fallback with `or` rather than a default argument; those show as *unset* and say +so in the last column.""" + + +def naive_line_scan(repo_root: Path = REPO_ROOT) -> set[str]: + """What a line-based grep for the direct call pattern would find. + + Reported on the page so the gap between this and the real inventory stays + true as the code changes, instead of being a number somebody wrote down once. + """ + pattern = re.compile( + r"""(?:os\.environ\.get|os\.getenv|environ\.get|getenv)\(\s*['"](ODYSSEUS_[A-Z0-9_]+)['"]""" + r"""|environ\[\s*['"](ODYSSEUS_[A-Z0-9_]+)['"]""" + ) + found = set() + for path in iter_source_files(repo_root): + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + for match in pattern.finditer(line): + found.add(match.group(1) or match.group(2)) + return found + + +def check_notes(variables: dict[str, Variable]) -> list[str]: + """Problems that make the page wrong. Empty means the notes table is sound.""" + problems = [] + for name in variables: + if name not in VARIABLE_NOTES: + location = variables[name].primary.location + problems.append( + f"{name} is read at {location} but has no entry in VARIABLE_NOTES " + f"in scripts/generate_env_reference.py - add one, with the area, " + f"whether an operator would ever set it, and one sentence on what " + f"it does" + ) + for name, (area, audience, summary) in VARIABLE_NOTES.items(): + if name not in variables: + problems.append( + f"{name} has a VARIABLE_NOTES entry but is no longer read anywhere " + f"- remove the entry" + ) + if area not in AREA_ORDER: + problems.append(f"{name} has area {area!r}, which is not in AREA_ORDER") + if audience not in (USER, INTERNAL): + problems.append(f"{name} has audience {audience!r}") + if not summary.endswith("."): + problems.append(f"{name} summary should end with a full stop") + return problems + + +def _cell(text: str) -> str: + return text.replace("|", "\\|") + + +def _read_cell(variable: Variable) -> str: + primary = f"`{variable.primary.location}`" + extra = len(variable.reads) - 1 + return primary if extra <= 0 else f"{primary} (+{extra} more)" + + +def _default_cell(variable: Variable) -> str: + default = variable.primary.default + return "*unset*" if default is None else f"`{_cell(default)}`" + + +def _table(rows: list[Variable]) -> list[str]: + lines = ["| Variable | Default | Read in | What it does |", "|---|---|---|---|"] + for variable in rows: + summary = VARIABLE_NOTES[variable.name][2] + lines.append( + f"| `{variable.name}` | {_default_cell(variable)} | " + f"{_read_cell(variable)} | {_cell(summary)} |" + ) + return lines + + +def _sections(variables: dict[str, Variable], audience: str) -> list[str]: + by_area: dict[str, list[Variable]] = defaultdict(list) + for variable in variables.values(): + area, own_audience, _ = VARIABLE_NOTES[variable.name] + if own_audience == audience: + by_area[area].append(variable) + lines = [] + for area in AREA_ORDER: + rows = by_area.get(area) + if not rows: + continue + lines += ["", f"### {area}", ""] + lines += _table(sorted(rows, key=lambda item: item.name)) + return lines + + +def render(variables: dict[str, Variable], naive: set[str]) -> str: + operator = [n for n, (_, aud, _) in VARIABLE_NOTES.items() if aud == USER and n in variables] + internal = [n for n, (_, aud, _) in VARIABLE_NOTES.items() if aud == INTERNAL and n in variables] + lines = [ + "---", + "layout: default", + "---", + "", + "# Configuration reference: ODYSSEUS_* environment variables", + "", + f"", + "", + INTRO, + "", + f"The source tree reads **{len(variables)}** `ODYSSEUS_*` variables: " + f"{len(operator)} an operator may want to set, and {len(internal)} that are " + f"internal - sentinels, fixture switches, capture hooks and development " + f"tooling. The internal ones are listed too, in their own section, so this " + f"page can be checked against the source mechanically.", + "", + "> This page is generated. Edit `scripts/generate_env_reference.py` and", + "> re-run it; `tests/test_env_reference.py` enforces that the committed page", + "> matches the source.", + "", + "## Variables you can set", + ] + lines += _sections(variables, USER) + lines += ["", "## Internal and development-only variables", "", + "Listed for completeness. Setting one of these on a real install is " + "either a no-op or a way to break something quietly."] + lines += _sections(variables, INTERNAL) + lines += [ + "", + "## How this page is generated", + "", + HOW_SECTION.format( + roots="`" + "`, `".join(SOURCE_ROOTS) + "`", + naive=len(naive & set(variables)), + total=len(variables), + ), + "", + "Regenerate it with:", + "", + "```bash", + "python3 scripts/generate_env_reference.py", + "```", + "", + ] + return "\n".join(lines) + + +def build(repo_root: Path = REPO_ROOT) -> tuple[str, dict[str, Variable], list[str]]: + """The page text, the inventory behind it, and any notes-table problems.""" + variables = collect(repo_root) + problems = check_notes(variables) + if problems: + # Rendering an undocumented variable would raise a KeyError and bury the + # message that actually tells a contributor what to do. + return "", variables, problems + return render(variables, naive_line_scan(repo_root)), variables, problems + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--check", action="store_true", + help="exit non-zero if the committed page is stale") + parser.add_argument("--stdout", action="store_true", + help="print the page instead of writing it") + parser.add_argument("--list", action="store_true", + help="print one variable name per line and nothing else") + args = parser.parse_args(argv) + + page, variables, problems = build() + + if args.list: + for name in variables: + print(name) + return 0 + + for problem in problems: + print(f"error: {problem}", file=sys.stderr) + if problems: + return 1 + + if args.stdout: + print(page, end="") + return 0 + + try: + relative = OUTPUT_PATH.relative_to(REPO_ROOT).as_posix() + except ValueError: # an output path outside the repo, as the tests use + relative = str(OUTPUT_PATH) + current = OUTPUT_PATH.read_text(encoding="utf-8") if OUTPUT_PATH.exists() else None + if args.check: + if current == page: + print(f"{relative} is up to date ({len(variables)} variables)") + return 0 + print(f"error: {relative} is stale; run " + f"python3 scripts/generate_env_reference.py", file=sys.stderr) + return 1 + + if current == page: + print(f"{relative} unchanged ({len(variables)} variables)") + return 0 + OUTPUT_PATH.write_text(page, encoding="utf-8") + print(f"wrote {relative} ({len(variables)} variables)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_docs_no_orphan_images.py b/tests/test_docs_no_orphan_images.py index 02e0c4494..5c3d27c60 100644 --- a/tests/test_docs_no_orphan_images.py +++ b/tests/test_docs_no_orphan_images.py @@ -19,6 +19,7 @@ PUBLIC_GUIDES = { "agent-migration.md", "attachments.md", "backup-restore.md", + "configuration-reference.md", "email-outlook.md", "pr-blocker-audit.md", "security-ci.md", diff --git a/tests/test_env_reference.py b/tests/test_env_reference.py new file mode 100644 index 000000000..56d58730e --- /dev/null +++ b/tests/test_env_reference.py @@ -0,0 +1,223 @@ +"""Guards for the generated ODYSSEUS_* configuration reference. + +`website/configuration-reference.md` is produced by +`scripts/generate_env_reference.py`. The point of these tests is that adding a +new `ODYSSEUS_*` read without documenting it fails the suite: the current state - +most of the configuration surface undiscoverable - happened because nothing +objected. + +The generator's detection is exercised against a synthetic source tree rather +than against the real one, so a test failure names a behavior rather than a +count that drifted. Only the two whole-tree tests touch the repository, and they +compare generator output to the committed page instead of asserting on source +text. +""" +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +from tests.helpers.cli_loader import load_script + +REPO = Path(__file__).resolve().parent.parent +PAGE = REPO / "website" / "configuration-reference.md" + + +@pytest.fixture(scope="module") +def generator(): + return load_script("generate_env_reference.py") + + +@pytest.fixture(scope="module") +def built(generator): + """The generator run once against the real tree; reused by the slow tests.""" + return generator.build() + + +def _write(root: Path, relative: str, body: str) -> None: + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + + +@pytest.fixture +def fake_tree(tmp_path, generator, monkeypatch): + """A miniature source tree covering every read pattern the generator claims.""" + monkeypatch.setattr(generator, "SOURCE_ROOTS", ("app.py", "src")) + _write(tmp_path, "app.py", """ +import os + +DIRECT = os.environ.get("ODYSSEUS_DIRECT_GET", "on") +GETENV = os.getenv("ODYSSEUS_PLAIN_GETENV") +SUBSCRIPT = os.environ["ODYSSEUS_SUBSCRIPT"] +SPANNING = os.environ.get( + "ODYSSEUS_SPANS_TWO_LINES", "spanned" +) +os.environ["ODYSSEUS_WRITE_ONLY"] = "1" +""") + _write(tmp_path, "src/indirect.py", ''' +import os + +NAME_HELD_IN_CONSTANT = "ODYSSEUS_VIA_CONSTANT" +FALLBACK = 7 + +value = os.environ.get(NAME_HELD_IN_CONSTANT, FALLBACK) + + +def read_limit(name, default): + """An env-reader helper: the generator should follow calls to this.""" + raw = os.getenv(name) + return default if raw is None else int(raw) + + +LIMIT = read_limit("ODYSSEUS_VIA_HELPER", 5 * 1024) + + +def flag(environ=None): + source = os.environ if environ is None else environ + return source.get("ODYSSEUS_VIA_MAPPING_ARG", "1") + + +GENERATED = [ + "import os", + "if os.environ.get('ODYSSEUS_INSIDE_A_STRING'): pass", +] +''') + return tmp_path + + +def test_finds_every_read_pattern_it_claims_to(generator, fake_tree): + found = generator.collect(fake_tree) + + assert set(found) == { + "ODYSSEUS_DIRECT_GET", + "ODYSSEUS_PLAIN_GETENV", + "ODYSSEUS_SUBSCRIPT", + "ODYSSEUS_SPANS_TWO_LINES", + "ODYSSEUS_VIA_CONSTANT", + "ODYSSEUS_VIA_HELPER", + "ODYSSEUS_VIA_MAPPING_ARG", + "ODYSSEUS_INSIDE_A_STRING", + } + + +def test_a_plain_grep_would_miss_what_the_extra_passes_find(generator, fake_tree): + """Pins why the generator is not a one-line grep.""" + naive = generator.naive_line_scan(fake_tree) + found = set(generator.collect(fake_tree)) + + assert found - naive == { + "ODYSSEUS_SPANS_TWO_LINES", + "ODYSSEUS_VIA_CONSTANT", + "ODYSSEUS_VIA_HELPER", + "ODYSSEUS_VIA_MAPPING_ARG", + } + # And it over-counts in the other direction: a line-based scan cannot tell a + # write from a read, which is why the page reports the intersection. + assert naive - found == {"ODYSSEUS_WRITE_ONLY"} + + +def test_records_defaults_and_locations_from_the_source(generator, fake_tree): + found = generator.collect(fake_tree) + + assert found["ODYSSEUS_DIRECT_GET"].primary.default == "'on'" + assert found["ODYSSEUS_SPANS_TWO_LINES"].primary.default == "'spanned'" + # One level of indirection is resolved: the name and the default both come + # from module-level constants. + assert found["ODYSSEUS_VIA_CONSTANT"].primary.default == "7" + assert found["ODYSSEUS_VIA_HELPER"].primary.default == "5 * 1024" + assert found["ODYSSEUS_PLAIN_GETENV"].primary.default is None + + assert found["ODYSSEUS_DIRECT_GET"].primary.location == "app.py:4" + assert found["ODYSSEUS_VIA_HELPER"].primary.path == "src/indirect.py" + + +def test_environ_writes_are_not_reads(generator, fake_tree): + assert "ODYSSEUS_WRITE_ONLY" not in generator.collect(fake_tree) + + +def test_an_undocumented_variable_is_reported(generator, fake_tree): + """The whole point: a new variable with no notes entry must fail loudly.""" + problems = generator.check_notes(generator.collect(fake_tree)) + + assert problems + offender = "ODYSSEUS_VIA_HELPER" + assert any(offender in problem for problem in problems) + assert any("VARIABLE_NOTES" in problem for problem in problems) + assert any("src/indirect.py" in problem for problem in problems) + + +def test_a_stale_notes_entry_is_reported(generator): + problems = generator.check_notes({}) + + assert len(problems) == len(generator.VARIABLE_NOTES) + assert all("no longer read anywhere" in problem for problem in problems) + + +def test_renders_one_table_row_per_variable(generator, fake_tree, monkeypatch): + monkeypatch.setitem( + generator.VARIABLE_NOTES, + "ODYSSEUS_VIA_HELPER", + ("Search", generator.USER, "A synthetic limit."), + ) + found = {"ODYSSEUS_VIA_HELPER": generator.collect(fake_tree)["ODYSSEUS_VIA_HELPER"]} + + page = generator.render(found, generator.naive_line_scan(fake_tree)) + + assert page.startswith("---\nlayout: default\n---\n") + assert "| `ODYSSEUS_VIA_HELPER` | `5 * 1024` | `src/indirect.py:16` | A synthetic limit. |" in page + assert "### Search" in page + + +def test_every_variable_read_in_the_repository_is_documented(built): + _, variables, problems = built + + assert not problems, "\n".join(problems) + assert variables, "expected the generator to find ODYSSEUS_* reads" + + +def test_committed_page_matches_the_source(built): + page, _, _ = built + + assert PAGE.read_text(encoding="utf-8") == page, ( + "website/configuration-reference.md is stale - regenerate it with " + "`python3 scripts/generate_env_reference.py`" + ) + + +def test_page_has_no_emoji_or_other_non_ascii(built): + page, _, _ = built + + offenders = sorted({character for character in page if ord(character) > 126}) + assert not offenders, f"non-ASCII in the generated page: {offenders}" + + +def test_check_mode_reports_a_stale_page(generator, tmp_path, monkeypatch): + stale = tmp_path / "configuration-reference.md" + stale.write_text("out of date\n", encoding="utf-8") + monkeypatch.setattr(generator, "OUTPUT_PATH", stale) + + assert generator.main(["--check"]) == 1 + assert generator.main([]) == 0 + assert generator.main(["--check"]) == 0 + + +def test_script_runs_as_a_subprocess_without_importing_the_app(): + result = subprocess.run( + [sys.executable, "scripts/generate_env_reference.py", "--check"], + cwd=REPO, capture_output=True, text=True, timeout=180, + ) + + assert result.returncode == 0, result.stderr + assert re.search(r"\d+ variables", result.stdout), result.stdout + + +def test_page_is_linked_from_the_places_a_reader_starts(): + assert "configuration-reference.md" in (REPO / "website" / "setup.md").read_text( + encoding="utf-8" + ) + assert "configuration-reference.md" in (REPO / ".env.example").read_text( + encoding="utf-8" + ) diff --git a/website/configuration-reference.md b/website/configuration-reference.md new file mode 100644 index 000000000..80a98b213 --- /dev/null +++ b/website/configuration-reference.md @@ -0,0 +1,267 @@ +--- +layout: default +--- + +# Configuration reference: ODYSSEUS_* environment variables + + + +Odysseus reads its runtime configuration from the Settings UI. The +environment variables below are the deployment-level escape hatches underneath +that: they are read directly from the process environment, mostly at import or +startup, and most installs never need any of them. + +`.env.example` stays a short, deployment-level example on purpose - `APP_BIND`, +`APP_PORT`, `AUTH_ENABLED`, `DATABASE_URL` and a pre-seeded admin password. This +page is the complete list, which is a different job. + +Truthiness is not uniform across the codebase. Where a variable is described as +"truthy" the read accepts `1`, `true`, `yes` and usually `on`; where it is +described as a switch that turns something off, the read rejects `0`, `false`, +`no` and `off` and treats everything else as on. The `Default` column is the +value the code falls back to when the variable is unset, quoted from the source. + +The source tree reads **98** `ODYSSEUS_*` variables: 78 an operator may want to set, and 20 that are internal - sentinels, fixture switches, capture hooks and development tooling. The internal ones are listed too, in their own section, so this page can be checked against the source mechanically. + +> This page is generated. Edit `scripts/generate_env_reference.py` and +> re-run it; `tests/test_env_reference.py` enforces that the committed page +> matches the source. + +## Variables you can set + +### Deployment and first run + +| Variable | Default | Read in | What it does | +|---|---|---|---| +| `ODYSSEUS_ADMIN_PASSWORD` | `''` | `setup.py:102` (+3 more) | Password for the admin account created on first run. Setup refuses a value shorter than its minimum length rather than silently falling back. | +| `ODYSSEUS_ADMIN_USER` | `''` | `setup.py:101` (+1 more) | Username for the admin account created on first run. Setup uses env vars first, then an interactive prompt, then a random password. | +| `ODYSSEUS_ALLOW_OLLAMA_CLI_SCAN` | *unset* | `routes/cookbook_helpers.py:544` (+1 more) | On Windows only, set truthy to let the Cookbook dependency probe shell out to `ollama list`. Ignored on other platforms, where the scan always runs. | +| `ODYSSEUS_CONTAINER_NETWORK_MODE` | `''` | `app.py:1021` (+1 more) | Declares the container's Docker network mode. Set to `host` to skip host-gateway probing when discovering local model endpoints. | +| `ODYSSEUS_ENABLE_HOST_DOCKER` | `''` | `src/host_docker_access.py:41` | Security-relevant. Must be exactly `true` before tools may use a mounted host Docker socket, and the socket itself must exist. | +| `ODYSSEUS_INPROCESS_POLLERS` | `'1'` | `routes/email_pollers.py:1722` | The same off switch for the in-process email pollers, when `odysseus-mail poll-scheduled` is the sole external driver. | +| `ODYSSEUS_INPROCESS_TASKS` | `'1'` | `app.py:1310` | Set to 0, false, no or off to stop the in-process scheduled-task runner, for deployments where an external worker drives task firing. | +| `ODYSSEUS_MODEL_KEEPALIVE` | `''` | `app.py:1217` | Opt-in periodic model keep-alive pings. Off by default: the ping path runs model discovery, so stale LAN endpoints add background pressure. | +| `ODYSSEUS_REQUIRE_TOOL_INDEX_READY` | `''` | `src/readiness.py:61` | Set truthy to make semantic tool-index readiness gate startup. Off by default so an install stays available on deterministic tool selection. | +| `ODYSSEUS_SKIP_ADMIN_PROMPT` | *unset* | `setup.py:112` | Any non-empty value suppresses the interactive admin-credential prompt even on a TTY, for unattended installs. | +| `ODYSSEUS_SLOW_REQUEST_LOG_SECONDS` | `'0.75'` | `app.py:239` | Request duration in seconds above which the middleware logs a slow-request warning. | +| `ODYSSEUS_STARTUP_WARMUPS` | `''` | `app.py:1191` | Opt-in startup pings of the configured model endpoints. Off by default because they compete with the first seconds of UI use. | +| `ODYSSEUS_TOOL_INDEX_PREWARM` | `'1'` | `src/tool_index.py:771` | Set to 0, false, no or off to skip background initialization of semantic tool retrieval at startup. | + +### Data directories and paths + +| Variable | Default | Read in | What it does | +|---|---|---|---| +| `ODYSSEUS_DATA_DIR` | `get_default_data_dir()` | `src/constants.py:56` (+1 more) | Root directory for every persisted file. Prefer this over the per-path overrides; the rest of `src/constants.py` derives from it. | +| `ODYSSEUS_MAIL_ATTACHMENTS_DIR` | `os.path.join(DATA_DIR, 'mail-attachments')` | `src/constants.py:102` | Dedicated override for the mail attachment store, which otherwise lives under the data directory. | + +### Model routing and providers + +| Variable | Default | Read in | What it does | +|---|---|---|---| +| `ODYSSEUS_COPILOT_API_VERSION` | `'2026-06-01'` | `src/copilot.py:39` | Dated API-version header the Copilot models and chat endpoints require. | +| `ODYSSEUS_COPILOT_CLIENT_ID` | `'01ab8ac9400c4e429b23'` | `src/copilot.py:34` | GitHub OAuth client id for the Copilot device flow. The default is the public VS Code client id; override it only with your own allow-listed app. | +| `ODYSSEUS_DEEPSEEK_REASONING_EFFORT` | `'high'` | `src/llm_core.py:1727` | Reasoning effort for DeepSeek. Only `high` and `max` are accepted; any other value falls back to the default. | +| `ODYSSEUS_FIRST_TOKEN_TIMEOUT` | `''` | `src/llm_core.py:223` | Seconds to wait for the first streamed token from a local endpoint before failing. Unset keeps the generous read timeout, which makes a stalled backend look like a hung agent. | +| `ODYSSEUS_LOCAL_MODEL_GATE` | `'true'` | `src/llm_core.py:95` | On by default. Set 0, false, no or off to drop the gate that checks a local endpoint before routing a request to it. | +| `ODYSSEUS_MISTRAL_REASONING_EFFORT` | `'high'` | `src/llm_core.py:1723` | Reasoning effort sent to Mistral thinking-capable models. The API accepts high, medium, low and none. | +| `ODYSSEUS_MLX_IMAGE_VLM_MODEL` | *unset* | `scripts/mlx_image_server.py:299` | Vision-language model id for the MLX image server script. Required unless `--vlm-model` is passed on the command line. | +| `ODYSSEUS_QWEN_ROUTE_THINKING` | `'auto'` | `src/agent_loop.py:166` | Thinking policy for the Qwen routing step. An unrecognized value falls back to `auto`. | + +### Agent loop and tool execution + +| Variable | Default | Read in | What it does | +|---|---|---|---| +| `ODYSSEUS_DISABLE_MCP` | `''` | `src/builtin_mcp.py:89` | Truthy disables MCP entirely, as an escape hatch for compatibility problems with a server. | +| `ODYSSEUS_MAX_VISUAL_EVIDENCE_FRAMES` | `'3'` | `src/agent_loop.py:15360` | How many video frames one tool result may contribute. Clamped to 1-8. | +| `ODYSSEUS_MAX_VISUAL_EVIDENCE_IMAGES` | `'1'` | `src/agent_loop.py:15328` | How many images one tool result may contribute to the model turn. Clamped to 1-8. | +| `ODYSSEUS_MCP_ALLOWED_COMMANDS` | `''` | `src/agent_tools/admin_tools.py:140` | Security-relevant. Comma-separated allowlist of MCP launcher basenames the agent may start. Empty by default, and the deny list still wins. | +| `ODYSSEUS_PYTHON_TOOL_SITE_PACKAGES` | `''` | `src/agent_tools/subprocess_tools.py:925` | Security-relevant. Absolute package roots, separated by the platform path separator, exposed to the sandboxed Python tool. Empty exposes none. | +| `ODYSSEUS_SCRIPT_HOST` | `'localhost'` | `src/builtin_actions.py:919` | Default host for the run-script action. `localhost`, `127.0.0.1`, `local` and empty run locally; any other value runs over SSH. | +| `ODYSSEUS_TOOL_APPROVAL_GATE` | `'0'` | `src/tool_capabilities.py:645` | Security-relevant. Truthy makes tool calls pass through the approval gate. Off by default. | + +### Browser automation + +| Variable | Default | Read in | What it does | +|---|---|---|---| +| `ODYSSEUS_BROWSER_EXECUTABLE` | `''` | `src/builtin_mcp.py:114` | Absolute path to the Chrome or Chromium binary. Empty searches the usual names, then lets Playwright MCP pick its own browser. | +| `ODYSSEUS_BROWSER_ISOLATED` | `'1'` | `src/builtin_mcp.py:139` | Security-relevant. On by default, adding `--isolated` so each browser session starts clean. Set 0, false or no to keep a persistent profile. | +| `ODYSSEUS_BROWSER_MCP_CACHE` | `os.path.join(base_dir, 'data', 'local', 'playwright-mcp-cache')` | `src/builtin_mcp.py:229` | Cache directory handed to the browser MCP server, so its npm download survives a container rebuild. | +| `ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE` | `''` | `src/builtin_mcp.py:90` | Truthy refuses to start the browser MCP server unless its npm package is already in the npx cache, instead of installing it at startup. | +| `ODYSSEUS_BROWSER_NAMESPACE` | `'odysseus-ui'` | `src/agent_tools/web_tools.py:2355` (+5 more) | Namespace for the detached agent-browser daemon's pid files, so two runtimes on one machine do not terminate each other's browsers. | +| `ODYSSEUS_BROWSER_NO_SANDBOX` | `'1'` | `src/builtin_mcp.py:142` | Security-relevant. On by default, adding `--no-sandbox` because the Docker image cannot use the Chromium sandbox. Set 0, false or no to keep it. | +| `ODYSSEUS_BROWSER_SCREENSHOT_DIR` | *unset* | `src/agent_tools/web_tools.py:3010` | Where private-browser screenshots are written. Falls back to the container path, then the system temp directory. | + +### Container and workspace mounts + +| Variable | Default | Read in | What it does | +|---|---|---|---| +| `ODYSSEUS_WORKSPACE_CONTAINER_ROOT` | *unset* | `src/workspace_paths.py:31` | Container-side root that the host root maps onto. An `or` fallback, not a read default, supplies `/workspace` when it is unset. | +| `ODYSSEUS_WORKSPACE_DEFAULT` | `''` | `routes/workspace_routes.py:97` | Default workspace path the admin-only workspace route reports. Empty means no default is configured. | +| `ODYSSEUS_WORKSPACE_HOST_ROOT` | *unset* | `src/workspace_paths.py:29` | Single host-side root, paired with the container root below. Simpler than the explicit mount list when there is only one mount. | +| `ODYSSEUS_WORKSPACE_MOUNTS` | `''` | `src/workspace_paths.py:19` | `host=container` path pairs separated by commas or semicolons, so the agent can translate a container path back to the host path a user typed. | + +### Email + +| Variable | Default | Read in | What it does | +|---|---|---|---| +| `ODYSSEUS_DOCUMENT_OWNER` | `''` | `mcp_servers/email_server.py:208` | Owner stamped on documents the email MCP server creates. Stdio MCP tools get no authenticated user, so without this a draft is invisible. | +| `ODYSSEUS_IMAP_TIMEOUT_SECONDS` | *unset* | `routes/email_helpers.py:1163` | IMAP socket timeout in seconds, clamped to 5-300. A non-numeric value falls back to 30 rather than failing. | + +### Calendar, notes and single-user mode + +| Variable | Default | Read in | What it does | +|---|---|---|---| +| `ODYSSEUS_ALLOW_PRIVATE_CALDAV` | `'0'` | `src/caldav_sync.py:52` | Security-relevant. Truthy lets CalDAV sync reach private and link-local addresses. Off by default; this is an SSRF guard. | +| `ODYSSEUS_FALLBACK_OWNER` | `'owner@localhost'` | `routes/calendar_routes.py:65` | Owner address that single-user mode attributes an unauthenticated request to. Only reachable while single-user mode is on. | +| `ODYSSEUS_SINGLE_USER` | `'1'` | `routes/calendar_routes.py:66` | Security-relevant. On by default. Set to 0 on a real multi-user install so unauthenticated calendar writes are rejected rather than absorbed. | + +### Upload and media limits + +| Variable | Default | Read in | What it does | +|---|---|---|---| +| `ODYSSEUS_CHAT_UPLOAD_MAX_BYTES` | `10 * 1024 * 1024` | `src/upload_limits.py:33` | Maximum bytes accepted for a chat attachment. | +| `ODYSSEUS_EDITOR_DRAFT_MAX_BYTES` | `256 * 1024 * 1024` | `src/upload_limits.py:47` | Maximum bytes accepted for a saved editor draft. | +| `ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES` | `25 * 1024 * 1024` | `src/upload_limits.py:56` | Maximum bytes accepted for an attachment added while composing mail. | +| `ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES` | `25 * 1024 * 1024` | `src/upload_limits.py:44` (+1 more) | Maximum bytes accepted for an image handed to a gallery transform. | +| `ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES` | `100 * 1024 * 1024` | `src/upload_limits.py:41` (+1 more) | Maximum bytes accepted for a gallery upload. | +| `ODYSSEUS_ICS_MAX_BYTES` | `10 * 1024 * 1024` | `src/upload_limits.py:62` | Maximum bytes accepted for an imported ICS file. | +| `ODYSSEUS_MEDIA_FRAME_TIMEOUT` | `30` | `src/media_ingress.py:146` | Seconds allowed for extracting frames from a video before giving up. | +| `ODYSSEUS_MEDIA_MAX_AUDIO_BYTES` | `32 * 1024 * 1024` | `src/media_ingress.py:129` | Largest source audio file the media pipeline will read. | +| `ODYSSEUS_MEDIA_MAX_DIMENSION` | `1600` | `src/media_ingress.py:138` | Longest edge in pixels an image is resized down to before encoding. | +| `ODYSSEUS_MEDIA_MAX_DOCUMENT_BYTES` | `32 * 1024 * 1024` | `src/media_ingress.py:126` | Largest source document the media pipeline will read. | +| `ODYSSEUS_MEDIA_MAX_DOCUMENT_CHARS` | `24000` | `src/media_ingress.py:135` | How many characters of an ingested document are inlined into the turn. | +| `ODYSSEUS_MEDIA_MAX_ENCODED_BYTES` | `24 * 1024 * 1024` | `src/media_ingress.py:132` | Budget for the encoded payload handed to the model, counted cumulatively across one turn's attachments rather than per file. | +| `ODYSSEUS_MEDIA_MAX_FILES` | `4` | `src/media_ingress.py:119` | How many local media files one agent turn may ingest. | +| `ODYSSEUS_MEDIA_MAX_IMAGE_BYTES` | `12 * 1024 * 1024` | `src/media_ingress.py:120` | Largest source image the media pipeline will read. | +| `ODYSSEUS_MEDIA_MAX_PIXELS` | `40000000` | `src/media_ingress.py:139` | Total pixel budget for a source image, as a decompression-bomb guard. | +| `ODYSSEUS_MEDIA_MAX_VIDEO_BYTES` | `128 * 1024 * 1024` | `src/media_ingress.py:123` | Largest source video the media pipeline will read. | +| `ODYSSEUS_MEDIA_MAX_VIDEO_FRAMES` | `8` | `src/media_ingress.py:140` | How many frames are sampled from a video. | +| `ODYSSEUS_MEDIA_PROBE_TIMEOUT` | `15` | `src/media_ingress.py:143` | Seconds allowed for probing a video's metadata before giving up. | +| `ODYSSEUS_MEMORY_IMPORT_MAX_BYTES` | `10 * 1024 * 1024` | `src/upload_limits.py:50` (+1 more) | Maximum bytes accepted for a memory import file. | +| `ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES` | `25 * 1024 * 1024` | `src/upload_limits.py:53` (+1 more) | Maximum bytes accepted for a personal-documents upload. | +| `ODYSSEUS_STT_MAX_AUDIO_BYTES` | `25 * 1024 * 1024` | `src/upload_limits.py:59` | Maximum bytes accepted for an audio file submitted for transcription. | + +### Search + +| Variable | Default | Read in | What it does | +|---|---|---|---| +| `ODYSSEUS_SEARCH_PROVIDER` | `''` | `services/search/providers.py:43` | Forces the search provider, overriding the Settings value. Empty keeps the UI authoritative, which is what a normal install wants. | + +### Memory and skills + +| Variable | Default | Read in | What it does | +|---|---|---|---| +| `ODYSSEUS_SKILL_SEMANTIC_RETRIEVAL` | `'1'` | `services/memory/skills.py:789` | On by default. Set 0, false, no or off to fall back to keyword-only skill retrieval when no vector store is reachable. | +| `ODYSSEUS_SKILL_SEMANTIC_THRESHOLD` | `'0.4'` | `services/memory/skills.py:800` | Minimum semantic score a skill needs to be retrieved. A non-numeric value falls back to the default. | + +### Speech and vision models + +| Variable | Default | Read in | What it does | +|---|---|---|---| +| `ODYSSEUS_GROUNDING_MODEL` | `'google/owlvit-base-patch32'` | `routes/gallery/gallery_routes.py:95` | Object-grounding model id the gallery loads for text-driven selection. | +| `ODYSSEUS_SAM_MODEL` | `'facebook/sam-vit-base'` | `routes/gallery/gallery_routes.py:59` | Segmentation model id the gallery loads for subject selection. | +| `ODYSSEUS_STT_MODEL` | *unset* | `src/agent_tools/media_tools.py:2184` | Default speech-to-text model for media transcription when the tool call does not name one. | +| `ODYSSEUS_TTS_CACHE_MAX_BYTES` | `500 * 1024 * 1024` | `services/tts/tts_service.py:47` | Cap on the synthesized-speech cache. A non-numeric value falls back to the default. | + +### Auth and internal API + +| Variable | Default | Read in | What it does | +|---|---|---|---| +| `ODYSSEUS_INTERNAL_BASE` | *unset* | `src/constants.py:178` | Base URL the in-app tool layer uses for loopback HTTP calls. Set it when the app is not reachable at the port it thinks it is bound to. | +| `ODYSSEUS_INTERNAL_TOKEN` | *unset* | `core/middleware.py:20` | Security-relevant. Token that lets the in-app tool layer reach admin-gated routes over loopback. Unset generates a fresh per-process token, which is what you want unless something outside the process needs the same value. | + +### Integrations (Claude, Codex) + +| Variable | Default | Read in | What it does | +|---|---|---|---| +| `ODYSSEUS_API_TOKEN` | `''` | `integrations/claude/skills/odysseus/scripts/odysseus_api.py:40` (+1 more) | API token those scripts authenticate with. Both this and the URL are required; the scripts name whichever is missing. | +| `ODYSSEUS_URL` | `''` | `integrations/claude/skills/odysseus/scripts/odysseus_api.py:39` (+1 more) | Base URL of the Odysseus instance the bundled integration scripts call. | + +## Internal and development-only variables + +Listed for completeness. Setting one of these on a real install is either a no-op or a way to break something quietly. + +### Model routing and providers + +| Variable | Default | Read in | What it does | +|---|---|---|---| +| `ODYSSEUS_COPILOT_EDITOR_VERSION` | `'Odysseus/1.0'` | `src/copilot.py:54` | Editor-version header presented to the Copilot API. Kept stable on purpose. | +| `ODYSSEUS_COPILOT_INTEGRATION_ID` | `'vscode-chat'` | `src/copilot.py:51` | Integration id presented to the Copilot API. Kept stable on purpose. | +| `ODYSSEUS_COPILOT_USER_AGENT` | `'Odysseus/1.0'` | `src/copilot.py:48` | Editor-like User-Agent presented to the Copilot API. Kept stable on purpose. | +| `ODYSSEUS_DEBUG_LLM_SHAPE` | `''` | `src/llm_core.py:3552` (+1 more) | Truthy logs the shape of streamed provider chunks. Debugging aid for provider response parsing. | + +### Agent loop and tool execution + +| Variable | Default | Read in | What it does | +|---|---|---|---| +| `ODYSSEUS_CAPTURE_MODEL_REQUESTS` | `''` | `src/agent_loop.py:3921` | Truthy writes model-request snapshots for local debugging. The marker file `/tmp/odysseus_capture_model_requests` enables the same thing. | +| `ODYSSEUS_EXPOSE_RAW_BROWSER_MCP` | `''` | `src/agent_loop.py:4126` | Truthy stops hiding the raw Playwright MCP tools from agent prompts when the private-browser tool is available. | +| `ODYSSEUS_TOOL_CONTRACT_ROOT` | `'/scripts'` | `src/clean_agent_preview.py:1952` (+1 more) | Directory holding the tool-contract scripts the clean-agent preview loads. The default is the repository's bundled scripts directory; set the variable to override it. | + +### Email + +| Variable | Default | Read in | What it does | +|---|---|---|---| +| `ODYSSEUS_EMAIL_FIXTURE` | *unset* | `mcp_servers/email_server.py:1122` (+7 more) | Exactly `1`, plus a fixture file on disk, makes the email MCP server serve fixtures instead of a real mailbox. | + +### Testing, capture and development tooling + +| Variable | Default | Read in | What it does | +|---|---|---|---| +| `ODYSSEUS_QA_TEACHER_ATTEMPTS` | `'3'` | `scripts/odysseus_conversation_qa.py:370` | Retry budget for the conversation-QA teacher model call. Clamped to 1-3. | +| `ODYSSEUS_QA_TEACHER_TIMEOUT` | `'120'` | `scripts/odysseus_conversation_qa.py:372` | Timeout in seconds for that call. Clamped to 15-120. | +| `ODYSSEUS_RUNTIME_REVISION` | `''` | `routes/chat_helpers.py:198` (+1 more) | Revision string stamped into each captured SFT trace record, so a trace can be tied back to the build that produced it. | +| `ODYSSEUS_SFT_DISABLE_WORKSPACE_TOOLS` | `'1'` | `src/agent_loop.py:7404` | On by default. Keeps synthetic personal-assistant fixtures out of workspace mode; set 0, false, no or off to let them through. | +| `ODYSSEUS_SFT_FORCE_UTC_TIMEZONE` | `'0'` | `routes/chat_routes.py:2070` | Truthy forces `sft_` accounts to UTC for deterministic batch generation. Interactive accounts still follow the browser timezone. | +| `ODYSSEUS_SFT_TRACE_CAPTURE` | `'1'` | `routes/chat_helpers.py:161` (+1 more) | On by default, but only for owners whose name starts with `sft_`. Set 0, false, no or off to stop writing training traces. | +| `ODYSSEUS_SFT_TRACE_DIR` | *unset* | `routes/chat_helpers.py:195` (+2 more) | Directory the SFT trace JSONL files are written to. Defaults to `sft_traces` under the data directory. | +| `ODYSSEUS_SKIP_RUN_HINT` | *unset* | `setup.py:284` | Any non-empty value suppresses the `start the server with` hint at the end of setup. `start-macos.sh` sets it because it starts the server itself. | +| `ODYSSEUS_TEST_STATIC_ORIGIN` | *unset* | `scripts/css_snapshot.py:249` (+6 more) | Origin an already-running static server is serving the repository from, so snapshot tooling reuses it instead of starting its own. | +| `ODYSSEUS_TEST_STATIC_PORT` | *unset* | `tests/conftest.py:137` | Fixed port for the test suite's static server. Unset takes an ephemeral port, which is what keeps parallel runs from colliding. | + +### Build and release metadata + +| Variable | Default | Read in | What it does | +|---|---|---|---| +| `ODYSSEUS_BUILD_VERSION` | `''` | `src/constants.py:15` | Overrides the build-version string the API and UI report, without touching the public application version. | +| `ODYSSEUS_SOURCE_COMMIT` | `''` | `src/constants.py:33` | Overrides the source commit reported for runtime provenance, for builds that ship without a git directory. | + +## How this page is generated + +The generator walks the Python sources under `app.py`, `launcher.py`, `setup.py`, `companion`, `config`, `core`, `integrations`, `mcp_servers`, `routes`, `scripts`, `services`, `src`, `tests` and finds +reads three ways, because no single pattern covers the codebase: + +- Direct reads: `os.getenv(...)`, and any `.get` / `.setdefault` / `.pop` call or + subscript keyed by an `ODYSSEUS_*` literal, including names held in a + module-level constant. The receiver is not required to be `os.environ`, because + several call sites read through a mapping passed in as an argument + (`src/tool_index.py`, `src/host_docker_access.py`). +- Calls to env-reader helpers - any function that forwards one of its own + parameters to an environment read. This is detected rather than hardcoded, so a + new helper needs no change here. It is what finds the upload caps in + `src/upload_limits.py` and the media-ingress overrides in + `src/media_ingress.py`. +- A regex sweep of the raw file text, for reads the AST cannot see. + `routes/cookbook_helpers.py` builds an Ollama probe script as a list of source + lines, so one read lives inside a string literal. + +The three passes are not redundancy. A line-based grep for a direct +`os.environ.get("ODYSSEUS_...` call finds 70 of the 98 variables on this +page. What it misses is reads through an env-reader helper, reads whose call +spans more than one line, reads whose variable name is held in a module +constant, and reads through a mapping passed in as an argument - which is the +whole reason this page is generated rather than maintained. + +The `Default` column shows the expression as written, with one level of +indirection resolved: a module-level constant and a dataclass field default are +replaced by the literal they hold, so `defaults.max_media_files` shows as `4`. +Anything computed at import time - `get_default_data_dir()` - is shown as +written, because that is the honest answer. A few call sites supply their +fallback with `or` rather than a default argument; those show as *unset* and say +so in the last column. + +Regenerate it with: + +```bash +python3 scripts/generate_env_reference.py +``` diff --git a/website/setup.md b/website/setup.md index aeaf6baf6..9dc826340 100644 --- a/website/setup.md +++ b/website/setup.md @@ -718,6 +718,12 @@ Key settings: All upload-limit vars are validated (must be a positive integer) and optional; an invalid value fails fast at startup. +The table above is the short list. The source tree reads a lot more `ODYSSEUS_*` +variables than this - browser automation, model routing, workspace mounts, media +limits, a few security switches - and the complete list, generated from the code +with the default each one falls back to, is in the +[configuration reference](configuration-reference.md). + ### Built-in MCP servers (optional setup) Odysseus auto-registers a few built-in MCP servers at startup. The npx-based ones (currently the browser server, `@playwright/mcp`) only start when their npm package is already in the local npx cache. If a package isn't cached, that server is skipped with a startup log message explaining what to do, so a fresh install does not block on a multi-minute npm download or hang if Playwright system deps are missing.