fabro(01KS9BXFGAZ32SGNRE4YJV1354): implement (succeeded)

Fabro-Run: 01KS9BXFGAZ32SGNRE4YJV1354
Fabro-Completed: 7
Fabro-Checkpoint: e2b2831ff6

⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
Fabro 2026-05-23 04:20:28 +00:00
parent be6f0ae4b7
commit 0b562dadb6
75 changed files with 2478 additions and 1360 deletions

View file

@ -4,18 +4,26 @@ _version = 1
enabled = true
draft = false
[run.sandbox.daytona]
auto_stop_interval = 30
[run.environment]
id = "fabro-dev"
[run.sandbox.daytona.labels]
[environments.fabro-dev]
provider = "daytona"
[environments.fabro-dev.lifecycle]
auto_stop = "30m"
[environments.fabro-dev.labels]
repo = "fabro-sh/fabro"
[run.sandbox.daytona.snapshot]
name = "fabro-v11"
[environments.fabro-dev.image]
ref = "fabro-v11"
dockerfile = { path = "Dockerfile" }
[environments.fabro-dev.resources]
cpu = 8
memory = "16GB"
disk = "20GB"
dockerfile = { path = "Dockerfile" }
# [[run.hooks]]
# id = "cargo-fmt"

View file

@ -3,8 +3,11 @@ _version = 1
[workflow]
graph = "workflow.fabro"
[run.sandbox]
[run.environment]
id = "daytona-medium"
[environments.daytona-medium]
provider = "daytona"
[run.sandbox.daytona.snapshot]
name = "daytona-medium"
[environments.daytona-medium.image]
ref = "daytona-medium"

View file

@ -9,7 +9,7 @@ Fabro supports three sandbox providers: `local` (no isolation), `docker` (contai
## Network access control
For cloud sandboxes (Daytona), you can control outbound network access with the `network` field in `[run.sandbox.daytona]`. Three modes are available: `"allow_all"` (default), `"block"`, and `{ allow_list = ["..."] }` for CIDR-based egress filtering.
For cloud sandboxes (Daytona), you can control outbound network access with `[environments.<slug>.network]`. Three modes are available: `"allow_all"` (default), `"block"`, and `"cidr_allow_list"` with an `allow = ["..."]` CIDR list.
Server defaults in `settings.toml` apply when a run config doesn't specify `network`. Individual run configs can override the server default.

View file

@ -18,7 +18,7 @@ Fabro only reads `settings.toml`. Older `server.toml`, `user.toml`, and `cli.tom
| Scope | Examples |
|---|---|
| Server-owned (runtime-only from local `settings.toml`) | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` |
| Shared run defaults (layered through `.fabro/project.toml`/`workflow.toml`) | `[run.model]`, `[run.prepare]`, `[run.sandbox]`, `[run.checkpoint]`, `[run.inputs]`, `[run.pull_request]`, `[run.git]`, `[run.hooks]`, `[run.agent]` |
| Shared run defaults (layered through `.fabro/project.toml`/`workflow.toml`) | `[run.model]`, `[run.prepare]`, `[run.environment]`, `[environments.<slug>]`, `[run.checkpoint]`, `[run.inputs]`, `[run.pull_request]`, `[run.git]`, `[run.hooks]`, `[run.agent]` |
The CLI-only `[cli.*]` sections (including `[cli.target]`) belong in the client machine's `settings.toml`. They tell CLI commands how to reach a server. The server process does not read `[cli.*]` for its own binding or routing.
@ -88,13 +88,16 @@ fallbacks = ["gemini", "openai"]
[[run.prepare.steps]]
script = "npm install"
[run.sandbox]
[run.environment]
id = "cloud"
[environments.cloud]
provider = "daytona"
[run.sandbox.daytona]
auto_stop_interval = 60
[environments.cloud.lifecycle]
auto_stop = "60m"
[run.sandbox.daytona.labels]
[environments.cloud.labels]
team = "platform"
[run.checkpoint]
@ -121,7 +124,7 @@ Several `settings.toml` settings can be overridden via `fabro server start` flag
| `--foreground` | — | Run in the foreground instead of daemonizing |
| `--model` | — | Override default LLM model |
| `--provider` | — | Override default LLM provider |
| `--sandbox` | — | Override default sandbox provider |
| `--environment` | — | Override default environment slug |
| `--max-concurrent-runs` | `5` | Maximum concurrent run executions |
| `--config` | `~/.fabro/settings.toml` | Path to server config file |
@ -228,7 +231,7 @@ On a same-machine setup, `settings.toml` is the shared machine-default layer und
On a remote setup, the client bundles workflow, project, and user config into the run manifest. The server then layers those bundled client configs over its own local defaults for run-shaped fields. Server-owned values like `[server.storage]`, `[server.api]`, `[server.web]`, and `[server.scheduler]` always come from the server machine's own `settings.toml` or `fabro server start` flags.
Merge rules follow the normative matrix: TOML `[run.inputs]` tables replace wholesale, CLI `-I` / `--input` values replayed from run manifests merge per key at highest precedence, `[run.sandbox.env]` and `[run.sandbox.daytona.labels]` merge by key, `[run.prepare.steps]` replaces whole-list, and `[[run.hooks]]` merge by optional `id`. Most other fields use "higher-precedence wins" field-wise merging.
Merge rules follow the normative matrix: TOML `[run.inputs]` tables replace wholesale, CLI `-I` / `--input` values replayed from run manifests merge per key at highest precedence, environment `env` and `labels` merge by key, environment `volumes` replace as a whole list, `[run.prepare.steps]` replaces whole-list, and `[[run.hooks]]` merge by optional `id`. Most other fields use "higher-precedence wins" field-wise merging.
### `[server.logging]` section

View file

@ -230,11 +230,14 @@ graph = "workflow.fabro"
[run]
goal = "Test the login page"
[run.sandbox]
[run.environment]
id = "cloud"
[environments.cloud]
provider = "daytona"
[run.sandbox.daytona.snapshot]
name = "daytona-medium"
[environments.cloud.image]
ref = "daytona-medium"
[run.artifacts]
include = ["screenshots/**"]

View file

@ -6572,11 +6572,12 @@ components:
type: string
provider:
type: string
sandbox:
environment:
type: string
description: Named environment slug to select for the run.
docker_image:
type: string
description: Per-run Docker sandbox image override.
description: Per-run environment image override.
verbose:
type: boolean
dry_run:
@ -10400,12 +10401,17 @@ components:
required:
- project
- workflow
- environments
- run
properties:
project:
$ref: "#/components/schemas/ProjectNamespace"
workflow:
$ref: "#/components/schemas/WorkflowNamespace"
environments:
type: object
additionalProperties:
$ref: "#/components/schemas/EnvironmentSettings"
run:
$ref: "#/components/schemas/RunNamespace"
@ -10460,7 +10466,7 @@ components:
- clone
- run_branch
- meta_branch
- sandbox
- environment
- notifications
- interviews
- agent
@ -10500,8 +10506,8 @@ components:
$ref: "#/components/schemas/RunBranchSettings"
meta_branch:
$ref: "#/components/schemas/RunMetaBranchSettings"
sandbox:
$ref: "#/components/schemas/RunSandboxSettings"
environment:
$ref: "#/components/schemas/RunEnvironmentSettings"
notifications:
type: object
additionalProperties:
@ -10670,88 +10676,123 @@ components:
push:
type: boolean
RunSandboxSettings:
RunEnvironmentSettings:
type: object
required: [provider, preserve, stop_on_terminal, devcontainer, env, docker, daytona]
required: [id, provider, image, resources, network, lifecycle, labels, volumes, env]
properties:
id:
type: string
provider:
$ref: "#/components/schemas/SandboxProvider"
preserve:
type: boolean
stop_on_terminal:
type: boolean
devcontainer:
type: boolean
$ref: "#/components/schemas/EnvironmentProvider"
image:
$ref: "#/components/schemas/EnvironmentImageSettings"
resources:
$ref: "#/components/schemas/EnvironmentResourcesSettings"
network:
$ref: "#/components/schemas/EnvironmentNetworkSettings"
lifecycle:
$ref: "#/components/schemas/EnvironmentLifecycleSettings"
labels:
$ref: "#/components/schemas/StringMap"
volumes:
type: array
items:
$ref: "#/components/schemas/EnvironmentVolumeSettings"
env:
type: object
additionalProperties:
$ref: "#/components/schemas/InterpString"
docker:
oneOf:
- $ref: "#/components/schemas/DockerSettings"
- type: "null"
daytona:
oneOf:
- $ref: "#/components/schemas/DaytonaSettings"
- type: "null"
DockerSettings:
EnvironmentSettings:
type: object
required: [image, network_mode, memory_limit, cpu_quota, env_vars]
required: [provider, image, resources, network, lifecycle, labels, volumes, env]
properties:
provider:
$ref: "#/components/schemas/EnvironmentProvider"
image:
type: string
network_mode:
type: ["string", "null"]
memory_limit:
type: ["integer", "null"]
format: int64
cpu_quota:
type: ["integer", "null"]
format: int64
env_vars:
$ref: "#/components/schemas/EnvironmentImageSettings"
resources:
$ref: "#/components/schemas/EnvironmentResourcesSettings"
network:
$ref: "#/components/schemas/EnvironmentNetworkSettings"
lifecycle:
$ref: "#/components/schemas/EnvironmentLifecycleSettings"
labels:
$ref: "#/components/schemas/StringMap"
volumes:
type: array
items:
$ref: "#/components/schemas/EnvironmentVolumeSettings"
env:
type: object
additionalProperties:
$ref: "#/components/schemas/InterpString"
DaytonaSettings:
type: object
required: [auto_stop_interval, labels, snapshot, network]
properties:
auto_stop_interval:
type: ["integer", "null"]
format: int32
labels:
$ref: "#/components/schemas/StringMap"
snapshot:
oneOf:
- $ref: "#/components/schemas/DaytonaSnapshotSettings"
- type: "null"
network:
oneOf:
- $ref: "#/components/schemas/DaytonaNetworkLayer"
- type: "null"
EnvironmentProvider:
description: Desired environment provider.
type: string
enum: [local, docker, daytona]
DaytonaSnapshotSettings:
EnvironmentImageSettings:
type: object
required: [name, cpu, memory_gb, disk_gb, dockerfile]
required: [ref, dockerfile]
properties:
name:
type: string
cpu:
type: ["integer", "null"]
format: int32
memory_gb:
type: ["integer", "null"]
format: int32
disk_gb:
type: ["integer", "null"]
format: int32
ref:
type: ["string", "null"]
dockerfile:
oneOf:
- $ref: "#/components/schemas/DockerfileSource"
- type: "null"
EnvironmentResourcesSettings:
type: object
required: [cpu, memory, disk]
properties:
cpu:
type: ["integer", "null"]
format: int32
memory:
type: ["string", "null"]
disk:
type: ["string", "null"]
EnvironmentNetworkSettings:
type: object
required: [mode, allow]
properties:
mode:
$ref: "#/components/schemas/EnvironmentNetworkMode"
allow:
type: array
items:
type: string
EnvironmentNetworkMode:
type: string
enum: [allow_all, block, cidr_allow_list]
EnvironmentLifecycleSettings:
type: object
required: [preserve, stop_on_terminal, auto_stop]
properties:
preserve:
type: boolean
stop_on_terminal:
type: boolean
auto_stop:
type: ["string", "null"]
EnvironmentVolumeSettings:
type: object
required: [id, mount_path, subpath]
properties:
id:
type: string
mount_path:
type: string
subpath:
type: ["string", "null"]
DockerfileSource:
oneOf:
- $ref: "#/components/schemas/DockerfileSourceInline"
@ -10777,23 +10818,6 @@ components:
path:
type: string
DaytonaNetworkLayer:
description: Daytona network access policy.
oneOf:
- type: string
enum: [block, allow_all]
- type: object
required: [allow_list]
properties:
allow_list:
type: object
required: [allow_list]
properties:
allow_list:
type: array
items:
type: string
NotificationRouteSettings:
type: object
required: [enabled, provider, events, slack]
@ -11651,4 +11675,4 @@ components:
login:
type: string
description: User's login identifier (e.g. GitHub username).
example: octocat
example: octocat

View file

@ -75,7 +75,7 @@ Node handlers execute tools (bash commands, file edits) inside a **sandbox**. Fa
| `local` | Tools run directly on the host machine |
| `daytona` | Tools run in a cloud VM with SSH access |
The sandbox is configured per-run via CLI flags (`--sandbox docker`) or the run config TOML. See [Environments](/execution/environments) for details on each provider.
Runs select a named environment via CLI flags (`--environment ci`) or run config TOML (`[run.environment] id = "ci"`). Fabro then creates a concrete sandbox from that environment. See [Environments](/execution/environments) for details.
## Events and observability

View file

@ -271,15 +271,20 @@ fallbacks = ["openai", "gemini"]
[[run.prepare.steps]]
script = "python3 -m venv .venv && . .venv/bin/activate && pip install pytest curses"
[run.sandbox]
[run.environment]
id = "python-dev"
[environments.python-dev]
provider = "daytona"
[run.sandbox.daytona.snapshot]
name = "python-dev"
[environments.python-dev.image]
ref = "python-dev"
dockerfile = "FROM python:3.12-slim\nRUN apt-get update && apt-get install -y git libncurses-dev"
[environments.python-dev.resources]
cpu = 4
memory = "8GB"
disk = "20GB"
dockerfile = "FROM python:3.12-slim\nRUN apt-get update && apt-get install -y git libncurses-dev"
```
```bash

View file

@ -1,45 +1,33 @@
---
title: "Devcontainers"
description: "Run Fabro workflows inside development containers"
description: "Using repository devcontainer metadata with Fabro environments"
---
Fabro can use your project's [devcontainer](https://containers.dev/) configuration to set up sandbox environments. When enabled, Fabro resolves `devcontainer.json` from the repository, uses its Dockerfile to build the Daytona sandbox snapshot, runs lifecycle hooks inside the sandbox, and merges devcontainer environment variables into the sandbox environment.
Named environments are the supported configuration surface for run execution. Define reusable environments under `[environments.<slug>]` and select one with `[run.environment] id = "..."`.
## Enabling devcontainer support
Devcontainer-specific run configuration (`[run.sandbox] devcontainer = true`) has been removed with the named environments configuration break. To use a devcontainer-style image today, build or reference it through an environment image:
Set `devcontainer = true` in the `[run.sandbox]` section of your run config:
```toml
[run.environment]
id = "dev"
```toml title="run.toml"
_version = 1
[environments.dev]
provider = "docker"
[workflow]
graph = "workflow.fabro"
[run.sandbox]
provider = "daytona"
devcontainer = true
[environments.dev.image]
ref = "ghcr.io/acme/project-devcontainer:latest"
```
Fabro looks for `.devcontainer/devcontainer.json` in the repository root. If found, it extracts the Dockerfile, lifecycle commands, and environment variables from the configuration.
For Daytona snapshot creation, provide a Dockerfile path on the selected environment:
## What Fabro uses from devcontainer.json
```toml
[run.environment]
id = "cloud-dev"
| Field | How Fabro uses it |
|---|---|
| `build.dockerfile` | Used as the Dockerfile for the Daytona sandbox snapshot. A deterministic snapshot name is generated from a hash of the Dockerfile content. |
| `onCreateCommand` | Runs inside the sandbox after it's created |
| `postCreateCommand` | Runs after `onCreateCommand` completes |
| `postStartCommand` | Runs after the sandbox starts |
| `containerEnv` | Merged into sandbox environment variables (TOML `[run.sandbox.env]` values take precedence on key collisions) |
[environments.cloud-dev]
provider = "daytona"
Lifecycle commands (`onCreateCommand`, `postCreateCommand`, `postStartCommand`) execute sequentially inside the sandbox. If any command fails, the run aborts before the workflow starts.
## Dockerfile limitations
Fabro detects and reports unsupported `COPY` and `ADD` instructions in devcontainer base Dockerfiles. These instructions reference files from the build context, which isn't available when building Daytona snapshots. If your Dockerfile uses `COPY` or `ADD`, you'll need to restructure it to use `RUN` commands that fetch files at build time (e.g., via `curl` or `wget`).
## Interaction with other sandbox settings
When `devcontainer = true`, the devcontainer Dockerfile overrides any `snapshot.dockerfile` setting in `[run.sandbox.daytona]`. Other Daytona settings (`cpu`, `memory`, `disk`, `auto_stop_interval`, `labels`) still apply.
Environment variables from `containerEnv` in the devcontainer config are merged with `[run.sandbox.env]` from the TOML config. On key collisions, the TOML config wins.
[environments.cloud-dev.image]
ref = "project-dev"
dockerfile = { path = ".devcontainer/Dockerfile" }
```

View file

@ -1,265 +1,197 @@
---
title: "Environments"
description: "Sandbox providers for workflow execution"
description: "Reusable named execution environments for workflow runs"
---
When an agent runs a shell command, edits a file, or searches code, it does so inside a **sandbox**. The sandbox is the execution environment for all tool operations — it controls where commands run, which files are visible, and how much isolation exists between the agent and the host.
Fabro separates **environments** from **sandboxes**:
Fabro supports three sandbox providers. Each one implements the same interface (file I/O, command execution, grep, glob), so workflows run identically regardless of which provider you choose. The difference is in where and how the tools execute.
- An **environment** is reusable desired configuration: provider, image, resources, network, lifecycle, labels, volumes, and environment variables.
- A **sandbox** is the concrete runtime instance Fabro creates for a run from the selected environment.
| Provider | Runs on | Use case | Status |
|---|---|---|---|
| `local` | Host machine | Development, trusted workflows | Available |
| `docker` | Docker container | Reproducible environments, untrusted code | Available |
| `daytona` | Cloud VM | CI/CD, team-shared runs, SSH debugging | Available |
Runs select environments by slug:
## Choosing a provider
Set the sandbox provider via CLI flag, [run config TOML](/execution/run-configuration), or server defaults:
```bash
# CLI flag
fabro run workflow.fabro --sandbox local
fabro run workflow.fabro --sandbox docker
fabro run workflow.fabro --sandbox daytona
```toml title="workflow.toml"
[run.environment]
id = "fabro-dev"
```
```toml title="run.toml"
# Run config TOML
[run.sandbox]
provider = "daytona"
Environment catalogs can live in `settings.toml`, `.fabro/project.toml`, or `workflow.toml`, and merge through the normal settings precedence. The built-in default is `default`, a Docker environment using `buildpack-deps:noble`.
## Defining environments
```toml title="workflow.toml"
[run.environment]
id = "fabro-dev"
[environments.fabro-dev]
provider = "daytona" # local | docker | daytona
[environments.fabro-dev.image]
ref = "fabro-v11" # Docker image or Daytona snapshot name
dockerfile = { path = "Dockerfile" }
[environments.fabro-dev.resources]
cpu = 8
memory = "16GB"
disk = "20GB"
[environments.fabro-dev.network]
mode = "cidr_allow_list" # allow_all | block | cidr_allow_list
allow = ["10.0.0.0/8"]
[environments.fabro-dev.lifecycle]
preserve = false
stop_on_terminal = true
auto_stop = "30m"
[environments.fabro-dev.labels]
repo = "fabro-sh/fabro"
[[environments.fabro-dev.volumes]]
id = "vol-agent-state"
mount_path = "/home/daytona/agent-state"
subpath = "auth"
[environments.fabro-dev.env]
NODE_ENV = "development"
```
The precedence order is: CLI flag > run config TOML > server defaults > built-in default (`docker`).
Run-level overrides are sparse and apply only to the selected environment:
## Local
```toml title="workflow.toml"
[run.environment]
id = "fabro-dev"
The local sandbox runs all tool operations directly on the host machine. Use it for trusted workflows, local development, or runs that must operate directly on the current working tree.
[run.environment.resources]
memory = "32GB"
### How it works
- **Working directory** — Set to the current directory (or `directory` from the run config). Fabro creates it if it doesn't exist.
- **Commands** — Executed via `/bin/bash -c` in the working directory.
- **File operations** — Read and write directly to the host filesystem. Relative paths resolve against the working directory.
- **Cleanup** — No-op. Local sandbox doesn't create or destroy anything on cleanup.
### Environment variable filtering
The local sandbox filters sensitive environment variables before passing them to commands. Variables ending in `_API_KEY`, `_SECRET`, `_TOKEN`, `_PASSWORD`, or `_CREDENTIAL` are stripped. A safelist of common variables (`PATH`, `HOME`, `USER`, `SHELL`, `LANG`, `TERM`, `TMPDIR`, `GOPATH`, `CARGO_HOME`, `NVM_DIR`) is always passed through.
<Note>
The local sandbox offers no isolation. Agents can read and modify any file on the host. Use `docker` or `daytona` when running untrusted workflows or when you need a reproducible environment.
</Note>
## Docker
The Docker sandbox runs all tool operations inside a Docker container. Docker is the built-in default runtime provider. Docker runs use a provider-owned workspace in the container; when the run has a GitHub origin, Fabro clones that repository into the workspace.
### Prerequisites
- Docker Engine running on the host
- The configured image available locally, or a Docker client that can pull it
- GitHub credentials configured when cloning private repositories
### How it works
- **Container lifecycle** — On `initialize()`, Fabro pulls the image (if needed), creates a container with `sleep infinity`, and starts it. On `cleanup()`, Fabro stops and removes the container.
- **Working directory** — Docker runs use a provider-owned workspace inside the container. When a run has a GitHub origin, Fabro clones that repository into the workspace instead of bind-mounting the host source tree.
- **Git clone** — Docker and Daytona accept GitHub origins for automatic cloning. If the run has a present non-GitHub origin, set `[run.clone] enabled = false` or switch to `local`.
- **Commands** — Executed via `docker exec` with `/bin/bash -c` inside the container. Timeout and cancellation are supported.
- **File writes** — Use the Docker API's tar upload to avoid shell escaping issues with special characters.
- **Platform detection** — The container's `uname -r` is cached at startup.
### Configuration
Configure Docker through `[run.sandbox.docker]`:
```toml title="run.toml"
[run.sandbox]
provider = "docker"
[run.sandbox.docker]
image = "buildpack-deps:noble"
network_mode = "bridge"
memory_limit = "4GB"
cpu_quota = 200000
```
| Setting | Default | Description |
|---|---|---|
| `image` | `buildpack-deps:noble` | Docker image to use |
| `network_mode` | `bridge` | Docker network mode |
| `memory_limit` | `4GB` | Memory limit |
| `cpu_quota` | `200000` | CPU quota (microseconds per 100ms period) |
Set `[run.clone] enabled = false` to start with an empty provider workspace instead of cloning the run's GitHub origin. Use [prepare steps](/execution/run-configuration#runprepare) to clone or create any files the workflow needs.
### Preserving the container
By default, the container is destroyed when the run finishes. To keep it alive for debugging:
```bash
fabro run workflow.fabro --sandbox docker --preserve-sandbox
```
Or in the run config:
```toml title="run.toml"
[run.sandbox]
provider = "docker"
[run.environment.lifecycle]
preserve = true
```
When preserved, Fabro prints the container ID so you can reconnect with `docker exec -it <id> bash`.
`env` and `labels` merge by key. `volumes` replace as a whole list when set at a higher-precedence layer.
## Selecting an environment from the CLI
Use `--environment` with an environment slug:
```bash
fabro run workflow.fabro --environment fabro-dev
fabro preflight workflow.fabro --environment ci
fabro server start --environment default
```
`--preserve-sandbox` still controls the concrete runtime instance lifecycle for a run. Runtime commands such as `fabro sandbox ssh` keep the word "sandbox" because they operate on an already-created runtime instance.
## Built-in default
```toml
[run.environment]
id = "default"
[environments.default]
provider = "docker"
[environments.default.image]
ref = "buildpack-deps:noble"
[environments.default.resources]
cpu = 2
memory = "4GB"
[environments.default.lifecycle]
preserve = false
stop_on_terminal = true
```
## Provider mappings
| Environment field | Local | Docker | Daytona |
|---|---|---|---|
| `image.ref` | Ignored | Docker image | Snapshot name |
| `image.dockerfile` | Ignored | Warning; ignored | Snapshot Dockerfile; requires `image.ref` |
| `resources.cpu` | Warning; ignored | `cpu_quota = cpu * 100000` | Snapshot CPU |
| `resources.memory` | Warning; ignored | Container memory limit | Snapshot memory |
| `resources.disk` | Warning; ignored | Warning; ignored | Snapshot disk |
| `network.mode = "allow_all"` | Host network | Docker default bridge | Daytona allow-all |
| `network.mode = "block"` | Error | Docker `none` network | Daytona block |
| `network.mode = "cidr_allow_list"` | Error | Error | Daytona CIDR allow-list |
| `labels` | Warning; ignored | Warning; ignored | Daytona labels |
| `volumes` | Warning; ignored | Warning; ignored | Daytona volume mounts |
| `lifecycle.auto_stop` | Warning; ignored | Warning; ignored | Daytona auto-stop |
| `env` | Process environment overlay | Container environment | Sandbox environment |
## Local
`local` runs tools directly in the resolved working directory. It offers no filesystem or network isolation, so use it only for trusted workflows.
```toml
[run.environment]
id = "host"
[environments.host]
provider = "local"
```
Fabro hard-errors if a local environment asks for blocked or CIDR-restricted networking because the provider cannot enforce it.
## Docker
Docker runs tools inside a container created from `image.ref`. Docker is the built-in default provider.
```toml
[run.environment]
id = "ci"
[environments.ci]
provider = "docker"
[environments.ci.image]
ref = "buildpack-deps:noble"
[environments.ci.resources]
cpu = 2
memory = "4GB"
[environments.ci.network]
mode = "block"
```
Docker and Daytona are clone-based providers. When a run has a GitHub origin, Fabro clones it into the provider workspace. Set `[run.clone] enabled = false` to start with an empty workspace.
## Daytona
The Daytona sandbox runs all tool operations inside a cloud-hosted VM managed by [Daytona](https://daytona.io). It provides full machine-level isolation, automatic git cloning, and SSH access for debugging.
Daytona runs tools in a cloud sandbox. `image.ref` is the snapshot name. If `image.dockerfile` is set and the snapshot does not exist, Fabro creates the snapshot; `image.ref` is required so the snapshot has a name.
### Prerequisites
```toml
[run.environment]
id = "cloud"
- A `DAYTONA_API_KEY` environment variable
- GitHub access configured via `fabro install` or `gh auth login` (for private repository cloning)
The Daytona API key must include `write:snapshots`, `delete:snapshots`, `write:sandboxes`, and `delete:sandboxes`. `fabro install`, `fabro secret set DAYTONA_API_KEY`, and `fabro doctor` validate these scopes before the first run reaches sandbox creation.
### How it works
- **Sandbox lifecycle** — On `initialize()`, Fabro creates a Daytona sandbox (from an image or a snapshot), clones the run's GitHub origin into it when one is available, and waits until it's ready. On `cleanup()`, the sandbox is deleted.
- **Working directory** — Fixed at `/home/daytona/workspace`. GitHub-origin runs clone the repository there automatically.
- **Git clone** — Fabro detects the run manifest's GitHub origin URL and branch, converts SSH URLs to HTTPS, and clones into the sandbox. For private repositories, Fabro uses a GitHub App Installation Access Token scoped to the specific repository. Public repositories are cloned without credentials. Set `[run.clone] enabled = false` to create an empty workspace instead.
- **Commands** — Executed via the Daytona process API. Commands are base64-encoded and piped through `sh` to support pipes, environment variables, and other shell features.
- **Ephemeral** — Sandboxes are created with `ephemeral: true` and a unique timestamped name (e.g. `fabro-20260305-142301-a3f2`).
### Snapshots
Snapshots let you pre-build an environment image so each run starts with dependencies already installed. If the named snapshot doesn't exist and a `dockerfile` is provided, Fabro creates it automatically and polls until it's ready (up to 10 minutes).
```toml title="run.toml"
[run.sandbox]
[environments.cloud]
provider = "daytona"
[run.sandbox.daytona]
auto_stop_interval = 60
[environments.cloud.image]
ref = "rust-dev"
dockerfile = { path = "Dockerfile" }
[run.sandbox.daytona.snapshot]
name = "rust-dev"
[environments.cloud.resources]
cpu = 4
memory = "8GB"
disk = "20GB"
dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update && apt-get install -y git ripgrep"
# Or reference a Dockerfile beside this TOML file:
# dockerfile = { path = "./Dockerfile" }
[environments.cloud.lifecycle]
auto_stop = "30m"
[environments.cloud.network]
mode = "cidr_allow_list"
allow = ["208.80.154.232/32", "10.0.0.0/8"]
```
| Field | Description |
|---|---|
| `name` | Snapshot identifier. Reused across runs if it already exists. |
| `cpu` | CPU cores for the snapshot VM. |
| `memory` | Memory in GB. |
| `disk` | Disk in GB. |
| `dockerfile` | Dockerfile content or `{ path = "..." }` reference for building the snapshot. Required when creating a new snapshot. Paths resolve relative to the TOML file that declares them. |
Daytona volumes reference existing provider-managed volumes:
If the snapshot already exists and is in `Active` state, Fabro uses it directly. If it's in `Building` or `Pending` state, Fabro polls with exponential backoff until it's ready.
### Volumes
Mount existing Daytona volumes into each sandbox at creation time:
```toml title="run.toml"
[run.sandbox]
provider = "daytona"
[[run.sandbox.daytona.volumes]]
volume_id = "vol-agent-state"
```toml
[[environments.cloud.volumes]]
id = "vol-agent-state"
mount_path = "/home/daytona/agent-state"
subpath = "agent-auth"
```
Fabro does not create or manage Daytona volumes. Create the volume in Daytona first, then reference its `volume_id` from the run config. Use `subpath` when a shared volume should expose only one prefix to the sandbox.
### Labels
Attach key-value labels to sandboxes for filtering and identification in the Daytona dashboard:
```toml title="run.toml"
[run.sandbox.daytona.labels]
project = "fabro"
env = "ci"
team = "platform"
```
When using server defaults, labels are merged — run config labels override default labels on key collisions.
### SSH access
Connect to a running Daytona sandbox via SSH for live debugging:
```bash
fabro sandbox ssh <run-id>
```
This creates temporary SSH credentials (valid for 60 minutes) and connects directly.
### Preserving the sandbox
Like Docker, Daytona sandboxes are destroyed on cleanup by default. Use `--preserve-sandbox` to keep them alive:
```bash
fabro run workflow.fabro --sandbox daytona --preserve-sandbox
```
Fabro prints the sandbox name so you can find it in the [Daytona dashboard](https://app.daytona.io/dashboard/sandboxes).
### Auto-stop
The `auto_stop_interval` setting (in minutes) tells Daytona to stop the sandbox after a period of inactivity. This saves costs for long-running sandboxes that may sit idle:
```toml title="run.toml"
[run.sandbox.daytona]
auto_stop_interval = 30
```
## Sandboxing
Sandboxes isolate agent execution from the host machine. When an agent runs a shell command, edits a file, or searches code, it does so inside a sandbox — preventing unintended side effects and providing a reproducible environment for each run.
### Filesystem
Each provider offers a different level of filesystem isolation:
| Provider | Isolation | What agents can access |
|---|---|---|
| `local` | None | The entire host filesystem. Agents can read and modify any file. |
| `docker` | Container-level | The provider-owned workspace (`/workspace` by default) and whatever is in the container image. Host files are not bind-mounted into the container. |
| `daytona` | Full machine | A cloud VM with the repository cloned into `/home/daytona/workspace`. The host filesystem is completely inaccessible. |
For `local`, Fabro filters sensitive environment variables (those ending in `_API_KEY`, `_SECRET`, `_TOKEN`, `_PASSWORD`, or `_CREDENTIAL`) but does not restrict file access. Use `docker` or `daytona` when running untrusted workflows.
### Network
Each provider handles outbound network access differently:
| Provider | Default | Controls |
|---|---|---|
| `local` | Full access | No network isolation. Agents have the same network access as the host. |
| `docker` | Bridge network | Set via the `network_mode` config option. Supports all Docker network modes (`bridge`, `none`, `host`, etc.). |
| `daytona` | Full access | Configurable via the `network` setting with three modes: `"allow_all"`, `"block"`, or CIDR-based allow lists. |
For Daytona, network access is configured in the `[run.sandbox.daytona]` section:
```toml title="run.toml"
# Block all egress
[run.sandbox.daytona]
network = "block"
# Allow only specific CIDRs
[run.sandbox.daytona]
network = { allow_list = ["208.80.154.232/32", "10.0.0.0/8"] }
```
When using server defaults, the run config `network` overrides the server default. If neither specifies `network`, Daytona's own default (full access) applies.
## Safety guardrails
Regardless of which provider you use, Fabro applies a **read-before-write** guardrail. Agents must read a file (via `read_file` or `grep`) before they can modify it with `write_file` or `delete_file`. Writing to new files that don't yet exist is always allowed. This prevents agents from blindly overwriting files they haven't inspected.

View file

@ -56,25 +56,30 @@ script = "git clone https://github.com/fabro-sh/fabro repo"
[[run.prepare.steps]]
script = "cd repo && npm install"
[run.sandbox]
[run.environment]
id = "cloud"
[environments.cloud]
provider = "daytona"
[environments.cloud.lifecycle]
preserve = false
auto_stop = "60m"
[run.sandbox.daytona]
auto_stop_interval = 60
[run.sandbox.daytona.labels]
[environments.cloud.labels]
project = "fabro"
env = "ci"
[run.sandbox.daytona.snapshot]
name = "node-20"
[environments.cloud.image]
ref = "node-20"
dockerfile = "FROM node:20-slim\nRUN apt-get update && apt-get install -y git"
[environments.cloud.resources]
cpu = 4
memory = "8GB"
disk = "20GB"
dockerfile = "FROM node:20-slim\nRUN apt-get update && apt-get install -y git"
[run.sandbox.env]
[environments.cloud.env]
API_KEY = "{{ env.MY_API_KEY }}"
NODE_ENV = "production"
@ -222,94 +227,66 @@ push = true
| `enabled` | When `false`, Fabro skips metadata branch snapshots. |
| `push` | When `false`, Fabro writes metadata snapshots locally but does not push `fabro/meta/<id>` to the remote. |
### `[run.sandbox]`
### `[run.environment]` and `[environments.<slug>]`
Configure how agent tools (bash, file edits) are executed.
Runs select a reusable named environment by slug. Environment catalogs can be
defined in `settings.toml`, `.fabro/project.toml`, or `workflow.toml`.
```toml title="run.toml"
[run.sandbox]
provider = "docker"
[run.environment]
id = "ci"
[environments.ci]
provider = "docker" # local | docker | daytona
[environments.ci.image]
ref = "buildpack-deps:noble"
[environments.ci.resources]
cpu = 2
memory = "4GB"
[environments.ci.lifecycle]
preserve = true
stop_on_terminal = true
[environments.ci.env]
NODE_ENV = "production"
```
| Field | Description |
|---|---|
| `provider` | Sandbox mode: `docker` (default), `local`, or `daytona`. |
| `preserve` | When `true`, keep the sandbox alive after the run finishes. Useful for debugging. |
| `devcontainer` | When `true`, use the repo's `devcontainer.json` to configure the sandbox. See [Devcontainers](/execution/devcontainers). |
Sparse run-level overrides live under `[run.environment.*]` and apply to the
selected environment only:
#### `[run.sandbox.docker]`
Additional settings when using the Docker sandbox:
```toml title="run.toml"
[run.sandbox]
provider = "docker"
[run.sandbox.docker]
image = "buildpack-deps:noble"
network_mode = "bridge"
memory_limit = "4GB"
cpu_quota = 200000
```
| Field | Description |
|---|---|
| `image` | Docker image used for the run container. Defaults to `buildpack-deps:noble`. |
| `network_mode` | Docker network mode. Defaults to `bridge`. |
| `memory_limit` | Memory limit using human-readable units such as `"4GB"` or `"512MiB"`. |
| `cpu_quota` | Docker CPU quota in microseconds per 100ms period. Defaults to `200000`. |
| `env_vars` | Provider-level environment variables passed to the Docker container. For workflow tool execution, prefer `[run.sandbox.env]`. |
#### `[run.sandbox.daytona]`
Additional settings when using the Daytona cloud sandbox:
```toml title="run.toml"
[run.sandbox.daytona]
auto_stop_interval = 60
[run.sandbox.daytona.labels]
project = "fabro"
env = "staging"
[[run.sandbox.daytona.volumes]]
volume_id = "vol-agent-state"
mount_path = "/home/daytona/agent-state"
subpath = "agent-auth"
[run.sandbox.daytona.snapshot]
name = "my-snapshot"
cpu = 4
```toml
[run.environment.resources]
memory = "8GB"
disk = "20GB"
dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update"
# Or reference an external Dockerfile:
# dockerfile = { path = "./Dockerfile" }
```
| Field | Description |
|---|---|
| `auto_stop_interval` | Minutes of inactivity before the sandbox auto-stops. |
| `labels` | Key-value labels attached to the sandbox for filtering and identification. Labels merge across layers (sticky merge-by-key). |
| `volumes` | Existing Daytona volumes to mount at sandbox creation. Each entry requires `volume_id` and `mount_path`; `subpath` is optional. Fabro does not create or manage volume lifecycle. |
| `snapshot.name` | Snapshot name to create or use for the sandbox. |
| `snapshot.cpu` | CPU cores for the snapshot (integer). |
| `snapshot.memory` | Memory size using human-readable units: `"8GB"`, `"16GiB"`, or bare integers that default to GB. |
| `snapshot.disk` | Disk size using the same units as `memory`. |
| `snapshot.dockerfile` | Dockerfile content (inline string) or path (`{ path = "..." }`) for building the snapshot image. Paths are resolved relative to the TOML file's directory. |
| `network` | Network access mode: `"allow_all"` (default), `"block"`, or `{ allow_list = ["..."] }`. See [Sandboxing](/administration/sandboxing#network-access-control). |
| `run.environment.id` | Environment slug to select. Defaults to `default`. |
| `environments.<slug>.provider` | Required provider: `local`, `docker`, or `daytona`. |
| `image.ref` | Docker image or Daytona snapshot name. |
| `image.dockerfile` | Inline Dockerfile or `{ path = "Dockerfile" }`; for Daytona, requires `image.ref`. |
| `resources.cpu` / `memory` / `disk` | Best-effort resource hints. Unsupported provider fields warn and continue. |
| `network.mode` | `allow_all`, `block`, or `cidr_allow_list`. Local cannot enforce blocked/CIDR networking; Docker cannot enforce CIDR allow-lists. |
| `network.allow` | CIDRs for `cidr_allow_list`; entries are validated as CIDRs. |
| `lifecycle.preserve` | Keep the created sandbox after the run finishes. |
| `lifecycle.stop_on_terminal` | Stop the sandbox when the run reaches a terminal state. |
| `lifecycle.auto_stop` | Daytona auto-stop duration, such as `"30m"`. |
| `labels` | Provider labels. Merge by key across layers. |
| `volumes` | Provider volume hints. Lists replace wholesale across layers. |
| `env` | Environment variables passed to command and agent execution. Merge by key across layers. |
#### Local sandbox
When `provider = "local"`, Fabro runs directly in the resolved working
directory. If you want local isolation, create or enter a separate clone or Git
worktree yourself.
When `run.sandbox.provider = "local"`, Fabro runs directly in the resolved working directory. If you want local isolation, create or enter a separate clone or Git worktree yourself, then run with `--sandbox local`.
#### `[run.sandbox.env]`
Pass environment variables into sandbox command and agent execution. Values can be literal strings or host environment references using `{{ env.VARNAME }}` syntax:
Environment variable values can be literal strings or host environment
references using `{{ env.VARNAME }}` syntax:
```toml title="run.toml"
[run.sandbox.env]
[environments.ci.env]
API_KEY = "{{ env.MY_API_KEY }}"
NODE_ENV = "production"
SERVICE_URL = "https://api.{{ env.REGION }}.example.com"
@ -321,7 +298,7 @@ SERVICE_URL = "https://api.{{ env.REGION }}.example.com"
| `"{{ env.VARNAME }}"` | Whole-value reference resolved from the host environment at consumption time |
| `"prefix-{{ env.X }}-suffix"` | Substring interpolation; multiple tokens per string are supported |
Missing host variables produce a hard error pointing at the specific field and unresolved token. `run.sandbox.env` is a sticky merge-by-key map: entries from all layers combine, with higher-precedence layers overriding individual keys.
Missing host variables produce a hard error pointing at the specific field and unresolved token.
### `[run.integrations.github.permissions]`
@ -514,7 +491,7 @@ Settings can come from multiple sources. Fabro resolves them in this order (firs
| Source | Priority |
|---|---|
| Node-level [stylesheet](/workflows/stylesheets) | Highest |
| CLI flags (`--model`, `--provider`, `--sandbox`) | |
| CLI flags (`--model`, `--provider`, `--environment`) | |
| Run config TOML (`workflow.toml` or equivalent) | |
| Project defaults (`.fabro/project.toml`) | |
| Machine defaults (`~/.fabro/settings.toml`) | |
@ -535,14 +512,17 @@ _version = 1
[run.model]
name = "claude-sonnet-4-5"
[run.sandbox]
[run.environment]
id = "cloud"
[environments.cloud]
provider = "daytona"
[run.sandbox.daytona.snapshot]
name = "my-project-snapshot"
[environments.cloud.image]
ref = "my-project-snapshot"
```
Project defaults and workflow config values merge per the normative merge matrix: most fields merge by field (higher-precedence wins per key), TOML `run.inputs` tables replace wholesale, CLI input flags merge per key at highest precedence, `run.sandbox.env` sticky-merges by key, and `run.prepare.steps` replaces whole-list.
Project defaults and workflow config values merge per the normative merge matrix: most fields merge by field (higher-precedence wins per key), TOML `run.inputs` tables replace wholesale, CLI input flags merge per key at highest precedence, environment `env` and `labels` merge by key, environment `volumes` replace as a whole list, and `run.prepare.steps` replaces whole-list.
### Machine defaults

View file

@ -29,20 +29,23 @@ fabro sandbox ssh <run-id> --ttl 120
By default, Daytona sandboxes are destroyed when the workflow finishes. To keep the sandbox running after the workflow completes — so you can continue debugging — pass `--preserve-sandbox`:
```bash
fabro run workflow.fabro --sandbox daytona --preserve-sandbox
fabro run workflow.fabro --environment cloud --preserve-sandbox
```
Without `--preserve-sandbox`, the SSH session is terminated when the run ends and the sandbox is cleaned up.
You can also set `auto_stop_interval` in your run config to control how long an idle sandbox stays alive:
You can also set `lifecycle.auto_stop` in your environment to control how long an idle sandbox stays alive:
```toml title="run.toml"
[run.sandbox]
provider = "daytona"
preserve = true
[run.environment]
id = "cloud"
[run.sandbox.daytona]
auto_stop_interval = 60
[environments.cloud]
provider = "daytona"
[environments.cloud.lifecycle]
preserve = true
auto_stop = "60m"
```
## What you can do over SSH

View file

@ -19,7 +19,7 @@ VS Code remote access requires [SSH access](/human-tools/ssh-access), which is o
1. Start a workflow with a preserved Daytona sandbox:
```bash
fabro run workflow.fabro --sandbox daytona --preserve-sandbox
fabro run workflow.fabro --environment cloud --preserve-sandbox
```
2. Use `fabro sandbox ssh` to get the connection command:

View file

@ -24,28 +24,34 @@ The Daytona key must include the snapshot and sandbox scopes Fabro uses to creat
## Configuration
Set the sandbox provider in your run config TOML or via CLI flag:
Select a Daytona environment in your run config TOML or via CLI flag:
```bash
fabro run workflow.fabro --sandbox daytona
fabro run workflow.fabro --environment cloud
```
```toml title="run.toml"
[run.sandbox]
[run.environment]
id = "cloud"
[environments.cloud]
provider = "daytona"
```
A full configuration example with all Daytona-specific options:
```toml title="run.toml"
[run.sandbox]
[run.environment]
id = "cloud"
[environments.cloud]
provider = "daytona"
[environments.cloud.lifecycle]
preserve = false
auto_stop = "60m"
[run.sandbox.daytona]
auto_stop_interval = 60
[run.sandbox.daytona.labels]
[environments.cloud.labels]
project = "fabro"
env = "staging"
team = "platform"
@ -58,17 +64,19 @@ back to Fabro-managed runs. User-provided values for these reserved keys are
overwritten.
</Note>
[run.sandbox.daytona.snapshot]
name = "rust-dev"
cpu = 4
memory = "8GB"
disk = "20GB"
[environments.cloud.image]
ref = "rust-dev"
dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update && apt-get install -y git ripgrep"
# Or keep the Dockerfile next to this TOML file:
# dockerfile = { path = "./Dockerfile" }
[environments.cloud.resources]
cpu = 4
memory = "8GB"
disk = "20GB"
```
See [Server Configuration](/administration/server-configuration) for the full reference on all sandbox fields and server defaults.
See [Environments](/execution/environments) for the full reference on all environment fields and server defaults.
### Network access control
@ -76,16 +84,17 @@ Control outbound network access with the `network` field. Three modes are availa
```toml title="run.toml"
# Full access (default)
[run.sandbox.daytona]
network = "allow_all"
[environments.cloud.network]
mode = "allow_all"
# Block all egress
[run.sandbox.daytona]
network = "block"
[environments.cloud.network]
mode = "block"
# CIDR-based allow list
[run.sandbox.daytona]
network = { allow_list = ["208.80.154.232/32", "10.0.0.0/8"] }
[environments.cloud.network]
mode = "cidr_allow_list"
allow = ["208.80.154.232/32", "10.0.0.0/8"]
```
Use `"block"` or a CIDR allow list when running untrusted or generated code to prevent agents from making arbitrary network requests.
@ -95,12 +104,14 @@ Use `"block"` or a CIDR allow list when running untrusted or generated code to p
Snapshots let you pre-build an environment image so each run starts with dependencies already installed rather than installing them in setup commands every time.
```toml title="run.toml"
[run.sandbox.daytona.snapshot]
name = "my-snapshot"
[environments.cloud.image]
ref = "my-snapshot"
dockerfile = "FROM node:20-slim\nRUN apt-get update && apt-get install -y git"
[environments.cloud.resources]
cpu = 4
memory = 8
disk = 20
dockerfile = "FROM node:20-slim\nRUN apt-get update && apt-get install -y git"
```
When a run starts with a snapshot configured, Fabro looks up the snapshot by name. If it doesn't exist and a `dockerfile` is provided, Fabro creates it automatically and polls until it reaches `Active` state (up to 10 minutes). `dockerfile` can be inline content or `{ path = "..." }`; paths are resolved relative to the TOML file that declares them and are bundled into run manifests. If the snapshot already exists, it's reused immediately.
@ -116,7 +127,10 @@ Fabro automatically clones the run's GitHub origin into the sandbox at `/home/da
Set `[run.clone] enabled = false` when a workflow should start with an empty Daytona workspace instead of cloning the run origin:
```toml title="run.toml"
[run.sandbox]
[run.environment]
id = "cloud"
[environments.cloud]
provider = "daytona"
[run.clone]
@ -153,14 +167,19 @@ Each sandbox gets a unique timestamped name (e.g. `fabro-20260307-143022-a3f2`)
To keep a sandbox alive for debugging:
```bash
fabro run workflow.fabro --sandbox daytona --preserve-sandbox
fabro run workflow.fabro --environment cloud --preserve-sandbox
```
Or in the run config:
```toml title="run.toml"
[run.sandbox]
[run.environment]
id = "cloud"
[environments.cloud]
provider = "daytona"
[environments.cloud.lifecycle]
preserve = true
```
@ -168,11 +187,11 @@ When preserved, Fabro prints the sandbox name so you can find it in the [Daytona
### Auto-stop
The `auto_stop_interval` setting tells Daytona to stop the sandbox after a period of inactivity, saving costs for preserved or long-running sandboxes:
The `lifecycle.auto_stop` setting tells Daytona to stop the sandbox after a period of inactivity, saving costs for preserved or long-running sandboxes:
```toml title="run.toml"
[run.sandbox.daytona]
auto_stop_interval = 30
[environments.cloud.lifecycle]
auto_stop = "30m"
```
## Server defaults
@ -191,7 +210,7 @@ If doctor reports missing scopes, regenerate the Daytona key with `write:snapsho
### "Snapshot does not exist and no dockerfile provided"
The run config references a snapshot name that doesn't exist on Daytona, and no `dockerfile` is provided to create it. Either create the snapshot manually in the Daytona dashboard or add a `dockerfile` field to `[run.sandbox.daytona.snapshot]`.
The run config references a snapshot name that doesn't exist on Daytona, and no `dockerfile` is provided to create it. Either create the snapshot manually in the Daytona dashboard or add `image.dockerfile` under the selected `[environments.<slug>.image]`.
### "Timed out waiting for snapshot to become active"

View file

@ -333,7 +333,7 @@ fabro create [OPTIONS] <WORKFLOW>
| `--parent <run>` | Link this run to an existing orchestration parent run |
| `--preserve-sandbox` | Keep the sandbox alive after the run finishes (for debugging) |
| `--provider <provider>` | Override default LLM provider |
| `--sandbox <sandbox>` | Sandbox for agent tools<br />Values: `local`, `docker`, `daytona` |
| `--environment <environment>` | Named environment slug for agent tools |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
| `-I, --input <key=value>` | Override a workflow input value (repeatable, format: KEY=VALUE) |
| `-v, --verbose` | Enable verbose output |
@ -873,7 +873,7 @@ fabro preflight [OPTIONS] <WORKFLOW>
| `--goal-file <goal_file>` | Read the workflow goal from a file |
| `--model <model>` | Override default LLM model |
| `--provider <provider>` | Override default LLM provider |
| `--sandbox <sandbox>` | Sandbox for agent tools<br />Values: `local`, `docker`, `daytona` |
| `--environment <environment>` | Named environment slug for agent tools |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
| `-I, --input <key=value>` | Override a workflow input value (repeatable, format: KEY=VALUE) |
| `-v, --verbose` | Enable verbose output |
@ -1037,7 +1037,7 @@ fabro run [OPTIONS] <WORKFLOW>
| `--parent <run>` | Link this run to an existing orchestration parent run |
| `--preserve-sandbox` | Keep the sandbox alive after the run finishes (for debugging) |
| `--provider <provider>` | Override default LLM provider |
| `--sandbox <sandbox>` | Sandbox for agent tools<br />Values: `local`, `docker`, `daytona` |
| `--environment <environment>` | Named environment slug for agent tools |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
| `-I, --input <key=value>` | Override a workflow input value (repeatable, format: KEY=VALUE) |
| `-v, --verbose` | Enable verbose output |
@ -1229,7 +1229,7 @@ fabro server restart [OPTIONS]
| `--model <model>` | Override default LLM model |
| `--no-web` | Disable the embedded web UI, browser auth routes, and web-only helper endpoints |
| `--provider <provider>` | Override default LLM provider |
| `--sandbox <sandbox>` | Sandbox for agent tools |
| `--environment <environment>` | Named environment slug for agent tools |
| `--storage-dir <storage_dir>` | Local storage directory (default: ~/.fabro/storage) |
| `--timeout <timeout>` | Seconds to wait for graceful shutdown before SIGKILL<br />Default: `10` |
| `--watch-web` | Run `bun run dev` in apps/fabro-web to watch/recompile web assets (debug only) |
@ -1254,7 +1254,7 @@ fabro server start [OPTIONS]
| `--model <model>` | Override default LLM model |
| `--no-web` | Disable the embedded web UI, browser auth routes, and web-only helper endpoints |
| `--provider <provider>` | Override default LLM provider |
| `--sandbox <sandbox>` | Sandbox for agent tools |
| `--environment <environment>` | Named environment slug for agent tools |
| `--storage-dir <storage_dir>` | Local storage directory (default: ~/.fabro/storage) |
| `--watch-web` | Run `bun run dev` in apps/fabro-web to watch/recompile web assets (debug only) |
| `--web` | Enable the embedded web UI and browser auth routes |

View file

@ -48,7 +48,7 @@ Common flags:
|---|---|---|
| `--bind` | `~/.fabro/fabro.sock` | Address to bind: `IP` or `IP:port` for TCP, or a path for Unix socket |
| `--model` | — | Override default LLM model |
| `--sandbox` | — | Override default sandbox provider |
| `--environment` | — | Override default environment slug |
| `--max-concurrent-runs` | `5` | Maximum concurrent run executions |
See [Server Configuration](/administration/server-configuration) for the full `settings.toml` reference.

View file

@ -34,7 +34,7 @@ Files that omit `_version` are treated as version `1`. The legacy top-level `ver
| Scope | Examples |
|---|---|
| CLI-only | `[cli.target]`, `[cli.auth]`, `[cli.exec]`, `[cli.output]`, `[cli.updates]`, `[cli.logging]` |
| Shared run defaults | `[run.model]`, `[run.sandbox]`, `[run.checkpoint]`, `[run.inputs]`, `[run.prepare]`, `[run.pull_request]`, `[run.integrations.github.permissions]`, `[run.hooks]`, `[run.agent.mcps]` |
| Shared run defaults | `[run.model]`, `[run.environment]`, `[environments.<slug>]`, `[run.checkpoint]`, `[run.inputs]`, `[run.prepare]`, `[run.pull_request]`, `[run.integrations.github.permissions]`, `[run.hooks]`, `[run.agent.mcps]` |
| Shared LLM catalog | `[llm.providers.<id>]`, `[llm.models.<id>]`, model limits, features, controls, and costs |
| Server-only | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` |

View file

@ -218,33 +218,6 @@ pub(crate) struct InputOverrideArgs {
pub(crate) values: Vec<String>,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum CliSandboxProvider {
Local,
Docker,
Daytona,
}
impl From<CliSandboxProvider> for fabro_sandbox::SandboxProvider {
fn from(value: CliSandboxProvider) -> Self {
match value {
CliSandboxProvider::Local => Self::Local,
CliSandboxProvider::Docker => Self::Docker,
CliSandboxProvider::Daytona => Self::Daytona,
}
}
}
impl From<fabro_sandbox::SandboxProvider> for CliSandboxProvider {
fn from(value: fabro_sandbox::SandboxProvider) -> Self {
match value {
fabro_sandbox::SandboxProvider::Local => Self::Local,
fabro_sandbox::SandboxProvider::Docker => Self::Docker,
fabro_sandbox::SandboxProvider::Daytona => Self::Daytona,
}
}
}
#[derive(Args)]
pub(crate) struct RunArgs {
#[command(flatten)]
@ -285,9 +258,9 @@ pub(crate) struct RunArgs {
#[arg(short, long)]
pub(crate) verbose: bool,
/// Sandbox for agent tools
#[arg(long, value_enum)]
pub(crate) sandbox: Option<CliSandboxProvider>,
/// Named environment for agent tools
#[arg(long)]
pub(crate) environment: Option<String>,
/// Attach a label to this run (repeatable, format: KEY=VALUE)
#[arg(long = "label", value_name = "KEY=VALUE")]
@ -341,9 +314,9 @@ pub(crate) struct PreflightArgs {
#[arg(short, long)]
pub(crate) verbose: bool,
/// Sandbox for agent tools
#[arg(long, value_enum)]
pub(crate) sandbox: Option<CliSandboxProvider>,
/// Named environment for agent tools
#[arg(long)]
pub(crate) environment: Option<String>,
}
#[derive(Args)]

View file

@ -121,7 +121,7 @@ draft = true
let toml_path = workflow_dir.join("workflow.toml");
std::fs::write(
&toml_path,
"_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n\n[run.sandbox]\nprovider = \"local\"\n",
"_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n\n[run.environment]\nid = \"local\"\n\n[environments.local]\nprovider = \"local\"\n",
)
.with_context(|| format!("failed to write {}", toml_path.display()))?;
created.push(".fabro/workflows/hello/workflow.toml".to_string());

View file

@ -6,7 +6,6 @@ use fabro_config::{
CliLayer, CliOutputLayer, RunGoalLayer, RunLayer, parse_input_overrides, parse_labels,
};
use fabro_manifest::{RunOverrideInput, build_run_overrides};
use fabro_sandbox::SandboxProvider;
use fabro_types::settings::cli::OutputVerbosity;
use fabro_types::settings::interp::InterpString;
@ -71,13 +70,11 @@ fn current_dir_or_dot() -> PathBuf {
pub(crate) fn run_args_overrides(args: &RunArgs) -> Result<ManifestSettingsOverrides> {
let cwd = current_dir_or_dot();
let goal = goal_layer_from_args(args.goal.as_deref(), args.goal_file.as_deref(), &cwd)?;
let sandbox = args.sandbox.map(SandboxProvider::from);
let sandbox_provider = sandbox.as_ref().map(ToString::to_string);
let mut run = build_run_overrides(RunOverrideInput {
goal: None,
model: args.model.as_deref(),
provider: args.provider.as_deref(),
sandbox: sandbox_provider.as_deref(),
environment: args.environment.as_deref(),
docker_image: None,
preserve_sandbox: sparse_flag(args.preserve_sandbox),
dry_run: sparse_flag(args.dry_run),
@ -96,14 +93,11 @@ pub(crate) fn run_args_overrides(args: &RunArgs) -> Result<ManifestSettingsOverr
pub(crate) fn preflight_args_overrides(args: &PreflightArgs) -> Result<ManifestSettingsOverrides> {
let cwd = current_dir_or_dot();
let goal = goal_layer_from_args(args.goal.as_deref(), args.goal_file.as_deref(), &cwd)?;
let sandbox_provider = args
.sandbox
.map(|sandbox| SandboxProvider::from(sandbox).to_string());
let mut run = build_run_overrides(RunOverrideInput {
goal: None,
model: args.model.as_deref(),
provider: args.provider.as_deref(),
sandbox: sandbox_provider.as_deref(),
environment: args.environment.as_deref(),
docker_image: None,
preserve_sandbox: None,
dry_run: None,

View file

@ -708,7 +708,7 @@ fn requires_github_credentials(run: &RunNamespace) -> bool {
return true;
}
run.execution.mode != RunMode::DryRun
&& clone_sandbox_requires_github_credentials(&run.sandbox.provider)
&& clone_sandbox_requires_github_credentials(&run.environment.provider.to_string())
}
fn clone_sandbox_requires_github_credentials(provider: &str) -> bool {
@ -1147,8 +1147,8 @@ mod tests {
use fabro_types::settings::InterpString;
use fabro_types::settings::run::{
RunIntegrationsGithubSettings, RunIntegrationsSettings, RunMode, RunNamespace,
RunSandboxSettings,
EnvironmentProvider, RunIntegrationsGithubSettings, RunIntegrationsSettings, RunMode,
RunNamespace,
};
use super::super::requires_github_credentials;
@ -1160,10 +1160,9 @@ mod tests {
) -> RunNamespace {
let mut run = RunNamespace::default();
run.execution.mode = mode;
run.sandbox = RunSandboxSettings {
provider: provider.to_string(),
..RunSandboxSettings::default()
};
run.environment.provider = provider
.parse::<EnvironmentProvider>()
.expect("test provider should parse");
run.integrations = RunIntegrationsSettings {
github: RunIntegrationsGithubSettings { permissions },
};

View file

@ -147,7 +147,7 @@ async fn ensure_server_running_with_bind(
no_web: false,
model: None,
provider: None,
sandbox: None,
environment: None,
max_concurrent_runs: server_max_concurrent_runs_override(),
config: Some(config_path.to_path_buf()),
#[cfg(debug_assertions)]
@ -322,8 +322,8 @@ async fn execute_daemon(
if serve_args.no_web {
cmd.arg("--no-web");
}
if let Some(ref sandbox) = serve_args.sandbox {
cmd.args(["--sandbox", &sandbox.to_string()]);
if let Some(ref environment) = serve_args.environment {
cmd.args(["--environment", environment]);
}
if let Some(max) = serve_args.max_concurrent_runs {
cmd.args(["--max-concurrent-runs", &max.to_string()]);
@ -589,7 +589,7 @@ destination = "{destination}"
no_web: false,
model: None,
provider: None,
sandbox: None,
environment: None,
max_concurrent_runs: None,
config: Some(config_path.to_path_buf()),
#[cfg(debug_assertions)]

View file

@ -10,9 +10,7 @@ pub(crate) fn run_manifest_args(args: &RunArgs) -> Option<types::ManifestArgs> {
model: args.model.clone(),
preserve_sandbox: args.preserve_sandbox.then_some(true),
provider: args.provider.clone(),
sandbox: args
.sandbox
.map(|provider| fabro_sandbox::SandboxProvider::from(provider).to_string()),
environment: args.environment.clone(),
docker_image: None,
input: args.inputs.values.clone(),
verbose: args.verbose.then_some(true),
@ -28,9 +26,7 @@ pub(crate) fn preflight_manifest_args(args: &PreflightArgs) -> Option<types::Man
model: args.model.clone(),
preserve_sandbox: None,
provider: args.provider.clone(),
sandbox: args
.sandbox
.map(|provider| fabro_sandbox::SandboxProvider::from(provider).to_string()),
environment: args.environment.clone(),
docker_image: None,
input: args.inputs.values.clone(),
verbose: args.verbose.then_some(true),

View file

@ -363,8 +363,8 @@ fn create_persists_requested_overrides_into_store() {
"gpt-5",
"--provider",
"openai",
"--sandbox",
"local",
"--environment",
"default",
"--label",
"env=dev",
"--label",
@ -410,9 +410,10 @@ fn create_persists_requested_overrides_into_store() {
"model": resolved_run.model.name.as_ref().map(fabro_types::settings::InterpString::as_source),
"provider": resolved_run.model.provider.as_ref().map(fabro_types::settings::InterpString::as_source),
},
"sandbox": {
"provider": resolved_run.sandbox.provider,
"preserve": resolved_run.sandbox.preserve,
"environment": {
"id": resolved_run.environment.id,
"provider": resolved_run.environment.provider.to_string(),
"preserve": resolved_run.environment.lifecycle.preserve,
},
},
"labels": labels,
@ -429,8 +430,9 @@ fn create_persists_requested_overrides_into_store() {
"model": "gpt-5",
"provider": "openai"
},
"sandbox": {
"provider": "local",
"environment": {
"id": "default",
"provider": "docker",
"preserve": true
}
},

View file

@ -10,13 +10,14 @@ use fabro_util::error::SharedError;
use crate::defaults::DEFAULTS_LAYER;
use crate::load::load_settings_path;
use crate::resolve::{
ResolveError, resolve_cli, resolve_project, resolve_run, resolve_server, resolve_workflow,
ResolveError, resolve_cli, resolve_environments, resolve_project, resolve_run, resolve_server,
resolve_workflow,
};
use crate::user::load_settings_config;
use crate::{
CliLayer, Combine, CostRates, Error, LlmLayer, LlmModelFeatures, LlmModelLimits, ModelControls,
ModelCostTable, ModelSettings, ProviderSettings, Result, RunLayer, ServerLayer, SettingsLayer,
run,
CliLayer, Combine, CostRates, EnvironmentLayer, Error, LlmLayer, LlmModelFeatures,
LlmModelLimits, MergeMap, ModelControls, ModelCostTable, ModelSettings, ProviderSettings,
Result, RunLayer, ServerLayer, SettingsLayer, run,
};
#[derive(Debug, Clone, PartialEq, Eq)]
@ -190,7 +191,12 @@ impl RunSettingsBuilder {
pub(crate) fn from_layer(layer: &SettingsLayer) -> Result<RunNamespace> {
let layer = layer.clone().combine(DEFAULTS_LAYER.clone());
let mut errors = Vec::new();
let run = resolve_run(&layer.run.clone().unwrap_or_default(), &mut errors);
let environments = resolve_environments(&layer.environments, &mut errors);
let run = resolve_run(
&layer.run.clone().unwrap_or_default(),
&environments,
&mut errors,
);
finish_result(run, "failed to resolve run settings", errors)
}
@ -204,10 +210,11 @@ impl RunSettingsBuilder {
#[derive(Clone)]
pub struct ServerRuntimeSettings {
pub server_settings: ServerSettings,
pub manifest_run_defaults: RunLayer,
pub manifest_run_settings: std::result::Result<RunNamespace, SharedError>,
pub llm_catalog_settings: model_catalog::LlmCatalogSettings,
pub server_settings: ServerSettings,
pub manifest_run_defaults: RunLayer,
pub manifest_environment_defaults: crate::MergeMap<crate::EnvironmentLayer>,
pub manifest_run_settings: std::result::Result<RunNamespace, SharedError>,
pub llm_catalog_settings: model_catalog::LlmCatalogSettings,
}
pub fn load_server_runtime_settings(
@ -263,12 +270,18 @@ fn resolve_server_runtime_settings(
}
let manifest_run_defaults = layer.run.clone().unwrap_or_default();
let manifest_environment_defaults = layer.environments.clone();
let llm_catalog_settings = llm_catalog_settings_from_layer(&layer);
Ok(ServerRuntimeSettings {
server_settings: ServerSettingsBuilder::from_layer(&layer)?,
manifest_run_settings: RunSettingsBuilder::from_run_layer(&manifest_run_defaults)
.map_err(|err| SharedError::new(anyhow::Error::new(err))),
manifest_run_settings: RunSettingsBuilder::from_layer(&SettingsLayer {
run: Some(manifest_run_defaults.clone()),
environments: manifest_environment_defaults.clone(),
..SettingsLayer::default()
})
.map_err(|err| SharedError::new(anyhow::Error::new(err))),
manifest_run_defaults,
manifest_environment_defaults,
llm_catalog_settings,
})
}
@ -429,7 +442,7 @@ impl WorkflowSettingsBuilder {
}
#[must_use]
pub(crate) fn workflow_layer(mut self, layer: SettingsLayer) -> Self {
pub fn workflow_layer(mut self, layer: SettingsLayer) -> Self {
self.workflow = layer;
self
}
@ -462,7 +475,7 @@ impl WorkflowSettingsBuilder {
}
#[must_use]
pub(crate) fn project_layer(mut self, layer: SettingsLayer) -> Self {
pub fn project_layer(mut self, layer: SettingsLayer) -> Self {
self.project = layer;
self
}
@ -517,6 +530,19 @@ impl WorkflowSettingsBuilder {
})
}
#[must_use]
pub fn server_manifest_defaults(
self,
run: RunLayer,
environments: MergeMap<EnvironmentLayer>,
) -> Self {
self.server_layer(SettingsLayer {
run: Some(run),
environments,
..SettingsLayer::default()
})
}
#[must_use]
pub fn run_overrides(self, run: RunLayer) -> Self {
self.args_layer(SettingsLayer {
@ -538,6 +564,7 @@ impl WorkflowSettingsBuilder {
let server_defaults = SettingsLayer {
version: self.server.version,
run: self.server.run,
environments: self.server.environments,
..SettingsLayer::default()
};
let mut layer = self
@ -563,11 +590,17 @@ impl WorkflowSettingsBuilder {
let mut errors = Vec::new();
let project = resolve_project(&layer.project.clone().unwrap_or_default(), &mut errors);
let workflow = resolve_workflow(&layer.workflow.clone().unwrap_or_default(), &mut errors);
let run = resolve_run(&layer.run.clone().unwrap_or_default(), &mut errors);
let environments = resolve_environments(&layer.environments, &mut errors);
let run = resolve_run(
&layer.run.clone().unwrap_or_default(),
&environments,
&mut errors,
);
finish_dense_result(
WorkflowSettings {
project,
workflow,
environments,
run,
},
errors,

View file

@ -23,16 +23,42 @@ push = true
enabled = true
push = true
[run.sandbox]
[run.environment]
id = "default"
[environments.default]
provider = "docker"
[environments.default.image]
ref = "buildpack-deps:noble"
[environments.default.resources]
cpu = 2
memory = "4GB"
[environments.default.lifecycle]
preserve = false
stop_on_terminal = true
devcontainer = false
[run.sandbox.docker]
image = "buildpack-deps:noble"
memory_limit = "4GB"
cpu_quota = 200000
[environments.local]
provider = "local"
[environments.docker]
provider = "docker"
[environments.docker.image]
ref = "buildpack-deps:noble"
[environments.docker.resources]
cpu = 2
memory = "4GB"
[environments.docker.lifecycle]
preserve = false
stop_on_terminal = true
[environments.daytona]
provider = "daytona"
[cli.output]
format = "text"

View file

@ -3,7 +3,8 @@ use std::collections::{BTreeMap, HashMap};
use fabro_model::{AgentProfileKind, BillingPolicy, ProviderAuthConfig};
use fabro_types::settings::cli::{CliAuthStrategy, OutputFormat, OutputVerbosity};
use fabro_types::settings::run::{
AgentPermissions, ApprovalMode, DaytonaNetworkLayer, MergeStrategy, RunMode,
AgentPermissions, ApprovalMode, EnvironmentNetworkMode, EnvironmentProvider, MergeStrategy,
RunMode,
};
use fabro_types::settings::server::{
GithubIntegrationStrategy, LogDestination, ObjectStoreProvider, ServerAuthMethod,
@ -13,11 +14,12 @@ use fabro_types::settings::{Duration, InterpString, Size};
use super::LogFilter;
use super::cli::{CliAuthLayer, CliLoggingLayer, CliTargetLayer};
use super::environment::{EnvironmentDockerfileLayer, EnvironmentVolumeLayer};
use super::llm::{CostRates, CredentialRef, HeaderValueRef, ReasoningEffortFeature};
use super::run::{
DaytonaSnapshotLayer, DaytonaVolumeLayer, HookAgentMarker, HookEntry, HookTlsMode,
InterviewProviderLayer, ModelRefOrSplice, NotificationProviderLayer, RunArtifactsLayer,
RunCheckpointLayer, RunGoalLayer, RunPrepareLayer, ScmGitHubLayer, StringOrSplice,
HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer, ModelRefOrSplice,
NotificationProviderLayer, RunArtifactsLayer, RunCheckpointLayer, RunGoalLayer,
RunPrepareLayer, ScmGitHubLayer, StringOrSplice,
};
use super::server::{
ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer, ServerAuthGithubLayer,
@ -43,7 +45,7 @@ impl<T: Combine> Combine for Option<T> {
}
}
impl Combine for Option<Vec<DaytonaVolumeLayer>> {
impl Combine for Option<Vec<EnvironmentVolumeLayer>> {
fn combine(self, other: Self) -> Self {
self.or(other)
}
@ -147,8 +149,9 @@ impl_combine_self!(
CliAuthLayer,
CliLoggingLayer,
CliTargetLayer,
DaytonaNetworkLayer,
DaytonaSnapshotLayer,
EnvironmentNetworkMode,
EnvironmentProvider,
EnvironmentDockerfileLayer,
InterviewProviderLayer,
NotificationProviderLayer,
RunArtifactsLayer,

View file

@ -0,0 +1,134 @@
//! Sparse top-level `[environments.<slug>]` settings layer definitions.
use fabro_types::settings::{Duration, InterpString, Size};
use serde::{Deserialize, Serialize};
use super::combine::Combine;
use super::maps::StickyMap;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct EnvironmentLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<EnvironmentImageLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resources: Option<EnvironmentResourcesLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network: Option<EnvironmentNetworkLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lifecycle: Option<EnvironmentLifecycleLayer>,
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
pub labels: StickyMap<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub volumes: Option<Vec<EnvironmentVolumeLayer>>,
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
pub env: StickyMap<InterpString>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct RunEnvironmentLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<EnvironmentImageLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resources: Option<EnvironmentResourcesLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network: Option<EnvironmentNetworkLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lifecycle: Option<EnvironmentLifecycleLayer>,
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
pub labels: StickyMap<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub volumes: Option<Vec<EnvironmentVolumeLayer>>,
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
pub env: StickyMap<InterpString>,
}
impl RunEnvironmentLayer {
#[must_use]
pub fn into_environment_override(self) -> EnvironmentLayer {
EnvironmentLayer {
provider: None,
image: self.image,
resources: self.resources,
network: self.network,
lifecycle: self.lifecycle,
labels: self.labels,
volumes: self.volumes,
env: self.env,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct EnvironmentImageLayer {
#[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
pub reference: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dockerfile: Option<EnvironmentDockerfileLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct EnvironmentResourcesLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory: Option<Size>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disk: Option<Size>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EnvironmentNetworkLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allow: Vec<String>,
}
impl Combine for EnvironmentNetworkLayer {
fn combine(self, other: Self) -> Self {
Self {
mode: self.mode.or(other.mode),
allow: if self.allow.is_empty() {
other.allow
} else {
self.allow
},
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct EnvironmentLifecycleLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preserve: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop_on_terminal: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_stop: Option<Duration>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EnvironmentVolumeLayer {
pub id: String,
pub mount_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subpath: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged, deny_unknown_fields)]
pub enum EnvironmentDockerfileLayer {
Inline(String),
Path { path: String },
}

View file

@ -1,5 +1,6 @@
mod cli;
mod combine;
mod environment;
mod llm;
mod log_filter;
mod maps;
@ -15,6 +16,11 @@ pub use cli::{
CliOutputLayer, CliTargetLayer, CliUpdatesLayer,
};
pub(crate) use combine::Combine;
pub use environment::{
EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer, EnvironmentLifecycleLayer,
EnvironmentNetworkLayer, EnvironmentResourcesLayer, EnvironmentVolumeLayer,
RunEnvironmentLayer,
};
pub use llm::{
CostRates, CredentialRef, CredentialRefParseError, HeaderValueRef, LlmLayer, ModelControls,
ModelCostTable, ModelFeatures as LlmModelFeatures, ModelLimits as LlmModelLimits,
@ -24,14 +30,13 @@ pub use log_filter::LogFilter;
pub use maps::{MergeMap, ReplaceMap, StickyMap};
pub use project::ProjectLayer;
pub use run::{
DaytonaDockerfileLayer, DaytonaSandboxLayer, DaytonaSnapshotLayer, DaytonaVolumeLayer,
DockerSandboxLayer, GitAuthorLayer, HookAgentMarker, HookEntry, HookTlsMode,
InterviewProviderLayer, InterviewsLayer, McpEntryLayer, ModelRefOrSplice,
NotificationProviderLayer, NotificationRouteLayer, PrepareStep, RunAgentLayer,
RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, RunExecutionLayer, RunGitLayer,
RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer, RunMetaBranchLayer,
RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer,
RunSandboxLayer, RunScmLayer, ScmGitHubLayer, StringOrSplice,
GitAuthorLayer, HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer,
InterviewsLayer, McpEntryLayer, ModelRefOrSplice, NotificationProviderLayer,
NotificationRouteLayer, PrepareStep, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer,
RunCloneLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer,
RunIntegrationsLayer, RunLayer, RunMetaBranchLayer, RunModelControlsLayer, RunModelLayer,
RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer, RunScmLayer, ScmGitHubLayer,
StringOrSplice,
};
pub use server::{
GithubIntegrationLayer, IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer,
@ -40,5 +45,5 @@ pub use server::{
ServerListenLayer, ServerLoggingLayer, ServerSchedulerLayer, ServerSlateDbLayer,
ServerStorageLayer, ServerWebLayer, SlackIntegrationLayer,
};
pub(crate) use settings::SettingsLayer;
pub use settings::SettingsLayer;
pub use workflow::WorkflowLayer;

View file

@ -3,12 +3,13 @@
use std::collections::HashMap;
use fabro_types::settings::run::{
AgentPermissions, ApprovalMode, DaytonaNetworkLayer, HookEvent, MergeStrategy, RunMode,
AgentPermissions, ApprovalMode, HookEvent, MergeStrategy, RunMode,
};
use fabro_types::settings::{Duration, InterpString, ModelRef, Size};
use fabro_types::settings::{Duration, InterpString, ModelRef};
use serde::{Deserialize, Serialize};
use super::combine::Combine;
use super::environment::RunEnvironmentLayer;
use super::maps::{MergeMap, ReplaceMap, StickyMap};
use super::splice_array::SPLICE_MARKER;
@ -42,7 +43,7 @@ pub struct RunLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meta_branch: Option<RunMetaBranchLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<RunSandboxLayer>,
pub environment: Option<RunEnvironmentLayer>,
#[serde(default, skip_serializing_if = "MergeMap::is_empty")]
pub notifications: MergeMap<NotificationRouteLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -314,90 +315,6 @@ pub struct RunMetaBranchLayer {
pub push: Option<bool>,
}
/// `[run.sandbox]` — sandbox selection and execution-environment surface.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct RunSandboxLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preserve: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop_on_terminal: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub devcontainer: Option<bool>,
/// Sticky merge-by-key across layers.
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
pub env: StickyMap<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub docker: Option<DockerSandboxLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub daytona: Option<DaytonaSandboxLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct DockerSandboxLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_limit: Option<Size>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_quota: Option<i64>,
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
pub env_vars: StickyMap<InterpString>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct DaytonaSandboxLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_stop_interval: Option<i32>,
/// Sticky merge-by-key (provider-native labels).
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
pub labels: StickyMap<String>,
/// Existing Daytona volumes to mount when creating the sandbox.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub volumes: Option<Vec<DaytonaVolumeLayer>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snapshot: Option<DaytonaSnapshotLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network: Option<DaytonaNetworkLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DaytonaVolumeLayer {
pub volume_id: String,
pub mount_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subpath: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DaytonaSnapshotLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory: Option<Size>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disk: Option<Size>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dockerfile: Option<DaytonaDockerfileLayer>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged, deny_unknown_fields)]
pub enum DaytonaDockerfileLayer {
Inline(String),
Path { path: String },
}
/// `[run.notifications.<name>]` — a keyed notification route.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]

View file

@ -10,7 +10,9 @@ use std::str::FromStr;
use serde::{Deserialize, Serialize};
use super::cli::CliLayer;
use super::environment::EnvironmentLayer;
use super::llm::LlmLayer;
use super::maps::MergeMap;
use super::project::ProjectLayer;
use super::run::RunLayer;
use super::server::ServerLayer;
@ -19,21 +21,23 @@ use crate::parse::{ParseError, parse_settings};
/// A sparse settings layer before merge/resolve.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
pub(crate) struct SettingsLayer {
pub struct SettingsLayer {
#[serde(default, rename = "_version", skip_serializing_if = "Option::is_none")]
pub version: Option<u32>,
pub version: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<ProjectLayer>,
pub project: Option<ProjectLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow: Option<WorkflowLayer>,
pub workflow: Option<WorkflowLayer>,
#[serde(default, skip_serializing_if = "MergeMap::is_empty")]
pub environments: MergeMap<EnvironmentLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run: Option<RunLayer>,
pub run: Option<RunLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cli: Option<CliLayer>,
pub cli: Option<CliLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server: Option<ServerLayer>,
pub server: Option<ServerLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub llm: Option<LlmLayer>,
pub llm: Option<LlmLayer>,
}
impl FromStr for SettingsLayer {

View file

@ -38,26 +38,27 @@ pub use error::{Error, Result};
pub use fabro_util::path::expand_tilde;
pub use home::Home;
pub use input_overrides::{InputOverrideParseError, parse_input_overrides, parse_labels};
pub(crate) use layers::Combine;
pub use layers::{
CliAuthLayer, CliExecAgentLayer, CliExecLayer, CliExecModelLayer, CliLayer, CliLoggingLayer,
CliOutputLayer, CliTargetLayer, CliUpdatesLayer, CostRates, CredentialRef,
CredentialRefParseError, DaytonaDockerfileLayer, DaytonaSandboxLayer, DaytonaSnapshotLayer,
DaytonaVolumeLayer, DockerSandboxLayer, GitAuthorLayer, GithubIntegrationLayer, HeaderValueRef,
CredentialRefParseError, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer,
EnvironmentLifecycleLayer, EnvironmentNetworkLayer, EnvironmentResourcesLayer,
EnvironmentVolumeLayer, GitAuthorLayer, GithubIntegrationLayer, HeaderValueRef,
HookAgentMarker, HookEntry, HookTlsMode, IntegrationWebhooksLayer, InterviewProviderLayer,
InterviewsLayer, LlmLayer, LlmModelFeatures, LlmModelLimits, LogFilter, McpEntryLayer,
MergeMap, ModelControls, ModelCostTable, ModelRefOrSplice, ModelSettings,
NotificationProviderLayer, NotificationRouteLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer,
PrepareStep, ProjectLayer, ProviderSettings, ReasoningEffortFeature, ReplaceMap, RunAgentLayer,
RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, RunExecutionLayer, RunGitLayer,
RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer, RunMetaBranchLayer,
RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer,
RunSandboxLayer, RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer,
RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, RunEnvironmentLayer, RunExecutionLayer,
RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer,
RunMetaBranchLayer, RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer,
RunRunBranchLayer, RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer,
ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerIpAllowlistLayer,
ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer,
ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer,
ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer, SettingsLayer,
SlackIntegrationLayer, StickyMap, StringOrSplice, WorkflowLayer,
};
pub(crate) use layers::{Combine, SettingsLayer};
pub use logging::{resolve_log_destination, resolve_log_destination_with_env};
pub use parse::ParseError;
pub use resolve::{

View file

@ -5,7 +5,14 @@ use crate::SettingsLayer;
const CURRENT_VERSION: u32 = 1;
const ALLOWED_TOP_LEVEL_KEYS: &[&str] = &[
"_version", "project", "workflow", "run", "cli", "server", "llm",
"_version",
"project",
"workflow",
"environments",
"run",
"cli",
"server",
"llm",
];
/// Legacy `[llm]` keys that pre-date the settings-driven catalog plan and
@ -38,7 +45,7 @@ impl fmt::Display for ParseError {
} else {
write!(
f,
"unknown top-level settings key `{key}`: expected one of `_version`, `project`, `workflow`, `run`, `cli`, `server`, `llm`"
"unknown top-level settings key `{key}`: expected one of `_version`, `project`, `workflow`, `environments`, `run`, `cli`, `server`, `llm`"
)
}
}
@ -128,7 +135,7 @@ fn rename_hint(key: &str) -> Option<String> {
"llm" => "rename to `[run.model]`",
"vars" => "rename to `[run.inputs]`",
"setup" => "rename to `[run.prepare]`",
"sandbox" => "move under `[run.sandbox]`",
"sandbox" => "rename to `[run.environment]` and `[environments.<slug>]`",
"checkpoint" => "move under `[run.checkpoint]`",
"pull_request" => "move under `[run.pull_request]`",
"artifacts" => "move under `[run.artifacts]`",

View file

@ -0,0 +1,335 @@
use std::collections::HashMap;
use fabro_types::settings::run::{
DockerfileSource, EnvironmentImageSettings, EnvironmentLifecycleSettings,
EnvironmentNetworkMode, EnvironmentNetworkSettings, EnvironmentProvider,
EnvironmentResourcesSettings, EnvironmentSettings, EnvironmentVolumeSettings,
RunEnvironmentSettings,
};
use super::ResolveError;
use crate::{
EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer, EnvironmentLifecycleLayer,
EnvironmentNetworkLayer, EnvironmentResourcesLayer, EnvironmentVolumeLayer, MergeMap,
RunEnvironmentLayer,
};
pub(crate) fn resolve_environments(
layers: &MergeMap<EnvironmentLayer>,
errors: &mut Vec<ResolveError>,
) -> HashMap<String, EnvironmentSettings> {
layers
.iter()
.map(|(slug, layer)| {
let path = format!("environments.{slug}");
(
slug.clone(),
resolve_environment_layer(layer, &path, errors),
)
})
.collect()
}
pub(crate) fn resolve_run_environment(
layer: Option<&RunEnvironmentLayer>,
environments: &HashMap<String, EnvironmentSettings>,
errors: &mut Vec<ResolveError>,
) -> RunEnvironmentSettings {
let layer = layer.expect("defaults.toml should provide run.environment defaults");
let id = layer.id.clone().unwrap_or_else(|| {
errors.push(ResolveError::Missing {
path: "run.environment.id".to_string(),
});
"default".to_string()
});
let Some(base) = environments.get(&id) else {
errors.push(ResolveError::Invalid {
path: "run.environment.id".to_string(),
reason: format!("unknown environment: {id}"),
});
return RunEnvironmentSettings::from_environment(id, EnvironmentSettings::default());
};
let mut environment = base.clone();
apply_run_environment_overrides(&mut environment, layer, errors);
validate_provider_capabilities(&environment, "run.environment", errors);
RunEnvironmentSettings::from_environment(id, environment)
}
fn resolve_environment_layer(
layer: &EnvironmentLayer,
path: &str,
errors: &mut Vec<ResolveError>,
) -> EnvironmentSettings {
let provider = if let Some(raw) = layer.provider.as_deref() {
parse_provider(raw, &format!("{path}.provider"), errors)
} else {
errors.push(ResolveError::Missing {
path: format!("{path}.provider"),
});
EnvironmentProvider::Local
};
let environment = EnvironmentSettings {
provider,
image: resolve_image(layer.image.as_ref()),
resources: resolve_resources(layer.resources.as_ref()),
network: resolve_network(layer.network.as_ref(), &format!("{path}.network"), errors),
lifecycle: resolve_lifecycle(layer.lifecycle.as_ref()),
labels: layer.labels.clone().into_inner(),
volumes: resolve_volumes(layer.volumes.as_deref()),
env: layer.env.clone().into_inner(),
};
validate_daytona_snapshot_name(&environment, path, errors);
environment
}
fn parse_provider(raw: &str, path: &str, errors: &mut Vec<ResolveError>) -> EnvironmentProvider {
if let Ok(provider) = raw.parse::<EnvironmentProvider>() {
provider
} else {
errors.push(ResolveError::Invalid {
path: path.to_string(),
reason: format!("unknown environment provider: {raw}"),
});
EnvironmentProvider::Local
}
}
fn resolve_image(layer: Option<&EnvironmentImageLayer>) -> EnvironmentImageSettings {
let Some(layer) = layer else {
return EnvironmentImageSettings::default();
};
EnvironmentImageSettings {
reference: layer.reference.clone(),
dockerfile: layer.dockerfile.as_ref().map(dockerfile_source),
}
}
fn resolve_resources(layer: Option<&EnvironmentResourcesLayer>) -> EnvironmentResourcesSettings {
let Some(layer) = layer else {
return EnvironmentResourcesSettings::default();
};
EnvironmentResourcesSettings {
cpu: layer.cpu,
memory: layer.memory,
disk: layer.disk,
}
}
fn resolve_network(
layer: Option<&EnvironmentNetworkLayer>,
path: &str,
errors: &mut Vec<ResolveError>,
) -> EnvironmentNetworkSettings {
let Some(layer) = layer else {
return EnvironmentNetworkSettings::default();
};
for (index, cidr) in layer.allow.iter().enumerate() {
if cidr.parse::<ipnet::IpNet>().is_err() {
errors.push(ResolveError::Invalid {
path: format!("{path}.allow[{index}]"),
reason: format!("invalid CIDR: {cidr}"),
});
}
}
let mode = match layer.mode.as_deref() {
Some(raw) => parse_network_mode(raw, &format!("{path}.mode"), errors),
None if layer.allow.is_empty() => EnvironmentNetworkMode::AllowAll,
None => EnvironmentNetworkMode::CidrAllowList,
};
EnvironmentNetworkSettings {
mode,
allow: layer.allow.clone(),
}
}
fn parse_network_mode(
raw: &str,
path: &str,
errors: &mut Vec<ResolveError>,
) -> EnvironmentNetworkMode {
if let Ok(mode) = raw.parse::<EnvironmentNetworkMode>() {
mode
} else {
errors.push(ResolveError::Invalid {
path: path.to_string(),
reason: format!("unknown environment network mode: {raw}"),
});
EnvironmentNetworkMode::AllowAll
}
}
fn resolve_lifecycle(layer: Option<&EnvironmentLifecycleLayer>) -> EnvironmentLifecycleSettings {
let Some(layer) = layer else {
return EnvironmentLifecycleSettings::default();
};
EnvironmentLifecycleSettings {
preserve: layer.preserve.unwrap_or(false),
stop_on_terminal: layer.stop_on_terminal.unwrap_or(true),
auto_stop: layer.auto_stop,
}
}
fn resolve_volumes(layers: Option<&[EnvironmentVolumeLayer]>) -> Vec<EnvironmentVolumeSettings> {
layers
.unwrap_or(&[])
.iter()
.map(|volume| EnvironmentVolumeSettings {
id: volume.id.clone(),
mount_path: volume.mount_path.clone(),
subpath: volume.subpath.clone(),
})
.collect()
}
fn apply_run_environment_overrides(
environment: &mut EnvironmentSettings,
layer: &RunEnvironmentLayer,
errors: &mut Vec<ResolveError>,
) {
if let Some(image) = layer.image.as_ref() {
apply_image_override(&mut environment.image, image);
}
if let Some(resources) = layer.resources.as_ref() {
apply_resources_override(&mut environment.resources, resources);
}
if let Some(network) = layer.network.as_ref() {
apply_network_override(
&mut environment.network,
network,
"run.environment.network",
errors,
);
}
if let Some(lifecycle) = layer.lifecycle.as_ref() {
apply_lifecycle_override(&mut environment.lifecycle, lifecycle);
}
environment.labels.extend(layer.labels.clone().into_inner());
if let Some(volumes) = layer.volumes.as_deref() {
environment.volumes = resolve_volumes(Some(volumes));
}
environment.env.extend(layer.env.clone().into_inner());
}
fn apply_image_override(target: &mut EnvironmentImageSettings, layer: &EnvironmentImageLayer) {
if let Some(reference) = layer.reference.as_ref() {
target.reference = Some(reference.clone());
}
if let Some(dockerfile) = layer.dockerfile.as_ref() {
target.dockerfile = Some(dockerfile_source(dockerfile));
}
}
fn apply_resources_override(
target: &mut EnvironmentResourcesSettings,
layer: &EnvironmentResourcesLayer,
) {
if layer.cpu.is_some() {
target.cpu = layer.cpu;
}
if layer.memory.is_some() {
target.memory = layer.memory;
}
if layer.disk.is_some() {
target.disk = layer.disk;
}
}
fn apply_network_override(
target: &mut EnvironmentNetworkSettings,
layer: &EnvironmentNetworkLayer,
path: &str,
errors: &mut Vec<ResolveError>,
) {
for (index, cidr) in layer.allow.iter().enumerate() {
if cidr.parse::<ipnet::IpNet>().is_err() {
errors.push(ResolveError::Invalid {
path: format!("{path}.allow[{index}]"),
reason: format!("invalid CIDR: {cidr}"),
});
}
}
if let Some(raw) = layer.mode.as_deref() {
target.mode = parse_network_mode(raw, &format!("{path}.mode"), errors);
}
if !layer.allow.is_empty() {
target.allow.clone_from(&layer.allow);
}
}
fn dockerfile_source(dockerfile: &EnvironmentDockerfileLayer) -> DockerfileSource {
match dockerfile {
EnvironmentDockerfileLayer::Inline(text) => DockerfileSource::Inline(text.clone()),
EnvironmentDockerfileLayer::Path { path } => DockerfileSource::Path { path: path.clone() },
}
}
fn apply_lifecycle_override(
target: &mut EnvironmentLifecycleSettings,
layer: &EnvironmentLifecycleLayer,
) {
if let Some(preserve) = layer.preserve {
target.preserve = preserve;
}
if let Some(stop_on_terminal) = layer.stop_on_terminal {
target.stop_on_terminal = stop_on_terminal;
}
if layer.auto_stop.is_some() {
target.auto_stop = layer.auto_stop;
}
}
fn validate_daytona_snapshot_name(
environment: &EnvironmentSettings,
path: &str,
errors: &mut Vec<ResolveError>,
) {
if environment.provider == EnvironmentProvider::Daytona
&& environment.image.dockerfile.is_some()
&& environment.image.reference.is_none()
{
errors.push(ResolveError::Invalid {
path: format!("{path}.image"),
reason: "daytona environments with image.dockerfile must also set image.ref"
.to_string(),
});
}
}
fn validate_provider_capabilities(
environment: &EnvironmentSettings,
path: &str,
errors: &mut Vec<ResolveError>,
) {
validate_daytona_snapshot_name(environment, path, errors);
match environment.provider {
EnvironmentProvider::Local => {
if matches!(
environment.network.mode,
EnvironmentNetworkMode::Block | EnvironmentNetworkMode::CidrAllowList
) {
errors.push(ResolveError::Invalid {
path: format!("{path}.network.mode"),
reason:
"local environments cannot enforce blocked or CIDR allow-list networking"
.to_string(),
});
}
}
EnvironmentProvider::Docker => {
if environment.network.mode == EnvironmentNetworkMode::CidrAllowList {
errors.push(ResolveError::Invalid {
path: format!("{path}.network.mode"),
reason: "docker environments cannot enforce CIDR allow-list networking"
.to_string(),
});
}
}
EnvironmentProvider::Daytona => {}
}
}

View file

@ -1,4 +1,5 @@
mod cli;
mod environment;
mod error;
mod project;
mod run;
@ -6,6 +7,7 @@ mod server;
mod workflow;
pub use cli::resolve_cli;
pub(crate) use environment::{resolve_environments, resolve_run_environment};
pub use error::ResolveError;
use fabro_types::settings::InterpString;
pub use project::resolve_project;

View file

@ -1,26 +1,28 @@
use fabro_types::settings::InterpString;
use fabro_types::settings::run::{
ArtifactsSettings, DaytonaSettings, DaytonaSnapshotSettings, DaytonaVolumeSettings,
DockerSettings, DockerfileSource, GitAuthorSettings, HookDefinition, HookType,
ArtifactsSettings, EnvironmentSettings, GitAuthorSettings, HookDefinition, HookType,
InterviewProviderSettings, McpServerSettings, McpTransport, MergeStrategy,
NotificationProviderSettings, NotificationRouteSettings, PullRequestSettings, RunAgentSettings,
RunBranchSettings, RunCheckpointSettings, RunCloneSettings, RunExecutionSettings,
RunGitSettings, RunGoal, RunIntegrationsGithubSettings, RunIntegrationsSettings,
RunInterviewsSettings, RunMetaBranchSettings, RunModelControls, RunModelSettings, RunNamespace,
RunPrepareSettings, RunSandboxSettings, RunScmSettings, ScmGitHubSettings, TlsMode,
RunPrepareSettings, RunScmSettings, ScmGitHubSettings, TlsMode,
};
use super::ResolveError;
use super::{ResolveError, resolve_run_environment};
use crate::{
DaytonaDockerfileLayer, DaytonaSandboxLayer, HookAgentMarker, HookEntry, HookTlsMode,
InterviewProviderLayer, InterviewsLayer, McpEntryLayer, ModelRefOrSplice,
NotificationProviderLayer, NotificationRouteLayer, RunAgentLayer, RunArtifactsLayer,
RunCheckpointLayer, RunCloneLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer,
RunIntegrationsLayer, RunLayer, RunMetaBranchLayer, RunModelLayer, RunPrepareLayer,
RunPullRequestLayer, RunRunBranchLayer, RunSandboxLayer, RunScmLayer, StringOrSplice,
HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer, InterviewsLayer,
McpEntryLayer, ModelRefOrSplice, NotificationProviderLayer, NotificationRouteLayer,
RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, RunExecutionLayer,
RunGitLayer, RunGoalLayer, RunIntegrationsLayer, RunLayer, RunMetaBranchLayer, RunModelLayer,
RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer, RunScmLayer, StringOrSplice,
};
pub fn resolve_run(layer: &RunLayer, errors: &mut Vec<ResolveError>) -> RunNamespace {
pub fn resolve_run(
layer: &RunLayer,
environments: &std::collections::HashMap<String, EnvironmentSettings>,
errors: &mut Vec<ResolveError>,
) -> RunNamespace {
let clone = resolve_clone(layer.clone.as_ref());
let run_branch = resolve_run_branch(layer.run_branch.as_ref());
let mut meta_branch = resolve_meta_branch(layer.meta_branch.as_ref());
@ -59,7 +61,7 @@ pub fn resolve_run(layer: &RunLayer, errors: &mut Vec<ResolveError>) -> RunNames
clone,
run_branch,
meta_branch,
sandbox: resolve_sandbox(layer.sandbox.as_ref(), errors),
environment: resolve_run_environment(layer.environment.as_ref(), environments, errors),
notifications: layer
.notifications
.iter()
@ -221,91 +223,6 @@ fn resolve_meta_branch(meta_branch: Option<&RunMetaBranchLayer>) -> RunMetaBranc
}
}
fn resolve_sandbox(
sandbox: Option<&RunSandboxLayer>,
errors: &mut Vec<ResolveError>,
) -> RunSandboxSettings {
let sandbox = sandbox.expect("defaults.toml should provide run.sandbox defaults");
let provider = sandbox
.provider
.clone()
.expect("defaults.toml should provide run.sandbox.provider");
match provider.as_str() {
"local" | "docker" | "daytona" => {}
other => errors.push(ResolveError::Invalid {
path: "run.sandbox.provider".to_string(),
reason: format!("unknown sandbox provider: {other}"),
}),
}
RunSandboxSettings {
provider,
preserve: sandbox
.preserve
.expect("defaults.toml should provide run.sandbox.preserve"),
stop_on_terminal: sandbox
.stop_on_terminal
.expect("defaults.toml should provide run.sandbox.stop_on_terminal"),
devcontainer: sandbox
.devcontainer
.expect("defaults.toml should provide run.sandbox.devcontainer"),
env: sandbox.env.clone().into_inner(),
docker: sandbox.docker.as_ref().map(resolve_docker),
daytona: sandbox.daytona.as_ref().map(resolve_daytona),
}
}
fn resolve_docker(docker: &crate::DockerSandboxLayer) -> DockerSettings {
DockerSettings {
image: docker.image.clone().unwrap_or_default(),
network_mode: docker.network_mode.clone(),
memory_limit: docker
.memory_limit
.and_then(|size| i64::try_from(size.as_bytes()).ok()),
cpu_quota: docker.cpu_quota,
env_vars: docker.env_vars.clone().into_inner(),
}
}
fn resolve_daytona(daytona: &DaytonaSandboxLayer) -> DaytonaSettings {
DaytonaSettings {
auto_stop_interval: daytona.auto_stop_interval,
labels: daytona.labels.clone().into_inner(),
volumes: daytona
.volumes
.as_deref()
.unwrap_or(&[])
.iter()
.map(|volume| DaytonaVolumeSettings {
volume_id: volume.volume_id.clone(),
mount_path: volume.mount_path.clone(),
subpath: volume.subpath.clone(),
})
.collect(),
snapshot: daytona.snapshot.as_ref().and_then(|snapshot| {
snapshot.name.as_ref().map(|name| DaytonaSnapshotSettings {
name: name.clone(),
cpu: snapshot.cpu,
memory_gb: snapshot.memory.map(|size| size_to_gb_i32(size.as_bytes())),
disk_gb: snapshot.disk.map(|size| size_to_gb_i32(size.as_bytes())),
dockerfile: snapshot
.dockerfile
.as_ref()
.map(|dockerfile| match dockerfile {
DaytonaDockerfileLayer::Inline(text) => {
DockerfileSource::Inline(text.clone())
}
DaytonaDockerfileLayer::Path { path } => {
DockerfileSource::Path { path: path.clone() }
}
}),
})
}),
network: daytona.network.clone(),
}
}
fn resolve_notification_route(route: &NotificationRouteLayer) -> NotificationRouteSettings {
NotificationRouteSettings {
enabled: route.enabled.unwrap_or(false),
@ -570,8 +487,3 @@ fn resolve_artifacts(artifacts: Option<&RunArtifactsLayer>) -> ArtifactsSettings
.unwrap_or_default(),
}
}
fn size_to_gb_i32(bytes: u64) -> i32 {
let gb = bytes / 1_000_000_000;
i32::try_from(gb).unwrap_or(i32::MAX)
}

View file

@ -33,30 +33,38 @@ a = "higher"
}
#[test]
fn run_sandbox_env_merges_sticky() {
fn run_environment_env_merges_sticky() {
let lower = parse(
r#"
[run.sandbox.env]
[run.environment.env]
A = "lower-a"
B = "lower-b"
"#,
);
let higher = parse(
r#"
[run.sandbox.env]
[run.environment.env]
A = "higher-a"
C = "higher-c"
"#,
);
let merged = higher.combine(lower);
let sandbox = merged.run.unwrap().sandbox.unwrap();
assert_eq!(sandbox.env.len(), 3);
let environment = merged.run.unwrap().environment.unwrap();
assert_eq!(environment.env.len(), 3);
assert_eq!(
sandbox.env.get("A").map(InterpString::as_source).as_deref(),
environment
.env
.get("A")
.map(InterpString::as_source)
.as_deref(),
Some("higher-a")
);
assert_eq!(
sandbox.env.get("B").map(InterpString::as_source).as_deref(),
environment
.env
.get("B")
.map(InterpString::as_source)
.as_deref(),
Some("lower-b")
);
}

View file

@ -58,8 +58,15 @@ fn apply_builtin_defaults_materializes_expected_layer() {
layer
.run
.as_ref()
.and_then(|run| run.sandbox.as_ref())
.and_then(|sandbox| sandbox.provider.as_deref()),
.and_then(|run| run.environment.as_ref())
.and_then(|environment| environment.id.as_deref()),
Some("default")
);
assert_eq!(
layer
.environments
.get("default")
.and_then(|environment| environment.provider.as_deref()),
Some("docker")
);
assert_eq!(

View file

@ -35,7 +35,10 @@ methods = ["github"]
[server.auth.github]
allowed_usernames = []
[run.sandbox]
[run.environment]
id = "bad"
[environments.bad]
provider = "not-a-provider"
"#;
@ -64,7 +67,7 @@ provider = "not-a-provider"
assert!(rendered.contains("server.listen.address"));
assert!(rendered.contains("server.auth.github.allowed_usernames"));
assert!(rendered.contains("run.sandbox.provider"));
assert!(rendered.contains("environments.bad.provider"));
}
#[test]
@ -165,12 +168,15 @@ shared = "run"
}
#[test]
fn workflow_settings_report_invalid_run_sandbox_provider() {
fn workflow_settings_report_invalid_environment_provider() {
let errors = match fabro_config::WorkflowSettingsBuilder::from_toml(
r#"
_version = 1
[run.sandbox]
[run.environment]
id = "bad"
[environments.bad]
provider = "not-a-provider"
"#,
)
@ -183,7 +189,7 @@ provider = "not-a-provider"
assert!(errors.iter().any(|error| {
matches!(
error,
fabro_config::ResolveError::Invalid { path, .. } if path == "run.sandbox.provider"
fabro_config::ResolveError::Invalid { path, .. } if path == "environments.bad.provider"
)
}));
}
@ -194,7 +200,10 @@ fn workflow_settings_accumulate_multiple_run_errors() {
r#"
_version = 1
[run.sandbox]
[run.environment]
id = "bad"
[environments.bad]
provider = "not-a-provider"
[[run.prepare.steps]]
@ -205,6 +214,6 @@ command = ["echo", "hi"]
.expect_err("invalid workflow settings should fail")
.to_string();
assert!(rendered.contains("run.sandbox.provider"));
assert!(rendered.contains("environments.bad.provider"));
assert!(rendered.contains("run.prepare.steps[0]"));
}

View file

@ -1,5 +1,7 @@
use fabro_types::settings::InterpString;
use fabro_types::settings::run::{ApprovalMode, RunGoal, RunMode};
use fabro_types::settings::run::{
ApprovalMode, EnvironmentNetworkMode, EnvironmentProvider, RunGoal, RunMode,
};
use crate::{SettingsLayer, WorkflowSettingsBuilder};
@ -43,16 +45,23 @@ fn resolves_run_defaults_from_empty_settings() {
assert_eq!(settings.execution.mode, RunMode::Normal);
assert_eq!(settings.execution.approval, ApprovalMode::Prompt);
assert_eq!(settings.prepare.timeout_ms, 300_000);
assert_eq!(settings.sandbox.provider, "docker");
assert!(settings.sandbox.stop_on_terminal);
let docker = settings
.sandbox
.docker
.as_ref()
.expect("defaults should provide docker settings");
assert_eq!(docker.image, "buildpack-deps:noble");
assert_eq!(docker.memory_limit, Some(4_000_000_000));
assert_eq!(docker.cpu_quota, Some(200_000));
assert_eq!(settings.environment.id, "default");
assert_eq!(settings.environment.provider, EnvironmentProvider::Docker);
assert_eq!(
settings.environment.image.reference.as_deref(),
Some("buildpack-deps:noble")
);
assert_eq!(settings.environment.resources.cpu, Some(2));
assert_eq!(
settings
.environment
.resources
.memory
.map(|size| size.as_bytes()),
Some(4_000_000_000)
);
assert!(!settings.environment.lifecycle.preserve);
assert!(settings.environment.lifecycle.stop_on_terminal);
assert!(settings.clone.enabled);
assert!(settings.run_branch.enabled);
assert!(settings.run_branch.push);
@ -62,33 +71,98 @@ fn resolves_run_defaults_from_empty_settings() {
}
#[test]
fn resolves_daytona_volume_mounts() {
fn resolves_named_daytona_environment_and_run_overrides() {
let settings = WorkflowSettingsBuilder::from_toml(
r#"
_version = 1
[run.sandbox]
[run.environment]
id = "fabro-dev"
[run.environment.resources]
memory = "32GB"
[run.environment.lifecycle]
preserve = true
[environments.fabro-dev]
provider = "daytona"
[[run.sandbox.daytona.volumes]]
volume_id = "vol_auth"
[environments.fabro-dev.image]
ref = "fabro-v11"
[environments.fabro-dev.resources]
cpu = 8
memory = "16GB"
disk = "20GB"
[environments.fabro-dev.network]
mode = "cidr_allow_list"
allow = ["10.0.0.0/8"]
[environments.fabro-dev.lifecycle]
preserve = false
stop_on_terminal = true
auto_stop = "30m"
[environments.fabro-dev.labels]
repo = "fabro-sh/fabro"
[[environments.fabro-dev.volumes]]
id = "vol_auth"
mount_path = "/home/daytona/.config"
subpath = "agents"
[environments.fabro-dev.env]
NODE_ENV = "development"
"#,
)
.expect("daytona volume mount should resolve")
.run;
.expect("daytona environment should resolve");
let daytona = settings
.sandbox
.daytona
.as_ref()
.expect("daytona settings should resolve");
assert!(settings.environments.contains_key("fabro-dev"));
let environment = settings.run.environment;
assert_eq!(daytona.volumes.len(), 1);
assert_eq!(daytona.volumes[0].volume_id, "vol_auth");
assert_eq!(daytona.volumes[0].mount_path, "/home/daytona/.config");
assert_eq!(daytona.volumes[0].subpath.as_deref(), Some("agents"));
assert_eq!(environment.id, "fabro-dev");
assert_eq!(environment.provider, EnvironmentProvider::Daytona);
assert_eq!(environment.image.reference.as_deref(), Some("fabro-v11"));
assert_eq!(environment.resources.cpu, Some(8));
assert_eq!(
environment.resources.memory.map(|size| size.as_bytes()),
Some(32_000_000_000)
);
assert_eq!(
environment.resources.disk.map(|size| size.as_bytes()),
Some(20_000_000_000)
);
assert_eq!(
environment.network.mode,
EnvironmentNetworkMode::CidrAllowList
);
assert_eq!(environment.network.allow, vec!["10.0.0.0/8"]);
assert!(environment.lifecycle.preserve);
assert_eq!(
environment
.lifecycle
.auto_stop
.map(|duration| duration.as_std().as_secs()),
Some(1800)
);
assert_eq!(
environment.labels.get("repo").map(String::as_str),
Some("fabro-sh/fabro")
);
assert_eq!(environment.volumes.len(), 1);
assert_eq!(environment.volumes[0].id, "vol_auth");
assert_eq!(environment.volumes[0].mount_path, "/home/daytona/.config");
assert_eq!(environment.volumes[0].subpath.as_deref(), Some("agents"));
assert_eq!(
environment
.env
.get("NODE_ENV")
.map(InterpString::as_source)
.as_deref(),
Some("development")
);
}
#[test]
@ -181,19 +255,19 @@ enabled = true
}
#[test]
fn provider_skip_clone_is_rejected() {
let err = r"
fn legacy_run_sandbox_is_rejected() {
let err = r#"
_version = 1
[run.sandbox.docker]
skip_clone = true
"
[run.sandbox]
provider = "local"
"#
.parse::<SettingsLayer>()
.expect_err("provider-level skip_clone should be unknown");
.expect_err("legacy run.sandbox should be unknown");
let message = err.to_string();
assert!(
message.contains("skip_clone") || message.contains("unknown field"),
"expected unknown-field error mentioning skip_clone, got: {message}"
message.contains("sandbox") || message.contains("unknown field"),
"expected unknown-field error mentioning sandbox, got: {message}"
);
}
@ -289,31 +363,131 @@ fn resolves_explicit_stop_on_terminal_false() {
r"
_version = 1
[run.sandbox]
[run.environment.lifecycle]
stop_on_terminal = false
",
)
.expect("sandbox stop_on_terminal setting should resolve")
.expect("environment stop_on_terminal setting should resolve")
.run;
assert!(!settings.sandbox.stop_on_terminal);
assert!(!settings.environment.lifecycle.stop_on_terminal);
}
#[test]
fn resolves_minimal_local_provider_without_docker_table() {
fn resolves_minimal_local_environment() {
let settings = WorkflowSettingsBuilder::from_toml(
r#"
_version = 1
[run.sandbox]
[run.environment]
id = "host"
[environments.host]
provider = "local"
"#,
)
.expect("minimal local sandbox settings should resolve")
.expect("minimal local environment settings should resolve")
.run;
assert_eq!(settings.sandbox.provider, "local");
assert!(settings.sandbox.docker.is_some());
assert_eq!(settings.environment.id, "host");
assert_eq!(settings.environment.provider, EnvironmentProvider::Local);
assert!(settings.environment.image.reference.is_none());
}
#[test]
fn missing_environment_slug_errors() {
let err = WorkflowSettingsBuilder::from_toml(
r#"
_version = 1
[run.environment]
id = "missing"
"#,
)
.expect_err("missing selected environment should error");
let message = err.to_string();
assert!(
message.contains("run.environment.id") && message.contains("missing"),
"expected missing environment diagnostic, got: {message}"
);
}
#[test]
fn docker_cidr_allow_list_errors() {
let err = WorkflowSettingsBuilder::from_toml(
r#"
_version = 1
[run.environment]
id = "locked"
[environments.locked]
provider = "docker"
[environments.locked.network]
mode = "cidr_allow_list"
allow = ["10.0.0.0/8"]
"#,
)
.expect_err("docker cannot enforce cidr allow list");
let message = err.to_string();
assert!(
message.contains("run.environment.network.mode") && message.contains("CIDR allow-list"),
"expected docker CIDR capability diagnostic, got: {message}"
);
}
#[test]
fn local_blocked_network_errors() {
let err = WorkflowSettingsBuilder::from_toml(
r#"
_version = 1
[run.environment]
id = "host"
[environments.host]
provider = "local"
[environments.host.network]
mode = "block"
"#,
)
.expect_err("local cannot enforce blocked networking");
let message = err.to_string();
assert!(
message.contains("run.environment.network.mode")
&& message.contains("local environments cannot enforce"),
"expected local blocked-network diagnostic, got: {message}"
);
}
#[test]
fn daytona_dockerfile_without_image_ref_errors() {
let err = WorkflowSettingsBuilder::from_toml(
r#"
_version = 1
[run.environment]
id = "cloud"
[environments.cloud]
provider = "daytona"
[environments.cloud.image]
dockerfile = { path = "Dockerfile" }
"#,
)
.expect_err("daytona dockerfile needs a snapshot name");
let message = err.to_string();
assert!(
message.contains("image.ref"),
"expected daytona dockerfile/image.ref diagnostic, got: {message}"
);
}
#[test]

View file

@ -12,8 +12,9 @@ use fabro_api::types;
use fabro_config::project::{self, WorkflowLocation, discover_project_config};
use fabro_config::run::{resolve_run_goal_from_layer, resolve_run_goal_from_namespace};
use fabro_config::{
CliLayer, DaytonaDockerfileLayer, DockerSandboxLayer, ReplaceMap, RunExecutionLayer,
RunGoalLayer, RunLayer, RunModelLayer, RunSandboxLayer, WorkflowSettingsBuilder,
CliLayer, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLifecycleLayer,
ReplaceMap, RunEnvironmentLayer, RunExecutionLayer, RunGoalLayer, RunLayer, RunModelLayer,
SettingsLayer, WorkflowSettingsBuilder,
};
use fabro_graphviz::graph::AttrValue;
use fabro_graphviz::parser;
@ -58,7 +59,7 @@ pub struct RunOverrideInput<'a> {
pub goal: Option<&'a str>,
pub model: Option<&'a str>,
pub provider: Option<&'a str>,
pub sandbox: Option<&'a str>,
pub environment: Option<&'a str>,
pub docker_image: Option<&'a str>,
pub preserve_sandbox: Option<bool>,
pub dry_run: Option<bool>,
@ -77,17 +78,22 @@ pub fn build_run_overrides(input: RunOverrideInput<'_>) -> RunLayer {
fallbacks: Vec::new(),
controls: None,
});
let sandbox = (input.sandbox.is_some()
let environment = (input.environment.is_some()
|| input.docker_image.is_some()
|| input.preserve_sandbox.is_some())
.then(|| RunSandboxLayer {
provider: input.sandbox.map(ToOwned::to_owned),
docker: input.docker_image.map(|image| DockerSandboxLayer {
image: Some(image.to_string()),
..DockerSandboxLayer::default()
.then(|| RunEnvironmentLayer {
id: input.environment.map(ToOwned::to_owned),
image: input.docker_image.map(|image| EnvironmentImageLayer {
reference: Some(image.to_string()),
..EnvironmentImageLayer::default()
}),
preserve: input.preserve_sandbox,
..RunSandboxLayer::default()
lifecycle: input
.preserve_sandbox
.map(|preserve| EnvironmentLifecycleLayer {
preserve: Some(preserve),
..EnvironmentLifecycleLayer::default()
}),
..RunEnvironmentLayer::default()
});
let execution =
(input.dry_run.is_some() || input.auto_approve.is_some()).then(|| RunExecutionLayer {
@ -111,7 +117,7 @@ pub fn build_run_overrides(input: RunOverrideInput<'_>) -> RunLayer {
goal,
metadata: ReplaceMap::from(input.labels),
model,
sandbox,
environment,
execution,
..RunLayer::default()
}
@ -123,7 +129,7 @@ pub fn build_sparse_run_overrides(input: RunOverrideInput<'_>) -> Option<RunLaye
(run.goal.is_some()
|| !run.metadata.is_empty()
|| run.model.is_some()
|| run.sandbox.is_some()
|| run.environment.is_some()
|| run.execution.is_some())
.then_some(run)
}
@ -558,29 +564,49 @@ fn collect_config_dockerfile(
source: &str,
files: &mut HashMap<String, types::ManifestFileEntry>,
) -> Result<()> {
let mut document: toml::Table = source.parse().context("Failed to parse run config TOML")?;
let run = document
.remove("run")
.map(toml::Value::try_into::<RunLayer>)
.transpose()
.context("Failed to parse run config TOML")?
.unwrap_or_default();
let dockerfile = run
.sandbox
.as_ref()
.and_then(|sandbox| sandbox.daytona.as_ref())
.and_then(|daytona| daytona.snapshot.as_ref())
.and_then(|snapshot| snapshot.dockerfile.as_ref());
let layer = source
.parse::<SettingsLayer>()
.context("Failed to parse run config TOML")?;
let absolute_config_path = cwd.join(config_path.as_path());
let base_dir = absolute_config_path
.parent()
.unwrap_or_else(|| Path::new("."));
let Some(DaytonaDockerfileLayer::Path { path }) = dockerfile else {
for environment in layer.environments.values() {
collect_environment_dockerfile(
files,
base_dir,
cwd,
config_path,
environment.image.as_ref(),
)?;
}
if let Some(run_environment) = layer.run.as_ref().and_then(|run| run.environment.as_ref()) {
collect_environment_dockerfile(
files,
base_dir,
cwd,
config_path,
run_environment.image.as_ref(),
)?;
}
Ok(())
}
fn collect_environment_dockerfile(
files: &mut HashMap<String, types::ManifestFileEntry>,
base_dir: &Path,
cwd: &Path,
config_path: &ManifestPath,
image: Option<&EnvironmentImageLayer>,
) -> Result<()> {
let dockerfile = image.and_then(|image| image.dockerfile.as_ref());
let Some(EnvironmentDockerfileLayer::Path { path }) = dockerfile else {
return Ok(());
};
let absolute_config_path = cwd.join(config_path.as_path());
collect_bundled_file(
files,
absolute_config_path
.parent()
.unwrap_or_else(|| Path::new(".")),
base_dir,
cwd,
path,
types::ManifestFileRefType::Dockerfile,
@ -849,7 +875,7 @@ pub fn manifest_args_is_empty(args: &types::ManifestArgs) -> bool {
&& args.model.is_none()
&& args.preserve_sandbox.is_none()
&& args.provider.is_none()
&& args.sandbox.is_none()
&& args.environment.is_none()
&& args.docker_image.is_none()
&& args.input.is_empty()
&& args.verbose.is_none()
@ -865,7 +891,7 @@ mod tests {
goal: Some("ship it"),
model: Some("gpt-5.4-mini"),
provider: Some("openai"),
sandbox: Some("local"),
environment: Some("local"),
docker_image: None,
preserve_sandbox: Some(true),
dry_run: Some(true),
@ -900,10 +926,20 @@ mod tests {
"openai"
);
assert_eq!(
overrides.sandbox.as_ref().unwrap().provider.as_deref(),
overrides.environment.as_ref().unwrap().id.as_deref(),
Some("local")
);
assert_eq!(overrides.sandbox.as_ref().unwrap().preserve, Some(true));
assert_eq!(
overrides
.environment
.as_ref()
.unwrap()
.lifecycle
.as_ref()
.unwrap()
.preserve,
Some(true)
);
assert_eq!(
overrides.execution.as_ref().unwrap().mode,
Some(RunMode::DryRun)

View file

@ -1041,8 +1041,9 @@ mod runs {
use fabro_api::types::*;
use fabro_types::settings::run::{
DaytonaSettings, DaytonaSnapshotSettings, RunGoal, RunModelSettings, RunNamespace,
RunPrepareSettings, RunSandboxSettings,
EnvironmentImageSettings, EnvironmentLifecycleSettings, EnvironmentProvider,
EnvironmentResourcesSettings, EnvironmentSettings, RunEnvironmentSettings, RunGoal,
RunModelSettings, RunNamespace, RunPrepareSettings,
};
use fabro_types::settings::{InterpString, ProjectNamespace, WorkflowNamespace};
use fabro_types::{
@ -1714,12 +1715,33 @@ mod runs {
pub(super) fn settings() -> serde_json::Value {
let settings = WorkflowSettings {
project: ProjectNamespace::default(),
workflow: WorkflowNamespace {
project: ProjectNamespace::default(),
workflow: WorkflowNamespace {
graph: "workflow.fabro".into(),
..WorkflowNamespace::default()
},
run: RunNamespace {
environments: HashMap::from([("api-server".to_string(), EnvironmentSettings {
provider: EnvironmentProvider::Daytona,
image: EnvironmentImageSettings {
reference: Some("api-server-dev".into()),
dockerfile: None,
},
resources: EnvironmentResourcesSettings {
cpu: Some(4),
memory: Some(fabro_types::settings::Size::from_gigabytes(8)),
disk: Some(fabro_types::settings::Size::from_gigabytes(10)),
},
lifecycle: EnvironmentLifecycleSettings {
preserve: false,
stop_on_terminal: true,
auto_stop: Some(
"60m".parse().expect("hardcoded demo duration should parse"),
),
},
labels: HashMap::from([("project".to_string(), "api-server".to_string())]),
..EnvironmentSettings::default()
})]),
run: RunNamespace {
goal: Some(RunGoal::Inline(InterpString::parse(
"Add rate limiting to auth endpoints",
))),
@ -1733,30 +1755,30 @@ mod runs {
commands: vec!["bun install".into(), "bun run typecheck".into()],
timeout_ms: 120_000,
},
sandbox: RunSandboxSettings {
provider: "daytona".into(),
preserve: false,
stop_on_terminal: true,
devcontainer: false,
env: HashMap::new(),
docker: None,
daytona: Some(DaytonaSettings {
auto_stop_interval: Some(60),
labels: HashMap::from([(
"project".to_string(),
"api-server".to_string(),
)]),
volumes: Vec::new(),
snapshot: Some(DaytonaSnapshotSettings {
name: "api-server-dev".into(),
cpu: Some(4),
memory_gb: Some(8),
disk_gb: Some(10),
environment: RunEnvironmentSettings::from_environment(
"api-server".to_string(),
EnvironmentSettings {
provider: EnvironmentProvider::Daytona,
image: EnvironmentImageSettings {
reference: Some("api-server-dev".into()),
dockerfile: None,
}),
network: None,
}),
},
},
resources: EnvironmentResourcesSettings {
cpu: Some(4),
memory: Some(fabro_types::settings::Size::from_gigabytes(8)),
disk: Some(fabro_types::settings::Size::from_gigabytes(10)),
},
lifecycle: EnvironmentLifecycleSettings {
preserve: false,
stop_on_terminal: true,
auto_stop: Some(
"60m".parse().expect("hardcoded demo duration should parse"),
),
},
labels: HashMap::from([("project".to_string(), "api-server".to_string())]),
..EnvironmentSettings::default()
},
),
..RunNamespace::default()
},
};

View file

@ -7,10 +7,10 @@ use std::time::Duration;
use anyhow::{Context as _, Result, anyhow, bail};
use fabro_api::types;
use fabro_auth::auth_issue_message;
use fabro_config::run::parse_run_layer_from_settings_toml;
use fabro_config::{
CliLayer, CliOutputLayer, DaytonaDockerfileLayer, RunLayer, WorkflowSettingsBuilder,
parse_input_overrides, parse_labels,
CliLayer, CliOutputLayer, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer,
MergeMap, RunLayer, SettingsLayer, WorkflowSettingsBuilder, parse_input_overrides,
parse_labels,
};
use fabro_graphviz::graph::{Graph, is_llm_handler_type};
use fabro_graphviz::render::apply_direction;
@ -27,8 +27,8 @@ use fabro_static::EnvVars;
use fabro_types::settings::cli::OutputVerbosity;
use fabro_types::settings::interp::InterpString;
use fabro_types::settings::run::{
DaytonaNetworkLayer, DaytonaSettings, DockerSettings, DockerfileSource, RunGoal, RunMode,
RunNamespace,
DockerfileSource, EnvironmentNetworkMode, EnvironmentProvider, RunEnvironmentSettings, RunGoal,
RunMode, RunNamespace,
};
use fabro_types::{ManifestPath, RunId, WorkflowSettings};
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
@ -76,6 +76,18 @@ pub(crate) fn manifest_run_defaults(run: Option<&RunLayer>) -> RunLayer {
pub(crate) fn prepare_manifest(
manifest_run_defaults: &RunLayer,
manifest: &types::RunManifest,
) -> Result<PreparedManifest> {
prepare_manifest_with_environment_defaults(
manifest_run_defaults,
&MergeMap::default(),
manifest,
)
}
pub(crate) fn prepare_manifest_with_environment_defaults(
manifest_run_defaults: &RunLayer,
manifest_environment_defaults: &MergeMap<EnvironmentLayer>,
manifest: &types::RunManifest,
) -> Result<PreparedManifest> {
if manifest.version != 1 {
bail!("unsupported manifest version {}", manifest.version);
@ -93,8 +105,10 @@ pub(crate) fn prepare_manifest(
let args_overrides =
manifest_args_overrides(manifest.args.as_ref()).context("failed to parse manifest args")?;
let mut workflow_settings_builder =
WorkflowSettingsBuilder::new().server_run_defaults(manifest_run_defaults.clone());
let mut workflow_settings_builder = WorkflowSettingsBuilder::new().server_manifest_defaults(
manifest_run_defaults.clone(),
manifest_environment_defaults.clone(),
);
if let Some(run) = args_overrides.run {
workflow_settings_builder = workflow_settings_builder.run_overrides(run);
}
@ -102,9 +116,12 @@ pub(crate) fn prepare_manifest(
workflow_settings_builder = workflow_settings_builder.cli_overrides(cli);
}
if let Some(config) = workflow_input.config.as_ref() {
let workflow_run_layer = workflow_run_layer_with_resolved_dockerfile(&workflow_input)?;
workflow_settings_builder = workflow_settings_builder
.workflow_toml_with_run_layer(&config.source, workflow_run_layer)?;
let layer = settings_layer_with_resolved_dockerfiles(
&config.source,
&config.path,
&workflow_input.files,
)?;
workflow_settings_builder = workflow_settings_builder.workflow_layer(layer);
}
for config in manifest
.configs
@ -112,16 +129,13 @@ pub(crate) fn prepare_manifest(
.filter(|config| config.type_ == types::ManifestConfigType::Project)
{
if let Some(source) = config.source.as_deref() {
let mut run = parse_run_layer_from_settings_toml(source)
.context("Failed to parse project config TOML")?;
if run_has_path_dockerfile(&run) {
let config_path = manifest_project_config_path(config, &cwd)?;
resolve_manifest_dockerfile(&mut run, &config_path, &workflow_input.files)?;
workflow_settings_builder =
workflow_settings_builder.project_toml_with_run_layer(source, run)?;
} else {
workflow_settings_builder = workflow_settings_builder.project_toml(source)?;
}
let config_path = manifest_project_config_path(config, &cwd)?;
let layer = settings_layer_with_resolved_dockerfiles(
source,
&config_path,
&workflow_input.files,
)?;
workflow_settings_builder = workflow_settings_builder.project_layer(layer);
}
}
for config in manifest
@ -316,21 +330,19 @@ fn workflow_files_from_manifest(
Ok(bundled)
}
fn workflow_run_layer_with_resolved_dockerfile(workflow: &BundledWorkflow) -> Result<RunLayer> {
let config = workflow
.config
.as_ref()
.expect("workflow config should exist before resolving its run layer");
fn settings_layer_with_resolved_dockerfiles(
source: &str,
config_path: &ManifestPath,
files: &HashMap<ManifestPath, String>,
) -> Result<SettingsLayer> {
// Parse via `SettingsLayer` so unknown nested keys (like a stale
// `[server.integrations.github.permissions]` after the move to
// `[run.integrations.github.permissions]`) trip
// `deny_unknown_fields`. Other valid top-level domains
// (`_version`, `[workflow]`, `[server.*]`) parse cleanly and the
// builder ignores everything outside `[run]` for this code path.
let mut run = parse_run_layer_from_settings_toml(&config.source)
// `[run.integrations.github.permissions]`) trip `deny_unknown_fields`.
let mut layer = source
.parse::<SettingsLayer>()
.context("Failed to parse run config TOML")?;
resolve_manifest_dockerfile(&mut run, &config.path, &workflow.files)?;
Ok(run)
resolve_manifest_dockerfiles(&mut layer, config_path, files)?;
Ok(layer)
}
fn manifest_args_overrides(
@ -344,7 +356,7 @@ fn manifest_args_overrides(
goal: None,
model: args.model.as_deref(),
provider: args.provider.as_deref(),
sandbox: args.sandbox.as_deref(),
environment: args.environment.as_deref(),
docker_image: args.docker_image.as_deref(),
preserve_sandbox: args.preserve_sandbox,
dry_run: args.dry_run,
@ -401,21 +413,37 @@ fn process_env_var(name: &str) -> Option<String> {
std::env::var(name).ok()
}
fn resolve_manifest_dockerfile(
run: &mut RunLayer,
fn resolve_manifest_dockerfiles(
layer: &mut SettingsLayer,
config_path: &ManifestPath,
files: &HashMap<ManifestPath, String>,
) -> Result<()> {
let source = run
.sandbox
if let Some(image) = layer
.run
.as_mut()
.and_then(|sandbox| sandbox.daytona.as_mut())
.and_then(|daytona| daytona.snapshot.as_mut())
.and_then(|snapshot| snapshot.dockerfile.as_mut());
.and_then(|run| run.environment.as_mut())
.and_then(|environment| environment.image.as_mut())
{
resolve_manifest_dockerfile(image, config_path, files)?;
}
for environment in layer.environments.values_mut() {
if let Some(image) = environment.image.as_mut() {
resolve_manifest_dockerfile(image, config_path, files)?;
}
}
Ok(())
}
fn resolve_manifest_dockerfile(
image: &mut EnvironmentImageLayer,
config_path: &ManifestPath,
files: &HashMap<ManifestPath, String>,
) -> Result<()> {
let source = image.dockerfile.as_mut();
let Some(source) = source else {
return Ok(());
};
let DaytonaDockerfileLayer::Path { path } = &*source else {
let EnvironmentDockerfileLayer::Path { path } = &*source else {
return Ok(());
};
let path_owned = path.clone();
@ -425,21 +453,10 @@ fn resolve_manifest_dockerfile(
.get(&manifest_path)
.cloned()
.ok_or_else(|| anyhow!("missing bundled dockerfile: {manifest_path}"))?;
*source = DaytonaDockerfileLayer::Inline(content);
*source = EnvironmentDockerfileLayer::Inline(content);
Ok(())
}
fn run_has_path_dockerfile(run: &RunLayer) -> bool {
matches!(
run.sandbox
.as_ref()
.and_then(|sandbox| sandbox.daytona.as_ref())
.and_then(|daytona| daytona.snapshot.as_ref())
.and_then(|snapshot| snapshot.dockerfile.as_ref()),
Some(DaytonaDockerfileLayer::Path { .. })
)
}
fn manifest_project_config_path(
config: &types::ManifestConfig,
cwd: &Path,
@ -495,13 +512,14 @@ async fn build_preflight_report(
let resolved_run = materialized.run;
let server_settings = state.server_settings();
let github_integration = &server_settings.server.integrations.github;
let sandbox_provider = resolve_sandbox_provider(&resolved_run)?;
let sandbox_provider = resolve_sandbox_provider(&resolved_run);
let sandbox_provider =
if resolved_run.execution.mode == RunMode::DryRun && !sandbox_provider.is_local() {
SandboxProvider::Local
} else {
sandbox_provider
};
run_environment_capability_check(&mut checks, &resolved_run);
let needs_github_credentials =
sandbox_provider.is_clone_based() || resolved_run.integrations.github.is_token_requested();
let github_app = if needs_github_credentials {
@ -609,33 +627,18 @@ fn base_preflight_checks(prepared: &PreparedManifest, graph: &Graph) -> Vec<Chec
]
}
fn resolve_sandbox_provider(settings: &RunNamespace) -> Result<SandboxProvider> {
Ok(Some(str::parse::<SandboxProvider>(
settings.sandbox.provider.as_str(),
))
.transpose()
.context("Invalid sandbox provider")?
.unwrap_or_default())
fn resolve_sandbox_provider(settings: &RunNamespace) -> SandboxProvider {
SandboxProvider::from(settings.environment.provider)
}
fn resolve_daytona_config(settings: &RunNamespace) -> DaytonaConfig {
let mut config = settings
.sandbox
.daytona
.as_ref()
.map(|daytona| runtime_daytona_config(daytona, !settings.clone.enabled))
.unwrap_or_default();
let mut config = runtime_daytona_config(&settings.environment, !settings.clone.enabled);
config.skip_clone = !settings.clone.enabled;
config
}
fn resolve_docker_config(settings: &RunNamespace) -> DockerSandboxOptions {
let mut config = settings
.sandbox
.docker
.as_ref()
.map(|docker| runtime_docker_config(docker, !settings.clone.enabled))
.unwrap_or_default();
let mut config = runtime_docker_config(&settings.environment, !settings.clone.enabled);
config.skip_clone = !settings.clone.enabled;
config
}
@ -653,6 +656,66 @@ fn clone_disabled_for_provider(provider: SandboxProvider, resolved_run: &RunName
}
}
fn run_environment_capability_check(checks: &mut Vec<CheckResult>, resolved_run: &RunNamespace) {
let warnings = environment_capability_warnings(resolved_run);
if warnings.is_empty() {
return;
}
checks.push(CheckResult {
name: "Environment Capabilities".into(),
status: CheckStatus::Warning,
summary: format!("{} unsupported hint(s) ignored", warnings.len()),
details: warnings
.into_iter()
.map(|text| CheckDetail { text, warn: true })
.collect(),
remediation: None,
});
}
fn environment_capability_warnings(resolved_run: &RunNamespace) -> Vec<String> {
let environment = &resolved_run.environment;
let mut warnings = Vec::new();
match environment.provider {
EnvironmentProvider::Local => {
if environment.resources.cpu.is_some()
|| environment.resources.memory.is_some()
|| environment.resources.disk.is_some()
{
warnings.push("local provider ignores resource limits".to_string());
}
if !environment.volumes.is_empty() {
warnings.push("local provider ignores volume mounts".to_string());
}
if !environment.labels.is_empty() {
warnings.push("local provider ignores labels".to_string());
}
if environment.lifecycle.auto_stop.is_some() {
warnings.push("local provider ignores lifecycle.auto_stop".to_string());
}
}
EnvironmentProvider::Docker => {
if environment.resources.disk.is_some() {
warnings.push("docker provider ignores disk resource limits".to_string());
}
if !environment.volumes.is_empty() {
warnings.push("docker provider ignores volume mounts".to_string());
}
if !environment.labels.is_empty() {
warnings.push("docker provider ignores labels".to_string());
}
if environment.lifecycle.auto_stop.is_some() {
warnings.push("docker provider ignores lifecycle.auto_stop".to_string());
}
if environment.image.dockerfile.is_some() {
warnings.push("docker provider ignores image.dockerfile".to_string());
}
}
EnvironmentProvider::Daytona => {}
}
warnings
}
fn repository_access_details(request: &GitRemoteRefCheck) -> Vec<CheckDetail> {
let mut details = vec![CheckDetail::new(format!("Origin: {}", request.origin_url))];
if let Some(branch) = request.branch.as_ref() {
@ -1121,28 +1184,39 @@ fn resolve_model_provider(
}
}
fn runtime_daytona_config(settings: &DaytonaSettings, skip_clone: bool) -> DaytonaConfig {
fn runtime_daytona_config(settings: &RunEnvironmentSettings, skip_clone: bool) -> DaytonaConfig {
DaytonaConfig {
auto_stop_interval: settings.auto_stop_interval,
auto_stop_interval: settings
.lifecycle
.auto_stop
.map(|duration| duration_to_minutes_i32(duration.as_std())),
labels: (!settings.labels.is_empty()).then_some(settings.labels.clone()),
volumes: settings
.volumes
.iter()
.map(|volume| DaytonaVolumeMount {
volume_id: volume.volume_id.clone(),
volume_id: volume.id.clone(),
mount_path: volume.mount_path.clone(),
subpath: volume.subpath.clone(),
})
.collect(),
snapshot: settings
.snapshot
.image
.reference
.as_ref()
.map(|snapshot| DaytonaSnapshotSettings {
name: snapshot.name.clone(),
cpu: snapshot.cpu,
memory: snapshot.memory_gb,
disk: snapshot.disk_gb,
dockerfile: snapshot
.map(|name| DaytonaSnapshotSettings {
name: name.clone(),
cpu: settings.resources.cpu,
memory: settings
.resources
.memory
.map(|size| size_to_gb_i32(size.as_bytes())),
disk: settings
.resources
.disk
.map(|size| size_to_gb_i32(size.as_bytes())),
dockerfile: settings
.image
.dockerfile
.as_ref()
.map(|dockerfile| match dockerfile {
@ -1154,36 +1228,65 @@ fn runtime_daytona_config(settings: &DaytonaSettings, skip_clone: bool) -> Dayto
}
}),
}),
network: settings.network.as_ref().map(|network| match network {
DaytonaNetworkLayer::Block => DaytonaNetwork::Block,
DaytonaNetworkLayer::AllowAll => DaytonaNetwork::AllowAll,
DaytonaNetworkLayer::AllowList { allow_list } => {
DaytonaNetwork::AllowList(allow_list.clone())
network: Some(match settings.network.mode {
EnvironmentNetworkMode::Block => DaytonaNetwork::Block,
EnvironmentNetworkMode::AllowAll => DaytonaNetwork::AllowAll,
EnvironmentNetworkMode::CidrAllowList => {
DaytonaNetwork::AllowList(settings.network.allow.clone())
}
}),
skip_clone,
}
}
fn runtime_docker_config(settings: &DockerSettings, skip_clone: bool) -> DockerSandboxOptions {
fn runtime_docker_config(
settings: &RunEnvironmentSettings,
skip_clone: bool,
) -> DockerSandboxOptions {
let mut env_vars = settings
.env_vars
.env
.iter()
.map(|(key, value)| format!("{key}={}", resolve_interp(value)))
.collect::<Vec<_>>();
env_vars.sort();
let default_options = DockerSandboxOptions::default();
DockerSandboxOptions {
image: settings.image.clone(),
network_mode: settings.network_mode.clone(),
memory_limit: settings.memory_limit,
cpu_quota: settings.cpu_quota,
image: settings
.image
.reference
.clone()
.unwrap_or(default_options.image),
network_mode: match settings.network.mode {
EnvironmentNetworkMode::Block => Some("none".to_string()),
EnvironmentNetworkMode::AllowAll | EnvironmentNetworkMode::CidrAllowList => {
default_options.network_mode
}
},
memory_limit: settings
.resources
.memory
.and_then(|size| i64::try_from(size.as_bytes()).ok()),
cpu_quota: settings
.resources
.cpu
.map(|cpu| i64::from(cpu).saturating_mul(100_000)),
env_vars,
skip_clone,
..DockerSandboxOptions::default()
}
}
fn duration_to_minutes_i32(duration: Duration) -> i32 {
let minutes = duration.as_secs() / 60;
i32::try_from(minutes).unwrap_or(i32::MAX)
}
fn size_to_gb_i32(bytes: u64) -> i32 {
let gb = bytes / 1_000_000_000;
i32::try_from(gb).unwrap_or(i32::MAX)
}
async fn run_github_token_check(
checks: &mut Vec<CheckResult>,
prepared: &PreparedManifest,
@ -1464,7 +1567,10 @@ mod tests {
r#"
_version = 1
[run.sandbox]
[run.environment]
id = "selected"
[environments.selected]
provider = "{provider}"
[run.clone]
@ -1493,14 +1599,17 @@ enabled = {clone_enabled}
#[test]
fn runtime_daytona_config_preserves_volume_mounts() {
let settings = DaytonaSettings {
volumes: vec![fabro_types::settings::run::DaytonaVolumeSettings {
volume_id: "vol_auth".to_string(),
mount_path: "/home/daytona/.config".to_string(),
subpath: Some("agents".to_string()),
}],
..DaytonaSettings::default()
};
let settings = RunEnvironmentSettings::from_environment(
"cloud".to_string(),
fabro_types::settings::run::EnvironmentSettings {
volumes: vec![fabro_types::settings::run::EnvironmentVolumeSettings {
id: "vol_auth".to_string(),
mount_path: "/home/daytona/.config".to_string(),
subpath: Some("agents".to_string()),
}],
..fabro_types::settings::run::EnvironmentSettings::default()
},
);
let config = runtime_daytona_config(&settings, false);
@ -1509,7 +1618,6 @@ enabled = {clone_enabled}
assert_eq!(config.volumes[0].mount_path, "/home/daytona/.config");
assert_eq!(config.volumes[0].subpath.as_deref(), Some("agents"));
}
#[test]
fn prepare_manifest_inlines_project_config_daytona_dockerfile_from_bundle() {
let mut manifest = minimal_manifest();
@ -1518,11 +1626,14 @@ enabled = {clone_enabled}
source: Some(
r#"_version = 1
[run.sandbox]
[run.environment]
id = "cloud"
[environments.cloud]
provider = "daytona"
[run.sandbox.daytona.snapshot]
name = "fabro-test"
[environments.cloud.image]
ref = "fabro-test"
dockerfile = { path = "Dockerfile" }
"#
.to_string(),
@ -1552,11 +1663,10 @@ dockerfile = { path = "Dockerfile" }
let dockerfile = prepared
.settings
.run
.sandbox
.daytona
.environment
.image
.dockerfile
.as_ref()
.and_then(|daytona| daytona.snapshot.as_ref())
.and_then(|snapshot| snapshot.dockerfile.as_ref())
.expect("project Dockerfile should resolve");
match dockerfile {
DockerfileSource::Inline(value) => assert_eq!(value, "FROM ubuntu:24.04\n"),
@ -1574,8 +1684,14 @@ dockerfile = { path = "Dockerfile" }
source: Some(
r#"_version = 1
[run.sandbox.daytona.snapshot]
name = "fabro-test"
[run.environment]
id = "cloud"
[environments.cloud]
provider = "daytona"
[environments.cloud.image]
ref = "fabro-test"
dockerfile = { path = "Dockerfile" }
"#
.to_string(),
@ -1844,7 +1960,7 @@ root = "/srv/fabro"
model: None,
preserve_sandbox: None,
provider: None,
sandbox: None,
environment: None,
docker_image: None,
input: Vec::new(),
verbose: None,
@ -1877,7 +1993,7 @@ override = "server"
model: None,
preserve_sandbox: None,
provider: None,
sandbox: None,
environment: None,
docker_image: None,
input: vec!["override=cli".to_string()],
verbose: None,
@ -1972,8 +2088,8 @@ description = "Move the feature through review"
team = "platform"
priority = "high"
[run.sandbox]
provider = "local"
[run.environment]
id = "local"
"#
.to_string(),
});
@ -2114,8 +2230,8 @@ name = "Control Plane"
path: "workflow.toml".to_string(),
source: r#"_version = 1
[run.sandbox]
provider = "local"
[run.environment]
id = "local"
[run.integrations.github.permissions]
issues = "read"
@ -2163,8 +2279,8 @@ _version = 1
[run.pull_request]
enabled = true
[run.sandbox]
provider = "local"
[run.environment]
id = "local"
"#
.to_string(),
),
@ -2272,8 +2388,8 @@ name = "Project Config Name"
r#"
_version = 1
[run.sandbox]
provider = "daytona"
[run.environment]
id = "daytona"
"#
.to_string(),
),
@ -2492,8 +2608,8 @@ digraph Demo {
);
}
mod workflow_run_layer_with_resolved_dockerfile_tests {
//! `workflow_run_layer_with_resolved_dockerfile` parses bundled
mod settings_layer_with_resolved_dockerfiles_tests {
//! `settings_layer_with_resolved_dockerfiles` parses bundled
//! workflow.toml through the strict `SettingsLayer` schema, so
//! unknown fields anywhere in the document trip
//! `deny_unknown_fields`.
@ -2501,7 +2617,7 @@ digraph Demo {
use fabro_types::ManifestPath;
use fabro_workflow::workflow_bundle::{BundledWorkflow, ParsedWorkflowConfig};
use super::super::workflow_run_layer_with_resolved_dockerfile;
use super::super::settings_layer_with_resolved_dockerfiles;
fn workflow_with_config(source: &str) -> BundledWorkflow {
BundledWorkflow {
@ -2526,8 +2642,13 @@ issues = "read"
"#,
);
let run = workflow_run_layer_with_resolved_dockerfile(&workflow)
.expect("workflow.toml should parse");
let layer = settings_layer_with_resolved_dockerfiles(
&workflow.config.as_ref().unwrap().source,
&workflow.config.as_ref().unwrap().path,
&workflow.files,
)
.expect("workflow.toml should parse");
let run = layer.run.expect("run layer should be present");
let github = run
.integrations
.as_ref()
@ -2551,8 +2672,12 @@ issues = "read"
"#,
);
let err = workflow_run_layer_with_resolved_dockerfile(&workflow)
.expect_err("stale [server.integrations.github.permissions] should be rejected");
let err = settings_layer_with_resolved_dockerfiles(
&workflow.config.as_ref().unwrap().source,
&workflow.config.as_ref().unwrap().path,
&workflow.files,
)
.expect_err("stale [server.integrations.github.permissions] should be rejected");
let message = format!("{err:#}");
assert!(
message.contains("permissions") || message.contains("unknown field"),
@ -2573,8 +2698,13 @@ contents = "read"
"#,
);
let run = workflow_run_layer_with_resolved_dockerfile(&workflow)
.expect("workflow + run blocks should parse");
let layer = settings_layer_with_resolved_dockerfiles(
&workflow.config.as_ref().unwrap().source,
&workflow.config.as_ref().unwrap().path,
&workflow.files,
)
.expect("workflow + run blocks should parse");
let run = layer.run.expect("run layer should be present");
assert!(run.integrations.is_some());
}
}

View file

@ -62,7 +62,7 @@ pub fn run_tool_manifest_args(spec: &ValidatedCreateRunSpec) -> Option<types::Ma
model: spec.model.clone(),
preserve_sandbox: spec.preserve_sandbox.filter(|value| *value),
provider: spec.provider.clone(),
sandbox: spec.sandbox.clone(),
environment: spec.environment.clone(),
verbose: None,
};
(!fabro_manifest::manifest_args_is_empty(&payload)).then_some(payload)
@ -73,7 +73,7 @@ pub fn run_tool_run_overrides(spec: &ValidatedCreateRunSpec) -> Option<RunLayer>
goal: spec.goal.as_deref(),
model: spec.model.as_deref(),
provider: spec.provider.as_deref(),
sandbox: spec.sandbox.as_deref(),
environment: spec.environment.as_deref(),
docker_image: None,
preserve_sandbox: spec.preserve_sandbox,
dry_run: spec.dry_run,
@ -88,7 +88,7 @@ pub fn run_tool_run_overrides(spec: &ValidatedCreateRunSpec) -> Option<RunLayer>
(run.goal.is_some()
|| !run.metadata.is_empty()
|| run.model.is_some()
|| run.sandbox.is_some()
|| run.environment.is_some()
|| run.execution.is_some())
.then_some(run)
}
@ -118,7 +118,7 @@ mod tests {
labels: HashMap::new(),
model: None,
provider: None,
sandbox: None,
environment: None,
dry_run: None,
auto_approve: None,
preserve_sandbox: None,
@ -143,7 +143,7 @@ mod tests {
labels: HashMap::new(),
model: None,
provider: None,
sandbox: None,
environment: None,
dry_run: None,
auto_approve: None,
preserve_sandbox: None,

View file

@ -9,11 +9,10 @@ use clap::Args;
use fabro_config::bind::{self, Bind, BindRequest};
use fabro_config::user::active_settings_path;
use fabro_config::{
RunLayer, RunModelLayer, RunSandboxLayer, ServerLayer, ServerWebLayer, Storage,
RunEnvironmentLayer, RunLayer, RunModelLayer, ServerLayer, ServerWebLayer, Storage,
load_config_file, load_server_runtime_settings,
};
use fabro_install::{OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_SECRET_ACCESS_KEY_ENV};
use fabro_sandbox::SandboxProvider;
use fabro_static::EnvVars;
use fabro_types::ServerSettings;
use fabro_types::settings::server::{GithubIntegrationStrategy, LogDestination, WebhookStrategy};
@ -205,9 +204,9 @@ pub struct ServeArgs {
#[arg(long)]
pub provider: Option<String>,
/// Sandbox for agent tools
#[arg(long, value_enum)]
pub sandbox: Option<SandboxProvider>,
/// Named environment for agent tools
#[arg(long)]
pub environment: Option<String>,
/// Maximum number of concurrent run executions
#[arg(long)]
@ -240,9 +239,11 @@ fn serve_overrides(args: &ServeArgs) -> (Option<RunLayer>, Option<ServerLayer>)
let model_layer = run.model.get_or_insert_with(RunModelLayer::default);
model_layer.provider = Some(InterpString::parse(provider));
}
if let Some(sandbox) = args.sandbox {
let sandbox_layer = run.sandbox.get_or_insert_with(RunSandboxLayer::default);
sandbox_layer.provider = Some(sandbox.to_string());
if let Some(environment) = args.environment.as_ref() {
let environment_layer = run
.environment
.get_or_insert_with(RunEnvironmentLayer::default);
environment_layer.id = Some(environment.clone());
}
(
(run != RunLayer::default()).then_some(run),
@ -722,10 +723,11 @@ where
effective_log_destination,
);
let resolved_app_settings = ResolvedAppStateSettings {
server_settings: runtime_settings.server_settings,
manifest_run_defaults: runtime_settings.manifest_run_defaults,
manifest_run_settings: runtime_settings.manifest_run_settings,
llm_catalog_settings: runtime_settings.llm_catalog_settings,
server_settings: runtime_settings.server_settings,
manifest_run_defaults: runtime_settings.manifest_run_defaults,
manifest_environment_defaults: runtime_settings.manifest_environment_defaults,
manifest_run_settings: runtime_settings.manifest_run_settings,
llm_catalog_settings: runtime_settings.llm_catalog_settings,
};
let resolved_server_settings = resolved_app_settings.server_settings.server.clone();
let (auth_mode, server_secrets) = resolve_startup(
@ -893,10 +895,12 @@ where
.server_settings
.with_storage_override(&data_dir_for_poll);
ResolvedAppStateSettings {
server_settings: resolved.server_settings,
manifest_run_defaults: resolved.manifest_run_defaults,
manifest_run_settings: resolved.manifest_run_settings,
llm_catalog_settings: resolved.llm_catalog_settings,
server_settings: resolved.server_settings,
manifest_run_defaults: resolved.manifest_run_defaults,
manifest_environment_defaults: resolved
.manifest_environment_defaults,
manifest_run_settings: resolved.manifest_run_settings,
llm_catalog_settings: resolved.llm_catalog_settings,
}
});
match resolved {
@ -1276,6 +1280,7 @@ mod tests {
manifest_run_settings: RunSettingsBuilder::from_run_layer(&manifest_run_defaults)
.map_err(|err| fabro_util::error::SharedError::new(anyhow::Error::new(err))),
manifest_run_defaults,
manifest_environment_defaults: fabro_config::MergeMap::default(),
server_settings: server_settings(source),
llm_catalog_settings: fabro_model::catalog::LlmCatalogSettings::default(),
}
@ -1468,7 +1473,7 @@ destination = "file"
bind: None,
model: None,
provider: None,
sandbox: None,
environment: None,
web: true,
no_web: false,
max_concurrent_runs: None,
@ -1494,7 +1499,7 @@ destination = "file"
bind: None,
model: None,
provider: None,
sandbox: None,
environment: None,
web: false,
no_web: true,
max_concurrent_runs: None,

View file

@ -45,7 +45,7 @@ use fabro_auth::{CredentialSource, VaultCredentialSource, auth_issue_message};
#[cfg(test)]
use fabro_config::RunSettingsBuilder;
use fabro_config::daemon::ServerDaemon;
use fabro_config::{RunLayer, Storage};
use fabro_config::{EnvironmentLayer, MergeMap, RunLayer, Storage};
use fabro_interview::{
Answer, AnswerSubmission, ControlInterviewer, Interviewer, Question, WorkerControlEnvelope,
};
@ -623,6 +623,7 @@ pub struct AppState {
pub(super) server_secrets: ServerSecrets,
pub(crate) llm_source: Arc<dyn CredentialSource>,
manifest_run_defaults: RwLock<Arc<RunLayer>>,
manifest_environment_defaults: RwLock<Arc<MergeMap<EnvironmentLayer>>>,
manifest_run_settings: RwLock<std::result::Result<RunNamespace, SharedError>>,
pub(crate) server_settings: RwLock<Arc<ServerSettings>>,
catalog: RwLock<Arc<Catalog>>,
@ -734,10 +735,11 @@ pub(crate) struct AppStateConfig {
#[derive(Clone)]
pub(crate) struct ResolvedAppStateSettings {
pub(crate) server_settings: ServerSettings,
pub(crate) manifest_run_defaults: RunLayer,
pub(crate) manifest_run_settings: std::result::Result<RunNamespace, SharedError>,
pub(crate) llm_catalog_settings: LlmCatalogSettings,
pub(crate) server_settings: ServerSettings,
pub(crate) manifest_run_defaults: RunLayer,
pub(crate) manifest_environment_defaults: MergeMap<EnvironmentLayer>,
pub(crate) manifest_run_settings: std::result::Result<RunNamespace, SharedError>,
pub(crate) llm_catalog_settings: LlmCatalogSettings,
}
fn accumulate_billing_rollup(
@ -784,6 +786,15 @@ impl AppState {
)
}
pub(crate) fn manifest_environment_defaults(&self) -> Arc<MergeMap<EnvironmentLayer>> {
Arc::clone(
&self
.manifest_environment_defaults
.read()
.expect("manifest environment defaults lock poisoned"),
)
}
pub(crate) fn server_settings(&self) -> Arc<ServerSettings> {
Arc::clone(
&self
@ -1031,11 +1042,13 @@ impl AppState {
let ResolvedAppStateSettings {
server_settings,
manifest_run_defaults,
manifest_environment_defaults,
manifest_run_settings,
llm_catalog_settings,
} = resolved_settings;
let server_settings = Arc::new(server_settings);
let manifest_run_defaults = Arc::new(manifest_run_defaults);
let manifest_environment_defaults = Arc::new(manifest_environment_defaults);
let catalog = Arc::new(
Catalog::from_builtin_with_overrides(&llm_catalog_settings)
.context("building LLM model catalog")?,
@ -1047,6 +1060,10 @@ impl AppState {
.manifest_run_defaults
.write()
.expect("manifest run defaults lock poisoned") = manifest_run_defaults;
*self
.manifest_environment_defaults
.write()
.expect("manifest environment defaults lock poisoned") = manifest_environment_defaults;
*self
.manifest_run_settings
.write()
@ -1663,7 +1680,7 @@ fn system_sandbox_provider(
) -> String {
manifest_run_settings.as_ref().map_or_else(
|_| SandboxProvider::default().to_string(),
|settings| settings.sandbox.provider.clone(),
|settings| settings.environment.provider.to_string(),
)
}
@ -1766,6 +1783,8 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
let (global_event_tx, _) = broadcast::channel(4096);
let current_server_settings = Arc::new(resolved_settings.server_settings);
let current_manifest_run_defaults = Arc::new(resolved_settings.manifest_run_defaults);
let current_manifest_environment_defaults =
Arc::new(resolved_settings.manifest_environment_defaults);
let current_manifest_run_settings = resolved_settings.manifest_run_settings;
let current_catalog = Arc::new(
Catalog::from_builtin_with_overrides(&resolved_settings.llm_catalog_settings)
@ -1816,6 +1835,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
server_secrets,
llm_source,
manifest_run_defaults: RwLock::new(current_manifest_run_defaults),
manifest_environment_defaults: RwLock::new(current_manifest_environment_defaults),
manifest_run_settings: RwLock::new(current_manifest_run_settings),
server_settings: RwLock::new(current_server_settings),
catalog: RwLock::new(current_catalog),
@ -1956,7 +1976,13 @@ async fn delete_run_sandbox_resource(
})?;
}
let preserve = projection.spec().settings.run.sandbox.preserve;
let preserve = projection
.spec()
.settings
.run
.environment
.lifecycle
.preserve;
let Some(record) = projection.sandbox else {
return Ok(DeleteRunOutcome::NoContent);
};
@ -3221,7 +3247,7 @@ async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
let run_spec = persisted.run_spec();
let settings = &run_spec.settings.run;
let clone_can_use_github_credentials = settings.execution.mode != RunMode::DryRun
&& clone_sandbox_can_use_github_credentials(&settings.sandbox.provider)
&& clone_sandbox_can_use_github_credentials(&settings.environment.provider.to_string())
&& run_spec
.repo_origin_url()
.is_some_and(|origin| !origin.trim().is_empty());

View file

@ -41,11 +41,15 @@ async fn render_graph_from_manifest(
Json(req): Json<RenderWorkflowGraphRequest>,
) -> Response {
let manifest_run_defaults = state.manifest_run_defaults();
let prepared =
match run_manifest::prepare_manifest(manifest_run_defaults.as_ref(), &req.manifest) {
Ok(prepared) => prepared,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
let manifest_environment_defaults = state.manifest_environment_defaults();
let prepared = match run_manifest::prepare_manifest_with_environment_defaults(
manifest_run_defaults.as_ref(),
manifest_environment_defaults.as_ref(),
&req.manifest,
) {
Ok(prepared) => prepared,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
let validated = match run_manifest::validate_prepared_manifest(&prepared, state.catalog()) {
Ok(validated) => validated,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),

View file

@ -547,7 +547,12 @@ async fn create_run(
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
let manifest_run_defaults = state.manifest_run_defaults();
let prepared = match run_manifest::prepare_manifest(manifest_run_defaults.as_ref(), &req) {
let manifest_environment_defaults = state.manifest_environment_defaults();
let prepared = match run_manifest::prepare_manifest_with_environment_defaults(
manifest_run_defaults.as_ref(),
manifest_environment_defaults.as_ref(),
&req,
) {
Ok(prepared) => prepared,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
@ -676,7 +681,12 @@ async fn run_preflight(
Json(req): Json<RunManifest>,
) -> Response {
let manifest_run_defaults = state.manifest_run_defaults();
let prepared = match run_manifest::prepare_manifest(manifest_run_defaults.as_ref(), &req) {
let manifest_environment_defaults = state.manifest_environment_defaults();
let prepared = match run_manifest::prepare_manifest_with_environment_defaults(
manifest_run_defaults.as_ref(),
manifest_environment_defaults.as_ref(),
&req,
) {
Ok(prepared) => prepared,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
@ -704,7 +714,12 @@ async fn validate_run_manifest(
Json(req): Json<RunManifest>,
) -> Response {
let manifest_run_defaults = state.manifest_run_defaults();
let prepared = match run_manifest::prepare_manifest(manifest_run_defaults.as_ref(), &req) {
let manifest_environment_defaults = state.manifest_environment_defaults();
let prepared = match run_manifest::prepare_manifest_with_environment_defaults(
manifest_run_defaults.as_ref(),
manifest_environment_defaults.as_ref(),
&req,
) {
Ok(prepared) => prepared,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};

View file

@ -855,8 +855,8 @@ methods = ["dev-token"]
[server.web]
url = "http://new.example.com"
[run.sandbox]
provider = "invalid-provider"
[run.environment]
id = "missing"
"#;
state
@ -875,8 +875,8 @@ fn system_sandbox_provider_uses_manifest_defaults() {
let source = r#"
_version = 1
[run.sandbox]
provider = "daytona"
[run.environment]
id = "daytona"
"#;
let manifest_run_settings = resolve_manifest_run_settings(
&run_manifest::manifest_run_defaults(Some(&manifest_run_defaults_from_toml(source))),
@ -890,8 +890,8 @@ fn system_sandbox_provider_defaults_when_manifest_run_settings_do_not_resolve()
let source = r#"
_version = 1
[run.sandbox]
provider = "invalid-provider"
[run.environment]
id = "missing"
"#;
let manifest_run_settings = resolve_manifest_run_settings(
&run_manifest::manifest_run_defaults(Some(&manifest_run_defaults_from_toml(source))),
@ -9022,7 +9022,7 @@ async fn delete_run_with_preserved_sandbox_returns_handoff() {
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = RunId::new();
let mut settings = fabro_types::WorkflowSettings::default();
settings.run.sandbox.preserve = true;
settings.run.environment.lifecycle.preserve = true;
let graph = Graph::new("test");
create_durable_run_with_events(&state, run_id, &[
@ -9488,8 +9488,8 @@ mode = "dry_run"
provider = "anthropic"
name = "claude-sonnet-4-5"
[run.sandbox]
provider = "local"
[run.environment]
id = "local"
[[run.hooks]]
name = "snapshot-hook"
@ -10095,8 +10095,8 @@ script = "sleep 5"
[run.prepare]
timeout = "30s"
[run.sandbox]
provider = "local"
[run.environment]
id = "local"
"#;
let state = test_app_state_with_settings_and_registry_factory(
server_settings_from_toml(source),

View file

@ -269,10 +269,12 @@ pub(crate) fn resolved_runtime_settings_for_tests(
manifest_run_defaults: RunLayer,
llm_catalog_settings: LlmCatalogSettings,
) -> ResolvedAppStateSettings {
let manifest_environment_defaults = fabro_config::MergeMap::default();
ResolvedAppStateSettings {
manifest_run_settings: RunSettingsBuilder::from_run_layer(&manifest_run_defaults)
.map_err(|err| SharedError::new(anyhow::Error::new(err))),
manifest_run_defaults,
manifest_environment_defaults,
server_settings,
llm_catalog_settings,
}

View file

@ -85,7 +85,7 @@ async fn spawn_served_listener(
no_web: true,
model: None,
provider: None,
sandbox: None,
environment: None,
max_concurrent_runs: None,
config: Some(config_path),
#[cfg(debug_assertions)]

View file

@ -4,7 +4,7 @@ use std::time::Duration;
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode};
use fabro_config::{RunLayer, RunSandboxLayer, ServerSettingsBuilder};
use fabro_config::{RunEnvironmentLayer, RunLayer, ServerSettingsBuilder};
use fabro_server::server::{AppState, spawn_scheduler};
use fabro_server::test_support::{
TestAppStateBuilder, build_test_router, llm_catalog_settings_with_provider_base_url,
@ -94,9 +94,9 @@ pub(crate) fn test_app_state_with_options(
pub(crate) fn test_settings() -> TestAppSettings {
TestAppSettings {
manifest_run_defaults: RunLayer {
sandbox: Some(RunSandboxLayer {
provider: Some("local".to_string()),
..RunSandboxLayer::default()
environment: Some(RunEnvironmentLayer {
id: Some("default".to_string()),
..RunEnvironmentLayer::default()
}),
..RunLayer::default()
},

View file

@ -7,7 +7,7 @@ use fabro_types::run_event::{
RunFailedProps, StageCompletedProps, StagePromptProps, TodoCreatedProps, TodoDeletedProps,
TodoUpdatedProps,
};
use fabro_types::settings::run::RunSandboxSettings;
use fabro_types::settings::run::{EnvironmentProvider, RunEnvironmentSettings};
use fabro_types::{
AgentBackend, AskFabro, BilledModelUsage, Checkpoint, CheckpointRecord, CommandTermination,
Conclusion, EventBody, FailureSignature, InterviewQuestionRecord, Outcome,
@ -535,27 +535,21 @@ fn projection_from_created(event: &EventEnvelope) -> Result<RunProjection> {
let mut projection = RunProjection::new(title, spec, stored.ts);
projection.parent_id = props.parent_id;
projection.web_url.clone_from(&props.web_url);
projection.sandbox = Some(planned_sandbox(&projection.spec.settings.run.sandbox));
projection.sandbox = Some(planned_sandbox(&projection.spec.settings.run.environment));
Ok(projection)
}
fn planned_sandbox(settings: &RunSandboxSettings) -> RunSandbox {
let provider = settings
.provider
.parse::<SandboxProvider>()
.unwrap_or(SandboxProvider::Local);
fn planned_sandbox(settings: &RunEnvironmentSettings) -> RunSandbox {
let provider = SandboxProvider::from(settings.provider);
RunSandbox {
provider,
image: settings
.docker
.as_ref()
.map(|docker| docker.image.clone())
image: (settings.provider == EnvironmentProvider::Docker)
.then(|| settings.image.reference.clone())
.flatten()
.filter(|image| !image.is_empty()),
snapshot: settings
.daytona
.as_ref()
.and_then(|daytona| daytona.snapshot.as_ref())
.map(|snapshot| snapshot.name.clone()),
snapshot: (settings.provider == EnvironmentProvider::Daytona)
.then(|| settings.image.reference.clone())
.flatten(),
runtime: None,
}
}

View file

@ -166,12 +166,12 @@ impl JsonSchema for CreateRunSpecInput {
],
"description": "Provider override for the run."
},
"sandbox": {
"environment": {
"anyOf": [
{ "type": "string" },
{ "type": "null" }
],
"description": "Sandbox provider override for the run."
"description": "Named environment slug override for the run."
},
"preserve_sandbox": {
"anyOf": [
@ -210,7 +210,7 @@ pub struct CreateRunSpec {
pub auto_approve: Option<bool>,
pub model: Option<String>,
pub provider: Option<String>,
pub sandbox: Option<String>,
pub environment: Option<String>,
pub preserve_sandbox: Option<bool>,
pub start: Option<bool>,
}
@ -272,7 +272,7 @@ pub struct ValidatedCreateRunSpec {
pub auto_approve: Option<bool>,
pub model: Option<String>,
pub provider: Option<String>,
pub sandbox: Option<String>,
pub environment: Option<String>,
pub preserve_sandbox: Option<bool>,
pub start: Option<bool>,
}
@ -314,7 +314,7 @@ impl TryFrom<CreateRunSpecInput> for ValidatedCreateRunSpec {
auto_approve: None,
model: None,
provider: None,
sandbox: None,
environment: None,
preserve_sandbox: None,
start: None,
})
@ -378,7 +378,7 @@ impl TryFrom<CreateRunSpec> for ValidatedCreateRunSpec {
auto_approve: spec.auto_approve,
model: spec.model,
provider: spec.provider,
sandbox: spec.sandbox,
environment: spec.environment,
preserve_sandbox: spec.preserve_sandbox,
start: spec.start,
})
@ -547,7 +547,7 @@ mod tests {
auto_approve: None,
model: None,
provider: None,
sandbox: None,
environment: None,
preserve_sandbox: None,
start: None,
})
@ -692,7 +692,7 @@ mod tests {
auto_approve: Some(true),
model: None,
provider: None,
sandbox: None,
environment: None,
preserve_sandbox: None,
start: Some(false),
}
@ -742,7 +742,7 @@ mod tests {
auto_approve: Some(true),
model: None,
provider: None,
sandbox: None,
environment: None,
preserve_sandbox: None,
start: Some(false),
})
@ -791,7 +791,7 @@ mod tests {
auto_approve: Some(true),
model: None,
provider: None,
sandbox: None,
environment: None,
preserve_sandbox: None,
start: Some(false),
}

View file

@ -4,8 +4,8 @@ use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::settings::{
CliNamespace, InterpString, ObjectStoreSettings, ProjectNamespace, RunNamespace,
ServerNamespace, WorkflowNamespace,
CliNamespace, EnvironmentSettings, InterpString, ObjectStoreSettings, ProjectNamespace,
RunNamespace, ServerNamespace, WorkflowNamespace,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -47,9 +47,10 @@ pub struct UserSettings {
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct WorkflowSettings {
pub project: ProjectNamespace,
pub workflow: WorkflowNamespace,
pub run: RunNamespace,
pub project: ProjectNamespace,
pub workflow: WorkflowNamespace,
pub environments: HashMap<String, EnvironmentSettings>,
pub run: RunNamespace,
}
impl WorkflowSettings {

View file

@ -34,12 +34,14 @@ pub use public_url::{
is_wildcard_host, replace_wildcard_host, validate_public_url, validate_public_url_with_label,
};
pub use run::{
ArtifactsSettings, DaytonaSettings, DaytonaSnapshotSettings, DockerSettings, DockerfileSource,
ArtifactsSettings, DockerfileSource, EnvironmentImageSettings, EnvironmentLifecycleSettings,
EnvironmentNetworkMode, EnvironmentNetworkSettings, EnvironmentProvider,
EnvironmentResourcesSettings, EnvironmentSettings, EnvironmentVolumeSettings,
GitAuthorSettings, HookDefinition, HookType, InterviewProviderSettings, McpServerSettings,
McpTransport, NotificationProviderSettings, NotificationRouteSettings, PullRequestSettings,
RunAgentSettings, RunCheckpointSettings, RunExecutionSettings, RunGitSettings, RunGoal,
RunIntegrationsGithubSettings, RunIntegrationsSettings, RunInterviewsSettings,
RunModelControls, RunModelSettings, RunNamespace, RunPrepareSettings, RunSandboxSettings,
RunAgentSettings, RunCheckpointSettings, RunEnvironmentSettings, RunExecutionSettings,
RunGitSettings, RunGoal, RunIntegrationsGithubSettings, RunIntegrationsSettings,
RunInterviewsSettings, RunModelControls, RunModelSettings, RunNamespace, RunPrepareSettings,
RunScmSettings, ScmGitHubSettings, TlsMode,
};
pub use server::{

View file

@ -13,8 +13,10 @@ use std::time::Duration as StdDuration;
use serde::ser::SerializeStruct;
use serde::{Deserialize, Serialize};
use super::duration::Duration;
use super::interp::InterpString;
use super::model_ref::ModelRef;
use super::size::Size;
/// A structurally resolved `[run]` view for consumers.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -31,7 +33,7 @@ pub struct RunNamespace {
pub clone: RunCloneSettings,
pub run_branch: RunBranchSettings,
pub meta_branch: RunMetaBranchSettings,
pub sandbox: RunSandboxSettings,
pub environment: RunEnvironmentSettings,
pub notifications: HashMap<String, NotificationRouteSettings>,
pub interviews: RunInterviewsSettings,
pub agent: RunAgentSettings,
@ -61,7 +63,7 @@ impl Default for RunNamespace {
clone: RunCloneSettings::default(),
run_branch: RunBranchSettings::default(),
meta_branch: RunMetaBranchSettings::default(),
sandbox: RunSandboxSettings::default(),
environment: RunEnvironmentSettings::default(),
notifications: HashMap::new(),
interviews: RunInterviewsSettings::default(),
agent: RunAgentSettings::default(),
@ -287,62 +289,193 @@ impl Default for RunMetaBranchSettings {
}
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Default,
Serialize,
Deserialize,
strum::Display,
strum::EnumString,
strum::IntoStaticStr,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
pub enum EnvironmentProvider {
#[default]
Local,
Docker,
Daytona,
}
impl EnvironmentProvider {
#[must_use]
pub fn is_local(self) -> bool {
matches!(self, Self::Local)
}
#[must_use]
pub fn is_clone_based(self) -> bool {
matches!(self, Self::Docker | Self::Daytona)
}
}
impl From<EnvironmentProvider> for crate::SandboxProvider {
fn from(value: EnvironmentProvider) -> Self {
match value {
EnvironmentProvider::Local => Self::Local,
EnvironmentProvider::Docker => Self::Docker,
EnvironmentProvider::Daytona => Self::Daytona,
}
}
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Default,
Serialize,
Deserialize,
strum::Display,
strum::EnumString,
strum::IntoStaticStr,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
pub enum EnvironmentNetworkMode {
#[default]
AllowAll,
Block,
CidrAllowList,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct EnvironmentImageSettings {
#[serde(rename = "ref")]
pub reference: Option<String>,
pub dockerfile: Option<DockerfileSource>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct EnvironmentResourcesSettings {
pub cpu: Option<i32>,
pub memory: Option<Size>,
pub disk: Option<Size>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunSandboxSettings {
pub provider: String,
pub struct EnvironmentNetworkSettings {
pub mode: EnvironmentNetworkMode,
pub allow: Vec<String>,
}
impl Default for EnvironmentNetworkSettings {
fn default() -> Self {
Self {
mode: EnvironmentNetworkMode::AllowAll,
allow: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EnvironmentLifecycleSettings {
pub preserve: bool,
#[serde(default = "default_stop_on_terminal")]
pub stop_on_terminal: bool,
pub devcontainer: bool,
pub env: HashMap<String, InterpString>,
pub docker: Option<DockerSettings>,
pub daytona: Option<DaytonaSettings>,
pub auto_stop: Option<Duration>,
}
fn default_stop_on_terminal() -> bool {
true
}
impl Default for RunSandboxSettings {
impl Default for EnvironmentLifecycleSettings {
fn default() -> Self {
Self {
provider: "local".to_string(),
preserve: false,
stop_on_terminal: true,
devcontainer: false,
env: HashMap::new(),
docker: None,
daytona: None,
auto_stop: None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct DockerSettings {
pub image: String,
pub network_mode: Option<String>,
pub memory_limit: Option<i64>,
pub cpu_quota: Option<i64>,
pub env_vars: HashMap<String, InterpString>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct DaytonaSettings {
pub auto_stop_interval: Option<i32>,
pub labels: HashMap<String, String>,
#[serde(default)]
pub volumes: Vec<DaytonaVolumeSettings>,
pub snapshot: Option<DaytonaSnapshotSettings>,
pub network: Option<DaytonaNetworkLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct DaytonaVolumeSettings {
pub volume_id: String,
pub struct EnvironmentVolumeSettings {
pub id: String,
pub mount_path: String,
pub subpath: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EnvironmentSettings {
pub provider: EnvironmentProvider,
pub image: EnvironmentImageSettings,
pub resources: EnvironmentResourcesSettings,
pub network: EnvironmentNetworkSettings,
pub lifecycle: EnvironmentLifecycleSettings,
pub labels: HashMap<String, String>,
pub volumes: Vec<EnvironmentVolumeSettings>,
pub env: HashMap<String, InterpString>,
}
impl Default for EnvironmentSettings {
fn default() -> Self {
Self {
provider: EnvironmentProvider::Local,
image: EnvironmentImageSettings::default(),
resources: EnvironmentResourcesSettings::default(),
network: EnvironmentNetworkSettings::default(),
lifecycle: EnvironmentLifecycleSettings::default(),
labels: HashMap::new(),
volumes: Vec::new(),
env: HashMap::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunEnvironmentSettings {
pub id: String,
pub provider: EnvironmentProvider,
pub image: EnvironmentImageSettings,
pub resources: EnvironmentResourcesSettings,
pub network: EnvironmentNetworkSettings,
pub lifecycle: EnvironmentLifecycleSettings,
pub labels: HashMap<String, String>,
pub volumes: Vec<EnvironmentVolumeSettings>,
pub env: HashMap<String, InterpString>,
}
impl RunEnvironmentSettings {
#[must_use]
pub fn from_environment(id: String, environment: EnvironmentSettings) -> Self {
Self {
id,
provider: environment.provider,
image: environment.image,
resources: environment.resources,
network: environment.network,
lifecycle: environment.lifecycle,
labels: environment.labels,
volumes: environment.volumes,
env: environment.env,
}
}
}
impl Default for RunEnvironmentSettings {
fn default() -> Self {
Self::from_environment("default".to_string(), EnvironmentSettings::default())
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum DockerfileSource {
Inline(String),
@ -388,15 +521,6 @@ impl<'de> Deserialize<'de> for DockerfileSource {
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DaytonaSnapshotSettings {
pub name: String,
pub cpu: Option<i32>,
pub memory_gb: Option<i32>,
pub disk_gb: Option<i32>,
pub dockerfile: Option<DockerfileSource>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct NotificationRouteSettings {
pub enabled: bool,
@ -707,14 +831,6 @@ pub enum ApprovalMode {
Auto,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum DaytonaNetworkLayer {
Block,
AllowAll,
AllowList { allow_list: Vec<String> },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AgentPermissions {

View file

@ -11,9 +11,7 @@ use std::sync::Arc;
use fabro_config::Storage;
use fabro_graphviz::graph::{AttrValue, Graph};
use fabro_model::{Catalog, ProviderId};
use fabro_sandbox::SandboxProvider;
use fabro_store::Database;
use fabro_types::settings::run::{RunMode, RunNamespace};
use fabro_types::{
ForkSourceRef, GitContext, ManifestPath, RunId, RunProvenance, WorkflowSettings,
};
@ -91,9 +89,6 @@ pub async fn create(
cwd: request.cwd,
})
.map_err(|err| Error::Parse(err.to_string()))?;
if resolved.settings.run.execution.mode != RunMode::DryRun {
validate_sandbox_provider(&resolved.settings.run)?;
}
let labels = resolved.settings.combined_labels();
let settings = resolved.settings.clone();
@ -277,15 +272,6 @@ fn store_error(err: impl std::fmt::Display) -> Error {
Error::engine(err.to_string())
}
fn validate_sandbox_provider(run: &RunNamespace) -> Result<(), Error> {
run.sandbox
.provider
.parse::<SandboxProvider>()
.map_err(|err| Error::Precondition(format!("Invalid sandbox provider: {err}")))?;
Ok(())
}
fn create_from_source(
dot_source: &str,
options: PersistCreateOptions,
@ -1130,53 +1116,6 @@ mod tests {
}
}
#[tokio::test]
async fn create_reports_workflow_settings_errors_with_rendered_message() {
let dir = tempfile::tempdir().unwrap();
let storage_root = dir.path().join("storage");
let store = memory_store();
let err = create(
&store,
CreateRunInput {
workflow: WorkflowInput::DotSource {
source: MINIMAL_DOT.to_string(),
base_dir: None,
},
settings: {
let mut settings = WorkflowSettings::default();
settings.run.execution.mode = RunMode::Normal;
settings.run.sandbox.provider = "not-a-provider".to_string();
settings
},
cwd: dir.path().to_path_buf(),
workflow_slug: None,
workflow_path: None,
workflow_bundle: None,
submitted_manifest_bytes: None,
run_id: None,
title: None,
git: None,
fork_source_ref: None,
parent_id: None,
provenance: None,
configured_providers: Vec::new(),
web_url: None,
},
storage_root,
test_catalog(),
)
.await
.unwrap_err();
match err {
Error::Precondition(message) => {
assert!(message.contains("Invalid sandbox provider"));
assert!(!message.contains('\n'));
}
other => panic!("expected Precondition, got {other:?}"),
}
}
#[tokio::test]
async fn create_persists_normalized_config_and_initial_state() {
let dir = tempfile::tempdir().unwrap();

View file

@ -16,12 +16,12 @@ use fabro_sandbox::daytona::DaytonaConfig;
use fabro_sandbox::{DockerSandboxOptions, SandboxProvider, SandboxSpec};
use fabro_static::EnvVars;
use fabro_types::settings::run::{
ApprovalMode, DaytonaNetworkLayer, DaytonaSettings, DockerSettings,
DockerfileSource as ResolvedDockerfileSource, HookDefinition as ResolvedHookDefinition,
HookEvent as ResolvedHookEvent, HookType as ResolvedHookType,
McpServerSettings as ResolvedMcpServerSettings, McpTransport as ResolvedMcpTransport,
PullRequestSettings, RunMode, RunModelSettings as ResolvedRunModelSettings,
RunNamespace as ResolvedRunSettings, TlsMode as ResolvedTlsMode,
ApprovalMode, DockerfileSource as ResolvedDockerfileSource, EnvironmentNetworkMode,
HookDefinition as ResolvedHookDefinition, HookEvent as ResolvedHookEvent,
HookType as ResolvedHookType, McpServerSettings as ResolvedMcpServerSettings,
McpTransport as ResolvedMcpTransport, PullRequestSettings, RunEnvironmentSettings, RunMode,
RunModelSettings as ResolvedRunModelSettings, RunNamespace as ResolvedRunSettings,
TlsMode as ResolvedTlsMode,
};
use fabro_types::settings::{InterpString, ModelRegistry, ResolvedModelRef};
use fabro_types::{ManifestPath, RunId};
@ -316,7 +316,7 @@ impl RunSession {
let resolved = &settings.run;
let sandbox_provider = resolve_sandbox_provider(resolved)?;
let sandbox_provider = resolve_sandbox_provider(resolved);
let sandbox_provider =
if resolved.execution.mode == RunMode::DryRun && !sandbox_provider.is_local() {
SandboxProvider::Local
@ -366,7 +366,7 @@ impl RunSession {
};
let toml_env: HashMap<String, String> = resolved
.sandbox
.environment
.env
.iter()
.map(|(k, v)| (k.clone(), resolve_interp(v)))
@ -380,10 +380,7 @@ impl RunSession {
origin_url: record.repo_origin_url().map(str::to_string),
};
let devcontainer = resolved.sandbox.devcontainer.then(|| DevcontainerSpec {
enabled: true,
resolve_dir: working_directory.clone(),
});
let devcontainer = None;
let interviewer: Arc<dyn Interviewer> = if resolved.execution.approval == ApprovalMode::Auto
{
@ -427,8 +424,8 @@ impl RunSession {
git,
github_app: services.github_app.clone(),
registry_override: services.registry_override,
preserve_sandbox: resolved.sandbox.preserve,
stop_on_terminal: resolved.sandbox.stop_on_terminal,
preserve_sandbox: resolved.environment.lifecycle.preserve,
stop_on_terminal: resolved.environment.lifecycle.stop_on_terminal,
pr_config,
pr_github_app: services.github_app,
pr_origin_url: record.repo_origin_url().map(str::to_string),
@ -515,33 +512,18 @@ async fn load_accepted_run_definition(
serde_json::from_slice(&bytes).map_err(|err| Error::Parse(err.to_string()))
}
fn resolve_sandbox_provider(settings: &ResolvedRunSettings) -> Result<SandboxProvider, Error> {
Some(str::parse::<SandboxProvider>(
settings.sandbox.provider.as_str(),
))
.transpose()
.map_err(|err| Error::Precondition(format!("Invalid sandbox provider: {err}")))?
.map_or_else(|| Ok(SandboxProvider::default()), Ok)
fn resolve_sandbox_provider(settings: &ResolvedRunSettings) -> SandboxProvider {
SandboxProvider::from(settings.environment.provider)
}
fn resolve_daytona_config(settings: &ResolvedRunSettings) -> DaytonaConfig {
let mut config = settings
.sandbox
.daytona
.as_ref()
.map(|daytona| runtime_daytona_config(daytona, !settings.clone.enabled))
.unwrap_or_default();
let mut config = runtime_daytona_config(&settings.environment, !settings.clone.enabled);
config.skip_clone = !settings.clone.enabled;
config
}
fn resolve_docker_config(settings: &ResolvedRunSettings) -> DockerSandboxOptions {
let mut config = settings
.sandbox
.docker
.as_ref()
.map(|docker| runtime_docker_config(docker, !settings.clone.enabled))
.unwrap_or_default();
let mut config = runtime_docker_config(&settings.environment, !settings.clone.enabled);
config.skip_clone = !settings.clone.enabled;
config
}
@ -676,28 +658,39 @@ fn runtime_mcp_server(settings: &ResolvedMcpServerSettings) -> McpServerSettings
}
}
fn runtime_daytona_config(settings: &DaytonaSettings, skip_clone: bool) -> DaytonaConfig {
fn runtime_daytona_config(settings: &RunEnvironmentSettings, skip_clone: bool) -> DaytonaConfig {
DaytonaConfig {
auto_stop_interval: settings.auto_stop_interval,
auto_stop_interval: settings
.lifecycle
.auto_stop
.map(|duration| duration_to_minutes_i32(duration.as_std())),
labels: (!settings.labels.is_empty()).then_some(settings.labels.clone()),
volumes: settings
.volumes
.iter()
.map(|volume| DaytonaVolumeMount {
volume_id: volume.volume_id.clone(),
volume_id: volume.id.clone(),
mount_path: volume.mount_path.clone(),
subpath: volume.subpath.clone(),
})
.collect(),
snapshot: settings
.snapshot
.image
.reference
.as_ref()
.map(|snapshot| DaytonaSnapshotSettings {
name: snapshot.name.clone(),
cpu: snapshot.cpu,
memory: snapshot.memory_gb,
disk: snapshot.disk_gb,
dockerfile: snapshot
.map(|name| DaytonaSnapshotSettings {
name: name.clone(),
cpu: settings.resources.cpu,
memory: settings
.resources
.memory
.map(|size| size_to_gb_i32(size.as_bytes())),
disk: settings
.resources
.disk
.map(|size| size_to_gb_i32(size.as_bytes())),
dockerfile: settings
.image
.dockerfile
.as_ref()
.map(|dockerfile| match dockerfile {
@ -709,36 +702,65 @@ fn runtime_daytona_config(settings: &DaytonaSettings, skip_clone: bool) -> Dayto
}
}),
}),
network: settings.network.as_ref().map(|network| match network {
DaytonaNetworkLayer::Block => DaytonaNetwork::Block,
DaytonaNetworkLayer::AllowAll => DaytonaNetwork::AllowAll,
DaytonaNetworkLayer::AllowList { allow_list } => {
DaytonaNetwork::AllowList(allow_list.clone())
network: Some(match settings.network.mode {
EnvironmentNetworkMode::Block => DaytonaNetwork::Block,
EnvironmentNetworkMode::AllowAll => DaytonaNetwork::AllowAll,
EnvironmentNetworkMode::CidrAllowList => {
DaytonaNetwork::AllowList(settings.network.allow.clone())
}
}),
skip_clone,
}
}
fn runtime_docker_config(settings: &DockerSettings, skip_clone: bool) -> DockerSandboxOptions {
fn runtime_docker_config(
settings: &RunEnvironmentSettings,
skip_clone: bool,
) -> DockerSandboxOptions {
let mut env_vars = settings
.env_vars
.env
.iter()
.map(|(key, value)| format!("{key}={}", resolve_interp(value)))
.collect::<Vec<_>>();
env_vars.sort();
let default_options = DockerSandboxOptions::default();
DockerSandboxOptions {
image: settings.image.clone(),
network_mode: settings.network_mode.clone(),
memory_limit: settings.memory_limit,
cpu_quota: settings.cpu_quota,
image: settings
.image
.reference
.clone()
.unwrap_or(default_options.image),
network_mode: match settings.network.mode {
EnvironmentNetworkMode::Block => Some("none".to_string()),
EnvironmentNetworkMode::AllowAll | EnvironmentNetworkMode::CidrAllowList => {
default_options.network_mode
}
},
memory_limit: settings
.resources
.memory
.and_then(|size| i64::try_from(size.as_bytes()).ok()),
cpu_quota: settings
.resources
.cpu
.map(|cpu| i64::from(cpu).saturating_mul(100_000)),
env_vars,
skip_clone,
..DockerSandboxOptions::default()
}
}
fn duration_to_minutes_i32(duration: Duration) -> i32 {
let minutes = duration.as_secs() / 60;
i32::try_from(minutes).unwrap_or(i32::MAX)
}
fn size_to_gb_i32(bytes: u64) -> i32 {
let gb = bytes / 1_000_000_000;
i32::try_from(gb).unwrap_or(i32::MAX)
}
fn runtime_hook_definition(definition: &ResolvedHookDefinition) -> fabro_hooks::HookDefinition {
fabro_hooks::HookDefinition {
name: definition.name.clone(),
@ -1135,12 +1157,13 @@ mod tests {
use chrono::Utc;
use fabro_config::{
DaytonaSandboxLayer, DaytonaVolumeLayer, RunCloneLayer, RunExecutionLayer, RunLayer,
RunSandboxLayer, WorkflowSettingsBuilder,
EnvironmentImageLayer, EnvironmentNetworkLayer, EnvironmentResourcesLayer,
EnvironmentVolumeLayer, RunCloneLayer, RunEnvironmentLayer, RunExecutionLayer, RunLayer,
StickyMap, WorkflowSettingsBuilder,
};
use fabro_store::Database;
use fabro_types::settings::ModelRef;
use fabro_types::settings::run::RunMode;
use fabro_types::settings::{InterpString, ModelRef};
use fabro_types::{ManifestPath, WorkflowSettings, fixtures};
use object_store::memory::InMemory;
@ -1284,19 +1307,51 @@ reasoning = false
assert!(resolve_daytona_config(&settings.run).skip_clone);
}
#[test]
fn runtime_docker_config_maps_environment_hints() {
let settings = settings_from_run_layer(RunLayer {
environment: Some(RunEnvironmentLayer {
image: Some(EnvironmentImageLayer {
reference: Some("ubuntu:24.04".to_string()),
..EnvironmentImageLayer::default()
}),
resources: Some(EnvironmentResourcesLayer {
cpu: Some(4),
memory: Some("2GB".parse().unwrap()),
disk: None,
}),
network: Some(EnvironmentNetworkLayer {
mode: Some("block".to_string()),
allow: Vec::new(),
}),
env: StickyMap::from(HashMap::from([(
"NODE_ENV".to_string(),
InterpString::parse("test"),
)])),
..RunEnvironmentLayer::default()
}),
..RunLayer::default()
});
let config = resolve_docker_config(&settings.run);
assert_eq!(config.image, "ubuntu:24.04");
assert_eq!(config.cpu_quota, Some(400_000));
assert_eq!(config.memory_limit, Some(2_000_000_000));
assert_eq!(config.network_mode.as_deref(), Some("none"));
assert_eq!(config.env_vars, vec!["NODE_ENV=test"]);
}
#[test]
fn runtime_daytona_config_preserves_volume_mounts() {
let settings = settings_from_run_layer(RunLayer {
sandbox: Some(RunSandboxLayer {
daytona: Some(DaytonaSandboxLayer {
volumes: Some(vec![DaytonaVolumeLayer {
volume_id: "vol_auth".to_string(),
mount_path: "/home/daytona/.config".to_string(),
subpath: Some("agents".to_string()),
}]),
..DaytonaSandboxLayer::default()
}),
..RunSandboxLayer::default()
environment: Some(RunEnvironmentLayer {
volumes: Some(vec![EnvironmentVolumeLayer {
id: "vol_auth".to_string(),
mount_path: "/home/daytona/.config".to_string(),
subpath: Some("agents".to_string()),
}]),
..RunEnvironmentLayer::default()
}),
..RunLayer::default()
});

View file

@ -67,11 +67,6 @@ models/create-completion-request.ts
models/create-run-pull-request-request.ts
models/create-run-session-request.ts
models/create-secret-request.ts
models/daytona-network-layer-one-of-allow-list.ts
models/daytona-network-layer-one-of.ts
models/daytona-network-layer.ts
models/daytona-settings.ts
models/daytona-snapshot-settings.ts
models/delete-run-response.ts
models/delete-run-sandbox.ts
models/delete-secret-request.ts
@ -90,10 +85,17 @@ models/dirty-status.ts
models/disk-usage-response.ts
models/disk-usage-run-row.ts
models/disk-usage-summary-row.ts
models/docker-settings.ts
models/dockerfile-source-inline.ts
models/dockerfile-source-path.ts
models/dockerfile-source.ts
models/environment-image-settings.ts
models/environment-lifecycle-settings.ts
models/environment-network-mode.ts
models/environment-network-settings.ts
models/environment-provider.ts
models/environment-resources-settings.ts
models/environment-settings.ts
models/environment-volume-settings.ts
models/error-response-entry.ts
models/error-response.ts
models/event-envelope.ts
@ -280,6 +282,7 @@ models/run-commit.ts
models/run-commits-meta.ts
models/run-control-action.ts
models/run-diff.ts
models/run-environment-settings.ts
models/run-error.ts
models/run-event-detail-response-content.ts
models/run-event-detail-response-event.ts
@ -311,7 +314,6 @@ models/run-provenance.ts
models/run-question.ts
models/run-reference.ts
models/run-sandbox-runtime.ts
models/run-sandbox-settings.ts
models/run-sandbox.ts
models/run-scm-settings.ts
models/run-server-provenance.ts
@ -410,6 +412,10 @@ models/system-resources-response.ts
models/system-run-counts.ts
models/timeline-entry-response.ts
models/tls-mode.ts
models/todo-list-kind.ts
models/todo-list-projection.ts
models/todo-projection.ts
models/todo-status.ts
models/update-run-parent-request.ts
models/update-run-request.ts
models/user-response.ts
@ -425,4 +431,4 @@ models/workflow-ref.ts
models/workflow-reference.ts
models/workflow-schedule-summary.ts
models/workflow-settings.ts
models/write-blob-response.ts
models/write-blob-response.ts

View file

@ -0,0 +1,23 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { DockerfileSource } from './dockerfile-source';
export interface EnvironmentImageSettings {
'ref': string | null;
'dockerfile': DockerfileSource | null;
}

View file

@ -0,0 +1,21 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface EnvironmentLifecycleSettings {
'preserve': boolean;
'stop_on_terminal': boolean;
'auto_stop': string | null;
}

View file

@ -0,0 +1,24 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export const EnvironmentNetworkMode = {
ALLOW_ALL: 'allow_all',
BLOCK: 'block',
CIDR_ALLOW_LIST: 'cidr_allow_list'
} as const;
export type EnvironmentNetworkMode = typeof EnvironmentNetworkMode[keyof typeof EnvironmentNetworkMode];

View file

@ -0,0 +1,23 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentNetworkMode } from './environment-network-mode';
export interface EnvironmentNetworkSettings {
'mode': EnvironmentNetworkMode;
'allow': Array<string>;
}

View file

@ -0,0 +1,27 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Desired environment provider.
*/
export const EnvironmentProvider = {
LOCAL: 'local',
DOCKER: 'docker',
DAYTONA: 'daytona'
} as const;
export type EnvironmentProvider = typeof EnvironmentProvider[keyof typeof EnvironmentProvider];

View file

@ -0,0 +1,21 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface EnvironmentResourcesSettings {
'cpu': number | null;
'memory': string | null;
'disk': string | null;
}

View file

@ -0,0 +1,44 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentImageSettings } from './environment-image-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentLifecycleSettings } from './environment-lifecycle-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentNetworkSettings } from './environment-network-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentProvider } from './environment-provider';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentResourcesSettings } from './environment-resources-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentVolumeSettings } from './environment-volume-settings';
export interface EnvironmentSettings {
'provider': EnvironmentProvider;
'image': EnvironmentImageSettings;
'resources': EnvironmentResourcesSettings;
'network': EnvironmentNetworkSettings;
'lifecycle': EnvironmentLifecycleSettings;
'labels': { [key: string]: string; };
'volumes': Array<EnvironmentVolumeSettings>;
'env': { [key: string]: string; };
}

View file

@ -0,0 +1,21 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface EnvironmentVolumeSettings {
'id': string;
'mount_path': string;
'subpath': string | null;
}

View file

@ -44,11 +44,6 @@ export * from './create-completion-request';
export * from './create-run-pull-request-request';
export * from './create-run-session-request';
export * from './create-secret-request';
export * from './daytona-network-layer';
export * from './daytona-network-layer-one-of';
export * from './daytona-network-layer-one-of-allow-list';
export * from './daytona-settings';
export * from './daytona-snapshot-settings';
export * from './delete-run-response';
export * from './delete-run-sandbox';
export * from './delete-secret-request';
@ -67,10 +62,17 @@ export * from './dirty-status';
export * from './disk-usage-response';
export * from './disk-usage-run-row';
export * from './disk-usage-summary-row';
export * from './docker-settings';
export * from './dockerfile-source';
export * from './dockerfile-source-inline';
export * from './dockerfile-source-path';
export * from './environment-image-settings';
export * from './environment-lifecycle-settings';
export * from './environment-network-mode';
export * from './environment-network-settings';
export * from './environment-provider';
export * from './environment-resources-settings';
export * from './environment-settings';
export * from './environment-volume-settings';
export * from './error-response';
export * from './error-response-entry';
export * from './event-envelope';
@ -257,6 +259,7 @@ export * from './run-commit-person';
export * from './run-commits-meta';
export * from './run-control-action';
export * from './run-diff';
export * from './run-environment-settings';
export * from './run-error';
export * from './run-event';
export * from './run-event-detail-response';
@ -289,7 +292,6 @@ export * from './run-question';
export * from './run-reference';
export * from './run-sandbox';
export * from './run-sandbox-runtime';
export * from './run-sandbox-settings';
export * from './run-scm-settings';
export * from './run-server-provenance';
export * from './run-spec';
@ -405,4 +407,4 @@ export * from './workflow-ref';
export * from './workflow-reference';
export * from './workflow-schedule-summary';
export * from './workflow-settings';
export * from './write-blob-response';
export * from './write-blob-response';

View file

@ -20,9 +20,12 @@
export interface ManifestArgs {
'model'?: string;
'provider'?: string;
'sandbox'?: string;
/**
* Per-run Docker sandbox image override.
* Named environment slug to select for the run.
*/
'environment'?: string;
/**
* Per-run environment image override.
*/
'docker_image'?: string;
'verbose'?: boolean;

View file

@ -18,8 +18,6 @@ export interface RunCheckpointSettings {
'exclude_globs': Array<string>;
/**
* When true, Fabro-managed run-branch checkpoint commits bypass local Git commit hooks. Does not affect Fabro `[[run.hooks]]` or metadata-branch snapshots. Defaults to false.
* @type {boolean}
* @memberof RunCheckpointSettings
*/
'skip_git_hooks': boolean;
}

View file

@ -0,0 +1,45 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentImageSettings } from './environment-image-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentLifecycleSettings } from './environment-lifecycle-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentNetworkSettings } from './environment-network-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentProvider } from './environment-provider';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentResourcesSettings } from './environment-resources-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentVolumeSettings } from './environment-volume-settings';
export interface RunEnvironmentSettings {
'id': string;
'provider': EnvironmentProvider;
'image': EnvironmentImageSettings;
'resources': EnvironmentResourcesSettings;
'network': EnvironmentNetworkSettings;
'lifecycle': EnvironmentLifecycleSettings;
'labels': { [key: string]: string; };
'volumes': Array<EnvironmentVolumeSettings>;
'env': { [key: string]: string; };
}

View file

@ -39,6 +39,9 @@ import type { RunCheckpointSettings } from './run-checkpoint-settings';
import type { RunCloneSettings } from './run-clone-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { RunEnvironmentSettings } from './run-environment-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { RunExecutionSettings } from './run-execution-settings';
// May contain unused imports in some cases
// @ts-ignore
@ -63,9 +66,6 @@ import type { RunModelSettings } from './run-model-settings';
import type { RunPrepareSettings } from './run-prepare-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { RunSandboxSettings } from './run-sandbox-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { RunScmSettings } from './run-scm-settings';
export interface RunNamespace {
@ -84,7 +84,7 @@ export interface RunNamespace {
'clone': RunCloneSettings;
'run_branch': RunBranchSettings;
'meta_branch': RunMetaBranchSettings;
'sandbox': RunSandboxSettings;
'environment': RunEnvironmentSettings;
'notifications': { [key: string]: NotificationRouteSettings; };
'interviews': RunInterviewsSettings;
'agent': RunAgentSettings;

View file

@ -87,4 +87,4 @@ export interface RunProjection {
* Map from StageId (`node_id@visit`) to stage projection data.
*/
'stages': { [key: string]: StageProjection; };
}
}

View file

@ -13,6 +13,9 @@
*/
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentSettings } from './environment-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { ProjectNamespace } from './project-namespace';
@ -29,5 +32,6 @@ import type { WorkflowNamespace } from './workflow-namespace';
export interface WorkflowSettings {
'project': ProjectNamespace;
'workflow': WorkflowNamespace;
'environments': { [key: string]: EnvironmentSettings; };
'run': RunNamespace;
}