Skip to content

Add BACKEND_RUNTIME=process to run without Docker#51

Draft
akozak-gd wants to merge 11 commits into
mainfrom
feat/sandbox-mode-without-docker
Draft

Add BACKEND_RUNTIME=process to run without Docker#51
akozak-gd wants to merge 11 commits into
mainfrom
feat/sandbox-mode-without-docker

Conversation

@akozak-gd

@akozak-gd akozak-gd commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds BACKEND_RUNTIME=process (default remains docker) so the TUI can start the backend as a detached bare-metal uvicorn process instead of via docker-compose.
  • Since Docker is the only OS-level isolation boundary today, process mode substitutes Claude Code's OS-level Bash sandbox (bubblewrap on Linux, Seatbelt on macOS) via ClaudeAgentOptions.sandbox, layered on top of the existing in-process controls.
  • Fails closed: sandbox availability is preflighted at two gates (TUI and the run_generation entrance, new SANDBOX_UNAVAILABLE rejection code); a run never starts agents unconfined on the host.
  • Grants the sandbox write access to the workspace caches/ subtree (npm/pip/go/gradle/…) so package installs during generation aren't denied in process mode.

akozak-gd added 11 commits July 14, 2026 14:15
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()],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potentally we could move this to different module since this is a lot of process-specific code

Comment thread mcp_server/tui/app.py
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"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we have 4 bindings I wouldn't do 2x "r" because it can easily got mistyped.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants