diff --git a/.env.quickstart.example b/.env.quickstart.example index 4a6ae8e..d4c728e 100644 --- a/.env.quickstart.example +++ b/.env.quickstart.example @@ -41,6 +41,28 @@ OPENROUTER_API_KEY= # LOCAL — pre-filled with sensible quickstart defaults; change if needed # ============================================================================= +# BACKEND_RUNTIME +# What: Where/how the backend service runs — and therefore what isolates the +# agents from your machine. +# "docker" = run in a container; the container is the isolation +# boundary (default, recommended). +# "process" = run the backend directly on this host (no Docker). You +# must already have the developer environment installed +# (Python 3.14, uv, `cd backend && uv sync`), and the host +# OS sandbox (bubblewrap+socat on Linux, Seatbelt on macOS) +# confines the agents instead. Refuses to start a generation +# if that sandbox is unavailable (fail closed). +# How: Normally you don't set this here. On first launch the TUI asks which +# runtime to use and remembers the pick in .specflow-local/backend-runtime +# (a local-launcher file — NOT mcp-config.json, since the MCP server just +# calls the backend and doesn't care how it's launched). You can also +# switch runtimes from the dashboard. Setting BACKEND_RUNTIME as an +# exported environment variable overrides both (highest precedence after +# an explicit CLI flag); a value in this .env file alone is NOT read by +# the launcher's runtime gate. +# Docs: docs/backend/backend-runtime.md +# BACKEND_RUNTIME=docker + # AUTH_MODE # What: Controls how the backend authenticates inbound requests. # "local" = single-user mode, no API key header required. diff --git a/.gitignore b/.gitignore index 1190b76..cb8b9e2 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,7 @@ agents/plans/*/ # Documentation archive docs/archive scripts/archive +docs/internal # Contributor-local E2E workspace pool config (may hold private repo URLs). # The committed template is e2e-workspace-config.example.json. diff --git a/CLAUDE.md b/CLAUDE.md index e489b12..9a0a057 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,9 +117,12 @@ Both layers return the same error shape so the user experience is identical rega | `E2E_PLAN_UNPARSEABLE` | JSON conversion of the e2e plan fails | "Couldn't parse `e2e-test-plan.md` into rounds. Re-run `run_planning` — check that each round has a heading and verification steps." | | `GENERATION_ALREADY_RUNNING` | A generation for this project is already in progress | "A generation is already running. Wait for the email notification before starting another one." | | `MODEL_UNAVAILABLE` | A configured LLM tier has a model that isn't available on the active provider (OpenRouter/Anthropic per `DEFAULT_PROVIDER`) | "The model(s) configured for {tier} aren't available on {provider}: {models}. Did you mean '{suggestion}'? Fix {tier} in your MCP config and try again." | +| `SANDBOX_UNAVAILABLE` | `BACKEND_RUNTIME=process` but the host OS agent sandbox (bubblewrap+socat on Linux / Seatbelt on macOS) can't initialize | "Linux sandbox dependencies missing: {deps}. Install with `sudo apt-get install bubblewrap socat` … " (macOS/platform variants per `os_sandbox.check_agent_sandbox_available`). | `MODEL_UNAVAILABLE` semantics (catalog fetch, block-on-any-invalid policy, the two gate locations, and why it doesn't release workspaces) are documented in `docs/backend/model-validation.md`. +`SANDBOX_UNAVAILABLE` semantics (the `BACKEND_RUNTIME` docker/process split, the fail-closed OS agent sandbox, the two gate locations, supported platforms, and residual risks) are documented in `docs/backend/backend-runtime.md`. + **Rules for implementers:** - Return shape from MCP tool: `{"error": "", "code": "", "missing_files": [...], "ambiguous": [...]}`. The IDE displays the message; the structured fields exist for tooling. - Never proceed to backend call if MCP-side precheck failed. diff --git a/Makefile b/Makefile index 5d771fe..b6e269f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: run stop stop-test clean help base build ops-retry-run ops-cancel-run check-complexity check-complexity-diff check-complexity-cc check-complexity-mi secret-scan secret-scan-history secret-scan-gitleaks secret-scan-trufflehog skip-mode-e2e-tests contract-validation-e2e-tests shutdown-recovery-e2e-tests real-e2e-tests quickstart require-e2e-workspace-config +.PHONY: run run-process stop-process stop stop-test clean help base build ops-retry-run ops-cancel-run check-complexity check-complexity-diff check-complexity-cc check-complexity-mi secret-scan secret-scan-history secret-scan-gitleaks secret-scan-trufflehog skip-mode-e2e-tests contract-validation-e2e-tests shutdown-recovery-e2e-tests real-e2e-tests quickstart require-e2e-workspace-config # Default target .DEFAULT_GOAL := build @@ -137,6 +137,19 @@ run-detached-skip: build @echo "⏭️ Agent execution: SKIPPED (testing mode)" WORKSPACE_MOUNT_PATH=$(WORKSPACE_MOUNT_PATH) SKIP_AGENT_EXECUTION=true docker-compose up -d --no-build +# Bare-metal backend (BACKEND_RUNTIME=process) — no Docker. Requires the developer +# environment already installed (Python 3.14, uv, `cd backend && uv sync`) and the +# host OS sandbox (bubblewrap on Linux / Seatbelt on macOS). Starts detached with a +# fail-closed sandbox preflight; see docs/backend/backend-runtime.md. +run-process: + @echo "🚀 Starting backend bare-metal (BACKEND_RUNTIME=process)..." + @cd mcp_server && uv run python -c "import asyncio, sys; from pathlib import Path; from services import local_backend_process; sys.exit(asyncio.run(local_backend_process.run_backend_process_cli(Path('$(CURDIR)'))))" + +# Stop a detached bare-metal backend started by run-process (or the TUI). The +# pidfile is machine-wide (~/.specflow), so no path is passed. +stop-process: + @cd mcp_server && uv run python -c "from services import local_backend_process; print('🛑 stopped' if local_backend_process.stop_backend_process() else 'ℹ️ no running backend process')" + # Stop ONLY the isolated local-testing stack (project: specflow-test) and wipe its ephemeral # workspace/database state. Quickstart is stopped outside this Make target. stop: diff --git a/QUICKSTART.md b/QUICKSTART.md index 7a3bdde..33a8c6c 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -49,6 +49,12 @@ in spec_dir "my_specs", and outputs dir in "results" - `curl` - Cursor, Claude Code, Claude Desktop, Copilot, or another MCP-capable IDE +> Docker is the default and what this guide assumes. To run the backend without +> Docker (bare-metal, agents confined by the host OS sandbox), set +> `BACKEND_RUNTIME=process` — you then need the developer environment installed +> and, on Linux, `bubblewrap`+`socat`. See +> [`docs/backend/backend-runtime.md`](docs/backend/backend-runtime.md). + ## 3. Required `.env` Values Keep first setup focused. Fill only these values before running `specflow init`: diff --git a/backend/app/agents_sandboxing/os_sandbox.py b/backend/app/agents_sandboxing/os_sandbox.py new file mode 100644 index 0000000..7a059d6 --- /dev/null +++ b/backend/app/agents_sandboxing/os_sandbox.py @@ -0,0 +1,174 @@ +"""OS-level agent sandbox for ``BACKEND_RUNTIME=process``. + +In ``docker`` mode the container is the isolation boundary and this module is a +no-op (Docker behaviour is unchanged). In ``process`` mode the backend runs on +the bare host, so the container boundary is gone; we substitute Claude Code's +built-in OS-level Bash sandbox (bubblewrap on Linux, Apple Seatbelt on macOS), +enabled per agent query via ``ClaudeAgentOptions.sandbox``. + +This confines each agent's Bash subprocesses — the exact surface that escapes the +in-process allowlist and PreToolUse guard (``agent_hooks.py`` notes "``python +script.py`` is not caught … the real boundary is the sandbox"). It is an added +OS-enforced layer, **not** a replacement for the existing in-process controls +(defense in depth). + +Security posture: **fail closed**. When process mode is active but the sandbox +cannot initialise (missing dependency / unsupported OS), ``run_generation`` +refuses synchronously rather than running agents unconfined on the host. +""" + +from __future__ import annotations + +import os +import shutil +import sys +from dataclasses import dataclass + +from claude_agent_sdk import SandboxNetworkConfig, SandboxSettings + +from app.core.config import WORKSPACE_CACHE_SUBDIR, settings +from app.core.enums import BackendRuntime + +# Curated network allowlist for sandboxed agent Bash commands (allow-only). Kept +# deliberately tight — see the domain-fronting / exfiltration warning in Claude +# Code's sandbox docs: only the package registries and git host the supported +# toolchains need to fetch dependencies during generation. The LLM API is NOT +# listed because the Claude CLI process itself runs OUTSIDE the Bash sandbox, so +# model connectivity is unaffected by this list. Override via +# ``settings.AGENT_SANDBOX_ALLOWED_DOMAINS`` (comma-separated). +DEFAULT_AGENT_SANDBOX_ALLOWED_DOMAINS: tuple[str, ...] = ( + # Python + "pypi.org", + "files.pythonhosted.org", + # Node + "registry.npmjs.org", + # Go + "proxy.golang.org", + "sum.golang.org", + # Java / Gradle / Android + "repo.maven.apache.org", + "repo1.maven.org", + "plugins.gradle.org", + "services.gradle.org", + "dl.google.com", + # Dart / Flutter + "pub.dev", + # Git host + release/object CDNs + "github.com", + "*.github.com", + "codeload.github.com", + "raw.githubusercontent.com", + "objects.githubusercontent.com", + # Shared object storage used by several toolchains (go, flutter, gradle) + "storage.googleapis.com", +) + +# Commands known to be incompatible with the sandbox → run outside it. ``docker`` +# is documented-incompatible; deploy/QA agents already gate docker/kubectl +# separately (see tool_usage.DEPLOY_BASH_USAGE). Kept minimal per the exfil warning. +_SANDBOX_EXCLUDED_COMMANDS: tuple[str, ...] = ("docker",) + + +@dataclass(frozen=True) +class SandboxUnavailable: + """Why the OS sandbox can't run on this host, with an actionable fix message.""" + + dependency: str + message: str + + +def _allowed_domains() -> list[str]: + raw = settings.AGENT_SANDBOX_ALLOWED_DOMAINS + if raw and raw.strip(): + return [domain.strip() for domain in raw.split(",") if domain.strip()] + return list(DEFAULT_AGENT_SANDBOX_ALLOWED_DOMAINS) + + +def get_agent_sandbox_settings() -> SandboxSettings | None: + """``SandboxSettings`` for agent queries, or ``None`` when no OS sandbox is engaged. + + Returns ``None`` in DOCKER mode (the container is the boundary — behaviour is + unchanged). In PROCESS mode returns a fail-closed, allow-only sandbox that + confines agent Bash subprocesses (and their children) at the OS level. Writes + are confined by the SDK to the query ``cwd`` (already the workspace) plus the + session temp dir, reinforcing the existing ``Write/Edit({workspace}/**)`` + allowlist at the OS level. + """ + if settings.BACKEND_RUNTIME != BackendRuntime.PROCESS: + return None + network: SandboxNetworkConfig = {"allowedDomains": _allowed_domains()} + return SandboxSettings( + enabled=True, + autoAllowBashIfSandboxed=True, + # Fail closed: no dangerouslyDisableSandbox escape hatch — a command that + # cannot run sandboxed fails rather than silently running on the bare host. + allowUnsandboxedCommands=False, + excludedCommands=list(_SANDBOX_EXCLUDED_COMMANDS), + network=network, + ) + + +def get_agent_sandbox_write_allowlist() -> list[str]: + """Extra ``Edit``/``Write`` tool rules that widen the sandbox writable set in + process mode; empty in docker mode (no sandbox engaged). + + The OS Bash sandbox only allows writes to the query ``cwd`` (the workspace) and + the session temp dir. But SpecFlow redirects every tool cache + (``setup_workspace_cache_directories``) to ``{WORKSPACE_BASE_PATH}/caches/…``, + which sits OUTSIDE ``cwd`` — so ``npm install`` / ``pip install`` / ``go mod + download`` would be denied write access under the sandbox. The Claude Agent SDK + intentionally has no ``SandboxSettings.filesystem`` field and directs filesystem + write scope through ``Edit`` allow-rules (see ``SandboxSettings`` docstring), + which merge into the subprocess writable set. Granting the caches subtree here + is strictly tighter than docker mode (where bash writes are unrestricted). + """ + if settings.BACKEND_RUNTIME != BackendRuntime.PROCESS: + return [] + caches_root = os.path.join(settings.WORKSPACE_BASE_PATH, WORKSPACE_CACHE_SUBDIR) + # Same single-slash absolute glob form as workspace_usage() in claude_code.py. + return [f"Edit({caches_root}/**)", f"Write({caches_root}/**)"] + + +def check_agent_sandbox_available() -> SandboxUnavailable | None: + """Return ``None`` if the OS sandbox can run on this host, else why not. + + - macOS: Seatbelt via the built-in ``sandbox-exec``. + - Linux: bubblewrap (``bwrap``) for filesystem/namespace isolation and + ``socat`` for the network proxy relay. + + Only meaningful in PROCESS mode; DOCKER mode always returns ``None`` (the + container is the boundary, so host sandbox tooling is irrelevant). + """ + if settings.BACKEND_RUNTIME != BackendRuntime.PROCESS: + return None + if sys.platform == "darwin": + if shutil.which("sandbox-exec") is None: + return SandboxUnavailable( + dependency="sandbox-exec", + message=( + "macOS sandbox tool `sandbox-exec` was not found on PATH. It ships " + "with macOS — ensure /usr/bin is on PATH." + ), + ) + return None + if sys.platform.startswith("linux"): + missing = [dep for dep in ("bwrap", "socat") if shutil.which(dep) is None] + if missing: + return SandboxUnavailable( + dependency=", ".join(missing), + message=( + f"Linux sandbox dependencies missing: {', '.join(missing)}. Install with " + "`sudo apt-get install bubblewrap socat` (Debian/Ubuntu) or " + "`sudo dnf install bubblewrap socat` (Fedora). On Ubuntu 24.04+ you may " + "also need an AppArmor profile allowing unprivileged user namespaces for " + "`bwrap` (see docs/backend/backend-runtime.md)." + ), + ) + return None + return SandboxUnavailable( + dependency=sys.platform, + message=( + f"The agent OS sandbox is not supported on this platform ({sys.platform}). Use " + "BACKEND_RUNTIME=docker, or run the backend on macOS or Linux." + ), + ) diff --git a/backend/app/api/v1/generation_sessions.py b/backend/app/api/v1/generation_sessions.py index 88f1a6f..3bcddff 100644 --- a/backend/app/api/v1/generation_sessions.py +++ b/backend/app/api/v1/generation_sessions.py @@ -56,6 +56,7 @@ from app.schemas.telemetry_workflow import PhaseKind from app.schemas.model_validation import TierValidation, TierValidationStatus from app.schemas.specification import GenerationWorkflowRequest +from app.agents_sandboxing.os_sandbox import check_agent_sandbox_available from app.services.contract_validator import RejectionCode from app.services.model_validation import validate_models_config from app.services.generation_session import ( @@ -1479,6 +1480,25 @@ async def run_generation_session( workflow_llm_overrides = build_llm_overrides(LLM_HIGH=LLM_HIGH, LLM_MEDIUM=LLM_MEDIUM, LLM_LOW=LLM_LOW) generation_session_params.update(workflow_llm_overrides) + # Authoritative agent-sandbox gate (BACKEND_RUNTIME=process only). With no + # container boundary, agents must be confined by the OS-level Bash sandbox; + # if it can't initialise we refuse here rather than run agents unconfined on + # the host (fail closed). Like the model gate this is a synchronous rejection, + # not a state-machine fail() — workspaces stay allocated; the user installs the + # dependency and calls run_generation again. See docs/backend/backend-runtime.md. + sandbox_unavailable = check_agent_sandbox_available() + if sandbox_unavailable is not None: + logger.warning( + f"run_generation rejected (SANDBOX_UNAVAILABLE): {sandbox_unavailable.message}" + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": sandbox_unavailable.message, + "code": RejectionCode.SANDBOX_UNAVAILABLE.value, + }, + ) + # Authoritative model gate (defense-in-depth; the MCP server also pre-flights this). # A 2–8h run must not start against a model the active provider doesn't offer. # Block-on-any-invalid; an unverifiable catalog (no key / outage) never blocks. diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 4609be6..97326c8 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -8,7 +8,7 @@ from pydantic import AliasChoices, Field, computed_field, field_validator, model_validator from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict -from app.core.enums import AuthMode, DatabaseType, LLMProvider +from app.core.enums import AuthMode, BackendRuntime, DatabaseType, LLMProvider # MCP server ids enabled for agent workflows when listed in MCP_SERVERS_ENABLED (comma-separated). # User-supplied names outside this set are ignored. See docs/agents/enabled-mcps.md. @@ -29,6 +29,11 @@ # SQLITE_DB_PATH (via env) to address the same bind-mounted file by its real host path. CONTAINER_SQLITE_DB_PATH = "/root/.specflow/db/specflow.db" +# Subdirectory under WORKSPACE_BASE_PATH holding per-workspace + shared tool caches +# (npm/pip/go/gradle/…). Single source of truth shared by claude_code's cache-dir +# setup and the agent OS-sandbox write allowlist (os_sandbox), so the two never drift. +WORKSPACE_CACHE_SUBDIR = "caches" + # Single source of truth for the P10Y/Compass endpoint. P10Y_DEFAULT_BASE_URL = "https://compass.p10y.com" @@ -105,8 +110,13 @@ class Settings(BaseSettings): STANDARDS_DIR_NAME: str = "standards" # Directory name for standards in each workspace (becomes ./standards) # Artifact Store Configuration - # Archived generation outputs are stored at ARTIFACTS_BASE_PATH/{generation_id}/ - ARTIFACTS_BASE_PATH: str = Field(default="/workspaces/artifacts") + # Archived generation outputs are stored at ARTIFACTS_BASE_PATH/{generation_id}/. + # Defaults to {WORKSPACE_BASE_PATH}/artifacts (derived via model_validator below, + # same as CLAUDE_CODE_TMPDIR_PATH) so it follows the workspace base instead of being + # a second hardcoded container path. Critical in BACKEND_RUNTIME=process, where the + # workspace base is a host path: a fixed /workspaces/artifacts would be created under + # the read-only filesystem root and crash archival. Set explicitly to override. + ARTIFACTS_BASE_PATH: Optional[str] = None # Claude Code temp directory — passed to every agent as CLAUDE_CODE_TMPDIR so that # Claude Code writes its internal temp files to the persistent NFS volume instead of @@ -341,6 +351,16 @@ def _empty_str_to_none_int(cls, v: object) -> object: GITHUB_TEAM_SLUG: Optional[str] = None WORKSPACE_REPO_PREFIX: str = "specflow-workspace" + # Backend runtime / agent OS-sandbox settings. + # DOCKER (default): the container is the isolation boundary; no in-process + # agent sandbox is engaged (decoupled — Docker behaviour is unchanged). + # PROCESS: the backend runs bare-metal, so agents are confined by the OS-level + # Bash sandbox. See app/agents_sandboxing/os_sandbox.py. + BACKEND_RUNTIME: BackendRuntime = BackendRuntime.DOCKER + # Optional comma-separated override for the agent sandbox network allowlist + # (see os_sandbox.DEFAULT_AGENT_SANDBOX_ALLOWED_DOMAINS). Empty → use default. + AGENT_SANDBOX_ALLOWED_DOMAINS: Optional[str] = None + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", @@ -359,6 +379,17 @@ def _validate_database_type(cls, v: object) -> object: ) return v + @field_validator("BACKEND_RUNTIME", mode="before") + @classmethod + def _validate_backend_runtime(cls, v: object) -> object: + if isinstance(v, str): + allowed = {member.value for member in BackendRuntime} + if v not in allowed: + raise ValueError( + f"Invalid BACKEND_RUNTIME: {v!r}. Allowed values: {sorted(allowed)}" + ) + return v + @model_validator(mode="after") def _reject_local_auth_in_protected_environments(self) -> "Settings": if self.AUTH_MODE != AuthMode.LOCAL: @@ -370,13 +401,22 @@ def _reject_local_auth_in_protected_environments(self) -> "Settings": return self @model_validator(mode="after") - def _derive_claude_code_tmpdir(self) -> "Settings": + def _derive_workspace_relative_paths(self) -> "Settings": + # Paths that nest under the workspace base share a single source of truth: + # overriding WORKSPACE_BASE_PATH (e.g. host paths in BACKEND_RUNTIME=process) + # cascades to both, so neither can drift back to a container-only default. if self.CLAUDE_CODE_TMPDIR_PATH is None: object.__setattr__( self, "CLAUDE_CODE_TMPDIR_PATH", os.path.join(self.WORKSPACE_BASE_PATH, "claude_code_tmpdir"), ) + if self.ARTIFACTS_BASE_PATH is None: + object.__setattr__( + self, + "ARTIFACTS_BASE_PATH", + os.path.join(self.WORKSPACE_BASE_PATH, "artifacts"), + ) return self @computed_field diff --git a/backend/app/core/enums.py b/backend/app/core/enums.py index 178f520..7e13d12 100644 --- a/backend/app/core/enums.py +++ b/backend/app/core/enums.py @@ -24,3 +24,19 @@ class DatabaseType(StrEnum): EMULATOR = "emulator" FIRESTORE = "firestore" SQLITE = "sqlite" + + +class BackendRuntime(StrEnum): + """Where/how the backend service is launched — and therefore what provides + the OS-level isolation boundary around the agents. + + - ``DOCKER`` (default): the backend runs in a container; the container *is* + the boundary, so no in-process agent sandbox is engaged. + - ``PROCESS``: the backend runs directly on the host (bare-metal uvicorn); + the container boundary is gone, so agents must be confined by the OS-level + Bash sandbox (bubblewrap on Linux, Seatbelt on macOS). See + ``app/agents_sandboxing/os_sandbox.py``. + """ + + DOCKER = "docker" + PROCESS = "process" diff --git a/backend/app/services/claude_code.py b/backend/app/services/claude_code.py index 9145013..907c886 100644 --- a/backend/app/services/claude_code.py +++ b/backend/app/services/claude_code.py @@ -21,7 +21,11 @@ ) -from app.core.config import SUPPORTED_MCPS, settings +from app.agents_sandboxing.os_sandbox import ( + get_agent_sandbox_settings, + get_agent_sandbox_write_allowlist, +) +from app.core.config import SUPPORTED_MCPS, WORKSPACE_CACHE_SUBDIR, settings from app.core.ttl_config import GenerationLifecyclePolicy from app.core.logging import create_agent_logger, format_json_to_log, log_agent_options from app.core.mcp_selection import McpSelector @@ -194,11 +198,13 @@ def setup_workspace_cache_directories(workspace_path: str) -> Dict[str, str]: Dictionary of environment variables ready to merge into the agent env. """ workspace_name = Path(workspace_path).name - cache_root = os.path.join(settings.WORKSPACE_BASE_PATH, "caches", workspace_name) + cache_root = os.path.join(settings.WORKSPACE_BASE_PATH, WORKSPACE_CACHE_SUBDIR, workspace_name) cache_base = os.path.join(cache_root, ".cache") data_base = os.path.join(cache_root, ".local", "share") config_base = os.path.join(cache_root, ".config") - android_sdk_root = os.path.join(settings.WORKSPACE_BASE_PATH, "caches", "common", "android") + android_sdk_root = os.path.join( + settings.WORKSPACE_BASE_PATH, WORKSPACE_CACHE_SUBDIR, "common", "android" + ) # Directories this function owns and creates via makedirs. managed_cache_dirs = { @@ -983,8 +989,10 @@ async def agent_query( cwd=workspace_path, # Constraints on tool usage - # Important - limit paths to specific workspace to avoid jailbreaking to external file system - allowed_tools=allowed_tools, + # Important - limit paths to specific workspace to avoid jailbreaking to external file system. + # In process mode, extend the write scope to the workspace tool-cache dirs (outside cwd) so the + # OS Bash sandbox permits package installs there; empty in docker mode. See os_sandbox. + allowed_tools=[*allowed_tools, *get_agent_sandbox_write_allowlist()], disallowed_tools=disallowed_tools, # Sources @@ -1012,6 +1020,12 @@ async def agent_query( # commands before they ever spawn. See app/services/agent_hooks.py. hooks=get_bash_guard_hooks(), + # OS-level Bash sandbox (bubblewrap / Seatbelt) — engaged only in + # BACKEND_RUNTIME=process, where the container boundary is gone; None in + # docker mode (unchanged). Added layer on top of the allowlist + guard + # above, not a replacement. See app/agents_sandboxing/os_sandbox.py. + sandbox=get_agent_sandbox_settings(), + # Capture subprocess stderr so startup errors (MCP failures, bad session, etc.) # are visible in logs instead of being swallowed as "Check stderr output for details" stderr=lambda line: logger.warning("[claude-cli stderr] %s", line), diff --git a/backend/app/services/contract_validator.py b/backend/app/services/contract_validator.py index 320dc18..298e0ff 100644 --- a/backend/app/services/contract_validator.py +++ b/backend/app/services/contract_validator.py @@ -49,6 +49,8 @@ class RejectionCode(StrEnum): E2E_PLAN_MISSING, AMBIGUOUS_FILE, ANALYSIS_UNREADABLE, PLAN_NO_PHASES, PLAN_UNPARSEABLE, E2E_PLAN_UNPARSEABLE. Both layers share this enum to keep the full catalog in one place. + The run_generation entrance additionally raises MODEL_UNAVAILABLE and + SANDBOX_UNAVAILABLE (the latter only when BACKEND_RUNTIME=process). """ SPEC_DIR_MISSING = "SPEC_DIR_MISSING" @@ -63,6 +65,7 @@ class RejectionCode(StrEnum): E2E_PLAN_UNPARSEABLE = "E2E_PLAN_UNPARSEABLE" GENERATION_ALREADY_RUNNING = "GENERATION_ALREADY_RUNNING" MODEL_UNAVAILABLE = "MODEL_UNAVAILABLE" + SANDBOX_UNAVAILABLE = "SANDBOX_UNAVAILABLE" @dataclass diff --git a/backend/test/agents_sandboxing/test_os_sandbox.py b/backend/test/agents_sandboxing/test_os_sandbox.py new file mode 100644 index 0000000..d195757 --- /dev/null +++ b/backend/test/agents_sandboxing/test_os_sandbox.py @@ -0,0 +1,118 @@ +"""Tests for the OS-level agent sandbox (BACKEND_RUNTIME=process). + +Covers get_agent_sandbox_settings (off in docker, fail-closed in process) and +check_agent_sandbox_available (platform-specific, fail-closed preflight). The +module reads the global settings singleton at call time, so tests monkeypatch +``settings.BACKEND_RUNTIME`` / ``settings.AGENT_SANDBOX_ALLOWED_DOMAINS`` and the +platform/PATH probes. +""" + +import app.agents_sandboxing.os_sandbox as os_sandbox +from app.agents_sandboxing.os_sandbox import ( + DEFAULT_AGENT_SANDBOX_ALLOWED_DOMAINS, + check_agent_sandbox_available, + get_agent_sandbox_settings, + get_agent_sandbox_write_allowlist, +) +from app.core.config import settings +from app.core.enums import BackendRuntime + + +class TestGetAgentSandboxSettings: + def test_none_in_docker_mode(self, monkeypatch): + monkeypatch.setattr(settings, "BACKEND_RUNTIME", BackendRuntime.DOCKER) + assert get_agent_sandbox_settings() is None + + def test_fail_closed_settings_in_process_mode(self, monkeypatch): + monkeypatch.setattr(settings, "BACKEND_RUNTIME", BackendRuntime.PROCESS) + monkeypatch.setattr(settings, "AGENT_SANDBOX_ALLOWED_DOMAINS", None) + cfg = get_agent_sandbox_settings() + assert cfg is not None + assert cfg["enabled"] is True + # Fail closed: no dangerouslyDisableSandbox escape hatch. + assert cfg["allowUnsandboxedCommands"] is False + assert cfg["network"]["allowedDomains"] == list(DEFAULT_AGENT_SANDBOX_ALLOWED_DOMAINS) + + def test_allowed_domains_override(self, monkeypatch): + monkeypatch.setattr(settings, "BACKEND_RUNTIME", BackendRuntime.PROCESS) + monkeypatch.setattr( + settings, "AGENT_SANDBOX_ALLOWED_DOMAINS", "example.com, foo.test ," + ) + cfg = get_agent_sandbox_settings() + assert cfg["network"]["allowedDomains"] == ["example.com", "foo.test"] + + +class TestGetAgentSandboxWriteAllowlist: + def test_empty_in_docker_mode(self, monkeypatch): + monkeypatch.setattr(settings, "BACKEND_RUNTIME", BackendRuntime.DOCKER) + assert get_agent_sandbox_write_allowlist() == [] + + def test_grants_cache_subtree_in_process_mode(self, monkeypatch): + monkeypatch.setattr(settings, "BACKEND_RUNTIME", BackendRuntime.PROCESS) + monkeypatch.setattr(settings, "WORKSPACE_BASE_PATH", "/workspaces") + # Must match claude_code.setup_workspace_cache_directories' caches root and + # workspace_usage()'s single-slash absolute glob form. + assert get_agent_sandbox_write_allowlist() == [ + "Edit(/workspaces/caches/**)", + "Write(/workspaces/caches/**)", + ] + + def test_allowlist_actually_covers_the_real_cache_dirs(self, monkeypatch, tmp_path): + """Lock the single-source contract: every dir setup_workspace_cache_directories + creates must fall under the granted caches subtree, or process-mode installs break.""" + from app.services.claude_code import setup_workspace_cache_directories + + monkeypatch.setattr(settings, "BACKEND_RUNTIME", BackendRuntime.PROCESS) + monkeypatch.setattr(settings, "WORKSPACE_BASE_PATH", str(tmp_path)) + rule = get_agent_sandbox_write_allowlist()[0] # "Edit(/**)" + caches_root = rule[len("Edit(") : -len("/**)")] + cache_env = setup_workspace_cache_directories(str(tmp_path / "ws-01-1")) + managed = [cache_env[k] for k in ("XDG_CACHE_HOME", "PIP_CACHE_DIR", "npm_config_cache")] + assert all(path.startswith(caches_root) for path in managed) + + +class TestCheckAgentSandboxAvailable: + def test_none_in_docker_mode_regardless_of_platform(self, monkeypatch): + monkeypatch.setattr(settings, "BACKEND_RUNTIME", BackendRuntime.DOCKER) + monkeypatch.setattr(os_sandbox.sys, "platform", "win32") + assert check_agent_sandbox_available() is None + + def test_macos_ok_when_sandbox_exec_present(self, monkeypatch): + monkeypatch.setattr(settings, "BACKEND_RUNTIME", BackendRuntime.PROCESS) + monkeypatch.setattr(os_sandbox.sys, "platform", "darwin") + monkeypatch.setattr(os_sandbox.shutil, "which", lambda name: "/usr/bin/sandbox-exec") + assert check_agent_sandbox_available() is None + + def test_macos_fail_closed_when_missing(self, monkeypatch): + monkeypatch.setattr(settings, "BACKEND_RUNTIME", BackendRuntime.PROCESS) + monkeypatch.setattr(os_sandbox.sys, "platform", "darwin") + monkeypatch.setattr(os_sandbox.shutil, "which", lambda name: None) + unavailable = check_agent_sandbox_available() + assert unavailable is not None + assert unavailable.dependency == "sandbox-exec" + + def test_linux_ok_when_both_present(self, monkeypatch): + monkeypatch.setattr(settings, "BACKEND_RUNTIME", BackendRuntime.PROCESS) + monkeypatch.setattr(os_sandbox.sys, "platform", "linux") + monkeypatch.setattr(os_sandbox.shutil, "which", lambda name: f"/usr/bin/{name}") + assert check_agent_sandbox_available() is None + + def test_linux_reports_each_missing_dep(self, monkeypatch): + monkeypatch.setattr(settings, "BACKEND_RUNTIME", BackendRuntime.PROCESS) + monkeypatch.setattr(os_sandbox.sys, "platform", "linux") + monkeypatch.setattr( + os_sandbox.shutil, + "which", + lambda name: "/usr/bin/bwrap" if name == "bwrap" else None, + ) + unavailable = check_agent_sandbox_available() + assert unavailable is not None + assert "socat" in unavailable.dependency + assert "bwrap" not in unavailable.dependency + + def test_unsupported_platform_fails_closed(self, monkeypatch): + monkeypatch.setattr(settings, "BACKEND_RUNTIME", BackendRuntime.PROCESS) + monkeypatch.setattr(os_sandbox.sys, "platform", "win32") + unavailable = check_agent_sandbox_available() + assert unavailable is not None + assert "win32" in unavailable.message diff --git a/backend/test/core/test_config_paths.py b/backend/test/core/test_config_paths.py index 0e21ffa..d97ba5e 100644 --- a/backend/test/core/test_config_paths.py +++ b/backend/test/core/test_config_paths.py @@ -40,6 +40,37 @@ def test_explicit_tmpdir_wins(self): assert s.CLAUDE_CODE_TMPDIR_PATH == "/custom/tmp" +class TestArtifactsBasePathDerivation: + def test_default_artifacts_base(self): + """Default artifacts base = /workspaces/artifacts (unchanged from the old + hardcoded default, so docker behaviour and existing paths are identical).""" + from app.core.config import Settings + + s = Settings() + assert s.ARTIFACTS_BASE_PATH == "/workspaces/artifacts" + + def test_custom_workspace_base_derives_artifacts(self): + """Custom WORKSPACE_BASE_PATH → artifacts follow the new base. This is the + process-mode fix: a host workspace base keeps archival off read-only ``/``.""" + from app.core.config import Settings + + with pytest.MonkeyPatch.context() as mp: + mp.setenv("WORKSPACE_BASE_PATH", "/home/u/proj/workspaces") + mp.delenv("ARTIFACTS_BASE_PATH", raising=False) + s = Settings() + assert s.ARTIFACTS_BASE_PATH == "/home/u/proj/workspaces/artifacts" + + def test_explicit_artifacts_base_wins(self): + """Explicit ARTIFACTS_BASE_PATH overrides the derivation.""" + from app.core.config import Settings + + with pytest.MonkeyPatch.context() as mp: + mp.setenv("WORKSPACE_BASE_PATH", "/ws") + mp.setenv("ARTIFACTS_BASE_PATH", "/custom/artifacts") + s = Settings() + assert s.ARTIFACTS_BASE_PATH == "/custom/artifacts" + + class TestWorkspaceDirAlias: def test_workspace_path_env_sets_workspace_dir(self): """WORKSPACE_PATH env var populates WORKSPACE_DIR (AliasChoices).""" diff --git a/backend/test/core/test_enums.py b/backend/test/core/test_enums.py index b8499e4..959dfce 100644 --- a/backend/test/core/test_enums.py +++ b/backend/test/core/test_enums.py @@ -8,7 +8,7 @@ import pytest from pydantic import ValidationError -from app.core.enums import AuthMode, DatabaseType, LLMProvider +from app.core.enums import AuthMode, BackendRuntime, DatabaseType, LLMProvider class TestLLMProvider: @@ -93,3 +93,39 @@ def test_database_type_bogus_raises(self): mp.setenv("DATABASE_TYPE", "bogus") with pytest.raises(ValidationError, match="Invalid DATABASE_TYPE"): Settings() + + +class TestBackendRuntime: + def test_values(self): + assert BackendRuntime.DOCKER == "docker" + assert BackendRuntime.PROCESS == "process" + + def test_invalid_raises(self): + with pytest.raises(ValueError): + BackendRuntime("vm") + + +class TestSettingsBackendRuntime: + def test_defaults_to_docker(self): + from app.core.config import Settings + + with pytest.MonkeyPatch.context() as mp: + mp.delenv("BACKEND_RUNTIME", raising=False) + s = Settings(_env_file=None) + assert s.BACKEND_RUNTIME == BackendRuntime.DOCKER + + def test_process_accepted(self): + from app.core.config import Settings + + with pytest.MonkeyPatch.context() as mp: + mp.setenv("BACKEND_RUNTIME", "process") + s = Settings(_env_file=None) + assert s.BACKEND_RUNTIME == BackendRuntime.PROCESS + + def test_bogus_raises(self): + from app.core.config import Settings + + with pytest.MonkeyPatch.context() as mp: + mp.setenv("BACKEND_RUNTIME", "vm") + with pytest.raises(ValidationError, match="Invalid BACKEND_RUNTIME"): + Settings(_env_file=None) diff --git a/docs/backend/DEVELOPMENT.md b/docs/backend/DEVELOPMENT.md index 104dd6d..3cba6b2 100644 --- a/docs/backend/DEVELOPMENT.md +++ b/docs/backend/DEVELOPMENT.md @@ -172,22 +172,39 @@ make test make check ``` -### Manual Startup (without Docker) +### Running without Docker (`BACKEND_RUNTIME=process`) + +The backend can run directly on the host instead of in a container. This is the +supported **process** runtime — set `BACKEND_RUNTIME=process` (in `.env` or the +environment). In this mode the container isolation boundary is gone, so agents +are confined by the host OS sandbox (bubblewrap on Linux, Seatbelt on macOS) and +the backend **refuses to start a generation if that sandbox is unavailable** +(fail closed). See [`backend-runtime.md`](backend-runtime.md) for the full model, +supported platforms, and the residual-risk list. + +You are responsible for having the developer environment installed +(Python 3.14, `uv`, backend deps). Two ways to start it: ```bash -cd backend +# Convenience wrapper — detached, with the fail-closed sandbox preflight, +# host paths, and a readiness wait (stop with `make stop-process`): +make run-process -# Install dependencies +# …or manually (foreground): +cd backend uv sync - -# Set environment (sqlite needs no separate process) +export BACKEND_RUNTIME=process export DATABASE_TYPE=sqlite export SQLITE_DB_PATH=~/.specflow/db/specflow.db - -# Run backend -uv run uvicorn app.main:app --reload --port 8000 +export WORKSPACE_BASE_PATH=$(git rev-parse --show-toplevel)/workspaces +uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000 ``` +The TUI (`specflow tui`) also uses this path automatically when +`BACKEND_RUNTIME=process` is set (via the Settings screen or `.env`): it warns +about the prerequisites, runs the sandbox preflight, and starts the backend +detached. + ### SKIP_MODE for Fast Testing Test workflow orchestration without triggering real AI execution: diff --git a/docs/backend/backend-runtime.md b/docs/backend/backend-runtime.md new file mode 100644 index 0000000..3f5c180 --- /dev/null +++ b/docs/backend/backend-runtime.md @@ -0,0 +1,169 @@ +# Backend runtime & agent isolation (`BACKEND_RUNTIME`) + +`BACKEND_RUNTIME` selects **where the backend service runs** and, as a direct +consequence, **what isolates the code-generation agents from the host machine**. +It is decoupled from everything else — it changes only the launch path and the +agent protection layer, not any workflow, state-machine, or estimation logic. + +| Value | Backend runs as | Agent isolation boundary | +|-------|-----------------|--------------------------| +| `docker` (default) | A container (`docker compose up`) | The **container** — unchanged behaviour | +| `process` | A bare-metal `uvicorn` process on the host | The **OS-level Bash sandbox** (bubblewrap on Linux, Seatbelt on macOS) engaged per agent query | + +Supported host OSes for `process` mode: **macOS and Linux**. On Windows, use +`docker`. + +## Selecting the runtime + +Which runtime to launch is a **local-launcher** decision, not an MCP-server +setting (the MCP server only calls `backend_url` and is indifferent to how the +backend is launched). It is resolved with this precedence: + +1. explicit CLI flag, +2. `BACKEND_RUNTIME` environment variable (must be exported — a value sitting only + in `.env` is **not** read by the gate), +3. the launcher's saved choice at `~/.specflow/backend-runtime`, +4. default `docker`. + +The TUI drives (3): on first launch, when the runtime isn't pinned by (1)/(2) +**and** nothing is already running (no containers, no bare process), it shows a +one-time chooser and writes the pick to `~/.specflow/backend-runtime`. If +something is already up, it infers the runtime from that instead of asking. The +runtime choice and the process pidfile (`~/.specflow/backend.pid`) are +machine-wide — the bare-metal backend is a singleton (one uvicorn on one port, +backed by the shared `~/.specflow/db/specflow.db`), so `stop` / `switch runtime` +from any checkout find the one running backend instead of spawning a duplicate. +Only the launch **log** stays per-project, at `.specflow-local/backend.log`. This +choice is deliberately **not** in `mcp-config.json`. + +## Why the agent sandbox matters in process mode + +In `docker` mode the container is the only OS-level boundary around the agents. +Every other control is in-process and, by design, bypassable: + +- the Bash allowlist (`backend/app/core/tool_usage.py`), +- workspace path scoping via `cwd` + `Read/Write/Edit({workspace}/**)`, +- the PreToolUse regex guard (`backend/app/services/agent_hooks.py`), +- credential-name redaction (`backend/app/agents_sandboxing/claude_env_vars.py`). + +`agent_hooks.py` says so explicitly: "`python script.py` is not caught … the real +boundary is the sandbox." Removing Docker removes that boundary, so `process` +mode substitutes an OS-enforced one. + +## How the substitute boundary works + +Claude Code's built-in Bash sandbox is enabled per agent query via +`ClaudeAgentOptions.sandbox` (see `backend/app/agents_sandboxing/os_sandbox.py`, +`get_agent_sandbox_settings`). It: + +- confines each agent's **Bash subprocesses and their children** to the query + working directory (already the workspace) + the session temp dir, at the OS + level (bubblewrap namespaces on Linux, Seatbelt on macOS). SpecFlow redirects + tool caches (npm/pip/go/…) to `{WORKSPACE_BASE_PATH}/caches/…`, which is outside + `cwd`, so `get_agent_sandbox_write_allowlist` grants that subtree write access + via `Edit`/`Write` rules (the SDK routes sandbox filesystem writes through + permission rules — `SandboxSettings` has no `filesystem` field) — otherwise + `npm install` / `pip install` would be denied; +- restricts outbound network to an **allow-only** domain list + (`DEFAULT_AGENT_SANDBOX_ALLOWED_DOMAINS`: package registries + the git host; + override with `AGENT_SANDBOX_ALLOWED_DOMAINS`, comma-separated); +- runs **fail closed** — `allowUnsandboxedCommands=False`, so a command that + cannot be sandboxed fails rather than silently running on the bare host. + +This is an **added** OS-enforced layer on top of the existing in-process controls +(defense in depth), engaged only when `BACKEND_RUNTIME=process`. In `docker` mode +`get_agent_sandbox_settings()` returns `None` and nothing changes. + +> The LLM API is intentionally **not** in the network allowlist: the Claude CLI +> process itself runs outside the Bash sandbox, so model connectivity is +> unaffected. The list only governs what agent shell commands (npm/pip/go/git…) +> may reach. + +## Fail-closed gates + +Because a 2–8h run cannot prompt the user mid-flight, the sandbox is preflighted +at two points (mirroring the `MODEL_UNAVAILABLE` two-gate pattern): + +1. **TUI** (`StartBackendProcessScreen`) — refuses before even starting the + backend, with an actionable install message. +2. **Backend `run_generation` entrance** — the authoritative gate + (`check_agent_sandbox_available`). On failure it returns a short rejection + (`code: SANDBOX_UNAVAILABLE`) and starts nothing. Like other entrance + rejections this is **not** a state-machine `fail()`: no `failed_at`, workspaces + stay allocated — install the dependency and call `run_generation` again. + +## Stopping the backend (process mode) + +Unlike docker mode (where the container stack is the lifecycle boundary), a +process-mode backend is a **detached host process that outlives the TUI** — it +keeps running after you quit the TUI, along with any in-flight generation. To +stop it: + +- **From the TUI** — press `k` (*stop backend*) on the dashboard or sessions + screen. The binding is shown **only in process mode**. It confirms first, + naming how many generations are in flight (a stop interrupts them). Per the + STEEL COMMANDMENTS a stop never releases workspaces: the generated code is + preserved and `retry_generation` resumes from the last checkpoint. On success + the TUI exits; relaunching re-runs the startup gate, which detects the backend + is down and offers to start it again. +- **From the shell** — `make stop-process` (SIGTERMs the process group and + clears the pidfile). Equivalent to what the TUI's `k` does. + +## Switching runtime from the TUI + +Press `t` (*switch runtime*) on the **sessions** screen to move docker↔process +without leaving the app. (It lives on the sessions overview, not the +single-generation dashboard, because it cancels *all* in-flight runs and restarts +the backend.) The switch: + +1. **Preflights the target first** (before touching the running backend): a + fail-closed sandbox check for →process, a `docker` CLI check for →docker. + If the target can't run, it refuses and leaves the current backend untouched. +2. **Confirms**, naming how many in-flight generations will be cancelled. +3. **Cancels those generations on the current backend** (they are marked + `CANCELLED`; workspaces and generated code are preserved per the STEEL + COMMANDMENTS) — this must happen before teardown, while the API is still up. +4. **Tears down** the current backend (`stop_backend_process` / `docker compose + down`), **persists** the new choice to `~/.specflow/backend-runtime`, and + **starts** the target backend, waiting for it to become healthy. + +Because both runtimes bind the same host:port and (in the default `sqlite` setup) +share `~/.specflow/db/specflow.db`, the cancelled runs remain visible after the +switch and the TUI keeps polling the new backend automatically. + +## Dependencies + +- **macOS**: nothing to install — `sandbox-exec` (Seatbelt) ships with the OS. +- **Linux**: `bubblewrap` and `socat`: + - Debian/Ubuntu: `sudo apt-get install bubblewrap socat` + - Fedora: `sudo dnf install bubblewrap socat` + - Ubuntu 24.04+: the default AppArmor policy blocks unprivileged user + namespaces `bwrap` needs. If + `sysctl kernel.apparmor_restrict_unprivileged_userns` returns `1`, add an + AppArmor profile for `/usr/bin/bwrap` (see the Claude Code sandboxing docs) + and reload AppArmor. + +**Licenses** (all separate binaries invoked out-of-process, not linked into +SpecFlow): bubblewrap — LGPL-2.1; socat — GPL-2.0; the Claude Agent SDK that +drives them — Apache-2.0; Seatbelt is a macOS system facility. + +## Residual risks (inherent to the mechanism) + +- The Bash sandbox confines **Bash subprocesses only**. `Read/Edit/Write` still + rely on the in-process path allowlist. +- Network filtering is **hostname-based, no TLS inspection**, so a broad + `allowedDomains` entry can be a data-exfiltration path (domain fronting). Keep + the allowlist tight. +- Do **not** enable `enableWeakerNestedSandbox` on Linux except inside an outer + container that already isolates you — it materially weakens the sandbox. + +## Alternatives considered (and why not) + +- **`@anthropic-ai/sandbox-runtime` (`srt`) wrapping the whole backend** + (Apache-2.0): too broad — the backend legitimately needs wide filesystem and + network access (workspaces, SQLite/Firestore, LLM APIs). Documented here as an + optional extra hardening layer for operators who want to also confine the + backend process itself; not built in. +- **firejail** (GPL-2.0, Linux-only, setuid-root) / **raw seccomp / Landlock** + (Linux-only, low-level): rejected — not integrated with the SDK we already use, + and the bubblewrap path already layers seccomp (Unix-socket blocking) for us. diff --git a/mcp_server/cli.py b/mcp_server/cli.py index ba83f70..f138072 100644 --- a/mcp_server/cli.py +++ b/mcp_server/cli.py @@ -97,6 +97,34 @@ def resolve_backend_config( return backend_url, user_email, workspace_count +def resolve_backend_runtime( + runtime_flag: str | None = None, home: Path | None = None +) -> local_env.BackendRuntime: + """Resolve how the backend is launched: docker (default) or process. + + Precedence: CLI flag → ``BACKEND_RUNTIME`` env var → the launcher's saved + choice (``~/.specflow/backend-runtime``, written by the TUI first-run + chooser) → default ``docker``. Unknown values fall back to ``docker``. + + The saved choice is machine-wide (the backend is a singleton), so it is read + from ``~/.specflow`` rather than any per-checkout dir. mcp-config.json is + deliberately NOT consulted: how the backend is launched is a local-launcher + concern, whereas mcp-config configures the MCP server, which merely calls + ``backend_url`` and is indifferent to the backend's runtime. + """ + raw = runtime_flag or os.getenv("BACKEND_RUNTIME") + if raw: + return local_env.BackendRuntime.parse(raw) + saved = local_env.read_saved_runtime(home) + return saved if saved is not None else local_env.BackendRuntime.DOCKER + + +def backend_runtime_is_configured(home: Path | None = None) -> bool: + """True when the runtime is explicitly pinned (``BACKEND_RUNTIME`` env var or a + saved choice), so the TUI's first-run chooser should be skipped.""" + return bool(os.getenv("BACKEND_RUNTIME")) or local_env.read_saved_runtime(home) is not None + + def _is_localhost(url: str) -> bool: """Return True if the URL host is localhost or 127.0.0.1.""" try: diff --git a/mcp_server/services/local_backend_process.py b/mcp_server/services/local_backend_process.py new file mode 100644 index 0000000..a8ddccd --- /dev/null +++ b/mcp_server/services/local_backend_process.py @@ -0,0 +1,219 @@ +"""Bare-metal ("process") backend control — ``BACKEND_RUNTIME=process``. + +In docker mode the container is the isolation boundary; in process mode the +backend runs directly on the host as a detached uvicorn process, and its agents +are confined instead by the OS-level Bash sandbox (see the backend's +``app/agents_sandboxing/os_sandbox.py``). This module owns only the host-side +process lifecycle: start / stop / detect the detached backend and preflight the +sandbox dependencies. + +Runtime *selection* (the ``BackendRuntime`` enum and the saved runtime choice) +and the shared filesystem / health helpers live in ``local_env``; this module is +strictly the process-mode control surface split out of it. +""" + +from __future__ import annotations + +import os +import shutil +import signal +import subprocess +import sys +from collections.abc import Callable +from pathlib import Path + +from services.local_env import ( + BackendRuntime, + read_dotenv, + specflow_home_dir, + wait_backend_ready, +) + +# The bare-metal backend is a machine-wide singleton (one uvicorn on one port, +# backed by the shared ~/.specflow db), so its PID lives under ~/.specflow — NOT +# per-checkout — so `stop` / `switch runtime` from any clone find the one running +# backend instead of spawning a duplicate on the same port. +_BACKEND_PID_BASENAME = "backend.pid" + +# The launch log stays per-project (under the repo's .specflow-local, beside +# mcp-config.json) so each checkout's launch output is inspectable next to it. +_BACKEND_LOG_FILENAME = ".specflow-local/backend.log" +_BACKEND_PORT_DEFAULT = "8000" + + +def backend_pid_path(home: Path | None = None) -> Path: + return specflow_home_dir(home) / _BACKEND_PID_BASENAME + + +def backend_log_path(root: Path) -> Path: + return root / _BACKEND_LOG_FILENAME + + +def _backend_port() -> str: + """Host port for the bare-metal backend — mirrors docker-compose's default.""" + return os.getenv("SPECFLOW_BACKEND_PORT", _BACKEND_PORT_DEFAULT) + + +def _read_backend_pid(home: Path | None = None) -> int | None: + """PID recorded by the last ``start_backend_process``; ``None`` if absent/garbage.""" + try: + return int(backend_pid_path(home).read_text().strip()) + except (OSError, ValueError): + return None + + +def _pid_alive(pid: int) -> bool: + """True iff a process with ``pid`` currently exists (POSIX ``kill -0``).""" + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True # exists but owned by another user + return True + + +def backend_process_running(home: Path | None = None) -> bool: + """True iff a previously-started bare-metal backend process is still alive.""" + pid = _read_backend_pid(home) + return pid is not None and _pid_alive(pid) + + +def build_process_backend_env(root: Path) -> dict[str, str]: + """Environment for the bare-metal backend — the host equivalent of the + docker-compose passthrough. + + docker-compose bind-mounts host dirs onto fixed *container* mount points and + passes those container paths to the backend (``/workspaces``, ``/agent_logs``, + ``/root/.specflow/...``). Those paths don't exist on the bare host and ``/`` is + read-only, so in process mode we **override** them with the host equivalents of + compose's host-side bind mounts — never ``setdefault``. A container path shipped + in ``.env`` (the quickstart sets ``WORKSPACE_BASE_PATH=/workspaces``) would + otherwise shadow the host path and the backend would crash creating a dir under + ``/`` (Errno 30, read-only file system). Genuine user choices that are *not* + mount targets (e.g. ``DATABASE_TYPE=firestore``) are still respected. + ``BACKEND_RUNTIME`` is forced to ``process`` so the backend engages the agent + OS-sandbox and its own fail-closed gate. + """ + env = dict(os.environ) + env.update(read_dotenv(root)) # API keys, provider, git identity, etc. + env.setdefault("DATABASE_TYPE", "sqlite") # a real user choice, not a mount target + # Container mount points → host equivalents of compose's bind-mount defaults + # (./workspaces, ./agent_logs, ${HOME}/.specflow). Overridden unconditionally: + # the container values are never valid on the bare host. + env["WORKSPACE_BASE_PATH"] = str(root / "workspaces") + env["AGENT_LOGS_BASE_PATH"] = str(root / "agent_logs") + env["SQLITE_DB_PATH"] = str(specflow_home_dir() / "db" / "specflow.db") + env["BACKEND_RUNTIME"] = BackendRuntime.PROCESS.value # always forced + return env + + +async def start_backend_process( + root: Path, + on_line: Callable[[str], None] | None = None, + home: Path | None = None, +) -> int: + """Launch the backend as a detached bare-metal ``uvicorn`` process. + + Mirrors the Dockerfile CMD on the host: ``uv run uvicorn app.main:app`` from + ``root/backend`` bound to localhost. Detaches from the terminal via a new + session (``start_new_session=True``) so it survives the TUI, redirecting + output to the per-project ``.specflow-local/backend.log`` and recording the + PID under the machine-wide ``~/.specflow/backend.pid``. Returns the spawned + PID; readiness is confirmed separately via ``wait_backend_ready``. + """ + backend_dir = root / "backend" + log_path = backend_log_path(root) + log_path.parent.mkdir(parents=True, exist_ok=True) + # Ensure the host workspace dir exists so the backend can allocate into it. + env = build_process_backend_env(root) + Path(env["WORKSPACE_BASE_PATH"]).mkdir(parents=True, exist_ok=True) + + argv = ["uv", "run", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", _backend_port()] + log_file = open(log_path, "ab") # child dups the fd; parent handle closes on GC + try: + proc = subprocess.Popen( + argv, + cwd=str(backend_dir), + stdout=log_file, + stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + start_new_session=True, + env=env, + ) + finally: + log_file.close() + pid_path = backend_pid_path(home) + pid_path.parent.mkdir(parents=True, exist_ok=True) + pid_path.write_text(str(proc.pid)) + if on_line is not None: + on_line(f"backend started detached (pid {proc.pid}); logs → {log_path}\n") + return proc.pid + + +def stop_backend_process(home: Path | None = None) -> bool: + """SIGTERM the detached backend's process group and clear the pidfile. + + Returns ``True`` if a live process was signalled, ``False`` if none was + recorded/alive. Signals the whole session group (the process is a group + leader from ``start_new_session``) so uvicorn workers are torn down too. + """ + pid = _read_backend_pid(home) + signalled = False + if pid is not None and _pid_alive(pid): + try: + os.killpg(os.getpgid(pid), signal.SIGTERM) + signalled = True + except (ProcessLookupError, PermissionError, OSError): + signalled = False + backend_pid_path(home).unlink(missing_ok=True) + return signalled + + +async def run_backend_process_cli(root: Path) -> int: + """Orchestrate ``make run-process``: fail-closed sandbox preflight → start + detached → wait for ready. Returns a shell exit code (0 = ready). + + Shares the exact launch path (``start_backend_process``) and health gate the + TUI uses, so the Makefile convenience target can never drift from it. + """ + reason = agent_sandbox_unavailable_reason() + if reason is not None: + print(f"❌ Cannot start in process mode — {reason}") + return 1 + await start_backend_process(root, on_line=lambda line: print(line, end="")) + if await wait_backend_ready(f"http://127.0.0.1:{_backend_port()}"): + print("✅ Backend ready (detached). Stop with `make stop-process`.") + return 0 + print("❌ Backend didn't become ready — see .specflow-local/backend.log") + return 1 + + +def agent_sandbox_unavailable_reason() -> str | None: + """Host-side preflight for the agent OS sandbox; ``None`` if it can run here. + + Deliberately mirrors the backend's authoritative + ``os_sandbox.check_agent_sandbox_available`` (the packages can't share code); + this is the fast local gate so the TUI refuses before even starting the + backend. macOS → Seatbelt (``sandbox-exec``); Linux → bubblewrap + socat. + """ + if sys.platform == "darwin": + if shutil.which("sandbox-exec") is None: + return ( + "macOS sandbox tool `sandbox-exec` was not found on PATH. It ships with " + "macOS — ensure /usr/bin is on PATH." + ) + return None + if sys.platform.startswith("linux"): + missing = [dep for dep in ("bwrap", "socat") if shutil.which(dep) is None] + if missing: + return ( + f"Linux sandbox dependencies missing: {', '.join(missing)}. Install with " + "`sudo apt-get install bubblewrap socat` (Debian/Ubuntu) or " + "`sudo dnf install bubblewrap socat` (Fedora)." + ) + return None + return ( + f"The agent OS sandbox is not supported on this platform ({sys.platform}). Use " + "BACKEND_RUNTIME=docker, or run on macOS or Linux." + ) diff --git a/mcp_server/services/local_env.py b/mcp_server/services/local_env.py index 5903590..b720d22 100644 --- a/mcp_server/services/local_env.py +++ b/mcp_server/services/local_env.py @@ -19,9 +19,11 @@ import asyncio import os +import shutil import subprocess from collections.abc import Callable from dataclasses import dataclass +from enum import StrEnum from pathlib import Path import httpx @@ -39,6 +41,87 @@ # Mirror docker-compose.yml container-name env-var defaults; sqlite has no separate container. _BACKEND_CONTAINER_DEFAULT = "specflow-backend" +# Per-user SpecFlow home (``~/.specflow``) — the machine-wide state dir already +# shared by the central SQLite db and the connected-AI-tools config +# (``tui.mcp_clients`` reuses this same constant for ~/.specflow/config.json). This +# is the single source of truth for the directory name. +SPECFLOW_HOME_DIRNAME = ".specflow" + +# The launcher's remembered runtime choice (docker | process) is machine-wide (the +# backend is a singleton), so it lives under ~/.specflow — NOT per-checkout — which +# lets `switch runtime` from any clone agree on one backend. It is a launcher fact, +# NOT an MCP-server setting (the MCP server only calls backend_url and is +# indifferent to how the backend is launched), so it is never written to +# mcp-config.json. The process-mode control surface (pidfile, log, start/stop) +# lives in ``services.local_backend_process``. +_BACKEND_RUNTIME_BASENAME = "backend-runtime" + + +def specflow_home_dir(home: Path | None = None) -> Path: + """The per-user SpecFlow home (``~/.specflow``); ``home`` injectable for tests.""" + return (home or Path.home()) / SPECFLOW_HOME_DIRNAME + + +class BackendRuntime(StrEnum): + """Where/how the backend service is launched (mcp_server view). + + Byte-identical to the backend's ``app.core.enums.BackendRuntime``; the two + packages can't import each other, so — like the MCP-side run_generation + precheck mirroring the backend contract validator — this is a deliberate, + minimal duplication of the shared string contract. + """ + + DOCKER = "docker" + PROCESS = "process" + + @classmethod + def parse(cls, raw: str | None) -> "BackendRuntime": + """Case-insensitive parse; unknown/empty → DOCKER (the safe default).""" + if raw: + value = raw.strip().lower() + for member in cls: + if member.value == value: + return member + return cls.DOCKER + + @classmethod + def parse_strict(cls, raw: str | None) -> "BackendRuntime | None": + """Like :meth:`parse` but returns ``None`` for unknown/empty instead of + defaulting to DOCKER — lets callers tell "never chosen" from "chose docker".""" + if raw: + value = raw.strip().lower() + for member in cls: + if member.value == value: + return member + return None + + +def backend_runtime_path(home: Path | None = None) -> Path: + return specflow_home_dir(home) / _BACKEND_RUNTIME_BASENAME + + +def read_saved_runtime(home: Path | None = None) -> "BackendRuntime | None": + """The runtime the launcher's first-run chooser persisted, or ``None``. + + ``None`` means "not chosen yet" (file absent or unrecognized) — deliberately + distinct from ``BackendRuntime.DOCKER`` so the startup gate can decide whether + to prompt. + """ + try: + raw = backend_runtime_path(home).read_text() + except OSError: + return None + return BackendRuntime.parse_strict(raw) + + +def save_backend_runtime(runtime: "BackendRuntime", home: Path | None = None) -> Path: + """Persist the launcher's runtime choice under ``~/.specflow`` (beside the + pidfile). Not written to mcp-config.json — see the module constants.""" + path = backend_runtime_path(home) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(runtime.value) + return path + # --------------------------------------------------------------------------- # Repo-root + path resolution @@ -350,6 +433,23 @@ async def start_containers(root: Path, on_line: Callable[[str], None] | None = N return await _stream_subprocess(["docker", "compose", "up", "-d", "--no-build"], root, on_line) +async def stop_containers(root: Path, on_line: Callable[[str], None] | None = None) -> int: + """Stop the SpecFlow stack (``docker compose down``), streamed. + + The counterpart to :func:`start_containers`, used when switching away from the + docker runtime. Returns the process exit code; non-zero surfaces through the + streamed output. + """ + return await _stream_subprocess(["docker", "compose", "down"], root, on_line) + + +def docker_cli_available() -> bool: + """True iff the ``docker`` CLI is on PATH — a cheap preflight before trying to + start the docker stack (distinct from :func:`containers_running`, which asks + whether the stack is already up).""" + return shutil.which("docker") is not None + + # --------------------------------------------------------------------------- # specflow-init.sh wrapper # --------------------------------------------------------------------------- diff --git a/mcp_server/tests/test_cli.py b/mcp_server/tests/test_cli.py index 84e635d..86751cd 100644 --- a/mcp_server/tests/test_cli.py +++ b/mcp_server/tests/test_cli.py @@ -122,6 +122,64 @@ def test_empty_list_still_renders(self): # --------------------------------------------------------------------------- +class TestResolveBackendRuntime: + def test_defaults_to_docker(self, tmp_path, monkeypatch): + from cli import resolve_backend_runtime + from services import local_env + monkeypatch.delenv("BACKEND_RUNTIME", raising=False) + assert resolve_backend_runtime(home=tmp_path) == local_env.BackendRuntime.DOCKER + + def test_flag_takes_priority_over_env(self, tmp_path, monkeypatch): + from cli import resolve_backend_runtime + from services import local_env + monkeypatch.setenv("BACKEND_RUNTIME", "docker") + assert resolve_backend_runtime("process", home=tmp_path) == local_env.BackendRuntime.PROCESS + + def test_env_takes_priority_over_saved_choice(self, tmp_path, monkeypatch): + from cli import resolve_backend_runtime + from services import local_env + local_env.save_backend_runtime(local_env.BackendRuntime.DOCKER, home=tmp_path) + monkeypatch.setenv("BACKEND_RUNTIME", "process") + assert resolve_backend_runtime(home=tmp_path) == local_env.BackendRuntime.PROCESS + + def test_saved_choice_fallback(self, tmp_path, monkeypatch): + from cli import resolve_backend_runtime + from services import local_env + monkeypatch.delenv("BACKEND_RUNTIME", raising=False) + local_env.save_backend_runtime(local_env.BackendRuntime.PROCESS, home=tmp_path) + assert resolve_backend_runtime(home=tmp_path) == local_env.BackendRuntime.PROCESS + + def test_mcp_config_runtime_is_ignored(self, tmp_path, monkeypatch): + # Runtime is a launcher concern, not an MCP-server setting: a stray + # BACKEND_RUNTIME in mcp-config.json must NOT drive resolution. + from cli import resolve_backend_runtime + from services import local_env + monkeypatch.delenv("BACKEND_RUNTIME", raising=False) + cfg_dir = tmp_path / ".specflow-local" + cfg_dir.mkdir() + (cfg_dir / "mcp-config.json").write_text( + json.dumps({"mcpServers": {"specflow": {"env": {"BACKEND_RUNTIME": "process"}}}}) + ) + assert resolve_backend_runtime(home=tmp_path) == local_env.BackendRuntime.DOCKER + + def test_is_configured_false_when_unset(self, tmp_path, monkeypatch): + from cli import backend_runtime_is_configured + monkeypatch.delenv("BACKEND_RUNTIME", raising=False) + assert backend_runtime_is_configured(home=tmp_path) is False + + def test_is_configured_true_from_env(self, tmp_path, monkeypatch): + from cli import backend_runtime_is_configured + monkeypatch.setenv("BACKEND_RUNTIME", "process") + assert backend_runtime_is_configured(home=tmp_path) is True + + def test_is_configured_true_from_saved_choice(self, tmp_path, monkeypatch): + from cli import backend_runtime_is_configured + from services import local_env + monkeypatch.delenv("BACKEND_RUNTIME", raising=False) + local_env.save_backend_runtime(local_env.BackendRuntime.DOCKER, home=tmp_path) + assert backend_runtime_is_configured(home=tmp_path) is True + + class TestResolveBackendConfig: def test_flag_takes_priority_over_env(self, tmp_path, monkeypatch): from cli import resolve_backend_config diff --git a/mcp_server/tests/test_local_backend_process.py b/mcp_server/tests/test_local_backend_process.py new file mode 100644 index 0000000..a749dc7 --- /dev/null +++ b/mcp_server/tests/test_local_backend_process.py @@ -0,0 +1,122 @@ +"""Unit tests for the bare-metal process-control helpers +(services/local_backend_process.py) — split out of local_env. + +Subprocess/HTTP-backed helpers are exercised with mocks so no docker or backend +is required; the machine-wide pidfile is redirected via the injectable ``home`` +arg so tests never touch the real ~/.specflow. +""" + +import pytest + +from services import local_backend_process + + +class TestBuildProcessBackendEnv: + def test_forces_process_and_fills_host_paths(self, tmp_path, monkeypatch): + monkeypatch.setattr(local_backend_process, "read_dotenv", lambda root: {"OPENROUTER_API_KEY": "k"}) + for var in ("DATABASE_TYPE", "SQLITE_DB_PATH", "WORKSPACE_BASE_PATH", "AGENT_LOGS_BASE_PATH"): + monkeypatch.delenv(var, raising=False) + env = local_backend_process.build_process_backend_env(tmp_path) + assert env["BACKEND_RUNTIME"] == "process" + assert env["OPENROUTER_API_KEY"] == "k" + assert env["DATABASE_TYPE"] == "sqlite" + assert env["WORKSPACE_BASE_PATH"] == str(tmp_path / "workspaces") + assert env["AGENT_LOGS_BASE_PATH"] == str(tmp_path / "agent_logs") + assert env["SQLITE_DB_PATH"] == str( + local_backend_process.specflow_home_dir() / "db" / "specflow.db" + ) + + def test_respects_non_mount_user_choices(self, tmp_path, monkeypatch): + # DATABASE_TYPE is a genuine user choice (not a container mount target) and + # is respected; the mount paths are handled by the override test below. + monkeypatch.setattr(local_backend_process, "read_dotenv", lambda root: {"DATABASE_TYPE": "firestore"}) + env = local_backend_process.build_process_backend_env(tmp_path) + assert env["DATABASE_TYPE"] == "firestore" # from .env, not overwritten + + def test_container_mount_paths_are_overridden_not_respected(self, tmp_path, monkeypatch): + # Regression: the quickstart .env ships container mount targets + # (WORKSPACE_BASE_PATH=/workspaces, AGENT_LOGS_BASE_PATH=/agent_logs); in + # process mode these MUST be overridden with host paths, else the backend + # crashes creating a dir under read-only "/" (Errno 30). See PR #51. + monkeypatch.setattr( + local_backend_process, + "read_dotenv", + lambda root: { + "WORKSPACE_BASE_PATH": "/workspaces", + "AGENT_LOGS_BASE_PATH": "/agent_logs", + "SQLITE_DB_PATH": "/root/.specflow/db/specflow.db", + }, + ) + # Even an exported shell value for a mount target must not win. + monkeypatch.setenv("WORKSPACE_BASE_PATH", "/workspaces") + env = local_backend_process.build_process_backend_env(tmp_path) + assert env["WORKSPACE_BASE_PATH"] == str(tmp_path / "workspaces") + assert env["AGENT_LOGS_BASE_PATH"] == str(tmp_path / "agent_logs") + assert env["SQLITE_DB_PATH"] == str( + local_backend_process.specflow_home_dir() / "db" / "specflow.db" + ) + assert not env["WORKSPACE_BASE_PATH"].startswith("/workspaces") + + +class TestProcessControl: + @pytest.mark.asyncio + async def test_start_records_pid_and_spawns_detached(self, tmp_path, monkeypatch): + monkeypatch.setattr(local_backend_process, "read_dotenv", lambda root: {}) + + class _FakePopen: + def __init__(self, argv, **kwargs): + self.pid = 4321 + _FakePopen.argv = argv + _FakePopen.kwargs = kwargs + + monkeypatch.setattr(local_backend_process.subprocess, "Popen", _FakePopen) + pid = await local_backend_process.start_backend_process(tmp_path, home=tmp_path) + assert pid == 4321 + assert local_backend_process._read_backend_pid(tmp_path) == 4321 + # Detached from the terminal + bound to localhost uvicorn from backend/. + assert _FakePopen.kwargs["start_new_session"] is True + assert _FakePopen.kwargs["cwd"] == str(tmp_path / "backend") + assert _FakePopen.argv[:4] == ["uv", "run", "uvicorn", "app.main:app"] + assert local_backend_process.backend_log_path(tmp_path).exists() + + def test_running_true_when_pid_alive(self, tmp_path, monkeypatch): + local_backend_process.backend_pid_path(tmp_path).parent.mkdir(parents=True, exist_ok=True) + local_backend_process.backend_pid_path(tmp_path).write_text("999") + monkeypatch.setattr(local_backend_process, "_pid_alive", lambda pid: True) + assert local_backend_process.backend_process_running(tmp_path) is True + + def test_running_false_when_no_pidfile(self, tmp_path): + assert local_backend_process.backend_process_running(tmp_path) is False + + def test_stop_signals_group_and_clears_pidfile(self, tmp_path, monkeypatch): + local_backend_process.backend_pid_path(tmp_path).parent.mkdir(parents=True, exist_ok=True) + local_backend_process.backend_pid_path(tmp_path).write_text("999") + monkeypatch.setattr(local_backend_process, "_pid_alive", lambda pid: True) + monkeypatch.setattr(local_backend_process.os, "getpgid", lambda pid: pid) + killed: list[int] = [] + monkeypatch.setattr(local_backend_process.os, "killpg", lambda pgid, sig: killed.append(pgid)) + assert local_backend_process.stop_backend_process(tmp_path) is True + assert killed == [999] + assert not local_backend_process.backend_pid_path(tmp_path).exists() + + def test_stop_noop_when_nothing_recorded(self, tmp_path): + assert local_backend_process.stop_backend_process(tmp_path) is False + + +class TestAgentSandboxUnavailableReason: + def test_macos_ok_when_present(self, monkeypatch): + monkeypatch.setattr(local_backend_process.sys, "platform", "darwin") + monkeypatch.setattr(local_backend_process.shutil, "which", lambda name: "/usr/bin/sandbox-exec") + assert local_backend_process.agent_sandbox_unavailable_reason() is None + + def test_linux_reports_missing_deps(self, monkeypatch): + monkeypatch.setattr(local_backend_process.sys, "platform", "linux") + monkeypatch.setattr(local_backend_process.shutil, "which", lambda name: None) + reason = local_backend_process.agent_sandbox_unavailable_reason() + assert reason is not None + assert "bwrap" in reason and "socat" in reason + + def test_unsupported_platform(self, monkeypatch): + monkeypatch.setattr(local_backend_process.sys, "platform", "win32") + reason = local_backend_process.agent_sandbox_unavailable_reason() + assert reason is not None and "win32" in reason diff --git a/mcp_server/tests/test_local_env.py b/mcp_server/tests/test_local_env.py index d3a14ab..0d56b37 100644 --- a/mcp_server/tests/test_local_env.py +++ b/mcp_server/tests/test_local_env.py @@ -307,6 +307,24 @@ async def test_start_containers_argv(self, tmp_path): ] assert exec_mock.call_args.kwargs["cwd"] == str(tmp_path) + @pytest.mark.asyncio + async def test_stop_containers_argv(self, tmp_path): + fake = _FakeProc([], code=0) + with patch( + "services.local_env.asyncio.create_subprocess_exec", + new=AsyncMock(return_value=fake), + ) as exec_mock: + rc = await local_env.stop_containers(tmp_path) + assert rc == 0 + assert list(exec_mock.call_args.args) == ["docker", "compose", "down"] + assert exec_mock.call_args.kwargs["cwd"] == str(tmp_path) + + def test_docker_cli_available(self, monkeypatch): + monkeypatch.setattr(local_env.shutil, "which", lambda name: "/usr/bin/docker") + assert local_env.docker_cli_available() is True + monkeypatch.setattr(local_env.shutil, "which", lambda name: None) + assert local_env.docker_cli_available() is False + class TestRunCommand: """run_command runs against real child processes — the point is to prove the @@ -352,3 +370,48 @@ async def test_timeout_kills_hung_process_quickly(self, tmp_path): # Returned promptly after the timeout — the child was killed and reaped, # not waited out for the full 60s (which would hang the caller). assert elapsed < 10 + + +# --------------------------------------------------------------------------- +# BACKEND_RUNTIME — enum + bare-metal ("process") backend control +# --------------------------------------------------------------------------- + + +class TestBackendRuntimeEnum: + def test_parse_none_defaults_docker(self): + assert local_env.BackendRuntime.parse(None) == local_env.BackendRuntime.DOCKER + + def test_parse_is_case_insensitive(self): + assert local_env.BackendRuntime.parse("PROCESS") == local_env.BackendRuntime.PROCESS + assert local_env.BackendRuntime.parse(" Docker ") == local_env.BackendRuntime.DOCKER + + def test_parse_unknown_falls_back_to_docker(self): + assert local_env.BackendRuntime.parse("vm") == local_env.BackendRuntime.DOCKER + + +class TestSavedBackendRuntime: + def test_read_none_when_absent(self, tmp_path): + assert local_env.read_saved_runtime(tmp_path) is None + + def test_read_none_when_garbage(self, tmp_path): + p = local_env.backend_runtime_path(tmp_path) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("nonsense") + assert local_env.read_saved_runtime(tmp_path) is None + + def test_save_then_read_roundtrip(self, tmp_path): + local_env.save_backend_runtime(local_env.BackendRuntime.PROCESS, home=tmp_path) + assert local_env.read_saved_runtime(tmp_path) == local_env.BackendRuntime.PROCESS + # Persisted under ~/.specflow (machine-wide), never in mcp-config.json. + assert local_env.backend_runtime_path(tmp_path).parent.name == ".specflow" + + def test_save_creates_dir_and_overwrites(self, tmp_path): + local_env.save_backend_runtime(local_env.BackendRuntime.DOCKER, home=tmp_path) + local_env.save_backend_runtime(local_env.BackendRuntime.PROCESS, home=tmp_path) + assert local_env.read_saved_runtime(tmp_path) == local_env.BackendRuntime.PROCESS + + def test_parse_strict_returns_none_for_unknown(self): + assert local_env.BackendRuntime.parse_strict(None) is None + assert local_env.BackendRuntime.parse_strict("") is None + assert local_env.BackendRuntime.parse_strict("vm") is None + assert local_env.BackendRuntime.parse_strict(" PROCESS ") == local_env.BackendRuntime.PROCESS diff --git a/mcp_server/tests/test_tui_app.py b/mcp_server/tests/test_tui_app.py index f606f6f..4119c52 100644 --- a/mcp_server/tests/test_tui_app.py +++ b/mcp_server/tests/test_tui_app.py @@ -385,6 +385,7 @@ async def test_client_setup_skip_proceeds_to_sessions(self): async def test_containers_down_prompts_start(self): with ( patch("tui.app.local_env.is_setup_complete", return_value=True), + patch("tui.app.backend_runtime_is_configured", return_value=True), patch("tui.app.local_env.containers_running", return_value=False), ): app = tui_app.SpecFlowTUI(root=Path("/tmp/x"), generation_id=None, poll_interval=999) @@ -396,12 +397,13 @@ async def test_containers_down_prompts_start(self): async def test_start_no_quits_app(self): with ( patch("tui.app.local_env.is_setup_complete", return_value=True), + patch("tui.app.backend_runtime_is_configured", return_value=True), patch("tui.app.local_env.containers_running", return_value=False), ): app = tui_app.SpecFlowTUI(root=Path("/tmp/x"), generation_id=None, poll_interval=999) async with app.run_test() as pilot: await pilot.pause() - await pilot.press("n") + await pilot.press("q") await pilot.pause() assert not app.is_running @@ -409,6 +411,7 @@ async def test_start_no_quits_app(self): async def test_start_yes_starts_then_proceeds(self): with ( patch("tui.app.local_env.is_setup_complete", return_value=True), + patch("tui.app.backend_runtime_is_configured", return_value=True), patch("tui.app.local_env.containers_running", return_value=False), patch("tui.app.local_env.start_containers", new=AsyncMock(return_value=0)), patch("tui.app.local_env.wait_backend_ready", new=AsyncMock(return_value=True)), @@ -418,7 +421,7 @@ async def test_start_yes_starts_then_proceeds(self): app = tui_app.SpecFlowTUI(root=Path("/tmp/x"), generation_id=None, poll_interval=999) async with app.run_test() as pilot: await pilot.pause() - await pilot.press("y") + await pilot.press("s") await pilot.pause() assert isinstance(app.screen, tui_app.SessionsScreen) @@ -438,6 +441,10 @@ async def test_containers_up_but_backend_not_ready_says_unhealthy_not_down(self) prompt = str(app.screen.query_one("#docker-prompt").render()) assert "aren't running" not in prompt assert "isn't healthy" in prompt + # Key hints live in the Footer, not the prompt. A `[s]`/`[q]` tail + # here would be parsed as Rich markup (`[s]` = strikethrough), + # crossing out the labels — so the prompt must carry no brackets. + assert "[" not in prompt @pytest.mark.asyncio async def test_backend_not_ready_retry_skips_compose_up(self): @@ -457,12 +464,109 @@ async def test_backend_not_ready_retry_skips_compose_up(self): app = tui_app.SpecFlowTUI(root=Path("/tmp/x"), generation_id=None, poll_interval=999) async with app.run_test() as pilot: await pilot.pause() - await pilot.press("y") + await pilot.press("s") await pilot.pause() assert isinstance(app.screen, tui_app.SessionsScreen) start.assert_not_awaited() +class TestRuntimeChooser: + """First-run runtime chooser: shown only when unconfigured AND nothing is + running; otherwise the gate infers the runtime from what's already up. The + pick is persisted via local_env.save_backend_runtime.""" + + @staticmethod + def _unconfigured(): + # Setup complete, runtime not pinned, nothing running yet. + return ( + patch("tui.app.local_env.is_setup_complete", return_value=True), + patch("tui.app.backend_runtime_is_configured", return_value=False), + patch("tui.app.local_backend_process.backend_process_running", return_value=False), + patch("tui.app.local_env.containers_running", return_value=False), + ) + + @pytest.mark.asyncio + async def test_chooser_shown_when_unconfigured_and_nothing_running(self): + a, b, c, d = self._unconfigured() + with a, b, c, d: + app = tui_app.SpecFlowTUI(root=Path("/tmp/x"), generation_id=None, poll_interval=999) + async with app.run_test() as pilot: + await pilot.pause() + assert isinstance(app.screen, tui_app.ChooseRuntimeScreen) + + @pytest.mark.asyncio + async def test_pick_process_saves_and_proceeds_to_process_gate(self): + a, b, c, d = self._unconfigured() + save = MagicMock() + with a, b, c, d, ( + patch("tui.app.local_env.backend_ready", new=AsyncMock(return_value=False)) + ), patch("tui.app.local_env.save_backend_runtime", new=save): + app = tui_app.SpecFlowTUI(root=Path("/tmp/x"), generation_id=None, poll_interval=999) + async with app.run_test() as pilot: + await pilot.pause() + assert isinstance(app.screen, tui_app.ChooseRuntimeScreen) + await pilot.press("p") + await pilot.pause() + save.assert_called_once_with(tui_app.local_env.BackendRuntime.PROCESS) + assert isinstance(app.screen, tui_app.StartBackendProcessScreen) + + @pytest.mark.asyncio + async def test_pick_docker_saves_and_proceeds_to_docker_gate(self): + a, b, c, d = self._unconfigured() + save = MagicMock() + with a, b, c, d, patch("tui.app.local_env.save_backend_runtime", new=save): + app = tui_app.SpecFlowTUI(root=Path("/tmp/x"), generation_id=None, poll_interval=999) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("d") + await pilot.pause() + save.assert_called_once_with(tui_app.local_env.BackendRuntime.DOCKER) + assert isinstance(app.screen, tui_app.StartContainersScreen) + + @pytest.mark.asyncio + async def test_infers_process_when_process_running_no_chooser(self): + save = MagicMock() + with ( + patch("tui.app.local_env.is_setup_complete", return_value=True), + patch("tui.app.backend_runtime_is_configured", return_value=False), + patch("tui.app.local_backend_process.backend_process_running", return_value=True), + patch("tui.app.local_env.containers_running", return_value=False), + patch("tui.app.local_env.backend_ready", new=AsyncMock(return_value=True)), + patch("tui.app.local_env.save_backend_runtime", new=save), + patch("tui.app.fetch_sessions", new=AsyncMock(return_value=[])), + patch("tui.app.mcp_clients.is_any_client_connected", return_value=True), + ): + app = tui_app.SpecFlowTUI(root=Path("/tmp/x"), generation_id=None, poll_interval=999) + async with app.run_test() as pilot: + await pilot.pause() + assert not isinstance(app.screen, tui_app.ChooseRuntimeScreen) + save.assert_not_called() + assert isinstance(app.screen, tui_app.SessionsScreen) + # Header shows the active runtime. + assert app.sub_title == "process runtime" + + @pytest.mark.asyncio + async def test_infers_docker_when_containers_running_no_chooser(self): + save = MagicMock() + with ( + patch("tui.app.local_env.is_setup_complete", return_value=True), + patch("tui.app.backend_runtime_is_configured", return_value=False), + patch("tui.app.local_backend_process.backend_process_running", return_value=False), + patch("tui.app.local_env.containers_running", return_value=True), + patch("tui.app.local_env.backend_ready", new=AsyncMock(return_value=True)), + patch("tui.app.local_env.save_backend_runtime", new=save), + patch("tui.app.fetch_sessions", new=AsyncMock(return_value=[])), + patch("tui.app.mcp_clients.is_any_client_connected", return_value=True), + ): + app = tui_app.SpecFlowTUI(root=Path("/tmp/x"), generation_id=None, poll_interval=999) + async with app.run_test() as pilot: + await pilot.pause() + assert not isinstance(app.screen, tui_app.ChooseRuntimeScreen) + save.assert_not_called() + assert isinstance(app.screen, tui_app.SessionsScreen) + assert app.sub_title == "docker runtime" + + class TestOnboarding: """Drive the step-by-step onboarding wizard via the pilot. @@ -1397,6 +1501,279 @@ async def test_clear_unavailable_when_pool_status_missing(self): assert isinstance(psw.await_args.args[0], tui_app.MessageScreen) +class TestStopBackendFlow: + """``k`` (process runtime only): confirm wording, hard-stop, exit-on-success, + and the docker-mode gating that hides the binding entirely.""" + + @staticmethod + def _process_runtime(): + return patch( + "tui.app.resolve_backend_runtime", + return_value=tui_app.local_env.BackendRuntime.PROCESS, + ) + + @pytest.mark.asyncio + async def test_confirmed_stops_process_and_exits(self): + a, b, c = _gate_ready() + with a, b, c, self._process_runtime(), patch( + "tui.app.poll_once", new=AsyncMock(return_value=_running_payload()) + ): + app = tui_app.SpecFlowTUI(root=Path("/tmp/x"), generation_id="gen_x", poll_interval=999) + async with app.run_test() as pilot: + await pilot.pause() + stop = MagicMock(return_value=True) + with ( + patch.object(app, "push_screen_wait", new=AsyncMock(return_value=True)) as psw, + patch("tui.app.fetch_sessions", new=AsyncMock(return_value=[{"status": "running"}])), + patch("tui.app.local_backend_process.stop_backend_process", new=stop), + ): + await app.stop_backend_flow() + await pilot.pause() + stop.assert_called_once_with() + # The one active run is named in the confirmation prompt. + assert "1 in-progress generation" in psw.await_args.args[0]._message + assert not app.is_running + + @pytest.mark.asyncio + async def test_cancelled_leaves_backend_running(self): + a, b, c = _gate_ready() + with a, b, c, self._process_runtime(), patch( + "tui.app.poll_once", new=AsyncMock(return_value=_running_payload()) + ): + app = tui_app.SpecFlowTUI(root=Path("/tmp/x"), generation_id="gen_x", poll_interval=999) + async with app.run_test() as pilot: + await pilot.pause() + stop = MagicMock(return_value=True) + with ( + patch.object(app, "push_screen_wait", new=AsyncMock(return_value=False)), + patch("tui.app.fetch_sessions", new=AsyncMock(return_value=[])), + patch("tui.app.local_backend_process.stop_backend_process", new=stop), + ): + await app.stop_backend_flow() + stop.assert_not_called() + assert app.is_running + + @pytest.mark.asyncio + async def test_no_process_found_warns_and_stays(self): + # stop returns False (nothing was running) → warn, do NOT exit the TUI. + a, b, c = _gate_ready() + with a, b, c, self._process_runtime(), patch( + "tui.app.poll_once", new=AsyncMock(return_value=_running_payload()) + ): + app = tui_app.SpecFlowTUI(root=Path("/tmp/x"), generation_id="gen_x", poll_interval=999) + async with app.run_test() as pilot: + await pilot.pause() + stop = MagicMock(return_value=False) + with ( + patch.object(app, "push_screen_wait", new=AsyncMock(return_value=True)), + patch("tui.app.fetch_sessions", new=AsyncMock(return_value=[])), + patch("tui.app.local_backend_process.stop_backend_process", new=stop), + ): + await app.stop_backend_flow() + await pilot.pause() + stop.assert_called_once_with() + assert app.is_running + + @pytest.mark.asyncio + async def test_unreadable_sessions_use_conservative_wording(self): + # Backend unreachable → don't claim "nothing running"; warn generically. + a, b, c = _gate_ready() + with a, b, c, self._process_runtime(), patch( + "tui.app.poll_once", new=AsyncMock(return_value=_running_payload()) + ): + app = tui_app.SpecFlowTUI(root=Path("/tmp/x"), generation_id="gen_x", poll_interval=999) + async with app.run_test() as pilot: + await pilot.pause() + with ( + patch.object(app, "push_screen_wait", new=AsyncMock(return_value=False)) as psw, + patch("tui.app.fetch_sessions", new=AsyncMock(side_effect=RuntimeError("down"))), + ): + await app.stop_backend_flow() + assert "Any in-progress generation" in psw.await_args.args[0]._message + + @staticmethod + def _land_on_sessions(): + # stop_backend lives on the sessions overview only, so land there + # (generation_id=None) rather than on a single-generation dashboard. + a, b, c = _gate_ready() + return ( + a, + b, + c, + patch("tui.app.fetch_sessions", new=AsyncMock(return_value=[])), + patch.object(tui_app.ClientSetupScreen, "_probe_verifiable", new=AsyncMock()), + patch("tui.app.mcp_clients.is_any_client_connected", return_value=True), + ) + + @pytest.mark.asyncio + async def test_binding_hidden_in_docker_mode(self): + a, b, c, d, e, f = self._land_on_sessions() + with a, b, c, d, e, f, patch( + "tui.app.resolve_backend_runtime", + return_value=tui_app.local_env.BackendRuntime.DOCKER, + ): + app = tui_app.SpecFlowTUI(root=Path("/tmp/x"), generation_id=None, poll_interval=999) + async with app.run_test() as pilot: + await pilot.pause() + screen = app.screen + assert isinstance(screen, tui_app.SessionsScreen) + assert app.is_process_runtime is False + assert screen.check_action("stop_backend", ()) is None + # Docker-mode action is a no-op even if invoked directly. + with patch("tui.app.local_backend_process.stop_backend_process") as stop: + screen.action_stop_backend() + await pilot.pause() + stop.assert_not_called() + + @pytest.mark.asyncio + async def test_binding_shown_in_process_mode(self): + a, b, c, d, e, f = self._land_on_sessions() + with a, b, c, d, e, f, self._process_runtime(): + app = tui_app.SpecFlowTUI(root=Path("/tmp/x"), generation_id=None, poll_interval=999) + async with app.run_test() as pilot: + await pilot.pause() + screen = app.screen + assert isinstance(screen, tui_app.SessionsScreen) + assert app.is_process_runtime is True + assert screen.check_action("stop_backend", ()) is True + + +class TestSwitchRuntime: + """``t`` switch runtime: fail-fast target preflight, confirm wording, and the + cancel→teardown→persist→start ordering performed by SwitchRuntimeScreen.""" + + @staticmethod + def _runtime(value): + return patch("tui.app.resolve_backend_runtime", return_value=value) + + @pytest.mark.asyncio + async def test_process_target_aborts_when_sandbox_unavailable(self): + # Currently docker → target process; sandbox missing → refuse, no confirm. + a, b, c = _gate_ready() + with a, b, c, self._runtime(tui_app.local_env.BackendRuntime.DOCKER), patch( + "tui.app.poll_once", new=AsyncMock(return_value=_running_payload()) + ): + app = tui_app.SpecFlowTUI(root=Path("/tmp/x"), generation_id="gen_x", poll_interval=999) + async with app.run_test() as pilot: + await pilot.pause() + with ( + patch("tui.app.local_backend_process.agent_sandbox_unavailable_reason", return_value="no bwrap"), + patch.object(app, "push_screen_wait", new=AsyncMock()) as psw, + ): + await app.switch_runtime_flow() + psw.assert_not_awaited() # never reached the confirm dialog + + @pytest.mark.asyncio + async def test_docker_target_aborts_when_docker_cli_missing(self): + # Currently process → target docker; docker CLI absent → refuse. + a, b, c = _gate_ready() + with a, b, c, self._runtime(tui_app.local_env.BackendRuntime.PROCESS), patch( + "tui.app.poll_once", new=AsyncMock(return_value=_running_payload()) + ): + app = tui_app.SpecFlowTUI(root=Path("/tmp/x"), generation_id="gen_x", poll_interval=999) + async with app.run_test() as pilot: + await pilot.pause() + with ( + patch("tui.app.local_env.docker_cli_available", return_value=False), + patch.object(app, "push_screen_wait", new=AsyncMock()) as psw, + ): + await app.switch_runtime_flow() + psw.assert_not_awaited() + + @pytest.mark.asyncio + async def test_confirm_names_active_runs_and_cancelled_when_declined(self): + a, b, c = _gate_ready() + with a, b, c, self._runtime(tui_app.local_env.BackendRuntime.DOCKER), patch( + "tui.app.poll_once", new=AsyncMock(return_value=_running_payload()) + ): + app = tui_app.SpecFlowTUI(root=Path("/tmp/x"), generation_id="gen_x", poll_interval=999) + async with app.run_test() as pilot: + await pilot.pause() + with ( + patch("tui.app.local_backend_process.agent_sandbox_unavailable_reason", return_value=None), + patch("tui.app.fetch_sessions", new=AsyncMock(return_value=[ + {"generation_id": "g1", "status": "running"}, + {"generation_id": "g2", "status": "completed"}, + ])), + patch.object(app, "push_screen_wait", new=AsyncMock(return_value=False)) as psw, + ): + await app.switch_runtime_flow() + # Only the running one is counted; user declined → single dialog, no switch. + assert psw.await_count == 1 + msg = psw.await_args.args[0]._message + assert "docker → process" in msg + assert "1 in-progress generation" in msg + + @pytest.mark.asyncio + async def test_confirmed_runs_switch_screen(self): + a, b, c = _gate_ready() + with a, b, c, self._runtime(tui_app.local_env.BackendRuntime.DOCKER), patch( + "tui.app.poll_once", new=AsyncMock(return_value=_running_payload()) + ): + app = tui_app.SpecFlowTUI(root=Path("/tmp/x"), generation_id="gen_x", poll_interval=999) + async with app.run_test() as pilot: + await pilot.pause() + # First push_screen_wait = confirm (True); second = SwitchRuntimeScreen (True). + psw = AsyncMock(side_effect=[True, True]) + with ( + patch("tui.app.local_backend_process.agent_sandbox_unavailable_reason", return_value=None), + patch("tui.app.fetch_sessions", new=AsyncMock(return_value=[])), + patch.object(app, "push_screen_wait", new=psw), + ): + await app.switch_runtime_flow() + assert psw.await_count == 2 + switch_screen = psw.await_args_list[1].args[0] + assert isinstance(switch_screen, tui_app.SwitchRuntimeScreen) + assert switch_screen._current == tui_app.local_env.BackendRuntime.DOCKER + assert switch_screen._target == tui_app.local_env.BackendRuntime.PROCESS + # Header indicator follows the new runtime after a successful switch. + assert app.sub_title == "process runtime" + + @pytest.mark.asyncio + async def test_switch_screen_cancels_then_teardown_then_start_in_order(self): + # SwitchRuntimeScreen must cancel the run, THEN tear down, THEN start. + a, b, c = _gate_ready() + with a, b, c, patch("tui.app.poll_once", new=AsyncMock(return_value=_running_payload())): + app = tui_app.SpecFlowTUI(root=Path("/tmp/x"), generation_id="gen_x", poll_interval=999) + async with app.run_test() as pilot: + await pilot.pause() + calls: list[str] = [] + cancel = AsyncMock(side_effect=lambda **k: calls.append(f"cancel:{k['endpoint'].rsplit('/', 1)[-1]}")) + stop_c = AsyncMock(side_effect=lambda *a, **k: calls.append("stop_containers")) + start_p = AsyncMock(side_effect=lambda *a, **k: calls.append("start_process")) + save = MagicMock(side_effect=lambda *a, **k: calls.append("save")) + with ( + patch("tui.app.call_backend_endpoint", new=cancel), + patch("tui.app.local_env.stop_containers", new=stop_c), + patch("tui.app.local_env.save_backend_runtime", new=save), + patch("tui.app.local_backend_process.start_backend_process", new=start_p), + patch("tui.app.local_env.wait_backend_ready", new=AsyncMock(return_value=True)), + ): + screen = tui_app.SwitchRuntimeScreen( + current=tui_app.local_env.BackendRuntime.DOCKER, + target=tui_app.local_env.BackendRuntime.PROCESS, + cancel_ids=["gen_abc"], + ) + app.push_screen(screen) + await pilot.pause() + await pilot.pause() + assert calls == ["cancel:gen_abc", "stop_containers", "save", "start_process"] + save.assert_called_once_with(tui_app.local_env.BackendRuntime.PROCESS) + + def test_app_wide_controls_live_on_sessions_not_dashboard(self): + # App-wide controls (switch runtime, stop backend, settings, connect + # client) act on the whole app, so they belong on the sessions overview + # only. The single-generation dashboard carries only that generation's + # controls (retry, cancel, clear ws, open ws, events, report). + app_wide = ("switch_runtime", "stop_backend", "settings", "connect_client") + for action in app_wide: + assert hasattr(tui_app.SessionsScreen, f"action_{action}"), action + assert not hasattr(tui_app.DashboardScreen, f"action_{action}"), action + # Generation-scoped controls remain on the dashboard. + for action in ("retry", "cancel", "clear", "open_workspace"): + assert hasattr(tui_app.DashboardScreen, f"action_{action}"), action + + class TestDashboardOpenReport: """``h`` fetches the HTML report over HTTP (the backend runs in a container, so there's no shared filesystem path to open directly) and caches it locally diff --git a/mcp_server/tests/test_tui_config.py b/mcp_server/tests/test_tui_config.py index b17862b..610b2cb 100644 --- a/mcp_server/tests/test_tui_config.py +++ b/mcp_server/tests/test_tui_config.py @@ -25,6 +25,11 @@ def test_uses_the_real_tier_keys_not_the_dead_llm_model_names(self): ] assert not any(k.startswith("LLM_MODEL_") for k in config.EDITABLE_KEYS) + def test_backend_runtime_is_not_editable(self): + # Runtime isn't an mcp-config setting (see cli.resolve_backend_runtime), + # so it must not be shown in / written by the Settings screen. + assert "BACKEND_RUNTIME" not in config.EDITABLE_KEYS + def test_tier_slice_is_the_llm_tiers_ssot(self): # The tier entries must BE the shared list the MCP server/backend read. assert config.EDITABLE_KEYS[1:4] == list(LLM_TIER_KEYS) @@ -77,18 +82,6 @@ def test_clearing_a_key_removes_it(self, tmp_path): config.save_env(tmp_path, {"USER_EMAIL": ""}) assert "USER_EMAIL" not in config.load_env(tmp_path) - def test_purges_dead_legacy_tier_keys(self, tmp_path): - # A block written by the buggy build carries LLM_MODEL_* nothing reads; - # saving must sweep them so they never linger or skew a fingerprint. - _write_config( - tmp_path, - {"mcpServers": {"specflow": {"env": {"LLM_MODEL_HIGH": "old/model", "KEEP": "yes"}}}}, - ) - config.save_env(tmp_path, {"LLM_HIGH": "new/model"}) - env = config.load_env(tmp_path) - assert "LLM_MODEL_HIGH" not in env - assert env["LLM_HIGH"] == "new/model" - assert env["KEEP"] == "yes" # unrelated entries still preserved class TestLangfuse: diff --git a/mcp_server/tui/app.py b/mcp_server/tui/app.py index f3f9968..bf3471d 100644 --- a/mcp_server/tui/app.py +++ b/mcp_server/tui/app.py @@ -60,8 +60,12 @@ Static, ) -from cli import resolve_backend_config -from services import local_env, validate_models +from cli import ( + backend_runtime_is_configured, + resolve_backend_config, + resolve_backend_runtime, +) +from services import local_backend_process, local_env, validate_models from services.llm_tiers import LLM_TIER_KEYS from services.retry import retry_generation_core from services.session import resolve_generation_id, set_project_root @@ -513,16 +517,48 @@ def action_decide_later(self) -> None: self.dismiss(None) +class _BackendControlScreen(_SpecFlowScreen): + """Base for the sessions overview — the ONLY screen with app-wide backend controls. + + ``k`` stop backend — process runtime only (in ``BACKEND_RUNTIME=process`` the + backend is a detached host process that outlives the TUI; in docker mode the + container stack is the boundary, so ``check_action`` hides it). Implemented once + on the app (``SpecFlowTUI.stop_backend_flow``). + + App-wide controls (stop backend, switch runtime, settings, connect client) act on + the whole app, so they live on the sessions overview only — never on the + single-generation dashboard (``DashboardScreen``), which carries only the controls + for the generation being viewed. Switching runtime additionally cancels ALL + in-flight runs, another reason it belongs on the overview. + """ + + BINDINGS = [Binding("k", "stop_backend", "stop backend")] + + def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: + if action == "stop_backend": + return True if getattr(self.app, "is_process_runtime", False) else None + return True + + def action_stop_backend(self) -> None: + if not getattr(self.app, "is_process_runtime", False): + return + self.run_worker(self.app.stop_backend_flow(), exclusive=True) + + class DashboardScreen(_SpecFlowScreen): - """Live dashboard for a single generation.""" + """Live dashboard for a single generation. + + Carries ONLY controls scoped to the generation being viewed (retry, cancel, + clear its workspaces, open a workspace, events, report). App-wide controls + (settings, connect client, stop backend, switch runtime) live on the sessions + overview (``SessionsScreen``), not here. + """ BINDINGS = [ Binding("r", "retry", "retry"), Binding("x", "cancel", "cancel"), Binding("w", "clear", "clear ws"), - Binding("s", "settings", "settings"), Binding("escape", "back", "back"), - Binding("c", "connect_client", "Add MCP to AI tool"), Binding("o", "open_workspace", "open ws"), Binding("enter", "open_workspace", "open ws", show=False), Binding("e", "open_events", "events"), @@ -608,7 +644,8 @@ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | No return True if status in {"pending", "initializing", "running"} else None if action == "open_report": return True if render.estimate_panel(self._payload) is not None else None - return True + # stop_backend (process-mode only) + default → the shared mixin decides. + return super().check_action(action, parameters) def _move_selection(self, delta: int) -> None: if not self._workspace_ids: @@ -784,15 +821,9 @@ async def _clear_flow(self) -> None: if ok: await self._run_suspended(actions.do_clear_set(set_no)) - def action_settings(self) -> None: - self.app.push_screen(SettingsScreen()) - def action_back(self) -> None: self.app.push_screen(SessionsScreen()) - def action_connect_client(self) -> None: - self.app.push_screen(ClientSetupScreen()) - class WorkspaceMessagesScreen(_SpecFlowScreen): """Workspace drill-in: live SDK message stream (top) + stats (bottom). @@ -959,19 +990,24 @@ def _session_label(s: dict) -> str: return f"{status_col} {gid[:22]:<24} {date_col} {checkpoint_label}" -class SessionsScreen(_SpecFlowScreen): +class SessionsScreen(_BackendControlScreen): """Picker across all recent generation sessions (active and completed).""" BINDINGS = [ Binding("r", "reload", "reload"), Binding("c", "connect_client", "Add MCP to AI tool"), Binding("s", "settings", "settings"), + # Switching runtime cancels ALL in-flight runs and restarts the backend, so + # it lives on this overview screen — not the single-generation dashboard. + # Bound to "t" (toggle) rather than "R": a shift-of-"reload" is too easy to + # mistype for so destructive an action. + Binding("t", "switch_runtime", "switch runtime"), ] def compose(self) -> ComposeResult: yield Header() yield Static( - "SpecFlow · sessions (↑/↓ select · ↵ open · r reload · s settings)", + "SpecFlow · sessions (↑/↓ select · ↵ open · r reload · s settings · t switch runtime)", id="sessions-title", ) yield ListView(id="sessions-list") @@ -1008,6 +1044,9 @@ def action_connect_client(self) -> None: def action_settings(self) -> None: self.app.push_screen(SettingsScreen()) + def action_switch_runtime(self) -> None: + self.run_worker(self.app.switch_runtime_flow(), exclusive=True) + def on_list_view_selected(self, event: ListView.Selected) -> None: item = event.item if isinstance(item, _SessionItem): @@ -1851,6 +1890,134 @@ async def _run_init(self) -> None: self.query_one("#onboard-go", Button).disabled = False +class ChooseRuntimeScreen(_SpecFlowScreen): + """First-run runtime picker: docker vs process. + + Shown by the startup gate only when the runtime isn't pinned (no + ``BACKEND_RUNTIME`` env var, no saved choice) AND nothing is already up + (no containers, no bare process) — otherwise the gate infers/uses whatever is + running. The pick is remembered under ``.specflow-local/backend-runtime`` so + later launches skip this screen; it can be changed later from the dashboard. + Dismisses with the chosen ``BackendRuntime``; ``q``/``ctrl+c`` quits the app. + """ + + BINDINGS = [ + Binding("d", "pick_docker", "docker"), + Binding("p", "pick_process", "process"), + ] + + def compose(self) -> ComposeResult: + yield Header() + yield Static( + "How should the SpecFlow backend run on this machine?\n\n" + "[b]d[/b] Docker — run the backend in containers (docker compose). " + "The container is the isolation boundary. Requires Docker installed and running.\n\n" + "[b]p[/b] Process — run the backend directly on this host (no Docker). " + "Agents are confined by the OS sandbox (bubblewrap on Linux, Seatbelt on " + "macOS); your dev environment (Python 3.14, uv, `cd backend && uv sync`) must " + "already be installed.\n\n" + "[dim]Your choice is remembered. You can switch runtimes later from the " + "dashboard.[/dim]", + id="runtime-prompt", + ) + yield Footer() + + def action_pick_docker(self) -> None: + self.dismiss(local_env.BackendRuntime.DOCKER) + + def action_pick_process(self) -> None: + self.dismiss(local_env.BackendRuntime.PROCESS) + + +class SwitchRuntimeScreen(_SpecFlowScreen): + """Executes a runtime switch, streaming each step into a log. + + The caller (``SpecFlowTUI.switch_runtime_flow``) has already preflighted the + target and taken confirmation; this screen just performs the switch, auto-run + on mount. Step order is load-bearing: + + 1. cancel each in-flight generation via the CURRENT backend's DELETE — must + happen *before* teardown, since the API is unreachable once it is down; + 2. tear down the current backend (``stop_backend_process`` / ``compose down``); + 3. persist the new runtime choice; + 4. start the target backend and wait for readiness. + + Dismisses ``True`` on success, ``False`` if the new backend never came up. + """ + + def __init__( + self, + *, + current: "local_env.BackendRuntime", + target: "local_env.BackendRuntime", + cancel_ids: list[str], + ) -> None: + super().__init__() + self._current = current + self._target = target + self._cancel_ids = list(cancel_ids) + + def compose(self) -> ComposeResult: + yield Header() + yield Static( + f"Switching backend runtime: {self._current.value} → {self._target.value}…", + id="switch-prompt", + ) + yield RichLog(id="switch-log", highlight=False, markup=False, wrap=True) + yield Footer() + + def on_mount(self) -> None: + self.run_worker(self._run(), exclusive=True) + + async def _run(self) -> None: + log = self.query_one("#switch-log", RichLog) + root = self.app.root + + # 1. Cancel in-flight generations on the still-running current backend. + for gid in self._cancel_ids: + try: + await call_backend_endpoint( + endpoint=f"/api/v1/generation-sessions/{gid}", + method="DELETE", + timeout_seconds=30, + ) + log.write(f"cancelled generation {gid[:18]}…\n") + except Exception as exc: # noqa: BLE001 - best-effort (e.g. already finished) + log.write(f"cancel {gid[:18]}… skipped: {exc}\n") + + # 2. Tear down the current backend. + if self._current == local_env.BackendRuntime.PROCESS: + log.write("stopping bare-metal backend…\n") + await asyncio.to_thread(local_backend_process.stop_backend_process) + else: + log.write("stopping docker stack (compose down)…\n") + await local_env.stop_containers(root, on_line=log.write) + + # 3. Persist the new choice so the next launch (and this session) agree. + await asyncio.to_thread(local_env.save_backend_runtime, self._target) + + # 4. Start the target backend and wait for readiness. + if self._target == local_env.BackendRuntime.PROCESS: + log.write("starting bare-metal backend…\n") + await local_backend_process.start_backend_process(root, on_line=log.write) + else: + log.write("starting docker stack (compose up)…\n") + await local_env.start_containers(root, on_line=log.write) + ok = await local_env.wait_backend_ready( + self.app.backend_url, + on_attempt=lambda i: log.write(f"waiting for backend to become ready… ({i})\n"), + ) + if ok: + self.dismiss(True) + else: + self.notify( + "Switched runtime, but the new backend didn't become healthy — check " + ".specflow-local/backend.log (process) or `docker compose logs` (docker).", + severity="error", + ) + self.dismiss(False) + + class StartContainersScreen(_SpecFlowScreen): """Docker gate: start the stack (or wait out an unhealthy backend), or quit. @@ -1867,8 +2034,10 @@ class StartContainersScreen(_SpecFlowScreen): """ BINDINGS = [ - Binding("y", "yes", "start"), - Binding("n", "no", "quit"), + # Key matches the visible label (start→s, quit→q), consistent with the + # runtime picker's docker→d / process→p. + Binding("s", "yes", "start"), + Binding("q", "no", "quit"), ] def __init__(self, *, containers_up: bool = False) -> None: @@ -1880,10 +2049,10 @@ def compose(self) -> ComposeResult: if self._containers_up: prompt = ( "Containers are running but the backend isn't healthy yet.\n\n" - "Retry the health check? [y] retry [n] quit" + "Retry the health check?" ) else: - prompt = "The SpecFlow containers aren't running.\n\n" "Start them now? [y] start [n] quit" + prompt = "The SpecFlow containers aren't running.\n\n" "Start them now?" yield Static(prompt, id="docker-prompt") log = RichLog(id="docker-log", highlight=False, markup=False, wrap=True) log.display = False @@ -1917,6 +2086,84 @@ async def _start(self) -> None: ) +class StartBackendProcessScreen(_SpecFlowScreen): + """Process gate (BACKEND_RUNTIME=process): start the backend on the bare host. + + Unlike the docker gate this runs uvicorn directly on this machine, so it + first warns that the developer environment must already be installed, then + fails closed if the OS-level agent sandbox (bubblewrap / Seatbelt) can't run + here — we must not launch agents unconfined on the host. On ``y`` it starts + the detached backend and waits for readiness; ``n`` quits. When ``process_up`` + is set the process is already running but unhealthy, so ``y`` only re-polls + readiness (no redundant relaunch), mirroring the docker gate. + """ + + BINDINGS = [ + # Key matches the visible label (start→s, quit→q), consistent with the + # runtime picker's docker→d / process→p. + Binding("s", "yes", "start"), + Binding("q", "no", "quit"), + ] + + def __init__(self, *, process_up: bool = False) -> None: + super().__init__() + self._process_up = process_up + + def compose(self) -> ComposeResult: + yield Header() + if self._process_up: + prompt = ( + "The backend process is running but isn't healthy yet.\n\n" + "Retry the health check?" + ) + else: + prompt = ( + "Backend runtime: process (no Docker).\n\n" + "The backend will run directly on THIS machine. Your developer environment " + "must already be installed: Python 3.14, `uv`, and the backend deps " + "(`cd backend && uv sync`).\n" + "Agents will be confined by the OS-level sandbox (bubblewrap on Linux, " + "Seatbelt on macOS).\n\n" + "Start the backend now?" + ) + yield Static(prompt, id="process-prompt") + log = RichLog(id="process-log", highlight=False, markup=False, wrap=True) + log.display = False + yield log + yield Footer() + + def action_no(self) -> None: + self.dismiss(False) + + def action_yes(self) -> None: + self.run_worker(self._start(), exclusive=True) + + async def _start(self) -> None: + log = self.query_one("#process-log", RichLog) + log.display = True + # Process already up → skip the relaunch and just re-poll readiness. + if not self._process_up: + # Fail closed: refuse to start unless the OS sandbox can confine agents. + reason = local_backend_process.agent_sandbox_unavailable_reason() + if reason is not None: + log.write(f"Cannot start in process mode — {reason}\n") + self.notify(reason, severity="error", timeout=15) + return + await local_backend_process.start_backend_process(self.app.root, on_line=log.write) + ok = await local_env.wait_backend_ready( + self.app.backend_url, + on_attempt=lambda i: log.write(f"waiting for backend to become ready… ({i})\n"), + ) + if ok: + self.dismiss(True) + else: + self.notify( + "Backend didn't become healthy — check .specflow-local/backend.log " + "(e.g. missing deps or an LLM provider/key mismatch).", + severity="error", + ) + + class RunInitScreen(_SpecFlowScreen): """Runs ``specflow init`` off an already-complete ``.env`` — no wizard. @@ -2130,7 +2377,8 @@ class SpecFlowTUI(App): CSS = """ #dashboard-body { padding: 0 1; } #sessions-title, #settings-title, #onboard-title { padding: 1 2; text-style: bold; } - #docker-prompt, #runinit-prompt { padding: 2 3; } + #docker-prompt, #runinit-prompt, #runtime-prompt, #switch-prompt { padding: 2 3; } + #switch-log { height: 1fr; border: round $primary; margin: 1 2; } .settings-section { padding: 1 2 0 2; text-style: bold; color: $accent; } .settings-row { height: 3; padding: 0 2; } .settings-label { width: 20; content-align: left middle; } @@ -2181,6 +2429,158 @@ def backend_url(self) -> str: """Resolved backend URL (flag → env → mcp-config → default).""" return resolve_backend_config(None, None, None, self.root)[0] + @property + def is_process_runtime(self) -> bool: + """True when the backend runs as a bare-metal host process (not Docker). + + Only in this mode can the TUI stop the backend — in docker mode the + container stack is the boundary. Resolved the same way as the startup gate + (flag → env → saved choice → default) so the two never disagree. + """ + return resolve_backend_runtime() == local_env.BackendRuntime.PROCESS + + def set_runtime_indicator(self, runtime: "local_env.BackendRuntime") -> None: + """Show the active runtime in the header sub-title (visible on every screen). + + Passed the resolved runtime explicitly rather than re-resolving, because at + startup it may be inferred from what's already running and not yet saved. + """ + self.sub_title = f"{runtime.value} runtime" + + async def _count_active_generations(self) -> int | None: + """Number of non-terminal generations, or ``None`` if it can't be read. + + Used only to word the stop confirmation; a ``None`` (backend unreachable / + service unavailable) is treated conservatively as "maybe active" by the + caller rather than silently claiming nothing is running. + """ + if fetch_sessions is None: + return None + try: + sessions = await fetch_sessions() + except Exception: # noqa: BLE001 - confirmation wording is best-effort + return None + return sum( + 1 + for s in sessions + if (s.get("status") or "").lower() not in TERMINAL_STATUSES + ) + + async def stop_backend_flow(self) -> None: + """Confirm, then stop the detached bare-metal backend (process runtime). + + A hard stop (SIGTERM to the process group) interrupts any in-flight + generation, so the confirmation names how many runs are affected. Per the + STEEL COMMANDMENTS a stop never releases workspaces: the code is preserved + and ``retry_generation`` resumes from the last checkpoint. On success the + TUI exits with a message — relaunching re-runs the startup gate, which + detects the backend is down and offers to start it again. + """ + active = await self._count_active_generations() + if active is None: + detail = "Any in-progress generation will be interrupted" + elif active > 0: + plural = "s" if active != 1 else "" + detail = f"This interrupts {active} in-progress generation{plural}" + else: + detail = "No generation is currently running" + confirmed = await self.push_screen_wait( + ConfirmScreen( + f"Stop the backend?\n\n{detail} — its workspaces and generated code " + "are preserved (retry resumes from the last checkpoint). Relaunch " + "SpecFlow to start the backend again.", + countdown=10 if (active is None or active > 0) else 0, + ) + ) + if not confirmed: + return + stopped = await asyncio.to_thread(local_backend_process.stop_backend_process) + if stopped: + self.exit(message="Backend stopped. Relaunch SpecFlow to start it again.") + else: + self.notify( + "No running backend process was found to stop.", + severity="warning", + ) + + async def _active_generation_ids(self) -> list[str]: + """Ids of non-terminal generations (empty on none or unreadable). + + Used by the runtime switch to cancel in-flight runs on the current backend + before tearing it down. + """ + if fetch_sessions is None: + return [] + try: + sessions = await fetch_sessions() + except Exception: # noqa: BLE001 - best-effort + return [] + return [ + s["generation_id"] + for s in sessions + if s.get("generation_id") + and (s.get("status") or "").lower() not in TERMINAL_STATUSES + ] + + async def switch_runtime_flow(self) -> None: + """Switch the backend runtime docker↔process from inside the TUI. + + Fail-fast preflight of the target (fail-closed sandbox for →process, docker + CLI present for →docker) BEFORE touching the running backend, then confirm + (naming in-flight runs that will be cancelled), then hand off to + ``SwitchRuntimeScreen`` which cancels those runs, tears down the current + backend, persists the choice, and starts the target. backend_url is the + same host:port for both runtimes, so the visible screen keeps polling the + new backend once it is up. + """ + current = resolve_backend_runtime() + target = ( + local_env.BackendRuntime.DOCKER + if current == local_env.BackendRuntime.PROCESS + else local_env.BackendRuntime.PROCESS + ) + + # Preflight the target; refuse without disturbing the running backend. + if target == local_env.BackendRuntime.PROCESS: + reason = local_backend_process.agent_sandbox_unavailable_reason() + if reason is not None: + self.notify(f"Can't switch to process mode — {reason}", severity="error", timeout=15) + return + elif not await asyncio.to_thread(local_env.docker_cli_available): + self.notify( + "Can't switch to docker mode — the `docker` CLI isn't installed or on PATH.", + severity="error", + timeout=15, + ) + return + + active = await self._active_generation_ids() + n = len(active) + if n: + plural = "s" if n != 1 else "" + detail = ( + f"{n} in-progress generation{plural} will be cancelled (cannot be " + "resumed; workspaces and generated code are preserved)" + ) + else: + detail = "No generation is currently running" + confirmed = await self.push_screen_wait( + ConfirmScreen( + f"Switch backend runtime: {current.value} → {target.value}?\n\n" + f"This stops the {current.value} backend and starts a {target.value} " + f"one. {detail}.", + countdown=10 if n else 0, + ) + ) + if not confirmed: + return + ok = await self.push_screen_wait( + SwitchRuntimeScreen(current=current, target=target, cancel_ids=active) + ) + if ok: + self.set_runtime_indicator(target) + self.notify(f"Runtime switched to {target.value}.", severity="information") + def on_mount(self) -> None: # Run the gating sequence in an exclusive worker so blocking subprocess # work inside the gate screens never freezes the render loop. @@ -2206,18 +2606,52 @@ async def _startup_gate(self) -> None: self.exit() return - # (b) Docker check — offer to start the stack, or quit. - running = await asyncio.to_thread(local_env.containers_running, self.root) - if not running: - if not await self.push_screen_wait(StartContainersScreen()): - self.exit() - return - elif not await local_env.backend_ready(self.backend_url): - # Containers up but not ready yet — wait it out with an accurate prompt - # (do not claim the containers aren't running; they are). - if not await self.push_screen_wait(StartContainersScreen(containers_up=True)): - self.exit() - return + # (b) Backend check — branch on the launch runtime. In docker mode the + # container stack is the boundary; in process mode the backend runs + # bare-metal and agents are confined by the OS sandbox instead. + runtime = resolve_backend_runtime() + if not backend_runtime_is_configured(): + # Runtime not pinned (no env var, no saved choice): infer from whatever + # is already up; if nothing is running, ask once and remember the pick. + proc_up = await asyncio.to_thread(local_backend_process.backend_process_running) + cont_up = await asyncio.to_thread(local_env.containers_running, self.root) + if proc_up: + runtime = local_env.BackendRuntime.PROCESS + elif cont_up: + runtime = local_env.BackendRuntime.DOCKER + else: + choice = await self.push_screen_wait(ChooseRuntimeScreen()) + if not choice: + self.exit() + return + await asyncio.to_thread(local_env.save_backend_runtime, choice) + runtime = choice + + self.set_runtime_indicator(runtime) + if runtime == local_env.BackendRuntime.PROCESS: + if not await local_env.backend_ready(self.backend_url): + # Process already up but not ready yet → just wait it out; not + # running → start it. Mirrors the docker path's containers_up handling. + process_up = await asyncio.to_thread( + local_backend_process.backend_process_running + ) + if not await self.push_screen_wait( + StartBackendProcessScreen(process_up=process_up) + ): + self.exit() + return + else: + running = await asyncio.to_thread(local_env.containers_running, self.root) + if not running: + if not await self.push_screen_wait(StartContainersScreen()): + self.exit() + return + elif not await local_env.backend_ready(self.backend_url): + # Containers up but not ready yet — wait it out with an accurate prompt + # (do not claim the containers aren't running; they are). + if not await self.push_screen_wait(StartContainersScreen(containers_up=True)): + self.exit() + return # (b2) First-run client setup — connect SpecFlow's MCP server to the # user's AI tool instead of dropping them on the empty Sessions list. diff --git a/mcp_server/tui/config.py b/mcp_server/tui/config.py index 5b0fdf7..12a13e8 100644 --- a/mcp_server/tui/config.py +++ b/mcp_server/tui/config.py @@ -28,10 +28,6 @@ "BACKEND_URL", ] -# Superseded tier key names an earlier build wrote into the env block; nothing -# reads them. Purged on save so they never linger or skew a config fingerprint. -_LEGACY_EDITABLE_KEYS: list[str] = ["LLM_MODEL_HIGH", "LLM_MODEL_MEDIUM", "LLM_MODEL_LOW"] - # Secret/identity keys, stored in .env (consumed by docker-compose / the backend # / the init script). Names match .env.quickstart.example so write_dotenv fills # the template entries in place rather than appending duplicates. @@ -111,9 +107,8 @@ def save_env(root: Path, env: dict[str, str]) -> Path: specflow = servers.setdefault("specflow", {}) existing_env = specflow.get("env") merged = existing_env if isinstance(existing_env, dict) else {} - # Replace only the editable keys (and sweep away any dead legacy tier keys); - # leave every other env entry untouched. - for key in (*EDITABLE_KEYS, *_LEGACY_EDITABLE_KEYS): + # Replace only the editable keys; leave every other env entry untouched. + for key in EDITABLE_KEYS: merged.pop(key, None) merged.update(cleaned) specflow["env"] = merged diff --git a/mcp_server/tui/mcp_clients.py b/mcp_server/tui/mcp_clients.py index 5db4e63..1c0fad3 100644 --- a/mcp_server/tui/mcp_clients.py +++ b/mcp_server/tui/mcp_clients.py @@ -39,7 +39,9 @@ # the user's HOME (not the project) because connecting a client is a machine-wide # action (claude/gemini `-s user`, Cursor `~/.cursor/mcp.json`) and the TUI must # reach it from any project. Future global settings get their own top-level key. -CONFIG_DIRNAME = ".specflow" +# Same ~/.specflow home as the backend pidfile/runtime and the central SQLite db — +# sourced from local_env so the dir name has a single definition. +CONFIG_DIRNAME = local_env.SPECFLOW_HOME_DIRNAME CONFIG_FILENAME = "config.json"