diff --git a/AGENTS.md b/AGENTS.md
index 392feb1f6..0cb2c23f6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/Cargo.lock b/Cargo.lock
index 5294e442f..ae3f05796 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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"
diff --git a/Cargo.toml b/Cargo.toml
index ed19abfab..ddc1772ac 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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"] }
diff --git a/docs/administration/sandboxing.mdx b/docs/administration/sandboxing.mdx
index 5a4e2dd55..9e5fabfb0 100644
--- a/docs/administration/sandboxing.mdx
+++ b/docs/administration/sandboxing.mdx
@@ -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.
diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml
index 00199ce10..937f3ca3f 100644
--- a/docs/api-reference/fabro-api.yaml
+++ b/docs/api-reference/fabro-api.yaml
@@ -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
diff --git a/docs/docs.json b/docs/docs.json
index 1c8e3e540..c98621aab 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -94,7 +94,6 @@
"integrations/github",
"integrations/daytona",
"integrations/slack",
- "integrations/exe-dev",
"integrations/sprites",
"integrations/brave-search"
]
diff --git a/docs/execution/environments.mdx b/docs/execution/environments.mdx
index 72756abac..f58adc377 100644
--- a/docs/execution/environments.mdx
+++ b/docs/execution/environments.mdx
@@ -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.
-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.
## 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
-
-
-The exe.dev sandbox provider is **under development** and requires building Fabro with the `exedev` feature flag. The API and configuration may change.
-
-
-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 ` 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 (`.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
-```
-
-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:
diff --git a/docs/execution/run-configuration.mdx b/docs/execution/run-configuration.mdx
index 1077ea7b1..f2f61122d 100644
--- a/docs/execution/run-configuration.mdx
+++ b/docs/execution/run-configuration.mdx
@@ -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:
diff --git a/docs/integrations/exe-dev.mdx b/docs/integrations/exe-dev.mdx
deleted file mode 100644
index 92aad3159..000000000
--- a/docs/integrations/exe-dev.mdx
+++ /dev/null
@@ -1,54 +0,0 @@
----
-title: "exe.dev (preview)"
-description: "Run Fabro workflows in fast ephemeral VMs from exe.dev"
----
-
-
-The exe.dev sandbox provider is **under development**. It requires building Fabro with the `exedev` feature flag enabled. The API and configuration may change.
-
-
-[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
-```
-
-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.
diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx
index 8bd099d7c..cfb1087e2 100644
--- a/docs/reference/cli.mdx
+++ b/docs/reference/cli.mdx
@@ -77,7 +77,7 @@ fabro run run.toml
| `--model ` | Override default LLM model |
| `--provider ` | Override default LLM provider |
| `-v, --verbose` | Enable verbose output |
-| `--sandbox ` | Sandbox for agent tools: `local`, `docker`, `daytona`, `ssh`, or `exe` |
+| `--sandbox ` | Sandbox for agent tools: `local`, `docker`, or `daytona` |
| `--label ` | Attach a label to this run (repeatable) |
| `--goal ` | Override the workflow goal (exposed as `$goal` in prompts) |
| `--goal-file ` | Read the goal from a file instead of inline text |
@@ -102,7 +102,7 @@ fabro preflight run.toml
| `--model ` | Override default LLM model |
| `--provider ` | Override default LLM provider |
| `-v, --verbose` | Enable verbose output |
-| `--sandbox ` | Sandbox for agent tools: `local`, `docker`, `daytona`, `ssh`, or `exe` |
+| `--sandbox ` | Sandbox for agent tools: `local`, `docker`, or `daytona` |
## `fabro resume`
@@ -344,7 +344,7 @@ fabro serve --sandbox daytona --max-concurrent-runs 4
| `--model ` | Override default LLM model | — |
| `--provider ` | Override default LLM provider | — |
| `--dry-run` | Execute with simulated LLM backend | — |
-| `--sandbox ` | Sandbox for agent tools: `local`, `docker`, `daytona`, `ssh`, or `exe` | — |
+| `--sandbox ` | Sandbox for agent tools: `local`, `docker`, or `daytona` | — |
| `--max-concurrent-runs ` | Maximum number of concurrent run executions | — |
| `--config ` | 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 :/path/in/sandbox ./local-dir # download
diff --git a/lib/crates/fabro-cli/Cargo.toml b/lib/crates/fabro-cli/Cargo.toml
index 6b1521592..3f69431d0 100644
--- a/lib/crates/fabro-cli/Cargo.toml
+++ b/lib/crates/fabro-cli/Cargo.toml
@@ -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" }
diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs
index c6a57442c..ec4051064 100644
--- a/lib/crates/fabro-cli/src/args.rs
+++ b/lib/crates/fabro-cli/src/args.rs
@@ -53,9 +53,6 @@ pub(crate) enum CliSandboxProvider {
Local,
Docker,
Daytona,
- #[cfg(feature = "exedev")]
- Exe,
- Ssh,
}
impl From for fabro_sandbox::SandboxProvider {
@@ -64,9 +61,6 @@ impl From 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 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,
}
}
}
diff --git a/lib/crates/fabro-cli/src/commands/preflight.rs b/lib/crates/fabro-cli/src/commands/preflight.rs
index c1b89a532..658f7e0ab 100644
--- a/lib/crates/fabro-cli/src/commands/preflight.rs
+++ b/lib/crates/fabro-cli/src/commands/preflight.rs
@@ -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 {
.and_then(|sandbox| sandbox.daytona.clone())
}
-#[cfg(feature = "exedev")]
-fn resolve_exe_config(settings: &FabroSettings) -> Option {
- settings
- .sandbox_settings()
- .and_then(|sandbox| sandbox.exe.clone())
-}
-
-fn resolve_ssh_config(settings: &FabroSettings) -> Option {
- 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, 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 {
diff --git a/lib/crates/fabro-cli/src/commands/store/dump.rs b/lib/crates/fabro-cli/src/commands/store/dump.rs
index 68e566ee3..83597a26e 100644
--- a/lib/crates/fabro-cli/src/commands/store/dump.rs
+++ b/lib/crates/fabro-cli/src/commands/store/dump.rs
@@ -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,
}
}
diff --git a/lib/crates/fabro-cli/tests/it/cmd/create.rs b/lib/crates/fabro-cli/tests/it/cmd/create.rs
index 63da7baed..c6fb38a71 100644
--- a/lib/crates/fabro-cli/tests/it/cmd/create.rs
+++ b/lib/crates/fabro-cli/tests/it/cmd/create.rs
@@ -28,7 +28,7 @@ fn help() {
--storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
--provider Override default LLM provider
-v, --verbose Enable verbose output
- --sandbox Sandbox for agent tools [possible values: local, docker, daytona, ssh]
+ --sandbox Sandbox for agent tools [possible values: local, docker, daytona]
--label 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)
diff --git a/lib/crates/fabro-cli/tests/it/cmd/preflight.rs b/lib/crates/fabro-cli/tests/it/cmd/preflight.rs
index bce95f863..4f6405a72 100644
--- a/lib/crates/fabro-cli/tests/it/cmd/preflight.rs
+++ b/lib/crates/fabro-cli/tests/it/cmd/preflight.rs
@@ -26,7 +26,7 @@ fn help() {
--provider Override default LLM provider
--storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
-v, --verbose Enable verbose output
- --sandbox Sandbox for agent tools [possible values: local, docker, daytona, ssh]
+ --sandbox Sandbox for agent tools [possible values: local, docker, daytona]
-h, --help Print help
----- stderr -----
");
diff --git a/lib/crates/fabro-cli/tests/it/cmd/run.rs b/lib/crates/fabro-cli/tests/it/cmd/run.rs
index 9980f30b9..cec482d06 100644
--- a/lib/crates/fabro-cli/tests/it/cmd/run.rs
+++ b/lib/crates/fabro-cli/tests/it/cmd/run.rs
@@ -310,7 +310,7 @@ fn help() {
--storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
--provider Override default LLM provider
-v, --verbose Enable verbose output
- --sandbox Sandbox for agent tools [possible values: local, docker, daytona, ssh]
+ --sandbox Sandbox for agent tools [possible values: local, docker, daytona]
--label 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)
diff --git a/lib/crates/fabro-config/Cargo.toml b/lib/crates/fabro-config/Cargo.toml
index 55f638c06..3e972afa9 100644
--- a/lib/crates/fabro-config/Cargo.toml
+++ b/lib/crates/fabro-config/Cargo.toml
@@ -11,7 +11,6 @@ doctest = false
[features]
default = []
-exedev = ["fabro-types/exedev"]
clap = ["dep:clap", "fabro-types/clap"]
[lints]
diff --git a/lib/crates/fabro-config/src/sandbox.rs b/lib/crates/fabro-config/src/sandbox.rs
index 827e8ead8..a90052d45 100644
--- a/lib/crates/fabro-config/src/sandbox.rs
+++ b/lib/crates/fabro-config/src/sandbox.rs
@@ -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 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,
-}
-
-#[cfg(feature = "exedev")]
-impl From 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,
- /// Remote working directory.
- pub working_directory: Option,
- /// Optional path to a custom SSH config file.
- pub config_file: Option,
- /// 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,
-}
-
-impl TryFrom for SshSettings {
- type Error = anyhow::Error;
-
- fn try_from(value: SshConfig) -> Result {
- 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,
@@ -126,9 +79,6 @@ pub struct SandboxConfig {
pub devcontainer: Option,
pub local: Option,
pub daytona: Option,
- #[cfg(feature = "exedev")]
- pub exe: Option,
- pub ssh: Option,
pub env: Option>,
}
@@ -142,9 +92,6 @@ impl TryFrom 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,
})
}
diff --git a/lib/crates/fabro-sandbox/Cargo.toml b/lib/crates/fabro-sandbox/Cargo.toml
index b018b3017..662de4ef4 100644
--- a/lib/crates/fabro-sandbox/Cargo.toml
+++ b/lib/crates/fabro-sandbox/Cargo.toml
@@ -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" }
diff --git a/lib/crates/fabro-sandbox/src/daytona/mod.rs b/lib/crates/fabro-sandbox/src/daytona/mod.rs
index 2e831c490..a7655b4c6 100644
--- a/lib/crates/fabro-sandbox/src/daytona/mod.rs
+++ b/lib/crates/fabro-sandbox/src/daytona/mod.rs
@@ -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,
+}
+
+pub fn detect_clone_params(cwd: &Path) -> Option {
+ 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
diff --git a/lib/crates/fabro-sandbox/src/exe/mod.rs b/lib/crates/fabro-sandbox/src/exe/mod.rs
deleted file mode 100644
index 02015634b..000000000
--- a/lib/crates/fabro-sandbox/src/exe/mod.rs
+++ /dev/null
@@ -1,1695 +0,0 @@
-mod openssh_runner;
-
-use std::collections::HashMap;
-use std::fmt::Write as _;
-use std::path::Path;
-use std::time::Instant;
-
-use crate::sandbox::resolve_path;
-use crate::shell_quote;
-use crate::ssh_common;
-use crate::{
- DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, SandboxEventCallback,
- format_lines_numbered,
-};
-use async_trait::async_trait;
-use fabro_github::GitHubAppCredentials;
-use fabro_types::RunId;
-use tokio::fs;
-use tokio::sync::OnceCell;
-use tokio_util::sync::CancellationToken;
-
-pub use crate::ssh_common::{GitCloneParams, SshOutput, SshRunner};
-pub use openssh_runner::OpensshRunner;
-
-pub use fabro_config::sandbox::ExeSettings as ExeConfig;
-
-const WORKING_DIRECTORY: &str = "/home/exedev";
-const PROVIDER: &str = "exe";
-
-/// No-op SSH runner used as a placeholder for the management plane
-/// when reconnecting to an existing VM via `from_existing`.
-struct NoopSshRunner;
-
-#[async_trait]
-impl SshRunner for NoopSshRunner {
- async fn run_command(&self, _command: &str) -> Result {
- Err("NoopSshRunner: management plane not available on reconnected sandbox".to_string())
- }
- async fn run_command_with_timeout(
- &self,
- _command: &str,
- _timeout: std::time::Duration,
- ) -> Result {
- Err("NoopSshRunner: management plane not available on reconnected sandbox".to_string())
- }
- async fn upload_file(&self, _path: &str, _content: &[u8]) -> Result<(), String> {
- Err("NoopSshRunner: management plane not available on reconnected sandbox".to_string())
- }
- async fn download_file(&self, _path: &str) -> Result, String> {
- Err("NoopSshRunner: management plane not available on reconnected sandbox".to_string())
- }
-}
-
-/// Factory function type for creating data-plane SSH runners.
-type DataSshFactory = Box<
- dyn Fn(
- &str,
- ) -> std::pin::Pin<
- Box, String>> + Send>,
- > + Send
- + Sync,
->;
-
-/// Sandbox that runs all operations inside an exe.dev VM via SSH.
-///
-/// Uses two SSH connections:
-/// - Management plane (`ssh exe.dev`) for VM lifecycle (create/destroy)
-/// - Data plane (`ssh .exe.xyz`) for command execution and file I/O
-pub struct ExeSandbox {
- mgmt_ssh: Box,
- data_ssh: OnceCell>,
- vm_name: OnceCell,
- data_host: OnceCell,
- rg_available: OnceCell,
- event_callback: Option,
- /// Factory for creating data-plane SSH runners, used during initialize().
- /// In production, this connects to the VM host via OpensshRunner.
- /// In tests, this is replaced with a closure that returns a MockSshRunner.
- data_ssh_factory: DataSshFactory,
- config: ExeConfig,
- clone_params: Option,
- run_id: Option,
- origin_url: OnceCell,
- github_app: Option,
-}
-
-impl ExeSandbox {
- /// Creates a new `ExeSandbox` with a management-plane SSH runner.
- pub fn new(
- mgmt_ssh: Box,
- config: ExeConfig,
- clone_params: Option,
- run_id: Option,
- github_app: Option,
- ) -> Self {
- Self {
- mgmt_ssh,
- data_ssh: OnceCell::new(),
- vm_name: OnceCell::new(),
- data_host: OnceCell::new(),
- rg_available: OnceCell::const_new(),
- event_callback: None,
- data_ssh_factory: Box::new(|host: &str| {
- let host = host.to_string();
- Box::pin(async move {
- OpensshRunner::connect(&host)
- .await
- .map(|r| Box::new(r) as Box)
- })
- }),
- config,
- clone_params,
- run_id,
- origin_url: OnceCell::new(),
- github_app,
- }
- }
-
- /// Create an `ExeSandbox` from a pre-connected data-plane SSH runner.
- /// Used for reconnection (e.g. `fabro cp`) when the VM already exists.
- pub fn from_existing(data_ssh: Box) -> Self {
- let data_cell = OnceCell::new();
- let _ = data_cell.set(data_ssh);
- Self {
- mgmt_ssh: Box::new(NoopSshRunner),
- data_ssh: data_cell,
- vm_name: OnceCell::new(),
- data_host: OnceCell::new(),
- rg_available: OnceCell::const_new(),
- event_callback: None,
- data_ssh_factory: Box::new(|_: &str| {
- Box::pin(async {
- Err("from_existing sandbox cannot create new SSH connections".to_string())
- })
- }),
- config: ExeConfig::default(),
- clone_params: None,
- run_id: None,
- origin_url: OnceCell::new(),
- github_app: None,
- }
- }
-
- /// The display URL of the cloned origin remote, if a clone was performed.
- pub fn origin_url(&self) -> Option<&str> {
- self.origin_url.get().map(String::as_str)
- }
-
- /// The VM name, available after initialization.
- pub fn vm_name(&self) -> Option<&str> {
- self.vm_name.get().map(String::as_str)
- }
-
- /// The data-plane SSH host, available after initialization.
- pub fn data_host(&self) -> Option<&str> {
- self.data_host.get().map(String::as_str)
- }
-
- pub fn set_event_callback(&mut self, cb: SandboxEventCallback) {
- self.event_callback = Some(cb);
- }
-
- fn emit(&self, event: SandboxEvent) {
- event.trace();
- if let Some(ref cb) = self.event_callback {
- cb(event);
- }
- }
-
- /// Get the data-plane SSH runner, returning an error if not yet initialized.
- fn data_ssh(&self) -> Result<&dyn SshRunner, String> {
- self.data_ssh
- .get()
- .map(std::convert::AsRef::as_ref)
- .ok_or_else(|| "Exe sandbox not initialized — call initialize() first".to_string())
- }
-
- /// Return the SSH command to connect to this VM's data host.
- pub fn ssh_command(&self) -> Result {
- let host = self.data_host.get().ok_or("Exe sandbox not initialized")?;
- Ok(format!("ssh {host}"))
- }
-
- /// Clone a git repo into the sandbox working directory.
- async fn clone_repo(&self, params: &GitCloneParams) -> Result<(), String> {
- let ssh = self.data_ssh()?;
- ssh_common::clone_repo(
- ssh,
- WORKING_DIRECTORY,
- params,
- self.github_app.as_ref(),
- &self.origin_url,
- &|event| self.emit(event),
- )
- .await
- }
-
- fn resolve_path(path: &str) -> String {
- resolve_path(path, WORKING_DIRECTORY)
- }
-}
-
-#[async_trait]
-impl Sandbox for ExeSandbox {
- async fn initialize(&self) -> Result<(), String> {
- self.emit(SandboxEvent::Initializing {
- provider: PROVIDER.into(),
- });
- let init_start = Instant::now();
-
- // Create a new VM via the management plane
- let mut cmd = "new --json".to_string();
- if let Some(ref image) = self.config.image {
- let _ = write!(cmd, " --image {}", shell_quote(image));
- }
- let output = self.mgmt_ssh.run_command(&cmd).await.map_err(|e| {
- let err = format!("Failed to create exe.dev VM: {e}");
- let duration_ms = u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
- self.emit(SandboxEvent::InitializeFailed {
- provider: PROVIDER.into(),
- error: err.clone(),
- duration_ms,
- });
- err
- })?;
-
- if output.exit_code != 0 {
- let stderr = String::from_utf8_lossy(&output.stderr);
- let err = format!(
- "exe.dev VM creation failed (exit {}): {stderr}",
- output.exit_code
- );
- let duration_ms = u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
- self.emit(SandboxEvent::InitializeFailed {
- provider: PROVIDER.into(),
- error: err.clone(),
- duration_ms,
- });
- return Err(err);
- }
-
- // Parse JSON response to get VM name and host
- let stdout = String::from_utf8_lossy(&output.stdout);
- let json: serde_json::Value = serde_json::from_str(stdout.trim()).map_err(|e| {
- let err = format!("Failed to parse exe.dev response: {e}");
- let duration_ms = u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
- self.emit(SandboxEvent::InitializeFailed {
- provider: PROVIDER.into(),
- error: err.clone(),
- duration_ms,
- });
- err
- })?;
-
- let vm_name = json["vm_name"]
- .as_str()
- .ok_or_else(|| "Missing 'vm_name' in exe.dev response".to_string())?
- .to_string();
- let data_host = json["ssh_dest"]
- .as_str()
- .ok_or_else(|| "Missing 'ssh_dest' in exe.dev response".to_string())?
- .to_string();
-
- self.vm_name
- .set(vm_name.clone())
- .map_err(|_| "Exe sandbox already initialized".to_string())?;
- self.data_host
- .set(data_host.clone())
- .map_err(|_| "Exe sandbox already initialized".to_string())?;
-
- // Create data-plane SSH connection
- let runner = (self.data_ssh_factory)(&data_host).await.map_err(|e| {
- let err = format!("Failed to connect to exe.dev VM {data_host}: {e}");
- let duration_ms = u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
- self.emit(SandboxEvent::InitializeFailed {
- provider: PROVIDER.into(),
- error: err.clone(),
- duration_ms,
- });
- err
- })?;
- self.data_ssh
- .set(runner)
- .map_err(|_| "Exe sandbox data SSH already set".to_string())?;
-
- // Clone git repo if clone params were provided
- if let Some(ref params) = self.clone_params {
- self.clone_repo(params).await?;
- }
-
- let init_duration = u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX);
- self.emit(SandboxEvent::Ready {
- provider: PROVIDER.into(),
- duration_ms: init_duration,
- name: Some(vm_name),
- cpu: None,
- memory: None,
- url: None,
- });
-
- Ok(())
- }
-
- async fn cleanup(&self) -> Result<(), String> {
- self.emit(SandboxEvent::CleanupStarted {
- provider: PROVIDER.into(),
- });
- let start = Instant::now();
-
- if let Some(vm_name) = self.vm_name.get() {
- let cmd = format!("rm {}", shell_quote(vm_name));
- if let Err(e) = self.mgmt_ssh.run_command(&cmd).await {
- let err = format!("Failed to destroy exe.dev VM: {e}");
- self.emit(SandboxEvent::CleanupFailed {
- provider: PROVIDER.into(),
- error: err.clone(),
- });
- return Err(err);
- }
- }
-
- let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
- self.emit(SandboxEvent::CleanupCompleted {
- provider: PROVIDER.into(),
- duration_ms,
- });
- Ok(())
- }
-
- async fn exec_command(
- &self,
- command: &str,
- timeout_ms: u64,
- working_dir: Option<&str>,
- env_vars: Option<&HashMap>,
- cancel_token: Option,
- ) -> Result {
- let ssh = self.data_ssh()?;
- let start = Instant::now();
-
- // Build inner script as plain text, then base64-wrap for safe transport
- let mut script = String::new();
-
- if let Some(vars) = env_vars {
- for (key, value) in vars {
- let _ = writeln!(script, "export {}={}", shell_quote(key), shell_quote(value));
- }
- }
-
- let dir = match working_dir {
- Some(dir) => Self::resolve_path(dir),
- None => WORKING_DIRECTORY.to_string(),
- };
- let _ = write!(script, "cd {} && {command}", shell_quote(&dir));
-
- let full_cmd = ssh_common::wrap_bash_command(&script);
-
- let timeout = std::time::Duration::from_millis(timeout_ms);
- let token = cancel_token.unwrap_or_default();
- let output = tokio::select! {
- res = ssh.run_command_with_timeout(&full_cmd, timeout) => res,
- () = token.cancelled() => {
- let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
- return Ok(ExecResult {
- stdout: String::new(),
- stderr: "Command cancelled".to_string(),
- exit_code: -1,
- timed_out: true,
- duration_ms,
- });
- }
- };
-
- let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
-
- match output {
- Ok(out) => Ok(ExecResult {
- stdout: String::from_utf8_lossy(&out.stdout).to_string(),
- stderr: String::from_utf8_lossy(&out.stderr).to_string(),
- exit_code: out.exit_code,
- timed_out: false,
- duration_ms,
- }),
- Err(e) if e.contains("timed out") => Ok(ExecResult {
- stdout: String::new(),
- stderr: "Command timed out".to_string(),
- exit_code: -1,
- timed_out: true,
- duration_ms,
- }),
- Err(e) => Err(e),
- }
- }
-
- async fn read_file(
- &self,
- path: &str,
- offset: Option,
- limit: Option,
- ) -> Result {
- let ssh = self.data_ssh()?;
- let resolved = Self::resolve_path(path);
-
- let output = ssh
- .run_command(&format!("cat {}", shell_quote(&resolved)))
- .await?;
-
- if output.exit_code != 0 {
- let stderr = String::from_utf8_lossy(&output.stderr);
- return Err(format!("Failed to read {resolved}: {stderr}"));
- }
-
- let content = String::from_utf8(output.stdout)
- .map_err(|e| format!("File is not valid UTF-8: {e}"))?;
-
- Ok(format_lines_numbered(&content, offset, limit))
- }
-
- async fn write_file(&self, path: &str, content: &str) -> Result<(), String> {
- let ssh = self.data_ssh()?;
- let resolved = Self::resolve_path(path);
-
- // Ensure parent directory exists
- if let Some(parent) = Path::new(&resolved).parent() {
- let parent_str = parent.to_string_lossy();
- if parent_str != "/" {
- ssh.run_command(&format!("mkdir -p {}", shell_quote(&parent_str)))
- .await?;
- }
- }
-
- ssh.upload_file(&resolved, content.as_bytes()).await
- }
-
- async fn delete_file(&self, path: &str) -> Result<(), String> {
- let ssh = self.data_ssh()?;
- let resolved = Self::resolve_path(path);
-
- let output = ssh
- .run_command(&format!("rm -f {}", shell_quote(&resolved)))
- .await?;
-
- if output.exit_code != 0 {
- let stderr = String::from_utf8_lossy(&output.stderr);
- return Err(format!("Failed to delete {resolved}: {stderr}"));
- }
- Ok(())
- }
-
- async fn file_exists(&self, path: &str) -> Result {
- let ssh = self.data_ssh()?;
- let resolved = Self::resolve_path(path);
-
- let output = ssh
- .run_command(&format!("test -e {}", shell_quote(&resolved)))
- .await?;
-
- Ok(output.exit_code == 0)
- }
-
- async fn list_directory(
- &self,
- path: &str,
- depth: Option,
- ) -> Result, String> {
- let resolved = Self::resolve_path(path);
- let max_depth = depth.unwrap_or(1);
-
- let cmd = format!(
- "find {} -mindepth 1 -maxdepth {} -printf '%y\\t%s\\t%P\\n'",
- shell_quote(&resolved),
- max_depth,
- );
-
- let result = self.exec_command(&cmd, 30_000, None, None, None).await?;
-
- if result.exit_code != 0 {
- return Err(format!(
- "Failed to list directory {resolved}: {}",
- result.stderr
- ));
- }
-
- let mut entries: Vec = result
- .stdout
- .lines()
- .filter(|line| !line.is_empty())
- .filter_map(|line| {
- let parts: Vec<&str> = line.splitn(3, '\t').collect();
- if parts.len() < 3 {
- return None;
- }
- let file_type = parts[0];
- let size: Option = parts[1].parse().ok();
- let name = parts[2].to_string();
- let is_dir = file_type == "d";
- Some(DirEntry {
- name,
- is_dir,
- size: if is_dir { None } else { size },
- })
- })
- .collect();
-
- entries.sort_by(|a, b| a.name.cmp(&b.name));
- Ok(entries)
- }
-
- async fn grep(
- &self,
- pattern: &str,
- path: &str,
- options: &GrepOptions,
- ) -> Result, String> {
- let resolved = Self::resolve_path(path);
-
- // Detect ripgrep availability (cached)
- let use_rg = *self
- .rg_available
- .get_or_init(|| async {
- let result = self
- .exec_command("rg --version", 10_000, None, None, None)
- .await;
- matches!(result, Ok(r) if r.exit_code == 0)
- })
- .await;
-
- let cmd = if use_rg {
- let mut cmd = "rg --line-number --no-heading".to_string();
- if options.case_insensitive {
- cmd.push_str(" -i");
- }
- if let Some(ref glob_filter) = options.glob_filter {
- let _ = write!(cmd, " --glob {}", shell_quote(glob_filter));
- }
- if let Some(max) = options.max_results {
- let _ = write!(cmd, " --max-count {max}");
- }
- let _ = write!(
- cmd,
- " -- {} {}",
- shell_quote(pattern),
- shell_quote(&resolved)
- );
- cmd
- } else {
- let mut cmd = "grep -rn".to_string();
- if options.case_insensitive {
- cmd.push_str(" -i");
- }
- if let Some(ref glob_filter) = options.glob_filter {
- let _ = write!(cmd, " --include {}", shell_quote(glob_filter));
- }
- if let Some(max) = options.max_results {
- let _ = write!(cmd, " -m {max}");
- }
- let _ = write!(
- cmd,
- " -- {} {}",
- shell_quote(pattern),
- shell_quote(&resolved)
- );
- cmd
- };
-
- let result = self.exec_command(&cmd, 30_000, None, None, None).await?;
-
- if result.exit_code == 1 {
- return Ok(Vec::new());
- }
- if result.exit_code != 0 {
- return Err(format!(
- "grep failed (exit {}): {}",
- result.exit_code, result.stderr
- ));
- }
-
- Ok(result.stdout.lines().map(String::from).collect())
- }
-
- async fn glob(&self, pattern: &str, path: Option<&str>) -> Result, String> {
- let base = path.map_or_else(|| WORKING_DIRECTORY.to_string(), Self::resolve_path);
-
- let cmd = format!(
- "find {} -name {} -type f | sort",
- shell_quote(&base),
- shell_quote(pattern),
- );
-
- let result = self.exec_command(&cmd, 30_000, None, None, None).await?;
-
- if result.exit_code != 0 {
- return Err(format!(
- "glob failed (exit {}): {}",
- result.exit_code, result.stderr
- ));
- }
-
- Ok(result
- .stdout
- .lines()
- .filter(|l| !l.is_empty())
- .map(String::from)
- .collect())
- }
-
- async fn download_file_to_local(
- &self,
- remote_path: &str,
- local_path: &Path,
- ) -> Result<(), String> {
- let ssh = self.data_ssh()?;
- let resolved = Self::resolve_path(remote_path);
-
- let bytes = ssh.download_file(&resolved).await?;
-
- if let Some(parent) = local_path.parent() {
- fs::create_dir_all(parent)
- .await
- .map_err(|e| format!("Failed to create parent dirs: {e}"))?;
- }
- fs::write(local_path, &bytes)
- .await
- .map_err(|e| format!("Failed to write {}: {e}", local_path.display()))?;
-
- Ok(())
- }
-
- async fn upload_file_from_local(
- &self,
- local_path: &Path,
- remote_path: &str,
- ) -> Result<(), String> {
- let ssh = self.data_ssh()?;
- let resolved = Self::resolve_path(remote_path);
-
- let bytes = fs::read(local_path)
- .await
- .map_err(|e| format!("Failed to read {}: {e}", local_path.display()))?;
-
- ssh.upload_file(&resolved, &bytes)
- .await
- .map_err(|e| format!("Failed to upload file {resolved}: {e}"))?;
-
- Ok(())
- }
-
- fn working_directory(&self) -> &str {
- WORKING_DIRECTORY
- }
-
- fn platform(&self) -> &'static str {
- "linux"
- }
-
- fn os_version(&self) -> String {
- "Linux (exe.dev)".to_string()
- }
-
- fn sandbox_info(&self) -> String {
- match (self.vm_name.get(), &self.run_id) {
- (Some(name), Some(id)) => format!("{name} (run {id})"),
- (Some(name), None) => name.clone(),
- _ => String::new(),
- }
- }
-
- async fn refresh_push_credentials(&self) -> Result<(), String> {
- let Some(origin_url) = self.origin_url() else {
- return Ok(());
- };
- let Some(creds) = &self.github_app else {
- return Ok(());
- };
-
- let auth_url = fabro_github::resolve_authenticated_url(creds, origin_url)
- .await
- .map_err(|e| format!("Failed to refresh GitHub App token: {e}"))?;
-
- let cmd = format!(
- "git -c maintenance.auto=0 remote set-url origin {}",
- shell_quote(&auth_url)
- );
- self.exec_command(&cmd, 10_000, None, None, None)
- .await
- .map_err(|e| format!("Failed to set refreshed push credentials: {e}"))?;
-
- Ok(())
- }
-
- async fn setup_git_for_run(&self, run_id: &str) -> Result