mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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:
parent
4d05c2a2a3
commit
3936f185cf
46 changed files with 55 additions and 4017 deletions
|
|
@ -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
15
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -94,7 +94,6 @@
|
|||
"integrations/github",
|
||||
"integrations/daytona",
|
||||
"integrations/slack",
|
||||
"integrations/exe-dev",
|
||||
"integrations/sprites",
|
||||
"integrations/brave-search"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 -----
|
||||
");
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ doctest = false
|
|||
|
||||
[features]
|
||||
default = []
|
||||
exedev = ["fabro-types/exedev"]
|
||||
clap = ["dep:clap", "fabro-types/clap"]
|
||||
|
||||
[lints]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(¶ms.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(())
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ doctest = false
|
|||
|
||||
[features]
|
||||
default = []
|
||||
exedev = []
|
||||
clap = ["dep:clap"]
|
||||
|
||||
[lints]
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -356,7 +356,6 @@ mod tests {
|
|||
identifier: None,
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
data_host: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue