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..6b74783 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,18 @@ 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_env; sys.exit(asyncio.run(local_env.run_backend_process_cli(Path('$(CURDIR)'))))" + +# Stop a detached bare-metal backend started by run-process (or the TUI). +stop-process: + @cd mcp_server && uv run python -c "from pathlib import Path; from services import local_env; print('🛑 stopped' if local_env.stop_backend_process(Path('$(CURDIR)')) 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 a85159b..6bf06df 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 ( @@ -1464,6 +1465,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..4c9c22b 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" @@ -341,6 +346,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 +374,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: 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 6545e86..c8a1e7f 100644 --- a/backend/app/services/claude_code.py +++ b/backend/app/services/claude_code.py @@ -19,7 +19,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 @@ -176,11 +180,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 = { @@ -781,8 +787,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 @@ -810,6 +818,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_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..253628e --- /dev/null +++ b/docs/backend/backend-runtime.md @@ -0,0 +1,165 @@ +# 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-local/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-local/backend-runtime`. If +something is already up, it infers the runtime from that instead of asking. This +file lives beside the process pidfile/log — 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 `R` (*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-local/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..465694c 100644 --- a/mcp_server/cli.py +++ b/mcp_server/cli.py @@ -97,6 +97,32 @@ def resolve_backend_config( return backend_url, user_email, workspace_count +def resolve_backend_runtime( + root: Path, runtime_flag: str | 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-local/backend-runtime``, written by the TUI first-run + chooser) → default ``docker``. Unknown values fall back to ``docker``. + + 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(root) + return saved if saved is not None else local_env.BackendRuntime.DOCKER + + +def backend_runtime_is_configured(root: Path) -> 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(root) 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_env.py b/mcp_server/services/local_env.py index 5903590..2188874 100644 --- a/mcp_server/services/local_env.py +++ b/mcp_server/services/local_env.py @@ -19,9 +19,13 @@ import asyncio import os +import shutil +import signal import subprocess +import sys from collections.abc import Callable from dataclasses import dataclass +from enum import StrEnum from pathlib import Path import httpx @@ -39,6 +43,80 @@ # Mirror docker-compose.yml container-name env-var defaults; sqlite has no separate container. _BACKEND_CONTAINER_DEFAULT = "specflow-backend" +# Bare-metal ("process") backend launch — pidfile + log live under .specflow-local +# (already the runtime-config home), port mirrors docker-compose's SPECFLOW_BACKEND_PORT. +_BACKEND_PID_FILENAME = ".specflow-local/backend.pid" +_BACKEND_LOG_FILENAME = ".specflow-local/backend.log" +_BACKEND_PORT_DEFAULT = "8000" + +# Launcher's remembered runtime choice (docker | process). A local-launcher fact, +# NOT an MCP-server setting: the MCP server only calls backend_url and is +# indifferent to how the backend is launched, so this lives beside the pidfile +# under .specflow-local — never in mcp-config.json. +_BACKEND_RUNTIME_FILENAME = ".specflow-local/backend-runtime" + + +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(root: Path) -> Path: + return root / _BACKEND_RUNTIME_FILENAME + + +def read_saved_runtime(root: 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. + """ + root = root or Path.cwd() + try: + raw = backend_runtime_path(root).read_text() + except OSError: + return None + return BackendRuntime.parse_strict(raw) + + +def save_backend_runtime(root: Path, runtime: "BackendRuntime") -> Path: + """Persist the launcher's runtime choice under ``.specflow-local/`` (beside the + pidfile/log). Not written to mcp-config.json — see ``_BACKEND_RUNTIME_FILENAME``.""" + path = backend_runtime_path(root) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(runtime.value) + return path + # --------------------------------------------------------------------------- # Repo-root + path resolution @@ -218,6 +296,185 @@ def containers_running(root: Path | None = None) -> bool: return backend in names +# --------------------------------------------------------------------------- +# Bare-metal ("process") backend control — BACKEND_RUNTIME=process +# --------------------------------------------------------------------------- +# +# The docker-mode boundary (the container) is gone here, so the backend runs +# directly on the host as a detached uvicorn process. The agents it launches are +# instead confined by the OS-level Bash sandbox (see the backend's +# app/agents_sandboxing/os_sandbox.py); this module only starts/stops/detects the +# process and preflights the host sandbox dependencies. + + +def backend_pid_path(root: Path) -> Path: + return root / _BACKEND_PID_FILENAME + + +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(root: Path) -> int | None: + """PID recorded by the last ``start_backend_process``; ``None`` if absent/garbage.""" + try: + return int(backend_pid_path(root).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(root: Path | None = None) -> bool: + """True iff a previously-started bare-metal backend process is still alive.""" + root = root or Path.cwd() + pid = _read_backend_pid(root) + 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 injects ``.env`` and then overrides a few paths with + *container* locations (``/workspaces``, ``/root/.specflow/...``) that don't + exist on the bare host. Here we merge the repo-root ``.env`` and substitute + host-appropriate defaults for those paths (only when the user hasn't already + set them), then force ``BACKEND_RUNTIME=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. + # Host equivalents of the compose container paths (setdefault → respect any + # value the user already exported or put in .env). + env.setdefault("DATABASE_TYPE", "sqlite") + env.setdefault("SQLITE_DB_PATH", str(Path.home() / ".specflow" / "db" / "specflow.db")) + env.setdefault("WORKSPACE_BASE_PATH", str(root / "workspaces")) + env["BACKEND_RUNTIME"] = BackendRuntime.PROCESS.value # always forced + return env + + +async def start_backend_process( + root: Path, on_line: Callable[[str], None] | 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 ``.specflow-local/backend.log`` and recording the 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() + backend_pid_path(root).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(root: 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. + """ + root = root or Path.cwd() + pid = _read_backend_pid(root) + 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(root).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." + ) + + # --------------------------------------------------------------------------- # Backend health # --------------------------------------------------------------------------- @@ -350,6 +607,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..28655f0 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(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(tmp_path, "process") == 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(tmp_path, local_env.BackendRuntime.DOCKER) + monkeypatch.setenv("BACKEND_RUNTIME", "process") + assert resolve_backend_runtime(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(tmp_path, local_env.BackendRuntime.PROCESS) + assert resolve_backend_runtime(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(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(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(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(tmp_path, local_env.BackendRuntime.DOCKER) + assert backend_runtime_is_configured(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_env.py b/mcp_server/tests/test_local_env.py index d3a14ab..89da619 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,131 @@ 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 TestBuildProcessBackendEnv: + def test_forces_process_and_fills_host_paths(self, tmp_path, monkeypatch): + monkeypatch.setattr(local_env, "read_dotenv", lambda root: {"OPENROUTER_API_KEY": "k"}) + for var in ("DATABASE_TYPE", "SQLITE_DB_PATH", "WORKSPACE_BASE_PATH"): + monkeypatch.delenv(var, raising=False) + env = local_env.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") + + def test_respects_user_overrides(self, tmp_path, monkeypatch): + monkeypatch.setattr(local_env, "read_dotenv", lambda root: {"DATABASE_TYPE": "firestore"}) + monkeypatch.setenv("WORKSPACE_BASE_PATH", "/custom/ws") + env = local_env.build_process_backend_env(tmp_path) + assert env["DATABASE_TYPE"] == "firestore" # from .env, not overwritten + assert env["WORKSPACE_BASE_PATH"] == "/custom/ws" # from env, not overwritten + + +class TestProcessControl: + @pytest.mark.asyncio + async def test_start_records_pid_and_spawns_detached(self, tmp_path, monkeypatch): + monkeypatch.setattr(local_env, "read_dotenv", lambda root: {}) + + class _FakePopen: + def __init__(self, argv, **kwargs): + self.pid = 4321 + _FakePopen.argv = argv + _FakePopen.kwargs = kwargs + + monkeypatch.setattr(local_env.subprocess, "Popen", _FakePopen) + pid = await local_env.start_backend_process(tmp_path) + assert pid == 4321 + assert local_env._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_env.backend_log_path(tmp_path).exists() + + def test_running_true_when_pid_alive(self, tmp_path, monkeypatch): + local_env.backend_pid_path(tmp_path).parent.mkdir(parents=True, exist_ok=True) + local_env.backend_pid_path(tmp_path).write_text("999") + monkeypatch.setattr(local_env, "_pid_alive", lambda pid: True) + assert local_env.backend_process_running(tmp_path) is True + + def test_running_false_when_no_pidfile(self, tmp_path): + assert local_env.backend_process_running(tmp_path) is False + + def test_stop_signals_group_and_clears_pidfile(self, tmp_path, monkeypatch): + local_env.backend_pid_path(tmp_path).parent.mkdir(parents=True, exist_ok=True) + local_env.backend_pid_path(tmp_path).write_text("999") + monkeypatch.setattr(local_env, "_pid_alive", lambda pid: True) + monkeypatch.setattr(local_env.os, "getpgid", lambda pid: pid) + killed: list[int] = [] + monkeypatch.setattr(local_env.os, "killpg", lambda pgid, sig: killed.append(pgid)) + assert local_env.stop_backend_process(tmp_path) is True + assert killed == [999] + assert not local_env.backend_pid_path(tmp_path).exists() + + def test_stop_noop_when_nothing_recorded(self, tmp_path): + assert local_env.stop_backend_process(tmp_path) is False + + +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(tmp_path, local_env.BackendRuntime.PROCESS) + assert local_env.read_saved_runtime(tmp_path) == local_env.BackendRuntime.PROCESS + # Persisted under .specflow-local, never in mcp-config.json. + assert local_env.backend_runtime_path(tmp_path).parent.name == ".specflow-local" + + def test_save_creates_dir_and_overwrites(self, tmp_path): + local_env.save_backend_runtime(tmp_path, local_env.BackendRuntime.DOCKER) + local_env.save_backend_runtime(tmp_path, local_env.BackendRuntime.PROCESS) + 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 + + +class TestAgentSandboxUnavailableReason: + def test_macos_ok_when_present(self, monkeypatch): + monkeypatch.setattr(local_env.sys, "platform", "darwin") + monkeypatch.setattr(local_env.shutil, "which", lambda name: "/usr/bin/sandbox-exec") + assert local_env.agent_sandbox_unavailable_reason() is None + + def test_linux_reports_missing_deps(self, monkeypatch): + monkeypatch.setattr(local_env.sys, "platform", "linux") + monkeypatch.setattr(local_env.shutil, "which", lambda name: None) + reason = local_env.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_env.sys, "platform", "win32") + reason = local_env.agent_sandbox_unavailable_reason() + assert reason is not None and "win32" in reason diff --git a/mcp_server/tests/test_tui_app.py b/mcp_server/tests/test_tui_app.py index 1859c3d..039be55 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,6 +397,7 @@ 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) @@ -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)), @@ -463,6 +466,103 @@ async def test_backend_not_ready_retry_skips_compose_up(self): 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_env.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(app.root, 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(app.root, 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_env.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_env.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 +1497,262 @@ 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_env.stop_backend_process", new=stop), + ): + await app.stop_backend_flow() + await pilot.pause() + stop.assert_called_once_with(app.root) + # 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_env.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_env.stop_backend_process", new=stop), + ): + await app.stop_backend_flow() + await pilot.pause() + stop.assert_called_once_with(app.root) + 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 + + @pytest.mark.asyncio + async def test_binding_hidden_in_docker_mode(self): + a, b, c = _gate_ready() + with a, b, c, patch( + "tui.app.resolve_backend_runtime", + return_value=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() + screen = app.screen + assert isinstance(screen, tui_app.DashboardScreen) + 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_env.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 = _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() + screen = app.screen + assert app.is_process_runtime is True + assert screen.check_action("stop_backend", ()) is True + + +class TestSwitchRuntime: + """``R`` 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_env.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_env.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_env.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_env.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(app.root, tui_app.local_env.BackendRuntime.PROCESS) + + def test_switch_binding_lives_on_sessions_not_dashboard(self): + # Switching cancels ALL runs + restarts the backend, so it belongs on the + # sessions overview, not the single-generation dashboard. Stop-backend + # stays shared (both screens). + assert hasattr(tui_app.SessionsScreen, "action_switch_runtime") + assert not hasattr(tui_app.DashboardScreen, "action_switch_runtime") + assert hasattr(tui_app.DashboardScreen, "action_stop_backend") + assert hasattr(tui_app.SessionsScreen, "action_stop_backend") + + 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..f989462 100644 --- a/mcp_server/tests/test_tui_config.py +++ b/mcp_server/tests/test_tui_config.py @@ -25,6 +25,12 @@ 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_and_is_purged(self): + # Runtime isn't an mcp-config setting (see cli.resolve_backend_runtime), so + # it must not be shown in Settings, and a stale value is swept on save. + assert "BACKEND_RUNTIME" not in config.EDITABLE_KEYS + assert "BACKEND_RUNTIME" in config._LEGACY_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) @@ -90,6 +96,18 @@ def test_purges_dead_legacy_tier_keys(self, tmp_path): assert env["LLM_HIGH"] == "new/model" assert env["KEEP"] == "yes" # unrelated entries still preserved + def test_purges_stale_backend_runtime(self, tmp_path): + # A BACKEND_RUNTIME left in the env block by an older build is dead (the + # runtime is resolved elsewhere); saving must sweep it out. + _write_config( + tmp_path, + {"mcpServers": {"specflow": {"env": {"BACKEND_RUNTIME": "process", "KEEP": "yes"}}}}, + ) + config.save_env(tmp_path, {"WORKSPACE_COUNT": "2"}) + env = config.load_env(tmp_path) + assert "BACKEND_RUNTIME" not in env + assert env["KEEP"] == "yes" + class TestLangfuse: def test_secret_key_is_masked_but_public_and_host_are_not(self): diff --git a/mcp_server/tui/app.py b/mcp_server/tui/app.py index 44439f8..c3580a5 100644 --- a/mcp_server/tui/app.py +++ b/mcp_server/tui/app.py @@ -60,7 +60,11 @@ Static, ) -from cli import resolve_backend_config +from cli import ( + backend_runtime_is_configured, + resolve_backend_config, + resolve_backend_runtime, +) from services import local_env, validate_models from services.llm_tiers import LLM_TIER_KEYS from services.retry import retry_generation_core @@ -462,7 +466,33 @@ def action_decide_later(self) -> None: self.dismiss(None) -class DashboardScreen(_SpecFlowScreen): +class _BackendControlScreen(_SpecFlowScreen): + """Base for the screens that may stop the bare-metal backend (dashboard + sessions). + + ``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``) so both screens share it. + + Switching runtime lives on the sessions list only (see ``SessionsScreen``), not + here: it cancels ALL in-flight runs and restarts the backend, so it belongs on + the overview screen rather than while viewing a single generation. + """ + + 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(_BackendControlScreen): """Live dashboard for a single generation.""" BINDINGS = [ @@ -556,7 +586,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: @@ -840,19 +871,22 @@ 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. + Binding("R", "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 · R switch runtime)", id="sessions-title", ) yield ListView(id="sessions-list") @@ -889,6 +923,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): @@ -1732,6 +1769,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_env.stop_backend_process, root) + 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, root, 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_env.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. @@ -1798,6 +1963,82 @@ 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 = [ + Binding("y", "yes", "start"), + Binding("n", "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? [y] retry [n] quit" + ) + 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? [y] start [n] quit" + ) + 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_env.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_env.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. @@ -2011,7 +2252,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; } @@ -2062,6 +2304,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(self.root) == 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_env.stop_backend_process, self.root) + 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(self.root) + 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_env.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. @@ -2087,18 +2481,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(self.root) + if not backend_runtime_is_configured(self.root): + # 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_env.backend_process_running, self.root) + 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, self.root, 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_env.backend_process_running, self.root + ) + 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..d9f6a33 100644 --- a/mcp_server/tui/config.py +++ b/mcp_server/tui/config.py @@ -28,9 +28,19 @@ "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"] +# Keys an earlier build wrote into the env block that nothing reads any more, +# purged on save so they never linger or skew a config fingerprint: +# - LLM_MODEL_* — superseded by the LLM_HIGH/MEDIUM/LOW tier keys. +# - BACKEND_RUNTIME — the runtime is a local-launcher fact resolved from the +# env var / .specflow-local/backend-runtime, never from mcp-config.json (the +# MCP server just calls the backend and is indifferent to how it is launched), +# so it must not be editable here. See cli.resolve_backend_runtime. +_LEGACY_EDITABLE_KEYS: list[str] = [ + "LLM_MODEL_HIGH", + "LLM_MODEL_MEDIUM", + "LLM_MODEL_LOW", + "BACKEND_RUNTIME", +] # Secret/identity keys, stored in .env (consumed by docker-compose / the backend # / the init script). Names match .env.quickstart.example so write_dotenv fills