Merge remote-tracking branch 'origin/main'

This commit is contained in:
Bryan Helmkamp 2026-05-10 23:39:34 -04:00
commit 8b522a057e
No known key found for this signature in database
5 changed files with 228 additions and 57 deletions

86
Cargo.lock generated
View file

@ -1539,7 +1539,7 @@ dependencies = [
[[package]]
name = "fabro-agent"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -1578,7 +1578,7 @@ dependencies = [
[[package]]
name = "fabro-api"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"chrono",
"fabro-config",
@ -1599,7 +1599,7 @@ dependencies = [
[[package]]
name = "fabro-auth"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -1623,11 +1623,11 @@ dependencies = [
[[package]]
name = "fabro-build-support"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
[[package]]
name = "fabro-checkpoint"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"chrono",
"fabro-config",
@ -1643,7 +1643,7 @@ dependencies = [
[[package]]
name = "fabro-cli"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -1741,7 +1741,7 @@ dependencies = [
[[package]]
name = "fabro-client"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"anyhow",
"bytes",
@ -1770,7 +1770,7 @@ dependencies = [
[[package]]
name = "fabro-config"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -1797,7 +1797,7 @@ dependencies = [
[[package]]
name = "fabro-core"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"async-trait",
"fabro-types",
@ -1812,7 +1812,7 @@ dependencies = [
[[package]]
name = "fabro-dev"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -1831,7 +1831,7 @@ dependencies = [
[[package]]
name = "fabro-devcontainer"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"fabro-http",
"fabro-static",
@ -1848,7 +1848,7 @@ dependencies = [
[[package]]
name = "fabro-dump"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"anyhow",
"bytes",
@ -1862,7 +1862,7 @@ dependencies = [
[[package]]
name = "fabro-github"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"anyhow",
"base64",
@ -1884,7 +1884,7 @@ dependencies = [
[[package]]
name = "fabro-graphviz"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"anyhow",
"fabro-types",
@ -1898,7 +1898,7 @@ dependencies = [
[[package]]
name = "fabro-hooks"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"async-trait",
"fabro-agent",
@ -1922,7 +1922,7 @@ dependencies = [
[[package]]
name = "fabro-http"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"fabro-static",
"http",
@ -1932,7 +1932,7 @@ dependencies = [
[[package]]
name = "fabro-install"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"anyhow",
"base64",
@ -1947,7 +1947,7 @@ dependencies = [
[[package]]
name = "fabro-interview"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"async-trait",
"dialoguer",
@ -1962,7 +1962,7 @@ dependencies = [
[[package]]
name = "fabro-llm"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -1994,7 +1994,7 @@ dependencies = [
[[package]]
name = "fabro-macros"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"clap",
"fabro-options-metadata",
@ -2005,7 +2005,7 @@ dependencies = [
[[package]]
name = "fabro-mcp"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"anyhow",
"fabro-config",
@ -2021,7 +2021,7 @@ dependencies = [
[[package]]
name = "fabro-model"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"fabro-static",
"insta",
@ -2032,7 +2032,7 @@ dependencies = [
[[package]]
name = "fabro-oauth"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"anyhow",
"axum",
@ -2054,7 +2054,7 @@ dependencies = [
[[package]]
name = "fabro-options-metadata"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"serde",
"serde_json",
@ -2062,7 +2062,7 @@ dependencies = [
[[package]]
name = "fabro-proc"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"cc",
"libc",
@ -2071,7 +2071,7 @@ dependencies = [
[[package]]
name = "fabro-redact"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"aho-corasick",
"ref-cast",
@ -2087,7 +2087,7 @@ dependencies = [
[[package]]
name = "fabro-sandbox"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2130,7 +2130,7 @@ dependencies = [
[[package]]
name = "fabro-server"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2211,7 +2211,7 @@ dependencies = [
[[package]]
name = "fabro-slack"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"fabro-http",
"fabro-interview",
@ -2232,18 +2232,18 @@ dependencies = [
[[package]]
name = "fabro-spa"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"rust-embed",
]
[[package]]
name = "fabro-static"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
[[package]]
name = "fabro-store"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"async-trait",
"bytes",
@ -2270,7 +2270,7 @@ dependencies = [
[[package]]
name = "fabro-telemetry"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"anyhow",
"base64",
@ -2296,7 +2296,7 @@ dependencies = [
[[package]]
name = "fabro-template"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"anyhow",
"fabro-util",
@ -2308,7 +2308,7 @@ dependencies = [
[[package]]
name = "fabro-test"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"assert_cmd",
"axum",
@ -2331,7 +2331,7 @@ dependencies = [
[[package]]
name = "fabro-tracker"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2345,7 +2345,7 @@ dependencies = [
[[package]]
name = "fabro-types"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"chrono",
"clap",
@ -2366,7 +2366,7 @@ dependencies = [
[[package]]
name = "fabro-util"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"anyhow",
"console 0.15.11",
@ -2386,7 +2386,7 @@ dependencies = [
[[package]]
name = "fabro-validate"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"fabro-graphviz",
"fabro-model",
@ -2396,7 +2396,7 @@ dependencies = [
[[package]]
name = "fabro-vault"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"chrono",
"fabro-types",
@ -2408,7 +2408,7 @@ dependencies = [
[[package]]
name = "fabro-workflow"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -7140,7 +7140,7 @@ dependencies = [
[[package]]
name = "twin-github"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"axum",
"base64",
@ -7159,7 +7159,7 @@ dependencies = [
[[package]]
name = "twin-openai"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
dependencies = [
"anyhow",
"async-stream",

View file

@ -5,7 +5,7 @@ resolver = "2"
[workspace.package]
edition = "2021"
version = "0.228.0-nightly.0"
version = "0.229.0-nightly.0"
license = "MIT"
[workspace.dependencies]

View file

@ -1,15 +1,21 @@
services:
fabro:
image: ghcr.io/fabro-sh/fabro:nightly
image: ghcr.io/fabro-sh/fabro:${FABRO_VERSION:-nightly}
restart: unless-stopped
ports:
- "32276:32276"
- "${FABRO_PORT:-32276}:32276"
volumes:
- fabro-storage:/storage
- /var/run/docker.sock:/var/run/docker.sock
env_file:
- path: .env
required: false
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:32276/health"]
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
volumes:
fabro-storage:

121
docker/preflight.sh Executable file
View file

@ -0,0 +1,121 @@
#!/usr/bin/env bash
set -u
errors=0
required_docker_version="${REQUIRED_DOCKER_VERSION:-24.0.0}"
required_cores="${REQUIRED_CORES:-2}"
required_ram_mb="${REQUIRED_RAM_MB:-4096}"
required_disk_gb="${REQUIRED_DISK_GB:-20}"
fabro_port="${FABRO_PORT:-32276}"
fail() {
printf 'FAIL: %s\n' "$1"
errors=$((errors + 1))
}
ok() {
printf 'OK: %s\n' "$1"
}
version_ge() {
local minimum="$1"
local actual="$2"
if command -v sort >/dev/null 2>&1 && sort -V </dev/null >/dev/null 2>&1; then
printf '%s\n%s\n' "$minimum" "$actual" | sort -V -C
else
[ "$minimum" = "$actual" ] || [ "$(printf '%s\n%s\n' "$minimum" "$actual" | sort | head -n1)" = "$minimum" ]
fi
}
if ! command -v docker >/dev/null 2>&1; then
fail "Docker is not installed."
else
docker_version="$(docker version --format '{{.Client.Version}}' 2>/dev/null || printf '0.0.0')"
if version_ge "$required_docker_version" "$docker_version"; then
ok "Docker version $docker_version"
else
fail "Docker version $docker_version is below required minimum $required_docker_version."
fi
if docker compose version >/dev/null 2>&1; then
ok "$(docker compose version)"
else
fail "Docker Compose v2 is not available. Install the Docker Compose plugin."
fi
if docker ps >/dev/null 2>&1; then
ok "Docker daemon is accessible"
else
fail "Current user cannot access the Docker daemon."
fi
fi
available_cores="$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || printf '0')"
if [ "$available_cores" -lt "$required_cores" ]; then
fail "Only $available_cores CPU core(s) available; need at least $required_cores."
else
ok "$available_cores CPU core(s)"
fi
if [ -f /proc/meminfo ]; then
total_ram_mb="$(awk '/MemTotal/ { print int($2 / 1024) }' /proc/meminfo)"
elif command -v sysctl >/dev/null 2>&1; then
total_ram_bytes="$(sysctl -n hw.memsize 2>/dev/null || printf '0')"
total_ram_mb=$((total_ram_bytes / 1024 / 1024))
else
total_ram_mb=0
fi
if [ "$total_ram_mb" -lt "$required_ram_mb" ]; then
fail "Only ${total_ram_mb}MB RAM available; need at least ${required_ram_mb}MB."
else
ok "${total_ram_mb}MB RAM"
fi
available_kb="$(df -Pk /var/lib/docker 2>/dev/null | awk 'NR == 2 { print $4 }')"
if [ -z "$available_kb" ]; then
available_kb="$(df -Pk / 2>/dev/null | awk 'NR == 2 { print $4 }')"
fi
available_gb=$((available_kb / 1024 / 1024))
if [ "$available_gb" -lt "$required_disk_gb" ]; then
fail "Only ${available_gb}GB free disk space; need at least ${required_disk_gb}GB."
else
ok "${available_gb}GB free disk space"
fi
if command -v ss >/dev/null 2>&1; then
if ss -ltn | awk '{ print $4 }' | grep -Eq "[:.]${fabro_port}$"; then
fail "Port ${fabro_port} is already in use."
else
ok "Port ${fabro_port} appears available"
fi
elif command -v lsof >/dev/null 2>&1; then
if lsof -nP -iTCP:"$fabro_port" -sTCP:LISTEN >/dev/null 2>&1; then
fail "Port ${fabro_port} is already in use."
else
ok "Port ${fabro_port} appears available"
fi
else
ok "Skipped port availability check; neither ss nor lsof is installed"
fi
if command -v curl >/dev/null 2>&1; then
ghcr_status="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 https://ghcr.io/v2/ 2>/dev/null || printf '000')"
if [ "$ghcr_status" != "000" ]; then
ok "ghcr.io is reachable"
else
fail "Cannot reach ghcr.io for pulling Fabro images."
fi
else
fail "curl is not installed."
fi
printf '\n'
if [ "$errors" -gt 0 ]; then
printf 'Pre-flight checks failed with %s error(s). Please fix them before deploying Fabro.\n' "$errors"
exit 1
fi
printf 'All Fabro pre-flight checks passed.\n'

View file

@ -4,42 +4,75 @@
Build an embedded run terminal for Daytona and Docker sandboxes.
The run page gets a `Terminal` tab at `/runs/:id/terminal`. The browser uses `xterm.js`, connects to Fabro over WebSocket, and Fabro bridges bytes to a provider-specific terminal session for that run's sandbox. Daytona uses Daytona PTY. Docker uses an attached Docker exec with TTY. Keep the existing SSH access endpoint as a separate copyable external-access command for Daytona; do not build an SSH-over-WebSocket fallback.
The run page gets a `Terminal` tab at `/runs/:id/terminal`. The browser uses `xterm.js`, connects to Fabro over WebSocket, and Fabro bridges bytes to a provider-specific terminal session for that run's sandbox. Daytona uses Daytona PTY. Docker uses an attached Docker exec with TTY. If the sandbox exists but is stopped, opening the terminal starts/resumes it before attaching the terminal. Keep the existing SSH access endpoint as a required copyable external-access command for providers that support SSH; do not build an SSH-over-WebSocket fallback.
## Interfaces
- Add WebSocket endpoint: `GET /api/v1/runs/{id}/terminal`.
- WebSocket auth uses existing Fabro web/session auth; reject invalid origins before upgrade.
- Add WebSocket endpoint: `GET /api/v1/runs/{id}/terminal?cols=120&rows=32`.
- `cols` and `rows` come from the fitted xterm viewport before the WebSocket opens. Validate them server-side and clamp or reject unreasonable values.
- WebSocket auth uses existing Fabro web/session auth. Since all authenticated users can access all runs, the handler does not need per-run ACL checks.
- Reject invalid browser `Origin`/`Host` combinations before upgrade.
- Validate that the requested run exists, has a sandbox record owned by that run, and that the sandbox is not deleted before opening a terminal.
- Browser protocol:
- Client binary message: raw PTY stdin bytes.
- Client text message: `{"type":"resize","cols":120,"rows":32}` or `{"type":"close"}`.
- Server binary message: raw PTY output bytes.
- Server text message: `{"type":"ready"}`, `{"type":"error","message":"..."}`, `{"type":"closed"}`.
- No OpenAPI/generated API client change for the WebSocket. Existing `POST /api/v1/runs/{id}/ssh` remains for "Copy SSH command".
- Server text message: `{"type":"starting"}`, `{"type":"ready"}`, `{"type":"error","message":"..."}`, `{"type":"closed"}`.
- Bound the protocol:
- Reject oversized text control messages.
- Use bounded channels between the WebSocket and provider session.
- Apply ping/pong or equivalent keepalive timeout so abandoned connections close.
- Treat terminal output as transient stream data and never persist or log it.
- No OpenAPI/generated API client change for the WebSocket.
- External access:
- Existing `POST /api/v1/runs/{id}/ssh` remains required for "Copy SSH command".
- The terminal page shows "Copy SSH command" when the run's sandbox/provider supports SSH access.
- SSH is only an external-access command; it is not used as the embedded terminal transport.
- Add a small terminal-specific capability in `fabro-sandbox`, not full terminal support on the existing `Sandbox` trait:
- `TerminalSize { cols: u16, rows: u16 }`
- `TerminalSession` with `write_input`, `read_output`, `resize`, and `close`
- `TerminalOptions { size: TerminalSize, cwd: Utf8PathBuf, env: BTreeMap<String, String> }`
- provider entry point: `open_terminal(options) -> TerminalSession`
- `TerminalSession` exposes an input sink, an output stream, `resize`, and `close`
- concrete `DaytonaTerminalSession` and `DockerTerminalSession` implementations
## Key Changes
- Backend:
- Enable Axum WebSocket support in `fabro-server`.
- Add a focused terminal handler that loads the run sandbox record, reconnects the concrete provider, starts/restores it, opens a terminal session, bridges browser input/output, handles resize, and closes/kills the terminal session when the browser disconnects.
- Add a focused terminal handler that:
- loads the run and sandbox record
- validates that the sandbox belongs to the run and is not deleted
- reconnects the concrete provider
- checks provider sandbox state
- starts/resumes the sandbox if it is stopped
- waits for the provider to report ready/running, with a timeout
- opens a terminal session with the initial xterm size
- bridges browser input/output
- handles resize
- closes/kills only the terminal session when the browser disconnects
- Add a Daytona PTY helper in `fabro-sandbox` that uses Daytona Toolbox APIs directly for create/connect/resize/kill because the pinned Rust SDK exposes PTY management but not a complete streaming handle.
- Daytona terminal connection must start/resume a stopped Daytona sandbox before creating the PTY.
- Add a Docker terminal helper in `fabro-sandbox` that creates an attached Docker exec with `tty=true`, `attach_stdin=true`, `attach_stdout=true`, `attach_stderr=true`, starts it attached, writes browser input into Bollard's exec input writer, forwards exec output to the browser, and calls `resize_exec` on resize.
- Docker disconnect cleanup should close the exec input/output and run the shell through a lightweight wrapper that records its PID so Fabro can terminate the shell process if the WebSocket drops.
- Docker terminal connection must start a stopped run container before creating the exec session. If the container is paused, deleted, or cannot be started, return a clean terminal error.
- Docker disconnect cleanup should close the exec input/output and run the shell through a lightweight wrapper that records its PID so Fabro can attempt to terminate the shell process if the WebSocket drops. Treat Docker exec cleanup as best-effort and prefer process-group cleanup when available.
- Use the run sandbox working directory as the PTY `cwd`; set `TERM=xterm-256color` and `LANG=C.UTF-8`.
- Keep provider credentials server-side only. Never send Daytona API keys, Daytona PTY URLs, Docker socket details, or provider connection handles to the browser.
- Terminal disconnect must not stop or delete the sandbox. Sandbox deletion stays tied to workflow run deletion.
- Frontend:
- Add `@xterm/xterm` and `@xterm/addon-fit`.
- Add `run-terminal.tsx`, mounted as `/runs/:id/terminal`, with full-height terminal layout.
- Add a `Terminal` tab when the run has a sandbox id.
- WebSocket URL uses `ws://` for `http://127.0.0.1` and `wss://` for HTTPS.
- Compute the initial fitted terminal size before opening the WebSocket and pass it as `cols`/`rows`.
- Send xterm `onData` input as binary WebSocket messages using `TextEncoder`.
- Debounce xterm resize events and send resize control messages.
- Add header actions: reconnect terminal, copy existing SSH command when the provider supports SSH, and connection status.
- Render connection states for starting, connecting, ready, closed, unsupported, and error.
- Behavior:
- Daytona and Docker are supported in v1. Local sandboxes show an unsupported-provider error.
- Stopped Daytona and Docker sandboxes are started/resumed when the terminal connects.
- PTY sessions are not persistent in v1. Closing or refreshing the tab starts a fresh shell.
- Closing the terminal cleans up the PTY/exec session but does not stop or delete the sandbox.
- Terminal input/output is not logged by Fabro.
## Test Plan
@ -47,22 +80,31 @@ The run page gets a `Terminal` tab at `/runs/:id/terminal`. The browser uses `xt
- Rust unit tests:
- WebSocket message parser accepts valid resize and rejects malformed/oversized control messages.
- Origin validation allows same-origin localhost and rejects cross-origin browser origins.
- Authenticated WebSocket requests for missing runs, runs without sandboxes, deleted sandboxes, and mismatched run/sandbox records fail cleanly.
- Protocol bridge uses bounded queues and closes cleanly on keepalive timeout.
- Daytona PTY helper builds the expected Toolbox REST/WebSocket URLs and auth headers.
- Docker terminal helper creates exec options with TTY, stdin/stdout/stderr attached, workspace cwd, and terminal env.
- Server tests:
- Unauthenticated terminal WebSocket upgrade is rejected.
- Local or missing sandbox returns a clean unsupported/unavailable failure.
- Stopped Daytona sandbox is resumed before PTY creation.
- Stopped Docker container is started before exec creation.
- Startup timeout or provider start failure sends a clean terminal error and closes the WebSocket.
- Daytona runs use the Daytona terminal adapter; Docker runs use the Docker terminal adapter.
- On browser disconnect, the handler closes/kills the provider terminal session.
- On browser disconnect, the handler closes/kills the provider terminal session without stopping or deleting the sandbox.
- Resize messages call the provider resize operation with the latest cols/rows.
- Web tests:
- Terminal tab appears for sandbox-backed runs.
- Route opens `ws://127.0.0.1:port/...` on local HTTP and `wss://` on HTTPS.
- Route opens `ws://127.0.0.1:port/...` on local HTTP and `wss://` on HTTPS, including the fitted `cols`/`rows` query parameters.
- Binary PTY output is written to xterm; keyboard input sends binary WebSocket messages.
- Resize events are debounced and sent as resize control messages.
- "Copy SSH command" appears when the provider supports SSH and is absent/disabled otherwise.
- Unsupported/error/closed states render without crashing.
- Manual acceptance:
- Open Daytona-backed and Docker-backed runs, use `ls`, `pwd`, `vim`/`less`, Ctrl-C, resize the browser, refresh the tab, and confirm the old shell session is cleaned up.
- Confirm "Copy SSH command" appears for Daytona and is absent/disabled for Docker.
- Stop a Daytona sandbox and a Docker container, open the terminal, and confirm Fabro starts/resumes the sandbox before connecting.
- Confirm closing or refreshing the terminal does not stop or delete the sandbox.
- Confirm "Copy SSH command" appears when SSH access is supported and is absent/disabled otherwise.
- Run `cargo nextest run -p fabro-server`, relevant `fabro-sandbox` tests, and `cd apps/fabro-web && bun test && bun run typecheck`.
## Assumptions
@ -70,4 +112,6 @@ The run page gets a `Terminal` tab at `/runs/:id/terminal`. The browser uses `xt
- Daytona PTY and Docker attached exec are the embedded-terminal transports for v1.
- `/api/v1/runs/{id}/ssh` stays as external access for local terminals and IDEs.
- WebSocket over plain `ws://127.0.0.1` is acceptable for local Fabro; hosted HTTPS deployments require `wss://`.
- All authenticated users can access all runs, so terminal access relies on existing session authentication plus run/sandbox existence and ownership validation, not per-run ACLs.
- Run sandboxes are created and deleted with their workflow run, but may be stopped independently. Terminal connect starts/resumes a stopped sandbox; terminal disconnect does not stop or delete it.
- Sources: Daytona PTY docs, Daytona SSH docs, Docker exec/Bollard APIs, and xterm.js docs.