diff --git a/Cargo.lock b/Cargo.lock index 3a3ace63b..20fc42e53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 9481e3cc6..1268de6a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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] diff --git a/docker-compose.yaml b/docker-compose.yaml index e3ee394c9..ee5858d13 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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: diff --git a/docker/preflight.sh b/docker/preflight.sh new file mode 100755 index 000000000..6bc1c9172 --- /dev/null +++ b/docker/preflight.sh @@ -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 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' diff --git a/docs/superpowers/plans/2026-05-09-daytona-run-terminal.md b/docs/superpowers/plans/2026-05-09-daytona-run-terminal.md index f54bf4333..b0e1f4e45 100644 --- a/docs/superpowers/plans/2026-05-09-daytona-run-terminal.md +++ b/docs/superpowers/plans/2026-05-09-daytona-run-terminal.md @@ -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 }` + - 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.