Add BACKEND_RUNTIME=process to run without Docker#51
Conversation
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.
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.
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.
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.
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).
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.
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.
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.
| # 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()], |
There was a problem hiding this comment.
here @akozak-gd -> is it possible that sandbox_write_allowlist() includes less "acceptable" tools than allowed_tools and because we concatenate both lists we add Tools we wouldn't want in sandbox env?
| # 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(), |
There was a problem hiding this comment.
I see that this variable is declared here - but where is it later used?
| # 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" |
There was a problem hiding this comment.
If I remember correctly we should stop everywhere using the .specflow-local and move the configuration and some settings in ~/.specflow
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Bare-metal ("process") backend control — BACKEND_RUNTIME=process |
There was a problem hiding this comment.
Potentally we could move this to different module since this is a lot of process-specific code
| 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"), |
There was a problem hiding this comment.
if we have 4 bindings I wouldn't do 2x "r" because it can easily got mistyped.
Summary
BACKEND_RUNTIME=process(default remainsdocker) so the TUI can start the backend as a detached bare-metal uvicorn process instead of via docker-compose.ClaudeAgentOptions.sandbox, layered on top of the existing in-process controls.run_generationentrance, newSANDBOX_UNAVAILABLErejection code); a run never starts agents unconfined on the host.caches/subtree (npm/pip/go/gradle/…) so package installs during generation aren't denied in process mode.