From 50ca7c8e9bab30d69ff56d6b2c1f1795f844a134 Mon Sep 17 00:00:00 2001 From: Artsiom Kozak Date: Tue, 14 Jul 2026 14:15:49 +0200 Subject: [PATCH 1/9] feat(runtime): add BACKEND_RUNTIME=process to run without Docker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a BACKEND_RUNTIME variable (docker default | process). In process mode the TUI starts the backend as a detached bare-metal uvicorn process instead of via docker-compose, for users who want a Docker-free backend. Docker is the only OS-level isolation boundary today: the agent CLI runs as root in the container and every other control (bash allowlist, workspace path scoping, PreToolUse guard, credential redaction) is in-process and bypassable. Removing Docker removes that boundary, so process mode substitutes Claude Code's OS-level Bash sandbox (bubblewrap on Linux, Seatbelt on macOS) via ClaudeAgentOptions.sandbox — allow-only network, no unsandboxed escape hatch — layered on top of the existing controls (defense in depth), engaged only in process mode. Fail closed: the sandbox is preflighted at two gates (TUI and the run_generation entrance, which returns SANDBOX_UNAVAILABLE); a run never starts agents unconfined on the host. Supported on macOS and Linux; Windows users stay on docker. Docker mode behaviour is unchanged. See docs/backend/backend-runtime.md. --- .env.quickstart.example | 15 ++ CLAUDE.md | 3 + Makefile | 14 +- QUICKSTART.md | 6 + backend/app/agents_sandboxing/os_sandbox.py | 152 +++++++++++++ backend/app/api/v1/generation_sessions.py | 20 ++ backend/app/core/config.py | 23 +- backend/app/core/enums.py | 16 ++ backend/app/services/claude_code.py | 7 + backend/app/services/contract_validator.py | 3 + .../test/agents_sandboxing/test_os_sandbox.py | 88 ++++++++ backend/test/core/test_enums.py | 38 +++- docs/backend/DEVELOPMENT.md | 33 ++- docs/backend/backend-runtime.md | 102 +++++++++ mcp_server/cli.py | 13 ++ mcp_server/services/local_env.py | 212 ++++++++++++++++++ mcp_server/tests/test_cli.py | 36 +++ mcp_server/tests/test_local_env.py | 100 +++++++++ mcp_server/tests/test_tui_config.py | 1 + mcp_server/tui/app.py | 117 ++++++++-- mcp_server/tui/config.py | 3 + 21 files changed, 978 insertions(+), 24 deletions(-) create mode 100644 backend/app/agents_sandboxing/os_sandbox.py create mode 100644 backend/test/agents_sandboxing/test_os_sandbox.py create mode 100644 docs/backend/backend-runtime.md diff --git a/.env.quickstart.example b/.env.quickstart.example index 4a6ae8e..7d853bd 100644 --- a/.env.quickstart.example +++ b/.env.quickstart.example @@ -41,6 +41,21 @@ 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: Leave as "docker" unless you specifically want a Docker-free backend. +# 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/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..924a363 --- /dev/null +++ b/backend/app/agents_sandboxing/os_sandbox.py @@ -0,0 +1,152 @@ +"""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 shutil +import sys +from dataclasses import dataclass + +from claude_agent_sdk import SandboxNetworkConfig, SandboxSettings + +from app.core.config import 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 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 9b210da..2617d01 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 ( @@ -1428,6 +1429,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..d328bfb 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. @@ -341,6 +341,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 +369,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 845eccb..652be8b 100644 --- a/backend/app/services/claude_code.py +++ b/backend/app/services/claude_code.py @@ -18,6 +18,7 @@ ) +from app.agents_sandboxing.os_sandbox import get_agent_sandbox_settings from app.core.config import SUPPORTED_MCPS, settings from app.core.ttl_config import GenerationLifecyclePolicy from app.core.logging import create_agent_logger, format_json_to_log, log_agent_options @@ -808,6 +809,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..0bbb5e8 --- /dev/null +++ b/backend/test/agents_sandboxing/test_os_sandbox.py @@ -0,0 +1,88 @@ +"""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, +) +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 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..bc6e532 --- /dev/null +++ b/docs/backend/backend-runtime.md @@ -0,0 +1,102 @@ +# 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`. + +## 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); +- 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. + +## 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 ac716e5..53bd622 100644 --- a/mcp_server/cli.py +++ b/mcp_server/cli.py @@ -97,6 +97,19 @@ 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. + + Same precedence as the other resolvers: CLI flag → env var → mcp-config.json + → default (``docker``). Unknown values fall back to ``docker`` (safe default). + """ + mcp_env = _load_mcp_config(root) + raw = runtime_flag or os.getenv("BACKEND_RUNTIME") or mcp_env.get("BACKEND_RUNTIME") + return local_env.BackendRuntime.parse(raw) + + 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..e9f7313 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,35 @@ # 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" + + +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 + # --------------------------------------------------------------------------- # Repo-root + path resolution @@ -218,6 +251,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 # --------------------------------------------------------------------------- diff --git a/mcp_server/tests/test_cli.py b/mcp_server/tests/test_cli.py index 9c93c75..f366532 100644 --- a/mcp_server/tests/test_cli.py +++ b/mcp_server/tests/test_cli.py @@ -122,6 +122,42 @@ 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_mcp_config(self, tmp_path, monkeypatch): + from cli import resolve_backend_runtime + from services import local_env + cfg_dir = tmp_path / ".specflow-local" + cfg_dir.mkdir() + (cfg_dir / "mcp-config.json").write_text( + json.dumps({"mcpServers": {"specflow": {"env": {"BACKEND_RUNTIME": "docker"}}}}) + ) + monkeypatch.setenv("BACKEND_RUNTIME", "process") + assert resolve_backend_runtime(tmp_path) == local_env.BackendRuntime.PROCESS + + def test_mcp_config_fallback(self, tmp_path, monkeypatch): + 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.PROCESS + + 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..eea7d33 100644 --- a/mcp_server/tests/test_local_env.py +++ b/mcp_server/tests/test_local_env.py @@ -352,3 +352,103 @@ 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 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_config.py b/mcp_server/tests/test_tui_config.py index b17862b..a63f642 100644 --- a/mcp_server/tests/test_tui_config.py +++ b/mcp_server/tests/test_tui_config.py @@ -22,6 +22,7 @@ def test_uses_the_real_tier_keys_not_the_dead_llm_model_names(self): "LLM_LOW", "USER_EMAIL", "BACKEND_URL", + "BACKEND_RUNTIME", ] assert not any(k.startswith("LLM_MODEL_") for k in config.EDITABLE_KEYS) diff --git a/mcp_server/tui/app.py b/mcp_server/tui/app.py index c909ad5..317abd5 100644 --- a/mcp_server/tui/app.py +++ b/mcp_server/tui/app.py @@ -60,7 +60,7 @@ Static, ) -from cli import resolve_backend_config +from cli import resolve_backend_config, resolve_backend_runtime from services import local_env, validate_models from services.llm_tiers import LLM_TIER_KEYS from services.session import resolve_generation_id, set_project_root @@ -1740,6 +1740,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. @@ -2029,18 +2105,33 @@ 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. + if resolve_backend_runtime(self.root) == 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..7c4c534 100644 --- a/mcp_server/tui/config.py +++ b/mcp_server/tui/config.py @@ -26,6 +26,9 @@ *LLM_TIER_KEYS, "USER_EMAIL", "BACKEND_URL", + # docker (default) | process — how the local backend is launched. See + # docs/backend/backend-runtime.md; resolved by cli.resolve_backend_runtime. + "BACKEND_RUNTIME", ] # Superseded tier key names an earlier build wrote into the env block; nothing From 73c19db6ee7d5803a8cade1520c310e36f72bc6b Mon Sep 17 00:00:00 2001 From: Artsiom Kozak Date: Thu, 16 Jul 2026 15:48:07 +0200 Subject: [PATCH 2/9] fix(runtime): grant agent sandbox write access to tool caches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In process mode the OS Bash sandbox only permits writes to the query cwd (the workspace) and the session temp dir, but SpecFlow redirects tool caches (npm/pip/go/gradle/…) to {WORKSPACE_BASE_PATH}/caches/…, which sits outside cwd. Without granting that subtree, package installs during generation (npm install, pip install, go mod download) would be denied and every real run would fail — the unit tests missed it because they mock the SDK. The SDK intentionally has no SandboxSettings.filesystem field and routes filesystem write scope through Edit allow-rules, so extend allowed_tools with Edit/Write rules for the caches subtree in process mode (empty in docker; strictly tighter than docker, where bash writes are unrestricted). The "caches" subdir name is now the single constant WORKSPACE_CACHE_SUBDIR shared by setup_workspace_cache_directories and the allowlist so the two cannot drift. --- backend/app/agents_sandboxing/os_sandbox.py | 24 ++++++++++++++- backend/app/core/config.py | 5 ++++ backend/app/services/claude_code.py | 19 ++++++++---- .../test/agents_sandboxing/test_os_sandbox.py | 30 +++++++++++++++++++ docs/backend/backend-runtime.md | 7 ++++- 5 files changed, 77 insertions(+), 8 deletions(-) diff --git a/backend/app/agents_sandboxing/os_sandbox.py b/backend/app/agents_sandboxing/os_sandbox.py index 924a363..7a059d6 100644 --- a/backend/app/agents_sandboxing/os_sandbox.py +++ b/backend/app/agents_sandboxing/os_sandbox.py @@ -19,13 +19,14 @@ 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 settings +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 @@ -107,6 +108,27 @@ def get_agent_sandbox_settings() -> SandboxSettings | None: ) +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. diff --git a/backend/app/core/config.py b/backend/app/core/config.py index d328bfb..4c9c22b 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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" diff --git a/backend/app/services/claude_code.py b/backend/app/services/claude_code.py index 652be8b..a510288 100644 --- a/backend/app/services/claude_code.py +++ b/backend/app/services/claude_code.py @@ -18,8 +18,11 @@ ) -from app.agents_sandboxing.os_sandbox import get_agent_sandbox_settings -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 @@ -175,11 +178,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 = { @@ -780,8 +785,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 diff --git a/backend/test/agents_sandboxing/test_os_sandbox.py b/backend/test/agents_sandboxing/test_os_sandbox.py index 0bbb5e8..d195757 100644 --- a/backend/test/agents_sandboxing/test_os_sandbox.py +++ b/backend/test/agents_sandboxing/test_os_sandbox.py @@ -12,6 +12,7 @@ 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 @@ -41,6 +42,35 @@ def test_allowed_domains_override(self, monkeypatch): 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) diff --git a/docs/backend/backend-runtime.md b/docs/backend/backend-runtime.md index bc6e532..6a73eb2 100644 --- a/docs/backend/backend-runtime.md +++ b/docs/backend/backend-runtime.md @@ -35,7 +35,12 @@ Claude Code's built-in Bash sandbox is enabled per agent query via - 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); + 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); From bceae49e2ba7aa0c80a440da33f1835abba43026 Mon Sep 17 00:00:00 2001 From: Artsiom Kozak Date: Wed, 22 Jul 2026 11:27:00 +0200 Subject: [PATCH 3/9] chore: ignore docs/internal directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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. From cbf967512306add1e9275ac8aa0959c232867979 Mon Sep 17 00:00:00 2001 From: Artsiom Kozak Date: Thu, 23 Jul 2026 09:01:18 +0200 Subject: [PATCH 4/9] feat(tui): stop the bare-metal backend from the TUI (process runtime) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In BACKEND_RUNTIME=process the backend is a detached host process that outlives the TUI, so add an in-app way to stop it. A `k` binding on the dashboard and sessions screens (shown only in process mode; hidden in docker where the container stack is the boundary) confirms first — naming how many generations are in flight, since a hard stop interrupts them — then SIGTERMs the process group via the existing local_env.stop_backend_process and exits the TUI. Relaunching re-runs the startup gate, which already detects the backend is down and offers to start it again. Per the STEEL COMMANDMENTS a stop never releases workspaces: generated code is preserved and retry_generation resumes from the last checkpoint, and the confirmation says so. The confirm-and-stop flow lives once on SpecFlowTUI.stop_backend_flow; a _BackendControlScreen mixin carries the shared binding/action for both screens so there is no second implementation. --- docs/backend/backend-runtime.md | 17 +++++ mcp_server/tests/test_tui_app.py | 124 +++++++++++++++++++++++++++++++ mcp_server/tui/app.py | 98 +++++++++++++++++++++++- 3 files changed, 236 insertions(+), 3 deletions(-) diff --git a/docs/backend/backend-runtime.md b/docs/backend/backend-runtime.md index 6a73eb2..8454e0d 100644 --- a/docs/backend/backend-runtime.md +++ b/docs/backend/backend-runtime.md @@ -69,6 +69,23 @@ at two points (mirroring the `MODEL_UNAVAILABLE` two-gate pattern): 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. + ## Dependencies - **macOS**: nothing to install — `sandbox-exec` (Seatbelt) ships with the OS. diff --git a/mcp_server/tests/test_tui_app.py b/mcp_server/tests/test_tui_app.py index 7b70857..833caf2 100644 --- a/mcp_server/tests/test_tui_app.py +++ b/mcp_server/tests/test_tui_app.py @@ -1275,6 +1275,130 @@ 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 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/tui/app.py b/mcp_server/tui/app.py index 317abd5..e82d2c9 100644 --- a/mcp_server/tui/app.py +++ b/mcp_server/tui/app.py @@ -461,7 +461,32 @@ 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. + + In ``BACKEND_RUNTIME=process`` the backend is a detached host process that + outlives the TUI (see ``local_env.start_backend_process``), so there must be + an in-app way to stop it. The ``k`` binding is shown only in process mode — + in docker mode the container stack is the boundary and this action is hidden + (``check_action`` returns ``None``). The confirm-and-stop flow itself lives on + the app (``SpecFlowTUI.stop_backend_flow``) so both the dashboard and the + sessions list reuse exactly one implementation. + """ + + 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 = [ @@ -547,7 +572,8 @@ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | No return True if status == "failed" 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: @@ -782,7 +808,7 @@ 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 = [ @@ -2080,6 +2106,72 @@ 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 → mcp-config → default) so the two never disagree. + """ + return resolve_backend_runtime(self.root) == local_env.BackendRuntime.PROCESS + + 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", + ) + 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. From 1a01452c9cdf3a8f87aadddc35c916a4d72fc667 Mon Sep 17 00:00:00 2001 From: Artsiom Kozak Date: Thu, 23 Jul 2026 16:42:20 +0200 Subject: [PATCH 5/9] feat(tui): first-launch runtime chooser, remembered locally On first launch, when the runtime isn't pinned (no BACKEND_RUNTIME env var, no saved choice) and nothing is already running (no containers, no bare process), the TUI asks whether to run the backend in docker or process mode and remembers the pick. If something is already up, it infers the runtime from that instead of asking. The choice is 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 it is persisted to .specflow-local/backend-runtime (beside the pidfile/log), NOT mcp-config.json. resolve_backend_runtime now reads flag -> BACKEND_RUNTIME env -> saved file -> default docker, and no longer consults mcp-config.json for the runtime. .env.quickstart.example is corrected: a value sitting only in .env is not read by the runtime gate (it must be an exported env var), and the normal path is the chooser. --- .env.quickstart.example | 11 +++- docs/backend/backend-runtime.md | 19 ++++++ mcp_server/cli.py | 23 +++++-- mcp_server/services/local_env.py | 45 ++++++++++++++ mcp_server/tests/test_cli.py | 38 +++++++++--- mcp_server/tests/test_local_env.py | 28 +++++++++ mcp_server/tests/test_tui_app.py | 97 ++++++++++++++++++++++++++++++ mcp_server/tui/app.py | 67 ++++++++++++++++++++- 8 files changed, 310 insertions(+), 18 deletions(-) diff --git a/.env.quickstart.example b/.env.quickstart.example index 7d853bd..d4c728e 100644 --- a/.env.quickstart.example +++ b/.env.quickstart.example @@ -52,9 +52,16 @@ OPENROUTER_API_KEY= # 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: Leave as "docker" unless you specifically want a Docker-free backend. +# 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 +# BACKEND_RUNTIME=docker # AUTH_MODE # What: Controls how the backend authenticates inbound requests. diff --git a/docs/backend/backend-runtime.md b/docs/backend/backend-runtime.md index 8454e0d..26b82f8 100644 --- a/docs/backend/backend-runtime.md +++ b/docs/backend/backend-runtime.md @@ -13,6 +13,25 @@ agent protection layer, not any workflow, state-machine, or estimation logic. 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. diff --git a/mcp_server/cli.py b/mcp_server/cli.py index 5d5d163..465694c 100644 --- a/mcp_server/cli.py +++ b/mcp_server/cli.py @@ -102,12 +102,25 @@ def resolve_backend_runtime( ) -> local_env.BackendRuntime: """Resolve how the backend is launched: docker (default) or process. - Same precedence as the other resolvers: CLI flag → env var → mcp-config.json - → default (``docker``). Unknown values fall back to ``docker`` (safe default). + 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. """ - mcp_env = _load_mcp_config(root) - raw = runtime_flag or os.getenv("BACKEND_RUNTIME") or mcp_env.get("BACKEND_RUNTIME") - return local_env.BackendRuntime.parse(raw) + 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: diff --git a/mcp_server/services/local_env.py b/mcp_server/services/local_env.py index e9f7313..223a357 100644 --- a/mcp_server/services/local_env.py +++ b/mcp_server/services/local_env.py @@ -49,6 +49,12 @@ _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). @@ -72,6 +78,45 @@ def parse(cls, raw: str | None) -> "BackendRuntime": 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 diff --git a/mcp_server/tests/test_cli.py b/mcp_server/tests/test_cli.py index dde669b..28655f0 100644 --- a/mcp_server/tests/test_cli.py +++ b/mcp_server/tests/test_cli.py @@ -135,18 +135,23 @@ def test_flag_takes_priority_over_env(self, tmp_path, monkeypatch): monkeypatch.setenv("BACKEND_RUNTIME", "docker") assert resolve_backend_runtime(tmp_path, "process") == local_env.BackendRuntime.PROCESS - def test_env_takes_priority_over_mcp_config(self, tmp_path, monkeypatch): + def test_env_takes_priority_over_saved_choice(self, tmp_path, monkeypatch): from cli import resolve_backend_runtime from services import local_env - cfg_dir = tmp_path / ".specflow-local" - cfg_dir.mkdir() - (cfg_dir / "mcp-config.json").write_text( - json.dumps({"mcpServers": {"specflow": {"env": {"BACKEND_RUNTIME": "docker"}}}}) - ) + 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_mcp_config_fallback(self, tmp_path, monkeypatch): + 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) @@ -155,7 +160,24 @@ def test_mcp_config_fallback(self, tmp_path, monkeypatch): (cfg_dir / "mcp-config.json").write_text( json.dumps({"mcpServers": {"specflow": {"env": {"BACKEND_RUNTIME": "process"}}}}) ) - assert resolve_backend_runtime(tmp_path) == local_env.BackendRuntime.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: diff --git a/mcp_server/tests/test_local_env.py b/mcp_server/tests/test_local_env.py index eea7d33..4c98efd 100644 --- a/mcp_server/tests/test_local_env.py +++ b/mcp_server/tests/test_local_env.py @@ -435,6 +435,34 @@ 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") diff --git a/mcp_server/tests/test_tui_app.py b/mcp_server/tests/test_tui_app.py index cf3a93a..8a7e19e 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,100 @@ 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) + + @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) + + class TestOnboarding: """Drive the step-by-step onboarding wizard via the pilot. diff --git a/mcp_server/tui/app.py b/mcp_server/tui/app.py index d43a21e..f77e0c0 100644 --- a/mcp_server/tui/app.py +++ b/mcp_server/tui/app.py @@ -60,7 +60,11 @@ Static, ) -from cli import resolve_backend_config, resolve_backend_runtime +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 @@ -1758,6 +1762,45 @@ 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 StartContainersScreen(_SpecFlowScreen): """Docker gate: start the stack (or wait out an unhealthy backend), or quit. @@ -2170,7 +2213,7 @@ def is_process_runtime(self) -> bool: 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 → mcp-config → default) so the two never disagree. + (flag → env → saved choice → default) so the two never disagree. """ return resolve_backend_runtime(self.root) == local_env.BackendRuntime.PROCESS @@ -2258,7 +2301,25 @@ async def _startup_gate(self) -> None: # (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. - if resolve_backend_runtime(self.root) == local_env.BackendRuntime.PROCESS: + 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 + + 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. From fc3c0700ac7ec3eba652ad7e0200b8ede0bd9cc3 Mon Sep 17 00:00:00 2001 From: Artsiom Kozak Date: Thu, 23 Jul 2026 16:50:57 +0200 Subject: [PATCH 6/9] feat(tui): switch backend runtime (docker<->process) from the TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `R` binding on the dashboard and sessions screens to move between docker and process runtimes without leaving the app. The flow preflights the target first (fail-closed sandbox for ->process, docker CLI present for ->docker) and refuses without disturbing the running backend if it can't run. It then confirms — naming how many in-flight generations will be cancelled — and hands off to SwitchRuntimeScreen, which performs the switch in a load-bearing order: cancel the active runs via the current backend's DELETE (before teardown, while the API is still reachable), tear down the current backend (stop_backend_process / docker compose down), persist the new choice to .specflow-local/backend-runtime, then start the target and wait for health. Per the STEEL COMMANDMENTS cancellation preserves workspaces and code (marked CANCELLED, not resumable). Both runtimes bind the same host:port and, in the default sqlite setup, share ~/.specflow/db/specflow.db, so cancelled runs stay visible and the TUI keeps polling the new backend automatically. Adds local_env.stop_containers (docker compose down) and local_env.docker_cli_available (PATH preflight). --- docs/backend/backend-runtime.md | 20 +++ mcp_server/services/local_env.py | 17 +++ mcp_server/tests/test_local_env.py | 18 +++ mcp_server/tests/test_tui_app.py | 121 ++++++++++++++++++ mcp_server/tui/app.py | 197 +++++++++++++++++++++++++++-- 5 files changed, 362 insertions(+), 11 deletions(-) diff --git a/docs/backend/backend-runtime.md b/docs/backend/backend-runtime.md index 26b82f8..4e91170 100644 --- a/docs/backend/backend-runtime.md +++ b/docs/backend/backend-runtime.md @@ -105,6 +105,26 @@ stop it: - **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 dashboard or sessions screen to move +docker↔process without leaving the app. 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. diff --git a/mcp_server/services/local_env.py b/mcp_server/services/local_env.py index 223a357..2188874 100644 --- a/mcp_server/services/local_env.py +++ b/mcp_server/services/local_env.py @@ -607,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_local_env.py b/mcp_server/tests/test_local_env.py index 4c98efd..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 diff --git a/mcp_server/tests/test_tui_app.py b/mcp_server/tests/test_tui_app.py index 8a7e19e..5a9d39b 100644 --- a/mcp_server/tests/test_tui_app.py +++ b/mcp_server/tests/test_tui_app.py @@ -1618,6 +1618,127 @@ async def test_binding_shown_in_process_mode(self): 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 + + @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) + + 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/tui/app.py b/mcp_server/tui/app.py index f77e0c0..6197e34 100644 --- a/mcp_server/tui/app.py +++ b/mcp_server/tui/app.py @@ -467,18 +467,23 @@ def action_decide_later(self) -> None: class _BackendControlScreen(_SpecFlowScreen): - """Base for the screens that may stop the bare-metal backend. - - In ``BACKEND_RUNTIME=process`` the backend is a detached host process that - outlives the TUI (see ``local_env.start_backend_process``), so there must be - an in-app way to stop it. The ``k`` binding is shown only in process mode — - in docker mode the container stack is the boundary and this action is hidden - (``check_action`` returns ``None``). The confirm-and-stop flow itself lives on - the app (``SpecFlowTUI.stop_backend_flow``) so both the dashboard and the - sessions list reuse exactly one implementation. + """Base for the screens that manage the backend runtime (dashboard + sessions). + + Two actions, both implemented once on the app so the two screens share one + implementation: + + * ``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). + * ``R`` switch runtime — always available: tears down the current backend and + brings up the other (docker↔process), cancelling any in-flight generation + first (``SpecFlowTUI.switch_runtime_flow``). """ - BINDINGS = [Binding("k", "stop_backend", "stop backend")] + BINDINGS = [ + Binding("k", "stop_backend", "stop backend"), + Binding("R", "switch_runtime", "switch runtime"), + ] def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: if action == "stop_backend": @@ -490,6 +495,9 @@ def action_stop_backend(self) -> None: return self.run_worker(self.app.stop_backend_flow(), exclusive=True) + def action_switch_runtime(self) -> None: + self.run_worker(self.app.switch_runtime_flow(), exclusive=True) + class DashboardScreen(_BackendControlScreen): """Live dashboard for a single generation.""" @@ -1801,6 +1809,95 @@ 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. @@ -2156,7 +2253,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; } @@ -2273,6 +2371,83 @@ async def stop_backend_flow(self) -> None: 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.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. From 0c08344777582439ba43831c9b0f2f6fcfe4a7af Mon Sep 17 00:00:00 2001 From: Artsiom Kozak Date: Thu, 23 Jul 2026 16:57:52 +0200 Subject: [PATCH 7/9] refactor(tui): move switch-runtime to the sessions screen only Switching runtime cancels ALL in-flight generations and restarts the backend, so it belongs on the sessions overview rather than the single-generation dashboard. Move the `R` binding and action_switch_runtime off the shared _BackendControlScreen mixin onto SessionsScreen; `k` stop backend stays shared by both screens. The switch flow itself is unchanged. --- docs/backend/backend-runtime.md | 6 ++++-- mcp_server/tests/test_tui_app.py | 9 +++++++++ mcp_server/tui/app.py | 33 ++++++++++++++++---------------- 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/docs/backend/backend-runtime.md b/docs/backend/backend-runtime.md index 4e91170..253628e 100644 --- a/docs/backend/backend-runtime.md +++ b/docs/backend/backend-runtime.md @@ -107,8 +107,10 @@ stop it: ## Switching runtime from the TUI -Press `R` (*switch runtime*) on the dashboard or sessions screen to move -docker↔process without leaving the app. The switch: +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. diff --git a/mcp_server/tests/test_tui_app.py b/mcp_server/tests/test_tui_app.py index 5a9d39b..fe85c21 100644 --- a/mcp_server/tests/test_tui_app.py +++ b/mcp_server/tests/test_tui_app.py @@ -1738,6 +1738,15 @@ async def test_switch_screen_cancels_then_teardown_then_start_in_order(self): 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, diff --git a/mcp_server/tui/app.py b/mcp_server/tui/app.py index 6197e34..596c600 100644 --- a/mcp_server/tui/app.py +++ b/mcp_server/tui/app.py @@ -467,23 +467,19 @@ def action_decide_later(self) -> None: class _BackendControlScreen(_SpecFlowScreen): - """Base for the screens that manage the backend runtime (dashboard + sessions). + """Base for the screens that may stop the bare-metal backend (dashboard + sessions). - Two actions, both implemented once on the app so the two screens share one - implementation: + ``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. - * ``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). - * ``R`` switch runtime — always available: tears down the current backend and - brings up the other (docker↔process), cancelling any in-flight generation - first (``SpecFlowTUI.switch_runtime_flow``). + 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"), - Binding("R", "switch_runtime", "switch runtime"), - ] + BINDINGS = [Binding("k", "stop_backend", "stop backend")] def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: if action == "stop_backend": @@ -495,9 +491,6 @@ def action_stop_backend(self) -> None: return self.run_worker(self.app.stop_backend_flow(), exclusive=True) - def action_switch_runtime(self) -> None: - self.run_worker(self.app.switch_runtime_flow(), exclusive=True) - class DashboardScreen(_BackendControlScreen): """Live dashboard for a single generation.""" @@ -885,12 +878,15 @@ class SessionsScreen(_BackendControlScreen): 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") @@ -927,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): From e7a35280eb597f8e996ef906b5af68bf32517a32 Mon Sep 17 00:00:00 2001 From: Artsiom Kozak Date: Thu, 23 Jul 2026 17:01:34 +0200 Subject: [PATCH 8/9] refactor(tui): drop BACKEND_RUNTIME from the Settings screen The runtime is a local-launcher fact resolved from the env var / .specflow-local/backend-runtime, never from mcp-config.json, so an editable BACKEND_RUNTIME field in Settings wrote to a location nothing reads. Remove it from EDITABLE_KEYS and add it to the purge list so any stale value an older build left in the env block is swept out on save. --- mcp_server/tests/test_tui_config.py | 19 ++++++++++++++++++- mcp_server/tui/config.py | 19 +++++++++++++------ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/mcp_server/tests/test_tui_config.py b/mcp_server/tests/test_tui_config.py index a63f642..f989462 100644 --- a/mcp_server/tests/test_tui_config.py +++ b/mcp_server/tests/test_tui_config.py @@ -22,10 +22,15 @@ def test_uses_the_real_tier_keys_not_the_dead_llm_model_names(self): "LLM_LOW", "USER_EMAIL", "BACKEND_URL", - "BACKEND_RUNTIME", ] 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) @@ -91,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/config.py b/mcp_server/tui/config.py index 7c4c534..d9f6a33 100644 --- a/mcp_server/tui/config.py +++ b/mcp_server/tui/config.py @@ -26,14 +26,21 @@ *LLM_TIER_KEYS, "USER_EMAIL", "BACKEND_URL", - # docker (default) | process — how the local backend is launched. See - # docs/backend/backend-runtime.md; resolved by cli.resolve_backend_runtime. - "BACKEND_RUNTIME", ] -# 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 From 11d9a3278795eaae3fdc60a0f222c8182104283d Mon Sep 17 00:00:00 2001 From: Artsiom Kozak Date: Thu, 23 Jul 2026 17:05:30 +0200 Subject: [PATCH 9/9] feat(tui): show the active backend runtime in the header Adds a header sub-title indicator ("docker runtime" / "process runtime") so the current backend runtime is visible on every screen. Set once the startup gate finalizes the runtime (pinned, inferred, or chosen) and updated after a successful runtime switch. The value is passed explicitly rather than re-resolved, since at startup it may be inferred from what's already running and not yet saved. --- mcp_server/tests/test_tui_app.py | 5 +++++ mcp_server/tui/app.py | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/mcp_server/tests/test_tui_app.py b/mcp_server/tests/test_tui_app.py index fe85c21..039be55 100644 --- a/mcp_server/tests/test_tui_app.py +++ b/mcp_server/tests/test_tui_app.py @@ -538,6 +538,8 @@ async def test_infers_process_when_process_running_no_chooser(self): 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): @@ -558,6 +560,7 @@ async def test_infers_docker_when_containers_running_no_chooser(self): 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: @@ -1706,6 +1709,8 @@ async def test_confirmed_runs_switch_screen(self): 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): diff --git a/mcp_server/tui/app.py b/mcp_server/tui/app.py index 596c600..0b4be61 100644 --- a/mcp_server/tui/app.py +++ b/mcp_server/tui/app.py @@ -2314,6 +2314,14 @@ def is_process_runtime(self) -> bool: """ 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. @@ -2445,6 +2453,7 @@ async def switch_runtime_flow(self) -> None: 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: @@ -2493,6 +2502,7 @@ async def _startup_gate(self) -> None: 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