Remove SSH and Exe sandbox providers

Only three sandbox providers remain: Local, Docker, and Daytona.

- Move detect_clone_params and GitCloneParams from ssh_common into daytona module
- Delete ssh/, exe/, and ssh_common.rs implementation files
- Remove Exe/Ssh variants from SandboxProvider, SandboxSpec, CliSandboxProvider
- Remove data_host from Sandbox trait and SandboxRecord
- Remove ExeSettings, SshSettings, ExeConfig, SshConfig types
- Remove ssh/exe/exedev feature flags from all Cargo.toml files
- Remove openssh workspace dependency
- Remove ExeSettings/SshSettings from OpenAPI spec
- Update docs to remove SSH/Exe references, delete exe-dev.mdx

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-30 12:17:20 -04:00
parent 4d05c2a2a3
commit 3936f185cf
46 changed files with 55 additions and 4017 deletions

View file

@ -54,8 +54,6 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
- **fabro-workflows** — Core workflow engine. Parses Graphviz graphs, runs stages, manages checkpoints/resume, hooks, retros, and human-in-the-loop interactions
- **fabro-agent** — AI coding agent with tool use (Bash, Read, Write, Edit, Glob, Grep, WebFetch). `Sandbox` trait abstracts execution environments
- **fabro-server** — Axum HTTP server. Routes for runs, sessions, models, completions, usage. SSE event streaming. Demo mode via header
- **fabro-exe** — SSH-based sandbox implementation (`ExeSandbox`)
- **fabro-sprites** — Sprites VM sandbox implementation via `sprite` CLI
- **fabro-llm** — Unified LLM client with providers: Anthropic, OpenAI, Gemini, OpenAI-compatible, plus retry/middleware/streaming
- **fabro-api-types** — Auto-generated Rust types from OpenAPI spec (build.rs + typify)
- **fabro-github** — GitHub App auth (JWT signing, installation tokens, PR creation)
@ -72,7 +70,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
- **lib/packages/fabro-api-client** — Auto-generated TypeScript Axios client from OpenAPI spec
### Key design patterns
- **Sandbox trait** — Uniform interface for local, Docker, SSH (ExeSandbox), Sprites, and Daytona execution environments
- **Sandbox trait** — Uniform interface for local, Docker, and Daytona execution environments
- **Graphviz graph workflows** — Stages and transitions defined as Graphviz graph attributes
- **OpenAPI-first**`fabro-api.yaml` drives both Rust type generation (typify) and TypeScript client generation (openapi-generator)
- **Checkpoint/resume** — Workflows can be paused, checkpointed, and resumed
@ -86,7 +84,7 @@ When working on Rust crates, read the relevant strategy doc **before** making ch
## Shell quoting in sandbox code
When interpolating values into shell command strings (in `fabro-exe` and `fabro-workflows`), always use the `shell_quote()` helper (backed by `shlex::try_quote`). Never use manual `replace('\'', "'\\''")` or unquoted interpolation. This applies to file paths, branch names, URLs, env vars, image names, glob patterns, and any other user-controlled input assembled into a shell script.
When interpolating values into shell command strings (in `fabro-workflows`), always use the `shell_quote()` helper (backed by `shlex::try_quote`). Never use manual `replace('\'', "'\\''")` or unquoted interpolation. This applies to file paths, branch names, URLs, env vars, image names, glob patterns, and any other user-controlled input assembled into a shell script.
## Rust import style

15
Cargo.lock generated
View file

@ -1771,7 +1771,6 @@ dependencies = [
"git2",
"glob",
"libc",
"openssh",
"rand 0.8.5",
"serde",
"serde_json",
@ -4026,20 +4025,6 @@ dependencies = [
"serde_json",
]
[[package]]
name = "openssh"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d534c4bfecb0ed71dea4db444a5922a294d15cf40e700548f27295e1feb0ef18"
dependencies = [
"libc",
"once_cell",
"shell-escape",
"tempfile",
"thiserror 2.0.18",
"tokio",
]
[[package]]
name = "openssl"
version = "0.10.75"

View file

@ -58,7 +58,6 @@ insta = "1"
fabro-test = { path = "lib/crates/fabro-test" }
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
futures-util = "0.3"
openssh = "0.11"
daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "06033ca", package = "daytona-sdk" }
daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "06033ca", package = "daytona-api-client" }
sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] }

View file

@ -5,11 +5,11 @@ description: "Sandboxing workflow execution"
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 on the host and providing a reproducible environment for each run.
Fabro supports six sandbox providers: `local` (no isolation), `docker` (container-level), `daytona` (cloud VM), `ssh` (any SSH host), `exe` (cloud VM via [exe.dev](https://exe.dev)), and `sprites` (persistent VM via [Sprites](https://sprites.dev), in progress). See [Environments](/execution/environments) for full provider-specific configuration.
Fabro supports three sandbox providers: `local` (no isolation), `docker` (container-level), and `daytona` (cloud VM). See [Environments](/execution/environments) for full provider-specific configuration.
## Network access control
For cloud sandboxes (Daytona), you can control outbound network access with the `network` field in `[sandbox.daytona]`. Three modes are available: `"allow_all"` (default), `"block"`, and `{ allow_list = ["..."] }` for CIDR-based egress filtering. The exe.dev provider does not currently support network access controls.
For cloud sandboxes (Daytona), you can control outbound network access with the `network` field in `[sandbox.daytona]`. Three modes are available: `"allow_all"` (default), `"block"`, and `{ allow_list = ["..."] }` for CIDR-based egress filtering.
Server defaults in `server.toml` apply when a run config doesn't specify `network`. Individual run configs can override the server default.

View file

@ -4072,10 +4072,6 @@ components:
description: Whether to use a devcontainer for the sandbox.
daytona:
$ref: "#/components/schemas/DaytonaSettings"
exe:
$ref: "#/components/schemas/ExeSettings"
ssh:
$ref: "#/components/schemas/SshSettings"
local:
$ref: "#/components/schemas/LocalSandboxSettings"
env:
@ -4094,31 +4090,6 @@ components:
enum: [always, clean, dirty, never]
default: clean
ExeSettings:
description: exe.dev sandbox configuration.
type: object
properties:
image:
type: string
description: VM image to use for the exe.dev sandbox.
SshSettings:
description: SSH sandbox configuration for user-provided hosts.
type: object
required:
- destination
- working_directory
properties:
destination:
type: string
description: SSH destination (e.g. user@host or an SSH alias).
working_directory:
type: string
description: Remote working directory.
config_file:
type: string
description: Optional path to a custom SSH config file.
DaytonaSettings:
description: Daytona-specific sandbox settings.
type: object

View file

@ -94,7 +94,6 @@
"integrations/github",
"integrations/daytona",
"integrations/slack",
"integrations/exe-dev",
"integrations/sprites",
"integrations/brave-search"
]

View file

@ -5,16 +5,13 @@ description: "Sandbox providers for workflow execution"
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 supports six 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.
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.
| 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 |
| `ssh` | Any SSH host | Existing remote machines, dev servers, NAS boxes | Available |
| `exe` | Cloud VM ([exe.dev](https://exe.dev)) | Fast ephemeral VMs, lightweight cloud sandboxing | Experimental |
| `sprites` | Cloud VM | Managed cloud sandboxes | In Development |
## Choosing a provider
@ -25,8 +22,6 @@ Set the sandbox provider via CLI flag, [run config TOML](/execution/run-configur
fabro run workflow.fabro --sandbox local
fabro run workflow.fabro --sandbox docker
fabro run workflow.fabro --sandbox daytona
fabro run workflow.fabro --sandbox ssh
fabro run workflow.fabro --sandbox exe
```
```toml title="run.toml"
@ -53,7 +48,7 @@ The local sandbox runs all tool operations directly on the host machine. It's th
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`, `daytona`, or `exe` when running untrusted workflows or when you need a reproducible environment.
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
@ -194,123 +189,6 @@ The `auto_stop_interval` setting (in minutes) tells Daytona to stop the sandbox
auto_stop_interval = 30
```
## SSH
The SSH sandbox runs all tool operations on an existing remote machine over SSH. Unlike cloud providers (Daytona, Exe), there is no VM lifecycle management — the host must already be running and reachable. This makes it ideal for persistent dev servers, NAS boxes, or any machine you SSH into regularly.
### Prerequisites
- SSH access to the target machine (key-based auth recommended)
- The remote working directory must exist, or the agent must be able to create it
- A GitHub App configured via `fabro install` (for private repository cloning)
### How it works
- **No lifecycle management** — Fabro does not create or destroy the remote host. It connects, runs operations, and disconnects. `cleanup()` is a no-op.
- **Working directory** — Set explicitly in the run config. Relative paths for all file operations resolve against this directory.
- **Git clone** — Fabro detects the local `origin` remote URL and current branch, converts SSH URLs to HTTPS, and clones into the working directory. For private repositories, Fabro uses a GitHub App Installation Access Token. Public repositories are cloned without credentials. If no git repo is detected, the working directory is used as-is.
- **Commands** — Executed via SSH. Commands are base64-encoded and piped through `sh` to support pipes, environment variables, and other shell features.
- **File I/O** — Reads use `cat` over SSH. Writes upload content via SCP. Parent directories are created automatically.
### Configuration
```toml title="run.toml"
[sandbox]
provider = "ssh"
[sandbox.ssh]
destination = "user@myserver"
working_directory = "/home/user/workspace"
```
| Field | Required | Description |
|---|---|---|
| `destination` | Yes | SSH destination — `user@host`, a hostname, or an SSH alias from `~/.ssh/config`. |
| `working_directory` | Yes | Absolute path to the working directory on the remote host. |
| `config_file` | No | Path to a custom SSH config file. Useful when using a non-default key or jump host. |
| `preview_url_base` | No | Base URL for port previews (e.g. `"http://myserver"`). When set, preview URLs are returned as `{preview_url_base}:{port}` instead of falling back to `localhost`. |
### SSH config file
Use `config_file` to point Fabro at a non-default SSH config — useful when the remote host requires a specific key, ProxyJump, or port:
```toml title="run.toml"
[sandbox.ssh]
destination = "devbox"
working_directory = "/home/user/projects/myapp"
config_file = "/home/user/.ssh/fabro_config"
```
```ssh-config title="~/.ssh/fabro_config"
Host devbox
HostName 192.168.1.42
User alice
IdentityFile ~/.ssh/id_ed25519_devbox
Port 2222
```
### Port previews
When a workflow stage starts a local server on a port, Fabro calls `get_preview_url(port)` to produce a clickable URL. For SSH sandboxes, this requires knowing the host's reachable address — Fabro can't infer it automatically.
Set `preview_url_base` to enable preview URLs:
```toml title="run.toml"
[sandbox.ssh]
destination = "alice@devbox"
working_directory = "/home/alice/projects/myapp"
preview_url_base = "http://devbox"
```
With this config, port 3000 on the remote host yields `http://devbox:3000`.
## Exe
<Warning>
The exe.dev sandbox provider is **under development** and requires building Fabro with the `exedev` feature flag. The API and configuration may change.
</Warning>
The Exe sandbox runs all tool operations inside a cloud VM managed by [exe.dev](https://exe.dev). It provides full machine-level isolation with fast VM startup via SSH.
### Prerequisites
- SSH keys configured for `exe.dev` (added via the exe.dev dashboard)
- A GitHub App configured via `fabro install` (for private repository cloning)
### How it works
- **VM lifecycle** — On `initialize()`, Fabro connects to the exe.dev management plane via SSH and runs `new --json` to create a VM. The response contains the VM name and an SSH destination (e.g. `my-vm.exe.xyz`). On `cleanup()`, Fabro runs `rm <vm_name>` to destroy the VM. Cancelling a run cleanly tears down the VM.
- **Working directory** — Fixed at `/home/exedev`. Relative paths resolve against this directory.
- **Git clone** — Fabro detects the local `origin` remote URL and current branch, converts SSH URLs to HTTPS, and clones into the VM. For private repositories, Fabro uses a GitHub App Installation Access Token. Public repositories are cloned without credentials. If no git repo is detected, the working directory is created empty.
- **Commands** — Executed via SSH on the data plane (`<vmname>.exe.xyz`). Commands are base64-encoded and piped through `sh` to handle pipes, environment variables, and other shell features. Environment variables and working directory overrides are supported.
- **File I/O** — Reads use `cat` over SSH. Writes use SCP upload. Parent directories are created automatically.
- **Git checkpointing** — Between stages, Fabro commits agent changes and pushes them to a remote branch, the same mechanism used by Daytona sandboxes.
- **Ephemeral** — Each run gets a fresh VM that is destroyed on cleanup.
### Configuration
```toml title="run.toml"
[sandbox]
provider = "exe"
[sandbox.exe]
image = "my-custom-image:latest"
```
| Field | Description |
|---|---|
| `image` | Custom container image for the VM. Optional — uses the exe.dev default when omitted. |
### SSH access
Connect to a running exe.dev sandbox via SSH for live debugging:
```bash
fabro sandbox ssh <run-id>
```
This prints the SSH connection command so you can connect to the VM while the workflow runs.
## 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.
@ -324,10 +202,8 @@ Each provider offers a different level of filesystem isolation:
| `local` | None | The entire host filesystem. Agents can read and modify any file. |
| `docker` | Container-level | Only the bind-mounted working directory (`/workspace` by default) and whatever is in the container image. Host files outside the mount are inaccessible. |
| `daytona` | Full machine | A cloud VM with the repository cloned into `/home/daytona/workspace`. The host filesystem is completely inaccessible. |
| `ssh` | Remote host | The remote machine's filesystem, rooted at the configured working directory. No isolation from other users or processes on that host. |
| `exe` | Full machine | A cloud VM with the repository cloned into `/home/exedev`. 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`, `daytona`, or `exe` when running untrusted workflows.
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
@ -338,8 +214,6 @@ Each provider handles outbound network access differently:
| `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. |
| `ssh` | Remote host's access | No network controls. Agents have the same outbound access as the remote host. |
| `exe` | Full access | No network isolation controls. VMs have full outbound access. |
For Daytona, network access is configured in the `[sandbox.daytona]` section:

View file

@ -153,7 +153,7 @@ preserve = true
| Field | Description |
|---|---|
| `provider` | Sandbox mode: `local` (default), `docker`, `daytona`, `ssh`, or `exe`. |
| `provider` | Sandbox mode: `local` (default), `docker`, 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). |
@ -203,44 +203,6 @@ worktree_mode = "always"
|---|---|
| `worktree_mode` | When to create a git worktree for the run: `always`, `clean` (default — only when the working tree is clean), `dirty` (also when dirty), or `never`. |
#### `[sandbox.ssh]`
Additional settings when using the SSH sandbox:
```toml title="run.toml"
[sandbox]
provider = "ssh"
[sandbox.ssh]
destination = "user@myserver"
working_directory = "/home/user/workspace"
```
| Field | Required | Description |
|---|---|---|
| `destination` | Yes | SSH destination — `user@host`, a hostname, or an SSH alias from `~/.ssh/config`. |
| `working_directory` | Yes | Absolute path to the working directory on the remote host. |
| `config_file` | No | Path to a custom SSH config file (e.g. for non-default keys or jump hosts). |
| `preview_url_base` | No | Base URL for port previews (e.g. `"http://myserver"`). When set, preview URLs are `{preview_url_base}:{port}` instead of `localhost`. |
See [SSH sandbox](/execution/environments#ssh) for full details.
#### `[sandbox.exe]`
Additional settings when using the [exe.dev](/integrations/exe-dev) cloud sandbox:
```toml title="run.toml"
[sandbox]
provider = "exe"
[sandbox.exe]
image = "my-custom-image:latest"
```
| Field | Description |
|---|---|
| `image` | Custom container image for the exe.dev VM. Optional — uses the exe.dev default when omitted. |
#### `[sandbox.env]`
Pass environment variables into sandbox command and agent execution. Values can be literal strings or host environment passthrough using `${env.VARNAME}` syntax:

View file

@ -1,54 +0,0 @@
---
title: "exe.dev (preview)"
description: "Run Fabro workflows in fast ephemeral VMs from exe.dev"
---
<Warning>
The exe.dev sandbox provider is **under development**. It requires building Fabro with the `exedev` feature flag enabled. The API and configuration may change.
</Warning>
[exe.dev](https://exe.dev) provides fast ephemeral cloud VMs for Fabro workflows. Each run gets a fresh VM with full machine-level isolation, connected via SSH.
## How it works
- Each run creates a new VM via the exe.dev management plane over SSH
- Fabro clones the current git repository into the VM automatically (using GitHub App credentials for private repos)
- Agent tool calls (shell commands, file edits, grep, glob) execute over SSH inside the VM
- Git checkpointing commits agent changes to a remote branch between stages
- Cancelling a run cleanly tears down the VM
- VMs are destroyed automatically when the run finishes
## Configuration
```bash
fabro run workflow.fabro --sandbox exe
```
```toml title="run.toml"
[sandbox]
provider = "exe"
[sandbox.exe]
image = "my-custom-image:latest"
```
| Field | Description |
|---|---|
| `image` | Custom container image for the VM. Optional — uses the exe.dev default when omitted. |
## SSH access
Connect to a running exe.dev sandbox via SSH for live debugging:
```bash
fabro sandbox ssh <run-id>
```
This prints the SSH connection command so you can connect to the VM while the workflow runs.
## Prerequisites
- SSH keys configured for exe.dev (added via the [exe.dev dashboard](https://exe.dev))
- A [GitHub App](/integrations/github) configured via `fabro install` (for private repository cloning)
See [Environments](/execution/environments#exe) for additional details on how the exe.dev sandbox operates.

View file

@ -77,7 +77,7 @@ fabro run run.toml
| `--model <MODEL>` | Override default LLM model |
| `--provider <PROVIDER>` | Override default LLM provider |
| `-v, --verbose` | Enable verbose output |
| `--sandbox <SANDBOX>` | Sandbox for agent tools: `local`, `docker`, `daytona`, `ssh`, or `exe` |
| `--sandbox <SANDBOX>` | Sandbox for agent tools: `local`, `docker`, or `daytona` |
| `--label <KEY=VALUE>` | Attach a label to this run (repeatable) |
| `--goal <GOAL>` | Override the workflow goal (exposed as `$goal` in prompts) |
| `--goal-file <FILE>` | Read the goal from a file instead of inline text |
@ -102,7 +102,7 @@ fabro preflight run.toml
| `--model <MODEL>` | Override default LLM model |
| `--provider <PROVIDER>` | Override default LLM provider |
| `-v, --verbose` | Enable verbose output |
| `--sandbox <SANDBOX>` | Sandbox for agent tools: `local`, `docker`, `daytona`, `ssh`, or `exe` |
| `--sandbox <SANDBOX>` | Sandbox for agent tools: `local`, `docker`, or `daytona` |
## `fabro resume`
@ -344,7 +344,7 @@ fabro serve --sandbox daytona --max-concurrent-runs 4
| `--model <MODEL>` | Override default LLM model | — |
| `--provider <PROVIDER>` | Override default LLM provider | — |
| `--dry-run` | Execute with simulated LLM backend | — |
| `--sandbox <SANDBOX>` | Sandbox for agent tools: `local`, `docker`, `daytona`, `ssh`, or `exe` | — |
| `--sandbox <SANDBOX>` | Sandbox for agent tools: `local`, `docker`, or `daytona` | — |
| `--max-concurrent-runs <N>` | Maximum number of concurrent run executions | — |
| `--config <PATH>` | Path to server config file | `~/.fabro/server.toml` |
@ -356,7 +356,7 @@ If no LLM provider API keys are configured, the server automatically falls back
## `fabro sandbox cp`
Copy files between a run's sandbox and the local filesystem. The run must have a persisted sandbox record (Daytona, exe.dev, or a preserved local/Docker sandbox).
Copy files between a run's sandbox and the local filesystem. The run must have a persisted sandbox record (Daytona or a preserved local/Docker sandbox).
```bash
fabro sandbox cp <run-id>:/path/in/sandbox ./local-dir # download

View file

@ -13,7 +13,6 @@ path = "src/main.rs"
[features]
default = []
server = ["dep:fabro-server"]
exedev = ["fabro-sandbox/exe", "fabro-config/exedev", "fabro-workflows/exedev", "fabro-types/exedev"]
sleep_inhibitor = ["dep:core-foundation"]
[lints]
@ -32,7 +31,7 @@ fabro-interview = { path = "../fabro-interview" }
fabro-mcp = { path = "../fabro-mcp" }
fabro-proctitle = { path = "../fabro-proctitle" }
fabro-retro = { path = "../fabro-retro" }
fabro-sandbox = { path = "../fabro-sandbox", features = ["ssh", "daytona"] }
fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona"] }
fabro-git-storage = { path = "../fabro-git-storage" }
fabro-graphviz = { path = "../fabro-graphviz" }
fabro-validate = { path = "../fabro-validate" }

View file

@ -53,9 +53,6 @@ pub(crate) enum CliSandboxProvider {
Local,
Docker,
Daytona,
#[cfg(feature = "exedev")]
Exe,
Ssh,
}
impl From<CliSandboxProvider> for fabro_sandbox::SandboxProvider {
@ -64,9 +61,6 @@ impl From<CliSandboxProvider> for fabro_sandbox::SandboxProvider {
CliSandboxProvider::Local => Self::Local,
CliSandboxProvider::Docker => Self::Docker,
CliSandboxProvider::Daytona => Self::Daytona,
#[cfg(feature = "exedev")]
CliSandboxProvider::Exe => Self::Exe,
CliSandboxProvider::Ssh => Self::Ssh,
}
}
}
@ -77,11 +71,6 @@ impl From<fabro_sandbox::SandboxProvider> for CliSandboxProvider {
fabro_sandbox::SandboxProvider::Local => Self::Local,
fabro_sandbox::SandboxProvider::Docker => Self::Docker,
fabro_sandbox::SandboxProvider::Daytona => Self::Daytona,
#[cfg(feature = "exedev")]
fabro_sandbox::SandboxProvider::Exe => Self::Exe,
#[cfg(not(feature = "exedev"))]
fabro_sandbox::SandboxProvider::Exe => Self::Local,
fabro_sandbox::SandboxProvider::Ssh => Self::Ssh,
}
}
}

View file

@ -8,10 +8,7 @@ use fabro_graphviz::graph::{Graph, is_llm_handler_type};
use fabro_llm::client::Client as LlmClient;
use fabro_model::{Catalog, Provider};
use fabro_sandbox::daytona::{DaytonaConfig, detect_repo_info};
use fabro_sandbox::ssh::SshConfig;
use fabro_sandbox::{
DockerSandboxConfig, Sandbox, SandboxProvider, SandboxSpec, detect_clone_params,
};
use fabro_sandbox::{DockerSandboxConfig, Sandbox, SandboxProvider, SandboxSpec};
use fabro_util::terminal::Styles;
use fabro_workflows::git::{GitSyncStatus, sync_status};
use fabro_workflows::operations::{ValidateInput, WorkflowInput, validate};
@ -139,19 +136,6 @@ fn resolve_daytona_config(settings: &FabroSettings) -> Option<DaytonaConfig> {
.and_then(|sandbox| sandbox.daytona.clone())
}
#[cfg(feature = "exedev")]
fn resolve_exe_config(settings: &FabroSettings) -> Option<fabro_sandbox::exe::ExeConfig> {
settings
.sandbox_settings()
.and_then(|sandbox| sandbox.exe.clone())
}
fn resolve_ssh_config(settings: &FabroSettings) -> Option<SshConfig> {
settings
.sandbox_settings()
.and_then(|sandbox| sandbox.ssh.clone())
}
async fn mint_github_token(
creds: &fabro_github::GitHubAppCredentials,
origin_url: &str,
@ -245,9 +229,6 @@ async fn run_preflight(
});
let daytona_config = resolve_daytona_config(settings);
#[cfg(feature = "exedev")]
let exe_config = resolve_exe_config(settings);
let ssh_config = resolve_ssh_config(settings);
let sandbox_result: Result<Arc<dyn Sandbox>, String> = match sandbox_provider {
SandboxProvider::Local => SandboxSpec::Local {
@ -274,31 +255,6 @@ async fn run_preflight(
.build(None)
.await
.map_err(|e| format!("Daytona sandbox creation failed: {e}")),
#[cfg(feature = "exedev")]
SandboxProvider::Exe => SandboxSpec::Exe {
config: exe_config.unwrap_or_default(),
clone_params: detect_clone_params(working_directory),
run_id: None,
github_app: None,
mgmt_destination: "exe.dev".to_string(),
}
.build(None)
.await
.map_err(|e| format!("exe sandbox creation failed: {e}")),
#[cfg(not(feature = "exedev"))]
SandboxProvider::Exe => Err("exe sandbox requires the exedev feature".to_string()),
SandboxProvider::Ssh => match ssh_config {
Some(config) => SandboxSpec::Ssh {
config,
clone_params: detect_clone_params(working_directory),
run_id: None,
github_app: None,
}
.build(None)
.await
.map_err(|e| e.to_string()),
None => Err("SSH sandbox requires [sandbox.ssh] config".to_string()),
},
};
let sandbox_ok = match sandbox_result {

View file

@ -471,7 +471,6 @@ mod tests {
identifier: Some("sandbox-1".to_string()),
host_working_directory: Some("/tmp/night-sky".to_string()),
container_mount_point: None,
data_host: None,
}
}

View file

@ -28,7 +28,7 @@ fn help() {
--storage-dir <STORAGE_DIR> Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
--provider <PROVIDER> Override default LLM provider
-v, --verbose Enable verbose output
--sandbox <SANDBOX> Sandbox for agent tools [possible values: local, docker, daytona, ssh]
--sandbox <SANDBOX> Sandbox for agent tools [possible values: local, docker, daytona]
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
--no-retro Skip retro generation after the run
--preserve-sandbox Keep the sandbox alive after the run finishes (for debugging)

View file

@ -26,7 +26,7 @@ fn help() {
--provider <PROVIDER> Override default LLM provider
--storage-dir <STORAGE_DIR> Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
-v, --verbose Enable verbose output
--sandbox <SANDBOX> Sandbox for agent tools [possible values: local, docker, daytona, ssh]
--sandbox <SANDBOX> Sandbox for agent tools [possible values: local, docker, daytona]
-h, --help Print help
----- stderr -----
");

View file

@ -310,7 +310,7 @@ fn help() {
--storage-dir <STORAGE_DIR> Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
--provider <PROVIDER> Override default LLM provider
-v, --verbose Enable verbose output
--sandbox <SANDBOX> Sandbox for agent tools [possible values: local, docker, daytona, ssh]
--sandbox <SANDBOX> Sandbox for agent tools [possible values: local, docker, daytona]
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
--no-retro Skip retro generation after the run
--preserve-sandbox Keep the sandbox alive after the run finishes (for debugging)

View file

@ -11,7 +11,6 @@ doctest = false
[features]
default = []
exedev = ["fabro-types/exedev"]
clap = ["dep:clap", "fabro-types/clap"]
[lints]

View file

@ -3,11 +3,9 @@ use std::collections::HashMap;
use anyhow::anyhow;
use serde::{Deserialize, Serialize};
#[cfg(feature = "exedev")]
pub use fabro_types::settings::sandbox::ExeSettings;
pub use fabro_types::settings::sandbox::{
DaytonaNetwork, DaytonaSettings, DaytonaSnapshotSettings, DockerfileSource,
LocalSandboxSettings, SandboxSettings, SshSettings, WorktreeMode,
LocalSandboxSettings, SandboxSettings, WorktreeMode,
};
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
@ -61,51 +59,6 @@ impl TryFrom<DaytonaSnapshotConfig> for DaytonaSnapshotSettings {
}
}
/// Configuration for an exe.dev sandbox (TOML target for `[sandbox.exe]`).
#[cfg(feature = "exedev")]
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct ExeConfig {
pub image: Option<String>,
}
#[cfg(feature = "exedev")]
impl From<ExeConfig> for ExeSettings {
fn from(value: ExeConfig) -> Self {
Self { image: value.image }
}
}
/// Configuration for an SSH sandbox (TOML target for `[sandbox.ssh]`).
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct SshConfig {
/// SSH destination (e.g. `user@host` or an SSH alias).
pub destination: Option<String>,
/// Remote working directory.
pub working_directory: Option<String>,
/// Optional path to a custom SSH config file.
pub config_file: Option<String>,
/// Base URL for port previews (e.g. `"http://beast"`).
/// When set, `get_preview_url(port)` returns `"{preview_url_base}:{port}"`.
pub preview_url_base: Option<String>,
}
impl TryFrom<SshConfig> for SshSettings {
type Error = anyhow::Error;
fn try_from(value: SshConfig) -> Result<Self, Self::Error> {
Ok(Self {
destination: value
.destination
.ok_or_else(|| anyhow!("sandbox.ssh.destination is required"))?,
working_directory: value
.working_directory
.ok_or_else(|| anyhow!("sandbox.ssh.working_directory is required"))?,
config_file: value.config_file,
preview_url_base: value.preview_url_base,
})
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct LocalSandboxConfig {
pub worktree_mode: Option<WorktreeMode>,
@ -126,9 +79,6 @@ pub struct SandboxConfig {
pub devcontainer: Option<bool>,
pub local: Option<LocalSandboxConfig>,
pub daytona: Option<DaytonaConfig>,
#[cfg(feature = "exedev")]
pub exe: Option<ExeConfig>,
pub ssh: Option<SshConfig>,
pub env: Option<HashMap<String, String>>,
}
@ -142,9 +92,6 @@ impl TryFrom<SandboxConfig> for SandboxSettings {
devcontainer: value.devcontainer,
local: value.local.map(Into::into),
daytona: value.daytona.map(TryInto::try_into).transpose()?,
#[cfg(feature = "exedev")]
exe: value.exe.map(Into::into),
ssh: value.ssh.map(TryInto::try_into).transpose()?,
env: value.env,
})
}

View file

@ -10,8 +10,6 @@ description = "Sandbox trait and implementations for Fabro agent execution envir
default = ["local"]
local = []
docker = ["dep:bollard", "dep:tar", "dep:futures"]
ssh = ["dep:openssh", "dep:fabro-github", "dep:fabro-config"]
exe = ["ssh", "fabro-config/exedev", "fabro-types/exedev"]
sprites = ["dep:chrono", "dep:rand"]
daytona = ["dep:daytona-sdk", "dep:daytona-api-client", "dep:git2", "dep:fabro-github", "dep:fabro-config", "dep:chrono", "dep:rand"]
test-support = []
@ -41,8 +39,7 @@ bollard = { workspace = true, optional = true }
tar = { workspace = true, optional = true }
futures = { workspace = true, optional = true }
# ssh / exe / daytona
openssh = { workspace = true, optional = true }
# daytona
fabro-config = { path = "../fabro-config", optional = true }
fabro-github = { path = "../fabro-github", optional = true }
fabro-types = { path = "../fabro-types" }

View file

@ -297,6 +297,27 @@ impl DaytonaSandbox {
use fabro_github::ssh_url_to_https;
/// Parameters for cloning a git repo into the sandbox during initialization.
#[derive(Clone, Debug)]
pub struct GitCloneParams {
/// Clean HTTPS URL (no embedded credentials).
pub url: String,
/// Branch to clone. If None, uses the remote's default.
pub branch: Option<String>,
}
pub fn detect_clone_params(cwd: &Path) -> Option<GitCloneParams> {
let (detected_url, branch) = match detect_repo_info(cwd) {
Ok(info) => info,
Err(err) => {
tracing::warn!("No git repo detected for sandbox clone: {err}");
return None;
}
};
let url = fabro_github::ssh_url_to_https(&detected_url);
Some(GitCloneParams { url, branch })
}
/// Detect the git remote URL and current branch from a local repository.
///
/// Uses `git2` to discover the repo at `path`, reads the `origin` remote URL

File diff suppressed because it is too large Load diff

View file

@ -1,122 +0,0 @@
use async_trait::async_trait;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use openssh::{KnownHosts, Session};
use tokio::time::timeout as tokio_timeout;
use super::{SshOutput, SshRunner};
use crate::shell_quote;
/// Real SSH implementation using the `openssh` crate (multiplexed connections).
pub struct OpensshRunner {
session: Session,
/// When true, commands are sent via `raw_command` (no shell wrapping).
/// Used for the exe.dev management plane which has a custom SSH command handler.
raw_mode: bool,
}
impl OpensshRunner {
/// Connect to a host via SSH, using the user's SSH agent for authentication.
/// Commands are executed through a shell (`sh -c`).
pub async fn connect(host: &str) -> Result<Self, String> {
let session = Session::connect(host, KnownHosts::Accept)
.await
.map_err(|e| format!("SSH connection to {host} failed: {e}"))?;
Ok(Self {
session,
raw_mode: false,
})
}
/// Connect to a host via SSH in raw mode (no shell wrapping).
/// Commands are sent directly as the SSH command string.
/// Used for the exe.dev management plane which has a custom command handler.
pub async fn connect_raw(host: &str) -> Result<Self, String> {
let session = Session::connect(host, KnownHosts::Accept)
.await
.map_err(|e| format!("SSH connection to {host} failed: {e}"))?;
Ok(Self {
session,
raw_mode: true,
})
}
fn build_command(&self, command: &str) -> openssh::OwningCommand<&Session> {
if self.raw_mode {
self.session.raw_command(command)
} else {
self.session.shell(command)
}
}
}
#[async_trait]
impl SshRunner for OpensshRunner {
async fn run_command(&self, command: &str) -> Result<SshOutput, String> {
let output = self
.build_command(command)
.output()
.await
.map_err(|e| format!("SSH command failed: {e}"))?;
let exit_code = output.status.code().unwrap_or(-1);
Ok(SshOutput {
stdout: output.stdout,
stderr: output.stderr,
exit_code,
})
}
async fn run_command_with_timeout(
&self,
command: &str,
timeout: std::time::Duration,
) -> Result<SshOutput, String> {
let mut child = self.build_command(command);
let fut = child.output();
match tokio_timeout(timeout, fut).await {
Ok(Ok(output)) => {
let exit_code = output.status.code().unwrap_or(-1);
Ok(SshOutput {
stdout: output.stdout,
stderr: output.stderr,
exit_code,
})
}
Ok(Err(e)) => Err(format!("SSH command failed: {e}")),
Err(_) => Err("Command timed out".to_string()),
}
}
async fn upload_file(&self, path: &str, content: &[u8]) -> Result<(), String> {
let encoded = STANDARD.encode(content);
let cmd = format!("echo '{}' | base64 -d > {}", encoded, shell_quote(path),);
let output = self
.build_command(&cmd)
.output()
.await
.map_err(|e| format!("SSH upload failed: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Upload to {path} failed: {stderr}"));
}
Ok(())
}
async fn download_file(&self, path: &str) -> Result<Vec<u8>, String> {
let cmd = format!("cat {}", shell_quote(path));
let output = self
.build_command(&cmd)
.output()
.await
.map_err(|e| format!("SSH download failed: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Download of {path} failed: {stderr}"));
}
Ok(output.stdout)
}
}

View file

@ -11,9 +11,6 @@ pub mod sandbox_record;
pub mod worktree;
#[cfg(feature = "ssh")]
pub(crate) mod ssh_common;
pub mod local;
#[cfg(feature = "docker")]
@ -22,12 +19,6 @@ pub mod docker;
#[cfg(feature = "sprites")]
pub mod sprites;
#[cfg(feature = "ssh")]
pub mod ssh;
#[cfg(feature = "exe")]
pub mod exe;
#[cfg(feature = "daytona")]
pub mod daytona;
@ -53,5 +44,5 @@ pub use docker::{DockerSandbox, DockerSandboxConfig};
pub use sandbox_record::{SandboxRecord, SandboxRecordExt};
#[cfg(all(feature = "ssh", feature = "daytona"))]
pub use ssh_common::detect_clone_params;
#[cfg(feature = "daytona")]
pub use daytona::detect_clone_params;

View file

@ -7,12 +7,8 @@ use anyhow::{Context, Result, bail};
use crate::daytona::DaytonaSandbox;
#[cfg(feature = "docker")]
use crate::docker::{DockerSandbox, DockerSandboxConfig};
#[cfg(feature = "exe")]
use crate::exe::{ExeSandbox, OpensshRunner as ExeOpensshRunner};
use crate::local::LocalSandbox;
use crate::sandbox_record::SandboxRecord;
#[cfg(feature = "ssh")]
use crate::ssh::{OpensshRunner, SshConfig, SshSandbox};
/// Reconnect to a sandbox from a saved record.
///
@ -55,42 +51,6 @@ pub async fn reconnect(record: &SandboxRecord) -> Result<Box<dyn crate::Sandbox>
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(Box::new(sandbox))
}
#[cfg(feature = "exe")]
"exe" => {
let data_host = record
.data_host
.as_deref()
.context("Exe sandbox record missing data_host")?;
let data_ssh = ExeOpensshRunner::connect(data_host).await.map_err(|e| {
anyhow::anyhow!("Failed to connect to exe sandbox '{data_host}': {e}")
})?;
let sandbox = ExeSandbox::from_existing(Box::new(data_ssh));
Ok(Box::new(sandbox))
}
#[cfg(feature = "ssh")]
"ssh" => {
let destination = record
.data_host
.as_deref()
.context("SSH sandbox record missing data_host (destination)")?;
let ssh = OpensshRunner::connect(destination, None)
.await
.map_err(|e| {
anyhow::anyhow!("Failed to connect to SSH sandbox '{destination}': {e}")
})?;
let config = SshConfig {
destination: destination.to_string(),
working_directory: record.working_directory.clone(),
config_file: None,
preview_url_base: None,
};
let sandbox = SshSandbox::from_existing(Box::new(ssh), config);
Ok(Box::new(sandbox))
}
other => bail!("Unknown sandbox provider: {other}"),
}
}

View file

@ -136,10 +136,6 @@ macro_rules! delegate_sandbox {
self.$field.host_git_dir()
}
fn data_host(&self) -> Option<&str> {
self.$field.data_host()
}
fn parallel_worktree_path(
&self,
run_dir: &std::path::Path,
@ -461,12 +457,6 @@ pub trait Sandbox: Send + Sync {
None
}
/// The remote host for reconnection (e.g. SSH destination, exe.dev data plane).
/// Default is None; Exe and Ssh override.
fn data_host(&self) -> Option<&str> {
None
}
/// Compute the filesystem path for a parallel branch worktree.
fn parallel_worktree_path(
&self,
@ -512,12 +502,7 @@ pub trait Sandbox: Send + Sync {
/// Resolve a path: relative paths are prepended with the working directory.
/// Used by feature-gated sandbox implementations (exe, ssh, sprites, daytona).
#[cfg(any(
feature = "exe",
feature = "ssh",
feature = "sprites",
feature = "daytona"
))]
#[cfg(any(feature = "sprites", feature = "daytona"))]
pub(crate) fn resolve_path(path: &str, working_dir: &str) -> String {
if std::path::Path::new(path).is_absolute() {
path.to_string()

View file

@ -11,10 +11,6 @@ pub enum SandboxProvider {
Docker,
/// Run tools inside a Daytona cloud sandbox
Daytona,
/// Run tools inside an exe.dev VM
Exe,
/// Run tools on a user-provided SSH host
Ssh,
}
impl SandboxProvider {
@ -31,8 +27,6 @@ impl fmt::Display for SandboxProvider {
Self::Local => write!(f, "local"),
Self::Docker => write!(f, "docker"),
Self::Daytona => write!(f, "daytona"),
Self::Exe => write!(f, "exe"),
Self::Ssh => write!(f, "ssh"),
}
}
}
@ -45,8 +39,6 @@ impl FromStr for SandboxProvider {
"local" => Ok(Self::Local),
"docker" => Ok(Self::Docker),
"daytona" => Ok(Self::Daytona),
"exe" => Ok(Self::Exe),
"ssh" => Ok(Self::Ssh),
other => Err(format!("unknown sandbox provider: {other}")),
}
}
@ -79,22 +71,6 @@ mod tests {
"LOCAL".parse::<SandboxProvider>().unwrap(),
SandboxProvider::Local
);
assert_eq!(
"exe".parse::<SandboxProvider>().unwrap(),
SandboxProvider::Exe
);
assert_eq!(
"EXE".parse::<SandboxProvider>().unwrap(),
SandboxProvider::Exe
);
assert_eq!(
"ssh".parse::<SandboxProvider>().unwrap(),
SandboxProvider::Ssh
);
assert_eq!(
"SSH".parse::<SandboxProvider>().unwrap(),
SandboxProvider::Ssh
);
assert!("invalid".parse::<SandboxProvider>().is_err());
}
@ -103,7 +79,5 @@ mod tests {
assert_eq!(SandboxProvider::Local.to_string(), "local");
assert_eq!(SandboxProvider::Docker.to_string(), "docker");
assert_eq!(SandboxProvider::Daytona.to_string(), "daytona");
assert_eq!(SandboxProvider::Exe.to_string(), "exe");
assert_eq!(SandboxProvider::Ssh.to_string(), "ssh");
}
}

View file

@ -3,7 +3,7 @@ use std::sync::Arc;
use fabro_types::{RunId, settings::WorktreeMode};
#[cfg(any(feature = "docker", feature = "daytona", feature = "exe"))]
#[cfg(any(feature = "docker", feature = "daytona"))]
use anyhow::anyhow;
use crate::sandbox_record::SandboxRecord;
@ -13,13 +13,9 @@ use crate::{Sandbox, SandboxEventCallback};
use crate::daytona::{DaytonaConfig, DaytonaSandbox, DaytonaSnapshotConfig};
#[cfg(feature = "docker")]
use crate::docker::{DockerSandbox, DockerSandboxConfig};
#[cfg(feature = "exe")]
use crate::exe::{ExeConfig, ExeSandbox, GitCloneParams as ExeGitCloneParams, OpensshRunner};
use crate::local::LocalSandbox;
#[cfg(feature = "ssh")]
use crate::ssh::{GitCloneParams as SshGitCloneParams, SshConfig, SshSandbox};
#[cfg(any(feature = "daytona", feature = "exe", feature = "ssh"))]
#[cfg(feature = "daytona")]
use fabro_github::GitHubAppCredentials;
/// Options for sandbox initialization and construction.
@ -38,21 +34,6 @@ pub enum SandboxSpec {
run_id: Option<RunId>,
clone_branch: Option<String>,
},
#[cfg(feature = "exe")]
Exe {
config: ExeConfig,
clone_params: Option<ExeGitCloneParams>,
run_id: Option<RunId>,
github_app: Option<GitHubAppCredentials>,
mgmt_destination: String,
},
#[cfg(feature = "ssh")]
Ssh {
config: SshConfig,
clone_params: Option<SshGitCloneParams>,
run_id: Option<RunId>,
github_app: Option<GitHubAppCredentials>,
},
}
#[derive(Clone, Copy, PartialEq, Eq)]
@ -70,10 +51,6 @@ impl SandboxSpec {
Self::Docker { .. } => "docker",
#[cfg(feature = "daytona")]
Self::Daytona { .. } => "daytona",
#[cfg(feature = "exe")]
Self::Exe { .. } => "exe",
#[cfg(feature = "ssh")]
Self::Ssh { .. } => "ssh",
}
}
@ -105,25 +82,6 @@ impl SandboxSpec {
identifier,
host_working_directory: Some(config.host_working_directory.clone()),
container_mount_point: Some(working_directory),
data_host: None,
},
#[cfg(feature = "ssh")]
Self::Ssh { .. } => SandboxRecord {
provider: self.provider_name().to_string(),
working_directory,
identifier,
host_working_directory: None,
container_mount_point: None,
data_host: sandbox.data_host().map(ToOwned::to_owned),
},
#[cfg(feature = "exe")]
Self::Exe { .. } => SandboxRecord {
provider: self.provider_name().to_string(),
working_directory,
identifier,
host_working_directory: None,
container_mount_point: None,
data_host: sandbox.data_host().map(ToOwned::to_owned),
},
_ => SandboxRecord {
provider: self.provider_name().to_string(),
@ -131,7 +89,6 @@ impl SandboxSpec {
identifier,
host_working_directory: None,
container_mount_point: None,
data_host: None,
},
}
}
@ -237,47 +194,6 @@ impl SandboxSpec {
}
Ok(Arc::new(sandbox))
}
#[cfg(feature = "exe")]
Self::Exe {
config,
clone_params,
run_id,
github_app,
mgmt_destination,
} => {
let mgmt_ssh = OpensshRunner::connect_raw(mgmt_destination)
.await
.map_err(|e| anyhow!("Failed to connect to {mgmt_destination}: {e}"))?;
let mut sandbox = ExeSandbox::new(
Box::new(mgmt_ssh),
config.clone(),
clone_params.clone(),
*run_id,
github_app.clone(),
);
if let Some(callback) = event_callback {
sandbox.set_event_callback(callback);
}
Ok(Arc::new(sandbox))
}
#[cfg(feature = "ssh")]
Self::Ssh {
config,
clone_params,
run_id,
github_app,
} => {
let mut sandbox = SshSandbox::new(
config.clone(),
clone_params.clone(),
*run_id,
github_app.clone(),
);
if let Some(callback) = event_callback {
sandbox.set_event_callback(callback);
}
Ok(Arc::new(sandbox))
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,104 +0,0 @@
use async_trait::async_trait;
use base64::engine::general_purpose::STANDARD;
use openssh::{KnownHosts, SessionBuilder};
use tokio::time;
use super::{SshOutput, SshRunner};
use crate::shell_quote;
/// Real SSH implementation using the `openssh` crate (multiplexed connections).
pub struct OpensshRunner {
session: openssh::Session,
}
impl OpensshRunner {
/// Connect to a host via SSH, using the user's SSH agent for authentication.
/// Commands are executed through a shell (`sh -c`).
pub async fn connect(destination: &str, config_file: Option<&str>) -> Result<Self, String> {
let mut builder = SessionBuilder::default();
builder.known_hosts_check(KnownHosts::Accept);
if let Some(cfg) = config_file {
builder.config_file(cfg);
}
let session = builder
.connect(destination)
.await
.map_err(|e| format!("SSH connection to {destination} failed: {e}"))?;
Ok(Self { session })
}
}
#[async_trait]
impl SshRunner for OpensshRunner {
async fn run_command(&self, command: &str) -> Result<SshOutput, String> {
let output = self
.session
.shell(command)
.output()
.await
.map_err(|e| format!("SSH command failed: {e}"))?;
let exit_code = output.status.code().unwrap_or(-1);
Ok(SshOutput {
stdout: output.stdout,
stderr: output.stderr,
exit_code,
})
}
async fn run_command_with_timeout(
&self,
command: &str,
timeout: std::time::Duration,
) -> Result<SshOutput, String> {
let mut child = self.session.shell(command);
let fut = child.output();
match time::timeout(timeout, fut).await {
Ok(Ok(output)) => {
let exit_code = output.status.code().unwrap_or(-1);
Ok(SshOutput {
stdout: output.stdout,
stderr: output.stderr,
exit_code,
})
}
Ok(Err(e)) => Err(format!("SSH command failed: {e}")),
Err(_) => Err("Command timed out".to_string()),
}
}
async fn upload_file(&self, path: &str, content: &[u8]) -> Result<(), String> {
use base64::Engine;
let encoded = STANDARD.encode(content);
let cmd = format!("echo '{}' | base64 -d > {}", encoded, shell_quote(path),);
let output = self
.session
.shell(&cmd)
.output()
.await
.map_err(|e| format!("SSH upload failed: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Upload to {path} failed: {stderr}"));
}
Ok(())
}
async fn download_file(&self, path: &str) -> Result<Vec<u8>, String> {
let cmd = format!("cat {}", shell_quote(path));
let output = self
.session
.shell(&cmd)
.output()
.await
.map_err(|e| format!("SSH download failed: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Download of {path} failed: {stderr}"));
}
Ok(output.stdout)
}
}

View file

@ -1,189 +0,0 @@
//! Shared types and utilities for SSH-based sandbox implementations (exe, ssh).
use std::time::Instant;
#[cfg(feature = "daytona")]
use crate::daytona;
#[cfg(feature = "daytona")]
use std::path::Path;
use async_trait::async_trait;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use tokio::sync::OnceCell;
use crate::{SandboxEvent, shell_quote};
/// Output from an SSH command execution.
pub struct SshOutput {
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub exit_code: i32,
}
/// Trait abstracting SSH operations for testability.
#[async_trait]
pub trait SshRunner: Send + Sync {
async fn run_command(&self, command: &str) -> Result<SshOutput, String>;
async fn run_command_with_timeout(
&self,
command: &str,
timeout: std::time::Duration,
) -> Result<SshOutput, String>;
async fn upload_file(&self, path: &str, content: &[u8]) -> Result<(), String>;
async fn download_file(&self, path: &str) -> Result<Vec<u8>, String>;
}
/// Parameters for cloning a git repo into the sandbox during initialization.
#[derive(Clone, Debug)]
pub struct GitCloneParams {
/// Clean HTTPS URL (no embedded credentials).
pub url: String,
/// Branch to clone. If None, uses the remote's default.
pub branch: Option<String>,
}
#[cfg(feature = "daytona")]
pub fn detect_clone_params(cwd: &Path) -> Option<GitCloneParams> {
let (detected_url, branch) = match daytona::detect_repo_info(cwd) {
Ok(info) => info,
Err(err) => {
tracing::warn!("No git repo detected for sandbox clone: {err}");
return None;
}
};
let url = fabro_github::ssh_url_to_https(&detected_url);
Some(GitCloneParams { url, branch })
}
/// Wrap a shell command in base64 encoding to avoid escaping issues.
pub(crate) fn wrap_bash_command(command: &str) -> String {
let encoded = STANDARD.encode(command);
format!("echo '{encoded}' | base64 -d | sh")
}
/// Resolve an authenticated clone URL using GitHub App credentials, falling back to the
/// original URL if authentication fails or no credentials are provided.
pub(crate) async fn resolve_clone_url(
url: &str,
github_app: Option<&fabro_github::GitHubAppCredentials>,
) -> Result<String, String> {
match github_app {
Some(creds) => fabro_github::resolve_authenticated_url(creds, url)
.await
.or_else(|_| Ok(url.to_string())),
None => Ok(url.to_string()),
}
}
/// Clone a git repo into a sandbox working directory over SSH.
///
/// Handles the common clone logic including fallback to init+fetch when the
/// directory is not empty, event emission, and origin URL tracking.
pub(crate) async fn clone_repo(
ssh: &dyn SshRunner,
working_dir: &str,
params: &GitCloneParams,
github_app: Option<&fabro_github::GitHubAppCredentials>,
origin_url: &OnceCell<String>,
emit: &(dyn Fn(SandboxEvent) + Send + Sync),
) -> Result<(), String> {
emit(SandboxEvent::GitCloneStarted {
url: params.url.clone(),
branch: params.branch.clone(),
});
let clone_start = Instant::now();
let clone_url = resolve_clone_url(&params.url, github_app).await?;
let branch_flag = params
.branch
.as_deref()
.map(|b| format!(" --branch {}", shell_quote(b)))
.unwrap_or_default();
let clone_script = format!(
"git clone{branch_flag} {} {}",
shell_quote(&clone_url),
shell_quote(working_dir),
);
let clone_cmd = wrap_bash_command(&clone_script);
let clone_timeout = std::time::Duration::from_secs(300);
let clone_output = ssh
.run_command_with_timeout(&clone_cmd, clone_timeout)
.await
.map_err(|e| {
let err = format!("git clone failed: {e}");
emit(SandboxEvent::GitCloneFailed {
url: params.url.clone(),
error: err.clone(),
});
err
})?;
if clone_output.exit_code != 0 {
let stderr = String::from_utf8_lossy(&clone_output.stderr);
// Fall back to init + fetch + checkout if directory is not empty
if stderr.contains("not an empty directory")
|| stderr.contains("already exists and is not an empty")
{
let branch = params.branch.as_deref().unwrap_or("main");
let fallback_script = format!(
"cd {} && git init && git remote add origin {} && git fetch origin && git checkout {}",
shell_quote(working_dir),
shell_quote(&clone_url),
shell_quote(branch),
);
let fallback_cmd = wrap_bash_command(&fallback_script);
let fallback_output = ssh
.run_command_with_timeout(&fallback_cmd, clone_timeout)
.await
.map_err(|e| {
let err = format!("git fallback clone failed: {e}");
emit(SandboxEvent::GitCloneFailed {
url: params.url.clone(),
error: err.clone(),
});
err
})?;
if fallback_output.exit_code != 0 {
let fallback_stderr = String::from_utf8_lossy(&fallback_output.stderr);
let err = format!(
"git fallback clone failed (exit {}): {fallback_stderr}",
fallback_output.exit_code,
);
emit(SandboxEvent::GitCloneFailed {
url: params.url.clone(),
error: err.clone(),
});
return Err(err);
}
} else {
let err = format!(
"git clone failed (exit {}): {stderr}",
clone_output.exit_code,
);
emit(SandboxEvent::GitCloneFailed {
url: params.url.clone(),
error: err.clone(),
});
return Err(err);
}
}
// Store the clean URL as origin_url for credential refresh
let _ = origin_url.set(params.url.clone());
let duration_ms = u64::try_from(clone_start.elapsed().as_millis()).unwrap_or(u64::MAX);
emit(SandboxEvent::GitCloneCompleted {
url: params.url.clone(),
duration_ms,
});
Ok(())
}

View file

@ -293,10 +293,6 @@ impl Sandbox for WorktreeSandbox {
Some(&self.config.worktree_path)
}
fn data_host(&self) -> Option<&str> {
self.inner.data_host()
}
async fn setup_git_for_run(&self, run_id: &str) -> Result<Option<crate::GitRunInfo>, String> {
self.inner.setup_git_for_run(run_id).await
}

View file

@ -13,7 +13,7 @@ doctest = false
workspace = true
[dependencies]
fabro-config = { path = "../fabro-config", features = ["exedev"] }
fabro-config = { path = "../fabro-config" }
fabro-graphviz = { path = "../fabro-graphviz" }
fabro-hooks = { path = "../fabro-hooks" }
fabro-interview = { path = "../fabro-interview" }
@ -24,7 +24,7 @@ fabro-agent = { path = "../fabro-agent" }
fabro-llm = { path = "../fabro-llm" }
fabro-model = { path = "../fabro-model" }
fabro-retro = { path = "../fabro-retro" }
fabro-types = { path = "../fabro-types", features = ["exedev"] }
fabro-types = { path = "../fabro-types" }
fabro-util = { path = "../fabro-util" }
fabro-db = { path = "../fabro-db" }
fabro-api-types = { path = "../fabro-api-types" }
@ -70,4 +70,4 @@ http-body-util = "0.1"
tempfile = "3"
openapiv3 = "2"
serde_yaml = "0.9"
fabro-sandbox = { path = "../fabro-sandbox", features = ["exe"] }
fabro-sandbox = { path = "../fabro-sandbox" }

View file

@ -1318,8 +1318,6 @@ mod runs {
network: Some(fabro_sandbox::daytona::DaytonaNetwork::Block),
skip_clone: false,
}),
exe: None,
ssh: None,
env: None,
}),
vars: Some(std::collections::HashMap::from([
@ -1492,9 +1490,7 @@ mod workflows {
network: None,
skip_clone: false,
}),
exe: None,
ssh: None,
env: None,
env: None,
}),
vars: Some(std::collections::HashMap::from([
("repo_url".into(), "https://github.com/org/service".into()),
@ -1567,9 +1563,7 @@ mod workflows {
network: None,
skip_clone: false,
}),
exe: None,
ssh: None,
env: None,
env: None,
}),
vars: Some(std::collections::HashMap::from([
("spec_path".into(), "specs/feature.md".into()),
@ -1653,9 +1647,7 @@ mod workflows {
network: None,
skip_clone: false,
}),
exe: None,
ssh: None,
env: None,
env: None,
}),
vars: Some(std::collections::HashMap::from([
("source_env".into(), "production".into()),
@ -1730,9 +1722,7 @@ mod workflows {
network: None,
skip_clone: false,
}),
exe: None,
ssh: None,
env: None,
env: None,
}),
vars: Some(std::collections::HashMap::from([
("analytics_window".into(), "30d".into()),
@ -3513,8 +3503,6 @@ mod settings {
network: Some(fabro_sandbox::daytona::DaytonaNetwork::Block),
skip_clone: false,
}),
exe: None,
ssh: None,
env: None,
}),
vars: None,

View file

@ -296,8 +296,6 @@ fn fully_populated_server_config() -> FabroSettings {
network: Some(DaytonaNetwork::Block),
skip_clone: false,
}),
exe: Some(fabro_sandbox::exe::ExeConfig { image: None }),
ssh: None,
env: Some(Default::default()),
}),
vars: Some(Default::default()),

View file

@ -497,7 +497,6 @@ mod tests {
identifier: Some("sandbox-1".to_string()),
host_working_directory: Some("/tmp/night-sky".to_string()),
container_mount_point: None,
data_host: None,
}
}

View file

@ -775,7 +775,6 @@ mod tests {
identifier: Some("sandbox-1".to_string()),
host_working_directory: Some("/tmp/night-sky".to_string()),
container_mount_point: None,
data_host: None,
}
}

View file

@ -11,7 +11,6 @@ doctest = false
[features]
default = []
exedev = []
clap = ["dep:clap"]
[lints]

View file

@ -10,6 +10,4 @@ pub struct SandboxRecord {
pub host_working_directory: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub container_mount_point: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data_host: Option<String>,
}

View file

@ -21,11 +21,9 @@ pub use run::{
AssetsSettings, CheckpointSettings, GitHubSettings, LlmSettings, MergeStrategy,
PullRequestSettings, SetupSettings,
};
#[cfg(feature = "exedev")]
pub use sandbox::ExeSettings;
pub use sandbox::{
DaytonaNetwork, DaytonaSettings, DaytonaSnapshotSettings, DockerfileSource,
LocalSandboxSettings, SandboxSettings, SshSettings, WorktreeMode,
LocalSandboxSettings, SandboxSettings, WorktreeMode,
};
pub use server::{
ApiAuthStrategy, ApiSettings, AuthProvider, AuthSettings, FeaturesSettings, GitAuthorSettings,

View file

@ -114,20 +114,6 @@ pub struct DaytonaSnapshotSettings {
pub dockerfile: Option<DockerfileSource>,
}
#[cfg(feature = "exedev")]
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct ExeSettings {
pub image: Option<String>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct SshSettings {
pub destination: String,
pub working_directory: String,
pub config_file: Option<String>,
pub preview_url_base: Option<String>,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
#[serde(rename_all = "snake_case")]
pub enum WorktreeMode {
@ -151,8 +137,5 @@ pub struct SandboxSettings {
pub devcontainer: Option<bool>,
pub local: Option<LocalSandboxSettings>,
pub daytona: Option<DaytonaSettings>,
#[cfg(feature = "exedev")]
pub exe: Option<ExeSettings>,
pub ssh: Option<SshSettings>,
pub env: Option<HashMap<String, String>>,
}

View file

@ -13,10 +13,6 @@ readme = "README.md"
[lib]
doctest = false
[features]
default = []
exedev = ["fabro-sandbox/exe", "fabro-config/exedev", "fabro-types/exedev"]
[lints]
workspace = true
@ -29,7 +25,7 @@ fabro-graphviz = { path = "../fabro-graphviz" }
fabro-hooks = { path = "../fabro-hooks" }
fabro-validate = { path = "../fabro-validate" }
fabro-devcontainer = { path = "../fabro-devcontainer" }
fabro-sandbox = { path = "../fabro-sandbox", features = ["ssh", "daytona"] }
fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona"] }
fabro-mcp = { path = "../fabro-mcp" }
fabro-github = { path = "../fabro-github" }
fabro-interview = { path = "../fabro-interview" }

View file

@ -356,7 +356,6 @@ mod tests {
identifier: None,
host_working_directory: None,
container_mount_point: None,
data_host: None,
}
}

View file

@ -10,7 +10,7 @@ use fabro_config::sandbox::WorktreeMode;
use fabro_config::{project as project_config, run as run_config, sandbox as sandbox_config};
use fabro_interview::{AutoApproveInterviewer, Interviewer};
use fabro_model::{Catalog, FallbackTarget, Provider};
use fabro_sandbox::{SandboxProvider, SandboxSpec, detect_clone_params};
use fabro_sandbox::{SandboxProvider, SandboxSpec};
use fabro_store::{DiskProjectingRunStore, ProjectionError, RunStore};
use fabro_types::RunId;
use serde::Serialize;
@ -35,7 +35,6 @@ use fabro_config::run::PullRequestSettings;
use fabro_retro::retro::Retro;
use fabro_sandbox::daytona::DaytonaConfig;
use fabro_sandbox::daytona::detect_repo_info;
use fabro_sandbox::ssh::SshConfig;
use tokio::runtime::Handle;
struct RunSession {
@ -320,30 +319,6 @@ impl RunSession {
run_id: Some(record.run_id),
clone_branch: detected_base_branch.or_else(|| record.base_branch.clone()),
},
#[cfg(feature = "exedev")]
SandboxProvider::Exe => SandboxSpec::Exe {
config: resolve_exe_config(&settings).unwrap_or_default(),
clone_params: detect_clone_params(&working_directory),
run_id: Some(record.run_id),
github_app: services.github_app.clone(),
mgmt_destination: "exe.dev".to_string(),
},
#[cfg(not(feature = "exedev"))]
SandboxProvider::Exe => {
return Err(FabroError::Precondition(
"exe sandbox requires the exedev feature".to_string(),
));
}
SandboxProvider::Ssh => SandboxSpec::Ssh {
config: resolve_ssh_config(&settings).ok_or_else(|| {
FabroError::Precondition(
"--sandbox ssh requires [sandbox.ssh] config".to_string(),
)
})?,
clone_params: detect_clone_params(&working_directory),
run_id: Some(record.run_id),
github_app: services.github_app.clone(),
},
};
let sandbox_env = SandboxEnvSpec {
@ -438,19 +413,6 @@ fn resolve_daytona_config(settings: &FabroSettings) -> Option<DaytonaConfig> {
.and_then(|sandbox| sandbox.daytona.clone())
}
#[cfg(feature = "exedev")]
fn resolve_exe_config(settings: &FabroSettings) -> Option<fabro_sandbox::exe::ExeConfig> {
settings
.sandbox_settings()
.and_then(|sandbox| sandbox.exe.clone())
}
fn resolve_ssh_config(settings: &FabroSettings) -> Option<SshConfig> {
settings
.sandbox_settings()
.and_then(|sandbox| sandbox.ssh.clone())
}
fn resolve_fallback_chain(
provider: Provider,
model: &str,

View file

@ -20,7 +20,6 @@ fn local_record(working_directory: &std::path::Path) -> SandboxRecord {
identifier: None,
host_working_directory: None,
container_mount_point: None,
data_host: None,
}
}
@ -142,7 +141,6 @@ fn docker_record(host_dir: &std::path::Path, mount_point: &str) -> SandboxRecord
identifier: None,
host_working_directory: Some(host_dir.to_string_lossy().to_string()),
container_mount_point: Some(mount_point.to_string()),
data_host: None,
}
}

View file

@ -1769,7 +1769,6 @@ async fn daytona_cp_upload_download_round_trip() {
identifier: Some(sandbox_name.clone()),
host_working_directory: None,
container_mount_point: None,
data_host: None,
};
// 3. Save to temp dir and reload (verify serialization round-trip)