diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index dededfa2b..50c043349 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -133,6 +133,47 @@ jobs: # strict mode, which fails (rather than skips) live tests without keys. - run: cargo nextest run --locked --workspace --status-level slow --profile ci --run-ignored only -E 'package(fabro-agent) + package(fabro-llm)' + sandbox-plugins: + name: Sandbox plugins (stdio) + runs-on: ubuntu-24.04-x86-32-cores + permissions: + contents: read + env: + # The plugin scenarios skip when an executable or daemon is missing; + # in CI a skip is a failure. + FABRO_REQUIRE_SANDBOX_PLUGINS: "1" + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + with: + toolchain: 1.97.1 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 + with: + cache-on-failure: true + - uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest + - run: docker pull buildpack-deps:noble + # The driver's own Host and Docker executables, installed at the rev the + # workspace pins so the plugins and the in-process providers are one + # build; the CLI scenarios find them on PATH and launch them over stdio. + - name: Install the sandbox-driver plugin executables + run: | + rev="$(sed -n 's/^sandbox-driver = { git = "[^"]*", rev = "\([0-9a-f]*\)" }$/\1/p' Cargo.toml)" + test -n "$rev" + cargo install --locked --git https://github.com/lithoscomputer/sandbox-driver --rev "$rev" sandbox-driver-host sandbox-driver-docker + # Host and Docker served as plugins through the workflow scenarios. The + # scenarios are e2e tests (ignored by default); the key-free ones run + # here, the LLM-backed ones self-skip without credentials. + - run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-cli --test it -E 'test(/host_plugin_|docker_plugin_/)' + # The stdio plugin proof (not ignored: it skips without the executable, + # which the environment above forbids) and the driver-backed Docker + # integration tests. + - run: cargo nextest run --locked --profile ci --status-level slow -p fabro-sandbox --test plugin_provider + - run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-sandbox --test docker_streaming + - run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-agent --test it -E 'test(docker_shell)' + - run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-workflow --test it -E 'test(asset_collection_docker_sandbox)' + test-macos: name: Test (macOS) if: github.event_name == 'workflow_dispatch' diff --git a/AGENTS.md b/AGENTS.md index 411d07e4a..23f7bfd56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,17 +32,19 @@ macOS note: if `cargo nextest run` fails with `Too many open files (os error 24) - The packaged compose service mounts `/var/run/docker.sock` so the server can create sibling run containers on the host daemon. This is host-root-equivalent under Docker's security model; only use it in the trusted, single-tenant deployment model described by the sandbox code/docs. - Docker and Daytona are clone-based providers. When a run manifest has a GitHub origin, they clone it into the provider workspace. Present non-GitHub origins fail unless the provider has `skip_clone = true`; absent origins or `skip_clone = true` create an empty workspace without repository files. For an exact commit, the submitted branch names the working branch and the syntactically valid SHA is requested directly. No layer proves branch/SHA ancestry: a fetchable commit is checked out, an unavailable commit fails setup, and branch HEAD is never substituted. - The sandbox layer also accepts an optional exact commit for future admitted - runs. An exact commit always requires a non-empty branch. Docker initializes - an empty repository, shallow-fetches the SHA at the same depth as a branch - clone, and checks it out; Daytona uses its official SDK clone with both - `branch` and `commit_id`. Both providers then point the admitted branch at - the commit and verify HEAD, so the workspace still reports the admitted - branch name. Keep those provider transports distinct, never fall back to a - newer branch HEAD, and do not wire this capability directly from legacy - `GitContext.sha`. The sandbox layer does not verify that the commit is - reachable from the branch; admission owns that check. Current production - callers remain branch-only until the RunIntent admission cutover supplies a - validated branch/SHA pair. + runs. An exact commit always requires a non-empty branch. The sandbox driver + performs the pin the same way on every provider: it initializes an empty + repository, fetches the SHA directly at the requested depth, and attaches + the admitted branch to it, so the workspace reports the admitted branch + name. Daytona's native toolbox clone serves plain branch clones only; its + commit pin checks the branch head out first, so the driver does not use + it. A successful clone has the pin checked out; the driver's + conformance suite verifies that on every provider, and fabro does not + re-verify HEAD. Never fall back to a newer branch HEAD, and do not wire + this capability directly from legacy `GitContext.sha`. The sandbox layer + does not verify that the commit is reachable from the branch; admission + owns that check. Current production callers remain branch-only until the + RunIntent admission cutover supplies a validated branch/SHA pair. ### Release automation - `cargo dev release` — creates the next stable release tag. Use `cargo dev release --nightly` for a nightly prerelease. Use `--dry-run` to print planned commands without mutating git or running Cargo, `--skip-tests` only after running the release-mode smoke yourself, and `--release-date YYYY-MM-DD` or `FABRO_RELEASE_DATE` for deterministic version computation. @@ -122,7 +124,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as ### Rust crates (`lib/apps/`, `lib/components/`, and `lib/foundation/`) - **fabro-cli** — CLI entry point. Commands: `run`, `exec`, `serve`, `validate`, `parse`, `cp`, `model`, `doctor`, `install`, `ps`, `system prune` - **fabro-workflow** — Core workflow engine. Parses Graphviz graphs, runs stages, manages checkpoints/resume, hooks, and human-in-the-loop interactions -- **fabro-agent** — AI coding agent with tool use (Bash, Read, Write, Edit, Glob, Grep, WebFetch). `Sandbox` trait abstracts execution environments +- **fabro-agent** — AI coding agent with tool use (Bash, Read, Write, Edit, Glob, Grep, WebFetch). Tools run through `RunSandbox`, fabro's one sandbox type over the sandbox driver - **fabro-sandbox** — Local, Docker, and Daytona sandbox providers. Docker is the default runtime provider and creates clone-based `/workspace` containers through the operator's Docker daemon; Daytona uses the same GitHub-only clone-source contract. Docker daemon access is host-root-equivalent and assumes trusted callers/payloads. - **fabro-server** — Axum HTTP server. Routes for runs, sessions, models, completions, usage. SSE event streaming. Demo mode via header - **fabro-llm** — Unified LLM client with providers: Anthropic, OpenAI, Gemini, OpenAI-compatible, plus retry/middleware/streaming @@ -139,7 +141,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as - **lib/packages/fabro-api-client** — Auto-generated TypeScript Axios client from OpenAPI spec ### Key design patterns -- **Sandbox trait** — Uniform interface for local, Docker, and Daytona execution environments. Clone-based providers use run-spec GitHub origin metadata rather than worker process cwd detection. +- **RunSandbox** — One concrete sandbox type for local, Docker, and Daytona execution environments, over the `sandbox-driver` facets (exec, filesystem, search, git). There is no fabro-side sandbox trait; tests use `fabro_sandbox::test_support::MockSandbox` over the driver's scripted doubles. Clone-based providers use run-spec GitHub origin metadata rather than worker process cwd detection. - **Graphviz graph workflows** — Stages and transitions defined as Graphviz graph attributes - **OpenAPI-first** — `fabro-api.yaml` drives Rust type + client generation (progenitor) and TypeScript client generation (openapi-generator) - **Checkpoint/resume** — Workflows can be paused, checkpointed, and resumed diff --git a/Cargo.lock b/Cargo.lock index ed97f7d8d..19f57246b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1859,7 +1859,7 @@ checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "daytona-api-client" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511" +source = "git+https://github.com/brynary/daytona-sdk-rust?rev=5e86990418e21f4288ce537c9852dfdf78768abc#5e86990418e21f4288ce537c9852dfdf78768abc" dependencies = [ "reqwest 0.13.4", "reqwest-middleware", @@ -1873,7 +1873,7 @@ dependencies = [ [[package]] name = "daytona-sdk" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511" +source = "git+https://github.com/brynary/daytona-sdk-rust?rev=5e86990418e21f4288ce537c9852dfdf78768abc#5e86990418e21f4288ce537c9852dfdf78768abc" dependencies = [ "daytona-api-client", "daytona-toolbox-client", @@ -1893,7 +1893,7 @@ dependencies = [ [[package]] name = "daytona-toolbox-client" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511" +source = "git+https://github.com/brynary/daytona-sdk-rust?rev=5e86990418e21f4288ce537c9852dfdf78768abc#5e86990418e21f4288ce537c9852dfdf78768abc" dependencies = [ "reqwest 0.13.4", "reqwest-middleware", @@ -2294,6 +2294,8 @@ dependencies = [ "libc", "lithos-llm", "paste", + "sandbox-driver", + "sandbox-driver-testing", "serde", "serde_json", "sha2 0.10.9", @@ -2326,6 +2328,7 @@ dependencies = [ "progenitor-client", "regress", "reqwest 0.13.4", + "sandbox-driver", "serde", "serde_json", "serde_yaml", @@ -2415,7 +2418,6 @@ dependencies = [ "cli-table", "console 0.15.11", "core-foundation 0.9.4", - "daytona-sdk", "dialoguer", "dirs", "dotenvy", @@ -2478,6 +2480,7 @@ dependencies = [ "reqwest 0.13.4", "ring", "rustls", + "sandbox-driver", "scopeguard", "semver", "serde", @@ -2941,13 +2944,8 @@ dependencies = [ "anyhow", "async-trait", "base64", - "bollard", "chrono", - "daytona-api-client", - "daytona-sdk", - "fabro-config", "fabro-github", - "fabro-http", "fabro-proc", "fabro-redact", "fabro-static", @@ -2955,23 +2953,21 @@ dependencies = [ "fabro-types", "fabro-util", "futures", - "futures-util", - "git2", - "hex", - "hmac 0.12.1", - "httpmock", - "rand 0.9.4", - "reqwest-middleware", - "rustls", + "reqwest 0.13.4", + "sandbox-driver", + "sandbox-driver-daytona", + "sandbox-driver-daytona-config", + "sandbox-driver-docker", + "sandbox-driver-docker-config", + "sandbox-driver-host", + "sandbox-driver-protocol", + "sandbox-driver-testing", "serde", "serde_json", - "sha2 0.10.9", "strum 0.28.0", - "tar", "tempfile", "thiserror 2.0.18", "tokio", - "tokio-tungstenite 0.26.2", "tokio-util", "toml 0.8.23", "tracing", @@ -3047,6 +3043,7 @@ dependencies = [ "rand 0.9.4", "regex", "reqwest 0.12.28", + "sandbox-driver", "semver", "serde", "serde_json", @@ -3250,6 +3247,7 @@ dependencies = [ "fabro-util", "hex", "lithos-llm", + "sandbox-driver", "serde", "serde_json", "sha2 0.10.9", @@ -3387,6 +3385,7 @@ dependencies = [ "predicates", "rand 0.9.4", "regex", + "sandbox-driver", "scopeguard", "serde", "serde_json", @@ -6994,6 +6993,135 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "sandbox-driver" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47" +dependencies = [ + "async-trait", + "globset", + "humantime", + "rand 0.10.1", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "sandbox-driver-daytona" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47" +dependencies = [ + "anyhow", + "async-trait", + "base64", + "daytona-api-client", + "daytona-sdk", + "hmac 0.12.1", + "rand 0.10.1", + "reqwest 0.13.4", + "sandbox-driver", + "sandbox-driver-daytona-config", + "sandbox-driver-docker", + "sandbox-driver-docker-config", + "sandbox-driver-protocol", + "serde", + "serde_json", + "sha2 0.10.9", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sandbox-driver-daytona-config" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47" +dependencies = [ + "sandbox-driver-docker-config", + "serde", + "serde_json", +] + +[[package]] +name = "sandbox-driver-docker" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47" +dependencies = [ + "anyhow", + "async-trait", + "bollard", + "futures-util", + "sandbox-driver", + "sandbox-driver-docker-config", + "sandbox-driver-protocol", + "serde", + "serde_json", + "tar", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sandbox-driver-docker-config" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "sandbox-driver-host" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47" +dependencies = [ + "anyhow", + "async-trait", + "nix 0.30.1", + "sandbox-driver", + "sandbox-driver-protocol", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sandbox-driver-protocol" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47" +dependencies = [ + "async-trait", + "base64", + "rand 0.10.1", + "sandbox-driver", + "serde", + "serde_json", + "sha2 0.10.9", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "sandbox-driver-testing" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47" +dependencies = [ + "async-trait", + "sandbox-driver", + "tokio", +] + [[package]] name = "schannel" version = "0.1.28" @@ -8529,9 +8657,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ "matchers", "nu-ansi-term", diff --git a/Cargo.toml b/Cargo.toml index 8db2ac0b6..9f8250ede 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,8 +61,6 @@ clap_complete = "4" jsonschema = { version = "0.42", default-features = false } chrono = { version = "0.4", features = ["clock", "serde"] } dashmap = "6" -bollard = "0.18" -tar = "0.4" cli-table = { version = "0.5", default-features = false } console = "0.15" dialoguer = "0.12" @@ -101,8 +99,22 @@ twin-openai = { git = "https://github.com/lithoscomputer/twins", rev = "ca45f0e5 twin-github = { path = "test/twin/github" } tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] } futures-util = "0.3" -daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "be2c7b7272740d47c023cac8abc9f63c1a51a511", package = "daytona-sdk" } -daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "be2c7b7272740d47c023cac8abc9f63c1a51a511", package = "daytona-api-client" } +# sandbox-driver: the sandbox provider layer. Bundled Host, Docker, and +# Daytona providers link in-process; third-party providers run as stdio +# plugins through sandbox-driver-protocol. Pinned by rev; currently the head of +# the sandbox-driver `section-4-driver-items` branch (provider-owned scopes, the +# supervisor as provider, Host attach by directory, git retry and verbs in the +# driver, status image/snapshot/network, Daytona snapshot caching, services port +# wait and list, RFC 3339 timestamps), to move to main on merge. The CI plugin +# job installs the driver executables at the same rev, read from this file. +sandbox-driver = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" } +sandbox-driver-protocol = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" } +sandbox-driver-host = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" } +sandbox-driver-docker = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" } +sandbox-driver-docker-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" } +sandbox-driver-daytona = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" } +sandbox-driver-daytona-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" } +sandbox-driver-testing = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" } sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] } fork = "0.2" exec = "0.3" diff --git a/apps/fabro-web/app/components/environment-form.tsx b/apps/fabro-web/app/components/environment-form.tsx index 688f0adbf..a78a6cc10 100644 --- a/apps/fabro-web/app/components/environment-form.tsx +++ b/apps/fabro-web/app/components/environment-form.tsx @@ -3,7 +3,6 @@ import { ChevronRightIcon } from "@heroicons/react/20/solid"; import { EnvironmentApiDockerfileSourceInlineTypeEnum, EnvironmentNetworkMode, - EnvironmentProvider, } from "@qltysh/fabro-api-client"; import type { CreateEnvironmentRequest, @@ -15,6 +14,7 @@ import type { ReplaceEnvironmentRequest, } from "@qltysh/fabro-api-client"; +import { DOCKER_PROVIDER, isCloneBasedProvider } from "../lib/environment-providers"; import { Label, Panel, Row } from "./settings-panel"; import { INPUT_CLASS } from "./ui"; import { @@ -25,11 +25,15 @@ import { } from "./key-value-editor"; // Parse the `provider` query param used by the create flow into a creatable -// provider, defaulting to Docker for anything unexpected. -export function parseCreatableProvider(value: string | null): EnvironmentProvider { - return value === EnvironmentProvider.DAYTONA - ? EnvironmentProvider.DAYTONA - : EnvironmentProvider.DOCKER; +// provider, defaulting to Docker for anything that cannot back a managed +// environment. Kind names are validated server-side on create. +const PROVIDER_KIND_PATTERN = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/; + +export function parseCreatableProvider(value: string | null): string { + if (value && PROVIDER_KIND_PATTERN.test(value) && isCloneBasedProvider(value)) { + return value; + } + return DOCKER_PROVIDER; } // Environment ids are server-managed file names: lowercase, digits, hyphens. @@ -49,7 +53,7 @@ type ImageSource = "image" | "dockerfile"; export interface EnvironmentFormValues { id: string; - provider: EnvironmentProvider; + provider: string; imageSource: ImageSource; dockerRef: string; dockerfile: string; @@ -69,7 +73,7 @@ export interface EnvironmentFormValues { export const EMPTY_ENVIRONMENT_FORM: EnvironmentFormValues = { id: "", - provider: EnvironmentProvider.DOCKER, + provider: DOCKER_PROVIDER, imageSource: "image", dockerRef: "", dockerfile: "", diff --git a/apps/fabro-web/app/components/run-summary-panel.test.tsx b/apps/fabro-web/app/components/run-summary-panel.test.tsx index 018184802..5ed6eca29 100644 --- a/apps/fabro-web/app/components/run-summary-panel.test.tsx +++ b/apps/fabro-web/app/components/run-summary-panel.test.tsx @@ -174,7 +174,7 @@ describe("RunSummaryPanelView", () => { const tree = render({ run: makeRun(), sandboxState: "running", - sandboxResources: { cpu_cores: 4, memory_bytes: 8 * 1024 * 1024 * 1024 } as any, + sandboxResources: { cpu_cores: 4, memory_mb: 8 * 1024 }, }); expect(instanceText(cellAfterLabel(tree, "Sandbox"))).toBe("4 CPU · 8 GiB"); }); diff --git a/apps/fabro-web/app/components/run-summary-panel.tsx b/apps/fabro-web/app/components/run-summary-panel.tsx index c3784a9ba..b462402a3 100644 --- a/apps/fabro-web/app/components/run-summary-panel.tsx +++ b/apps/fabro-web/app/components/run-summary-panel.tsx @@ -80,10 +80,10 @@ function SandboxValue({ }) { const display = SANDBOX_STATE_DISPLAY[state] ?? SANDBOX_STATE_DISPLAY.unknown; const cpu = resources?.cpu_cores; - const memory = resources?.memory_bytes; + const memoryMb = resources?.memory_mb; const valueText = - cpu != null && memory != null - ? `${formatCpuCores(cpu)} CPU · ${formatBytesAsMemory(memory)}` + cpu != null && memoryMb != null + ? `${formatCpuCores(cpu)} CPU · ${formatBytesAsMemory(memoryMb * 1024 * 1024)}` : display.label; return ( @@ -221,8 +221,8 @@ export function RunSummaryPanel({ runId }: { runId: string }) { `. +export const LOCAL_PROVIDER = "local"; +export const DOCKER_PROVIDER = "docker"; +export const DAYTONA_PROVIDER = "daytona"; + +export const BUNDLED_PROVIDERS = [LOCAL_PROVIDER, DOCKER_PROVIDER, DAYTONA_PROVIDER] as const; + +export type ProviderSettingsMap = { [kind: string]: ServerSandboxProviderSettings }; + +// `local` runs in the caller's directory and never clones. Every other +// provider owns an isolated workspace that Fabro clones into. +export function isCloneBasedProvider(provider: string): boolean { + return provider !== LOCAL_PROVIDER; +} // Whether a server-managed environment can back Git-targeted work such as -// automations: only the clone-based (creatable) providers qualify. +// automations: only clone-based providers qualify. export function isCloneBasedEnvironment(environment: Environment): boolean { - return (CREATABLE_PROVIDERS as readonly string[]).includes(environment.provider); + return isCloneBasedProvider(environment.provider); +} + +// Providers a managed environment can be created with: every enabled +// clone-based provider. `local` is a reserved, in-memory environment, never a +// managed-environment provider, so it is never offered. +export function creatableProviders(providers: ProviderSettingsMap): string[] { + return Object.keys(providers) + .filter((kind) => isCloneBasedProvider(kind) && providers[kind]?.enabled) + .sort(compareProviderKinds); +} + +// Bundled kinds first, in their canonical order, then plugins alphabetically. +export function compareProviderKinds(left: string, right: string): number { + const rank = (kind: string) => { + const index = (BUNDLED_PROVIDERS as readonly string[]).indexOf(kind); + return index === -1 ? BUNDLED_PROVIDERS.length : index; + }; + return rank(left) - rank(right) || left.localeCompare(right); } export function providerLabel(provider: string): string { diff --git a/apps/fabro-web/app/lib/sandbox-state.ts b/apps/fabro-web/app/lib/sandbox-state.ts index 73aca46bd..6eb47020f 100644 --- a/apps/fabro-web/app/lib/sandbox-state.ts +++ b/apps/fabro-web/app/lib/sandbox-state.ts @@ -11,29 +11,31 @@ export interface SandboxStateDisplay { text: string; } +const PENDING = { dot: "bg-amber", text: "text-amber" } as const; +const QUIET = { dot: "bg-fg-muted", text: "text-fg-muted" } as const; +const GONE = { dot: "bg-coral", text: "text-coral" } as const; + /** - * Display metadata for every normalized sandbox lifecycle state. Shared by the + * Display metadata for every sandbox driver lifecycle state. Shared by the * run overview summary panel and the dedicated sandbox page so the dot color, - * label, and hover copy stay consistent. + * label, and hover copy stay consistent. A state this build does not know + * renders as `unknown`. */ export const SANDBOX_STATE_DISPLAY: Record = { unknown: { label: "Unknown", description: "The sandbox state could not be determined.", - dot: "bg-fg-muted", - text: "text-fg-muted", + ...QUIET, }, - provisioning: { - label: "Provisioning", - description: "The sandbox is being provisioned.", - dot: "bg-amber", - text: "text-amber", + creating: { + label: "Creating", + description: "The sandbox is being created.", + ...PENDING, }, starting: { label: "Starting", description: "The sandbox is starting up.", - dot: "bg-amber", - text: "text-amber", + ...PENDING, }, running: { label: "Running", @@ -44,55 +46,71 @@ export const SANDBOX_STATE_DISPLAY: Record = stopping: { label: "Stopping", description: "The sandbox is shutting down.", - dot: "bg-amber", - text: "text-amber", + ...PENDING, }, stopped: { label: "Stopped", description: "The sandbox is stopped.", - dot: "bg-fg-muted", - text: "text-fg-muted", + ...QUIET, + }, + pausing: { + label: "Pausing", + description: "The sandbox is being paused.", + ...PENDING, }, paused: { label: "Paused", description: "The sandbox is paused.", - dot: "bg-amber", - text: "text-amber", + ...PENDING, }, - deleting: { - label: "Deleting", - description: "The sandbox is being deleted.", - dot: "bg-amber", - text: "text-amber", + resuming: { + label: "Resuming", + description: "The sandbox is resuming.", + ...PENDING, }, - deleted: { - label: "Deleted", - description: "The sandbox has been deleted.", - dot: "bg-coral", - text: "text-coral", + archiving: { + label: "Archiving", + description: "The sandbox is being archived.", + ...PENDING, }, archived: { label: "Archived", description: "The sandbox has been archived.", - dot: "bg-fg-muted", - text: "text-fg-muted", + ...QUIET, }, restoring: { label: "Restoring", description: "The sandbox is being restored.", - dot: "bg-amber", - text: "text-amber", + ...PENDING, }, resizing: { label: "Resizing", description: "The sandbox resources are being resized.", - dot: "bg-amber", - text: "text-amber", + ...PENDING, + }, + forking: { + label: "Forking", + description: "The sandbox is being forked.", + ...PENDING, + }, + snapshotting: { + label: "Snapshotting", + description: "A snapshot of the sandbox is being taken.", + ...PENDING, + }, + deleting: { + label: "Deleting", + description: "The sandbox is being deleted.", + ...PENDING, + }, + deleted: { + label: "Deleted", + description: "The sandbox has been deleted.", + ...GONE, }, error: { label: "Error", description: "The sandbox encountered an error.", - dot: "bg-coral", - text: "text-coral", + ...GONE, }, }; diff --git a/apps/fabro-web/app/routes/run-sandbox.test.tsx b/apps/fabro-web/app/routes/run-sandbox.test.tsx index 958d0a217..8772f9283 100644 --- a/apps/fabro-web/app/routes/run-sandbox.test.tsx +++ b/apps/fabro-web/app/routes/run-sandbox.test.tsx @@ -103,14 +103,15 @@ mock.restore(); const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; function sandboxDetails( - overrides: Partial & { + overrides: { sandbox?: Partial & { runtime?: Partial>; }; + status?: Partial; } = {}, ): SandboxDetails { const sandbox = overrides.sandbox ?? {}; - const { sandbox: _sandboxOverride, ...detailOverrides } = overrides; + const status = overrides.status ?? {}; return { sandbox: { provider: "docker", @@ -126,34 +127,27 @@ function sandboxDetails( }, ...sandbox, }, - state: "running", - native_state: null, - region: null, - resources: { cpu_cores: null, memory_bytes: null, disk_bytes: null }, - network: networkDetails(), - labels: {}, - timestamps: { created_at: null, last_activity_at: null }, - ...detailOverrides, + status: { + id: sandbox.runtime?.id ?? "", + state: "running", + provider_state: "", + error_reason: null, + resources: null, + sandbox_kind: null, + region: null, + labels: {}, + image: null, + snapshot: null, + network: null, + workspace_ownership: null, + web_url: null, + created_at: null, + updated_at: null, + ...status, + }, }; } -function networkDetails( - overrides: Partial = {}, -): SandboxDetails["network"] { - return { - egress: networkPolicy("unknown"), - ingress: networkPolicy("unknown"), - ...overrides, - }; -} - -function networkPolicy( - mode: SandboxDetails["network"]["egress"]["mode"], - cidrs: string[] = [], -): SandboxDetails["network"]["egress"] { - return { mode, cidrs }; -} - function textContent(renderer: TestRenderer.ReactTestRenderer): string { return renderer.root .findAll((node) => typeof node.type === "string") @@ -230,22 +224,18 @@ describe("RunSandbox route", () => { working_directory: "/workspace", }, }, - state: "running", - native_state: "running", - region: undefined, - resources: { - cpu_cores: 2, - memory_bytes: 4 * 1024 * 1024 * 1024, - disk_bytes: undefined, - }, - network: networkDetails({ - egress: networkPolicy("open"), - ingress: networkPolicy("blocked"), - }), - labels: { run: "abc" }, - timestamps: { - created_at: "2026-05-09T12:00:00Z", - last_activity_at: undefined, + status: { + state: "running", + provider_state: "running", + resources: { + cpu_cores: 2, + memory_mb: 4 * 1024, + disk_mb: null, + gpus: null, + }, + network: "allow_all", + labels: { run: "abc" }, + created_at: "2026-05-09T12:00:00Z", }, }); const renderer = renderRoute(); @@ -256,8 +246,8 @@ describe("RunSandbox route", () => { .filter((text): text is string => typeof text === "string"); expect(panelHeadings).toEqual(["Overview", "Resources", "Network", "Labels", "Timestamps"]); const copy = textContent(renderer); - expect(copy).toContain("Open"); - expect(copy).toContain("Blocked"); + expect(copy).toContain("Allow all"); + expect(copy).toContain("4 GiB"); }); test("links to the provider dashboard when a sandbox web URL is present", () => { @@ -269,8 +259,10 @@ describe("RunSandbox route", () => { working_directory: "/workspace", }, }, - web_url: - "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9", + status: { + web_url: + "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9", + }, }); const renderer = renderRoute(); @@ -296,18 +288,12 @@ describe("RunSandbox route", () => { working_directory: "/tmp/project", }, }, - state: "unknown", - native_state: undefined, - region: undefined, - resources: { - cpu_cores: undefined, - memory_bytes: undefined, - disk_bytes: undefined, - }, - labels: {}, - timestamps: { - created_at: undefined, - last_activity_at: undefined, + status: { + state: "unknown", + resources: { cpu_cores: null, memory_mb: null, disk_mb: null, gpus: null }, + labels: {}, + created_at: null, + updated_at: null, }, }); const renderer = renderRoute(); @@ -328,35 +314,29 @@ describe("RunSandbox route", () => { expect(noLabelsCopy).toHaveLength(1); }); - test("renders unknown network policies", () => { - currentDetails = sandboxDetails({ - network: networkDetails({ - egress: networkPolicy("unknown"), - ingress: networkPolicy("unknown"), - }), - }); + test("renders an unknown network policy", () => { + currentDetails = sandboxDetails({ status: { network: null } }); const renderer = renderRoute(); const copy = textContent(renderer); expect(copy).toContain("Network"); - expect(copy).toContain("Egress"); - expect(copy).toContain("Ingress"); + expect(copy).toContain("Policy"); expect(copy).toContain("Unknown"); }); - test("renders blocked, essentials, and CIDR network policies", () => { + test("renders blocked and CIDR allow list network policies", () => { currentDetails = sandboxDetails({ - network: networkDetails({ - egress: networkPolicy("cidr_allow_list", ["10.0.0.0/8", "192.168.0.0/16"]), - ingress: networkPolicy("essentials_only"), - }), + status: { network: { cidr_allow_list: { cidrs: ["10.0.0.0/8", "192.168.0.0/16"] } } }, }); const renderer = renderRoute(); const copy = textContent(renderer); expect(copy).toContain("CIDR allow list"); expect(copy).toContain("10.0.0.0/8, 192.168.0.0/16"); - expect(copy).toContain("Essentials only"); + + currentDetails = sandboxDetails({ status: { network: "block" } }); + const blocked = renderRoute(); + expect(textContent(blocked)).toContain("Blocked"); }); test("shows the empty state when no sandbox is reported", () => { diff --git a/apps/fabro-web/app/routes/run-sandbox.tsx b/apps/fabro-web/app/routes/run-sandbox.tsx index d190bd5e0..c93a3f139 100644 --- a/apps/fabro-web/app/routes/run-sandbox.tsx +++ b/apps/fabro-web/app/routes/run-sandbox.tsx @@ -22,7 +22,7 @@ import { SANDBOX_STATE_DISPLAY } from "../lib/sandbox-state"; import type { RunSandbox, SandboxDetails, - SandboxNetwork, + SandboxNetworkPolicy, SandboxResources, } from "@qltysh/fabro-api-client"; import FilesystemPanel from "./run-sandbox/filesystem-panel"; @@ -57,27 +57,47 @@ function nullableTimestamp(value: string | null | undefined): string { return value ? formatAbsoluteTs(value) : EMPTY_VALUE; } -function nullableMemory(bytes: number | null | undefined): string { - return bytes != null ? formatBytesAsMemory(bytes) : EMPTY_VALUE; +function nullableMegabytes(megabytes: number | null | undefined): string { + return megabytes != null ? formatBytesAsMemory(megabytes * 1024 * 1024) : EMPTY_VALUE; } function nullableCpu(cores: number | null | undefined): string { return cores != null ? formatCpuCores(cores) : EMPTY_VALUE; } -type SandboxNetworkPolicy = SandboxNetwork["egress"]; -type SandboxNetworkPolicyMode = SandboxNetworkPolicy["mode"]; +function nullableCount(count: number | null | undefined): string { + return count != null ? String(count) : EMPTY_VALUE; +} -const NETWORK_POLICY_DISPLAY: Record = { - unknown: "Unknown", - open: "Open", - blocked: "Blocked", - cidr_allow_list: "CIDR allow list", - essentials_only: "Essentials only", +const NETWORK_POLICY_DISPLAY: Record = { + provider_default: "Provider default", + allow_all: "Allow all", + block: "Blocked", }; -function networkPolicySummary(policy: SandboxNetworkPolicy): string { - return NETWORK_POLICY_DISPLAY[policy.mode] ?? policy.mode; +/** The policy's name, and the entries of an allow list when it carries one. */ +function describeNetworkPolicy( + policy: SandboxNetworkPolicy | null | undefined, +): { summary: string; entries: { label: string; values: string[] } | null } { + if (policy == null) { + return { summary: "Unknown", entries: null }; + } + if (typeof policy === "string") { + return { summary: NETWORK_POLICY_DISPLAY[policy] ?? policy, entries: null }; + } + if ("cidr_allow_list" in policy) { + return { + summary: "CIDR allow list", + entries: { label: "Allowed CIDRs", values: policy.cidr_allow_list.cidrs }, + }; + } + if ("domain_allow_list" in policy) { + return { + summary: "Domain allow list", + entries: { label: "Allowed domains", values: policy.domain_allow_list.domains }, + }; + } + return { summary: "Unknown", entries: null }; } interface RowProps { @@ -142,11 +162,12 @@ function Panel({ title, children }: PanelProps) { } function StatusStrip({ details }: { details: SandboxDetails }) { - const display = SANDBOX_STATE_DISPLAY[details.state] ?? SANDBOX_STATE_DISPLAY.unknown; + const status = details.status; + const display = SANDBOX_STATE_DISPLAY[status.state] ?? SANDBOX_STATE_DISPLAY.unknown; const provider = details.sandbox.provider; + const providerState = status.provider_state ?? ""; const showNative = - details.native_state && - details.native_state.toLowerCase() !== details.state.toLowerCase(); + providerState.length > 0 && providerState.toLowerCase() !== status.state.toLowerCase(); return (
@@ -158,7 +179,7 @@ function StatusStrip({ details }: { details: SandboxDetails }) { {showNative && ( - ({details.native_state}) + ({providerState}) )}
@@ -167,20 +188,25 @@ function StatusStrip({ details }: { details: SandboxDetails }) { function OverviewPanel({ details }: { details: SandboxDetails }) { const sandbox = details.sandbox; + const status = details.status; const runtime = sandbox.runtime; return ( - + - - {details.web_url && ( + + {status.sandbox_kind && } + {status.web_url && ( - - - + + + + {resources?.gpus != null && } ); } -function NetworkPanel({ network }: { network: SandboxNetwork }) { - const cidrRows: Array<{ label: string; policy: SandboxNetworkPolicy }> = [ - { label: "Egress CIDRs", policy: network.egress }, - { label: "Ingress CIDRs", policy: network.ingress }, - ].filter(({ policy }) => policy.mode === "cidr_allow_list"); - +function NetworkPanel({ network }: { network: SandboxNetworkPolicy | null | undefined }) { + const { summary, entries } = describeNetworkPolicy(network); return ( - - - {cidrRows.map(({ label, policy }) => ( - - ))} + + {entries && ( + + )} ); } @@ -237,11 +259,8 @@ function LabelsPanel({ labels }: { labels: { [key: string]: string } | null | un function TimestampsPanel({ details }: { details: SandboxDetails }) { return ( - - + + ); } @@ -259,9 +278,9 @@ function DetailsColumn({ details }: { details: SandboxDetails | null }) {
- - - + + +
); diff --git a/apps/fabro-web/app/routes/run-sandbox/services-panel.test.tsx b/apps/fabro-web/app/routes/run-sandbox/services-panel.test.tsx index 38403196e..a20c95a49 100644 --- a/apps/fabro-web/app/routes/run-sandbox/services-panel.test.tsx +++ b/apps/fabro-web/app/routes/run-sandbox/services-panel.test.tsx @@ -26,10 +26,7 @@ function makeIdlePreview(): PreviewMutationShape { } function makeServicesData(data: SandboxService[]) { - return { - data, - meta: { source: "ss" as const }, - }; + return { data }; } const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; @@ -116,46 +113,6 @@ describe("ServicesPanelView", () => { expect(titles).toHaveLength(1); }); - test("shows an iproute2 tip when services were discovered from procfs", () => { - const service: SandboxService = { - port: 3000, - addresses: ["0.0.0.0:3000"], - processes: [], - preview_supported: true, - }; - const renderer = renderView({ - servicesQuery: { - ...makeIdleQuery(), - data: { - data: [service], - meta: { source: "procfs" }, - }, - }, - previewMutation: makeIdlePreview(), - }); - - const tipLabels = renderer.root.findAll( - (node) => - node.type === "span" && - Array.isArray(node.children) && - node.children.includes("Tip:"), - ); - expect(tipLabels).toHaveLength(1); - - const commands = renderer.root.findAll( - (node) => - node.type === "code" && - Array.isArray(node.children) && - node.children.includes("apt-get install iproute2"), - ); - expect(commands).toHaveLength(1); - - const tipText = JSON.stringify(renderer.toJSON()); - expect(tipText).toContain("Install "); - expect(tipText).toContain("ss"); - expect(tipText).toContain(" in the sandbox for improved services listing:"); - }); - test("shows API error state with the error message", () => { const renderer = renderView({ servicesQuery: { diff --git a/apps/fabro-web/app/routes/run-sandbox/services-panel.tsx b/apps/fabro-web/app/routes/run-sandbox/services-panel.tsx index 313cac6ba..400cb6365 100644 --- a/apps/fabro-web/app/routes/run-sandbox/services-panel.tsx +++ b/apps/fabro-web/app/routes/run-sandbox/services-panel.tsx @@ -77,7 +77,6 @@ export function ServicesPanelView({ const [previewError, setPreviewError] = useState(null); const services = servicesQuery.data?.data ?? []; - const discoverySource = servicesQuery.data?.meta.source; const queryErrorMessage = describeQueryError(servicesQuery.error); const showLoading = servicesQuery.isLoading && !servicesQuery.data; const showError = queryErrorMessage !== null && !servicesQuery.data; @@ -150,7 +149,6 @@ export function ServicesPanelView({ ) : ( <> - {discoverySource === "procfs" ? : null} - Tip:{" "} - Install ss in the sandbox - for improved services listing:{" "} - apt-get install iproute2 - - ); -} - function ServicesTable({ services, pendingPort, diff --git a/apps/fabro-web/app/routes/settings-environments.tsx b/apps/fabro-web/app/routes/settings-environments.tsx index b3059ab52..fdd13aee4 100644 --- a/apps/fabro-web/app/routes/settings-environments.tsx +++ b/apps/fabro-web/app/routes/settings-environments.tsx @@ -9,7 +9,7 @@ import type { Environment } from "@qltysh/fabro-api-client"; import { ApiError, apiData, environmentsApi } from "../lib/api-client"; import { useEnvironments, useServerSettings } from "../lib/queries"; import { queryKeys } from "../lib/query-keys"; -import { CREATABLE_PROVIDERS, providerLabel } from "../lib/environment-providers"; +import { creatableProviders, providerLabel } from "../lib/environment-providers"; import { Badge, Muted, @@ -67,9 +67,7 @@ const NEW_BUTTON_CLASS = // environment's lifetime. `local` is never offered (it's reserved/in-memory). function NewEnvironmentMenu() { const { data } = useServerSettings(); - const providers = data - ? CREATABLE_PROVIDERS.filter((provider) => data.server.sandbox.providers[provider].enabled) - : []; + const providers = data ? creatableProviders(data.server.sandbox.providers) : []; if (providers.length === 0) { return ( diff --git a/apps/fabro-web/app/routes/settings-sandboxes.tsx b/apps/fabro-web/app/routes/settings-sandboxes.tsx index d51197c73..577f81866 100644 --- a/apps/fabro-web/app/routes/settings-sandboxes.tsx +++ b/apps/fabro-web/app/routes/settings-sandboxes.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; import { Link } from "react-router"; import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { ComputerDesktopIcon } from "@heroicons/react/24/outline"; -import type { ServerSandboxProvidersSettings } from "@qltysh/fabro-api-client"; +import type { ServerSandboxProviderSettings } from "@qltysh/fabro-api-client"; import { useServerSettings } from "../lib/queries"; import { Dot, @@ -12,24 +12,56 @@ import { SettingsPageIntro, } from "../components/settings-panel"; import { plural } from "../lib/plural"; +import { + DAYTONA_PROVIDER, + DOCKER_PROVIDER, + LOCAL_PROVIDER, + compareProviderKinds, + providerLabel, + type ProviderSettingsMap, +} from "../lib/environment-providers"; export function meta() { return [{ title: "Sandboxes — Fabro" }]; } -type SandboxProviderId = "local" | "docker" | "daytona"; - type SandboxProvider = { - id: SandboxProviderId; + id: string; name: string; description: string; enabled: boolean; + bundled: boolean; secretName?: string; }; const DESCRIPTION = "Runtime environments where workflow stages execute. Configured via settings.toml."; +// Display copy for the providers linked into the server. Any other kind is a +// sandbox-driver plugin configured under `server.sandbox.providers.`. +const BUNDLED_PROVIDER_COPY: Record> = { + [LOCAL_PROVIDER]: { + name: "Local", + description: "Run stages directly on the Fabro host.", + }, + [DOCKER_PROVIDER]: { + name: "Docker", + description: "Run stages in isolated Docker containers on the host daemon.", + }, + [DAYTONA_PROVIDER]: { + name: "Daytona", + description: "Run stages in cloud sandboxes managed by Daytona.", + secretName: "DAYTONA_API_KEY", + }, +}; + +function pluginDescription(settings: ServerSandboxProviderSettings): string { + const path = settings.plugin?.path; + return path + ? `Sandbox plugin executable at ${path}.` + : "Sandbox plugin executable resolved from PATH."; +} + export default function SettingsSandboxes() { const query = useServerSettings(); const settings = query.data; @@ -42,29 +74,24 @@ export default function SettingsSandboxes() { ); } -function ProvidersPanel({ settings }: { settings: ServerSandboxProvidersSettings }) { +function ProvidersPanel({ settings }: { settings: ProviderSettingsMap }) { const providers: SandboxProvider[] = useMemo( - () => [ - { - id: "local", - name: "Local", - description: "Run stages directly on the Fabro host.", - enabled: settings.local.enabled, - }, - { - id: "docker", - name: "Docker", - description: "Run stages in isolated Docker containers on the host daemon.", - enabled: settings.docker.enabled, - }, - { - id: "daytona", - name: "Daytona", - description: "Run stages in cloud sandboxes managed by Daytona.", - enabled: settings.daytona.enabled, - secretName: "DAYTONA_API_KEY", - }, - ], + () => + Object.keys(settings) + .sort(compareProviderKinds) + .map((id) => { + const entry = settings[id]; + const copy = BUNDLED_PROVIDER_COPY[id]; + return copy + ? { id, enabled: entry.enabled, bundled: true, ...copy } + : { + id, + enabled: entry.enabled, + bundled: false, + name: providerLabel(id), + description: pluginDescription(entry), + }; + }), [settings], ); @@ -138,7 +165,7 @@ function ProviderLogo({ provider }: { provider: SandboxProvider }) { "grid size-10 shrink-0 place-items-center rounded-md bg-ice-50 ring-1 ring-line-strong"; const dim = provider.enabled ? "" : "opacity-60"; - if (provider.id === "local") { + if (provider.id === LOCAL_PROVIDER) { return (