From 3936f185cfa01a330057fbd85150421114e39185 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 30 Mar 2026 12:17:20 -0400 Subject: [PATCH] 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) --- AGENTS.md | 6 +- Cargo.lock | 15 - Cargo.toml | 1 - docs/administration/sandboxing.mdx | 4 +- docs/api-reference/fabro-api.yaml | 29 - docs/docs.json | 1 - docs/execution/environments.mdx | 132 +- docs/execution/run-configuration.mdx | 40 +- docs/integrations/exe-dev.mdx | 54 - docs/reference/cli.mdx | 8 +- lib/crates/fabro-cli/Cargo.toml | 3 +- lib/crates/fabro-cli/src/args.rs | 11 - .../fabro-cli/src/commands/preflight.rs | 46 +- .../fabro-cli/src/commands/store/dump.rs | 1 - lib/crates/fabro-cli/tests/it/cmd/create.rs | 2 +- .../fabro-cli/tests/it/cmd/preflight.rs | 2 +- lib/crates/fabro-cli/tests/it/cmd/run.rs | 2 +- lib/crates/fabro-config/Cargo.toml | 1 - lib/crates/fabro-config/src/sandbox.rs | 55 +- lib/crates/fabro-sandbox/Cargo.toml | 5 +- lib/crates/fabro-sandbox/src/daytona/mod.rs | 21 + lib/crates/fabro-sandbox/src/exe/mod.rs | 1695 ----------------- .../fabro-sandbox/src/exe/openssh_runner.rs | 122 -- lib/crates/fabro-sandbox/src/lib.rs | 13 +- lib/crates/fabro-sandbox/src/reconnect.rs | 40 - lib/crates/fabro-sandbox/src/sandbox.rs | 17 +- .../fabro-sandbox/src/sandbox_provider.rs | 26 - lib/crates/fabro-sandbox/src/sandbox_spec.rs | 88 +- lib/crates/fabro-sandbox/src/ssh/mod.rs | 1231 ------------ .../fabro-sandbox/src/ssh/openssh_runner.rs | 104 - lib/crates/fabro-sandbox/src/ssh_common.rs | 189 -- lib/crates/fabro-sandbox/src/worktree.rs | 4 - lib/crates/fabro-server/Cargo.toml | 6 +- lib/crates/fabro-server/src/demo/mod.rs | 20 +- .../tests/it/openapi_conformance.rs | 2 - lib/crates/fabro-store/src/disk_projecting.rs | 1 - lib/crates/fabro-store/src/memory.rs | 1 - lib/crates/fabro-types/Cargo.toml | 1 - lib/crates/fabro-types/src/sandbox_record.rs | 2 - lib/crates/fabro-types/src/settings/mod.rs | 4 +- .../fabro-types/src/settings/sandbox.rs | 17 - lib/crates/fabro-workflows/Cargo.toml | 6 +- .../src/operations/rebuild_meta.rs | 1 - .../fabro-workflows/src/operations/start.rs | 40 +- .../tests/it/cp_integration.rs | 2 - .../tests/it/daytona_integration.rs | 1 - 46 files changed, 55 insertions(+), 4017 deletions(-) delete mode 100644 docs/integrations/exe-dev.mdx delete mode 100644 lib/crates/fabro-sandbox/src/exe/mod.rs delete mode 100644 lib/crates/fabro-sandbox/src/exe/openssh_runner.rs delete mode 100644 lib/crates/fabro-sandbox/src/ssh/mod.rs delete mode 100644 lib/crates/fabro-sandbox/src/ssh/openssh_runner.rs delete mode 100644 lib/crates/fabro-sandbox/src/ssh_common.rs 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, String> { - crate::setup_git_via_exec(self, run_id).await.map(Some) - } - - fn resume_setup_commands(&self, run_branch: &str) -> Vec { - vec![format!( - "git fetch origin {run_branch} && git checkout {run_branch}" - )] - } - - async fn git_push_branch(&self, branch: &str) -> bool { - crate::git_push_via_exec(self, branch).await - } - - fn parallel_worktree_path( - &self, - _run_dir: &std::path::Path, - run_id: &str, - node_id: &str, - key: &str, - ) -> String { - format!( - "{}/.fabro/runs/{}/parallel/{}/{}", - self.working_directory(), - run_id, - node_id, - key - ) - } - - async fn ssh_access_command(&self) -> Result, String> { - self.ssh_command().map(Some) - } - - fn data_host(&self) -> Option<&str> { - self.data_host.get().map(String::as_str) - } - - fn origin_url(&self) -> Option<&str> { - self.origin_url.get().map(String::as_str) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use base64::Engine; - use base64::engine::general_purpose::STANDARD; - use std::sync::{Arc, Mutex}; - use tokio::fs; - - /// A recorded command sent to the mock SSH runner. - #[derive(Debug, Clone)] - struct RecordedCommand { - command: String, - } - - /// A queued response for MockSshRunner. - struct MockResponse { - stdout: Vec, - stderr: Vec, - exit_code: i32, - } - - /// Mock upload record. - #[derive(Debug, Clone)] - struct RecordedUpload { - path: String, - content: Vec, - } - - /// Mock download response. - struct MockDownload { - content: Vec, - } - - /// Mock SSH runner for unit tests. - struct MockSshRunner { - commands: Arc>>, - responses: Arc>>, - uploads: Arc>>, - downloads: Arc>>, - } - - impl MockSshRunner { - fn new() -> Self { - Self { - commands: Arc::new(Mutex::new(Vec::new())), - responses: Arc::new(Mutex::new(Vec::new())), - uploads: Arc::new(Mutex::new(Vec::new())), - downloads: Arc::new(Mutex::new(Vec::new())), - } - } - - fn queue_response(&self, stdout: &str, stderr: &str, exit_code: i32) { - self.responses.lock().unwrap().push(MockResponse { - stdout: stdout.as_bytes().to_vec(), - stderr: stderr.as_bytes().to_vec(), - exit_code, - }); - } - - fn queue_response_bytes(&self, stdout: Vec, stderr: &str, exit_code: i32) { - self.responses.lock().unwrap().push(MockResponse { - stdout, - stderr: stderr.as_bytes().to_vec(), - exit_code, - }); - } - - fn queue_download(&self, content: Vec) { - self.downloads - .lock() - .unwrap() - .push(MockDownload { content }); - } - - fn pop_response(&self) -> MockResponse { - let mut responses = self.responses.lock().unwrap(); - if responses.is_empty() { - MockResponse { - stdout: Vec::new(), - stderr: b"no mock response queued".to_vec(), - exit_code: 1, - } - } else { - responses.remove(0) - } - } - } - - #[async_trait] - impl SshRunner for MockSshRunner { - async fn run_command(&self, command: &str) -> Result { - self.commands.lock().unwrap().push(RecordedCommand { - command: command.to_string(), - }); - let resp = self.pop_response(); - Ok(SshOutput { - stdout: resp.stdout, - stderr: resp.stderr, - exit_code: resp.exit_code, - }) - } - - async fn run_command_with_timeout( - &self, - command: &str, - _timeout: std::time::Duration, - ) -> Result { - self.commands.lock().unwrap().push(RecordedCommand { - command: command.to_string(), - }); - let resp = self.pop_response(); - if resp.exit_code == -99 { - return Err("Command timed out".to_string()); - } - Ok(SshOutput { - stdout: resp.stdout, - stderr: resp.stderr, - exit_code: resp.exit_code, - }) - } - - async fn upload_file(&self, path: &str, content: &[u8]) -> Result<(), String> { - self.uploads.lock().unwrap().push(RecordedUpload { - path: path.to_string(), - content: content.to_vec(), - }); - Ok(()) - } - - async fn download_file(&self, _path: &str) -> Result, String> { - let mut downloads = self.downloads.lock().unwrap(); - if downloads.is_empty() { - Err("no mock download queued".to_string()) - } else { - Ok(downloads.remove(0).content) - } - } - } - - /// Extract and decode the inner command from a base64-wrapped SSH command. - /// The format is: echo '' | base64 -d | sh - fn decode_bash_payload(wrapped: &str) -> String { - let start = wrapped.find("echo '").expect("missing echo prefix") + 6; - let end = wrapped[start..].find('\'').expect("missing closing quote") + start; - let encoded = &wrapped[start..end]; - let bytes = STANDARD.decode(encoded).expect("invalid base64"); - String::from_utf8(bytes).expect("invalid utf8") - } - - /// Helper: create an ExeSandbox with mock data SSH already initialized (skipping lifecycle). - fn sandbox_with_mock_data(data_ssh: impl SshRunner + 'static) -> ExeSandbox { - let mgmt = MockSshRunner::new(); - let sandbox = ExeSandbox::new(Box::new(mgmt), ExeConfig::default(), None, None, None); - let _ = sandbox.vm_name.set("test-vm".to_string()); - let _ = sandbox.data_host.set("test-vm.exe.xyz".to_string()); - let _ = sandbox.data_ssh.set(Box::new(data_ssh)); - sandbox - } - - // ---- Step 1: Metadata accessors ---- - - #[test] - fn working_directory_returns_home_user() { - let sandbox = sandbox_with_mock_data(MockSshRunner::new()); - assert_eq!(sandbox.working_directory(), "/home/exedev"); - } - - #[test] - fn platform_returns_linux() { - let sandbox = sandbox_with_mock_data(MockSshRunner::new()); - assert_eq!(sandbox.platform(), "linux"); - } - - #[test] - fn sandbox_info_returns_vm_name() { - let sandbox = sandbox_with_mock_data(MockSshRunner::new()); - assert_eq!(sandbox.sandbox_info(), "test-vm"); - } - - #[test] - fn os_version_returns_linux_exe() { - let sandbox = sandbox_with_mock_data(MockSshRunner::new()); - assert_eq!(sandbox.os_version(), "Linux (exe.dev)"); - } - - // ---- ssh_command ---- - - #[test] - fn ssh_command_returns_host_after_init() { - let sandbox = sandbox_with_mock_data(MockSshRunner::new()); - assert_eq!(sandbox.ssh_command().unwrap(), "ssh test-vm.exe.xyz"); - } - - #[test] - fn ssh_command_errors_before_init() { - let mgmt = MockSshRunner::new(); - let sandbox = ExeSandbox::new(Box::new(mgmt), ExeConfig::default(), None, None, None); - assert!(sandbox.ssh_command().is_err()); - } - - // ---- Step 2: exec_command ---- - - #[tokio::test] - async fn exec_command_runs_via_ssh() { - let data = MockSshRunner::new(); - data.queue_response("hello world\n", "", 0); - let sandbox = sandbox_with_mock_data(data); - - let result = sandbox - .exec_command("echo hello world", 5000, None, None, None) - .await - .unwrap(); - - assert_eq!(result.stdout.trim(), "hello world"); - assert_eq!(result.exit_code, 0); - assert!(!result.timed_out); - } - - #[tokio::test] - async fn exec_command_with_working_dir() { - let data = MockSshRunner::new(); - let commands = data.commands.clone(); - data.queue_response("", "", 0); - let sandbox = sandbox_with_mock_data(data); - - sandbox - .exec_command("ls", 5000, Some("/tmp/work"), None, None) - .await - .unwrap(); - - let recorded = commands.lock().unwrap(); - let inner = decode_bash_payload(&recorded[0].command); - assert!( - inner.contains("cd /tmp/work"), - "expected cd to working dir, got: {inner}", - ); - } - - #[tokio::test] - async fn exec_command_with_env_vars() { - let data = MockSshRunner::new(); - let commands = data.commands.clone(); - data.queue_response("", "", 0); - let sandbox = sandbox_with_mock_data(data); - - let mut env = HashMap::new(); - env.insert("FOO".to_string(), "bar".to_string()); - - sandbox - .exec_command("echo $FOO", 5000, None, Some(&env), None) - .await - .unwrap(); - - let recorded = commands.lock().unwrap(); - let inner = decode_bash_payload(&recorded[0].command); - assert!( - inner.contains("export FOO=bar"), - "expected env var export, got: {inner}", - ); - } - - #[tokio::test] - async fn exec_command_timeout() { - let data = MockSshRunner::new(); - // Use exit code -99 as the sentinel for timeout in our mock - data.queue_response_bytes(Vec::new(), "", -99); - let sandbox = sandbox_with_mock_data(data); - - let result = sandbox - .exec_command("sleep 999", 100, None, None, None) - .await - .unwrap(); - - assert!(result.timed_out); - assert_eq!(result.exit_code, -1); - } - - /// SSH runner that never completes — blocks forever on a Notify. - struct HangingSshRunner; - - #[async_trait] - impl SshRunner for HangingSshRunner { - async fn run_command(&self, _command: &str) -> Result { - std::future::pending().await - } - - async fn run_command_with_timeout( - &self, - _command: &str, - _timeout: std::time::Duration, - ) -> Result { - std::future::pending().await - } - - async fn upload_file(&self, _path: &str, _content: &[u8]) -> Result<(), String> { - Ok(()) - } - - async fn download_file(&self, _path: &str) -> Result, String> { - Ok(Vec::new()) - } - } - - #[tokio::test] - async fn exec_command_cancelled() { - let sandbox = sandbox_with_mock_data(HangingSshRunner); - - let token = CancellationToken::new(); - let token_clone = token.clone(); - - // Cancel immediately so the select! picks it up - token_clone.cancel(); - - let result = sandbox - .exec_command("sleep 999", 60_000, None, None, Some(token)) - .await - .unwrap(); - - assert!(result.timed_out); - assert_eq!(result.exit_code, -1); - assert_eq!(result.stderr, "Command cancelled"); - assert!(result.stdout.is_empty()); - } - - // ---- Step 3: read_file ---- - - #[tokio::test] - async fn read_file_returns_numbered_lines() { - let data = MockSshRunner::new(); - data.queue_response("line one\nline two\nline three\n", "", 0); - let sandbox = sandbox_with_mock_data(data); - - let content = sandbox.read_file("test.txt", None, None).await.unwrap(); - assert!(content.contains("1 | line one")); - assert!(content.contains("2 | line two")); - assert!(content.contains("3 | line three")); - } - - #[tokio::test] - async fn read_file_with_offset_and_limit() { - let data = MockSshRunner::new(); - data.queue_response("a\nb\nc\nd\ne\n", "", 0); - let sandbox = sandbox_with_mock_data(data); - - let content = sandbox - .read_file("test.txt", Some(1), Some(2)) - .await - .unwrap(); - assert!(content.contains("2 | b")); - assert!(content.contains("3 | c")); - assert!(!content.contains("1 | a")); - assert!(!content.contains("4 | d")); - } - - #[tokio::test] - async fn read_file_absolute_path() { - let data = MockSshRunner::new(); - let commands = data.commands.clone(); - data.queue_response("content\n", "", 0); - let sandbox = sandbox_with_mock_data(data); - - sandbox.read_file("/etc/hosts", None, None).await.unwrap(); - - let recorded = commands.lock().unwrap(); - assert!( - recorded[0].command.contains("/etc/hosts"), - "expected absolute path, got: {}", - recorded[0].command, - ); - assert!( - !recorded[0].command.contains("/home/user"), - "should not prepend working dir for absolute path", - ); - } - - // ---- Step 4: write_file ---- - - #[tokio::test] - async fn write_file_uploads_content() { - let data = MockSshRunner::new(); - let uploads = data.uploads.clone(); - // Response for mkdir -p - data.queue_response("", "", 0); - let sandbox = sandbox_with_mock_data(data); - - sandbox - .write_file("src/main.rs", "fn main() {}") - .await - .unwrap(); - - let recorded = uploads.lock().unwrap(); - assert_eq!(recorded[0].path, "/home/exedev/src/main.rs"); - assert_eq!(recorded[0].content, b"fn main() {}"); - } - - #[tokio::test] - async fn write_file_creates_parent_dirs() { - let data = MockSshRunner::new(); - let commands = data.commands.clone(); - // Response for mkdir -p - data.queue_response("", "", 0); - let sandbox = sandbox_with_mock_data(data); - - sandbox - .write_file("deep/nested/file.txt", "content") - .await - .unwrap(); - - let recorded = commands.lock().unwrap(); - assert!( - recorded[0].command.contains("mkdir -p"), - "expected mkdir -p, got: {}", - recorded[0].command, - ); - assert!( - recorded[0].command.contains("/home/exedev/deep/nested"), - "expected parent path, got: {}", - recorded[0].command, - ); - } - - // ---- Step 5: delete_file + file_exists ---- - - #[tokio::test] - async fn delete_file_runs_rm() { - let data = MockSshRunner::new(); - let commands = data.commands.clone(); - data.queue_response("", "", 0); - let sandbox = sandbox_with_mock_data(data); - - sandbox.delete_file("old.txt").await.unwrap(); - - let recorded = commands.lock().unwrap(); - assert!( - recorded[0].command.contains("rm -f"), - "expected rm -f, got: {}", - recorded[0].command, - ); - } - - #[tokio::test] - async fn file_exists_true() { - let data = MockSshRunner::new(); - data.queue_response("", "", 0); - let sandbox = sandbox_with_mock_data(data); - - assert!(sandbox.file_exists("exists.txt").await.unwrap()); - } - - #[tokio::test] - async fn file_exists_false() { - let data = MockSshRunner::new(); - data.queue_response("", "", 1); - let sandbox = sandbox_with_mock_data(data); - - assert!(!sandbox.file_exists("missing.txt").await.unwrap()); - } - - // ---- Step 6: list_directory ---- - - #[tokio::test] - async fn list_directory_parses_find_output() { - let data = MockSshRunner::new(); - // find output for list_directory (run via exec_command, so two responses: - // first for the rg_available check if it fires... but exec_command calls - // run_command_with_timeout directly, which will get the next response) - data.queue_response( - "f\t1024\tfile.txt\nd\t4096\tsrc\nf\t512\tREADME.md\n", - "", - 0, - ); - let sandbox = sandbox_with_mock_data(data); - - let entries = sandbox.list_directory(".", None).await.unwrap(); - assert_eq!(entries.len(), 3); - // Sorted alphabetically - assert_eq!(entries[0].name, "README.md"); - assert!(!entries[0].is_dir); - assert_eq!(entries[0].size, Some(512)); - assert_eq!(entries[1].name, "file.txt"); - assert_eq!(entries[2].name, "src"); - assert!(entries[2].is_dir); - assert!(entries[2].size.is_none()); - } - - // ---- Step 7: grep ---- - - #[tokio::test] - async fn grep_returns_matches() { - let data = MockSshRunner::new(); - // First call: rg --version check (cached) - data.queue_response("ripgrep 14.0.0", "", 0); - // Second call: the actual grep - data.queue_response( - "src/main.rs:1:fn main() {}\nsrc/lib.rs:5:fn helper() {}\n", - "", - 0, - ); - let sandbox = sandbox_with_mock_data(data); - - let results = sandbox - .grep("fn ", ".", &GrepOptions::default()) - .await - .unwrap(); - - assert_eq!(results.len(), 2); - assert!(results[0].contains("main.rs")); - } - - #[tokio::test] - async fn grep_no_matches_returns_empty() { - let data = MockSshRunner::new(); - // rg --version - data.queue_response("ripgrep 14.0.0", "", 0); - // grep with no matches (exit code 1) - data.queue_response("", "", 1); - let sandbox = sandbox_with_mock_data(data); - - let results = sandbox - .grep("nonexistent", ".", &GrepOptions::default()) - .await - .unwrap(); - - assert!(results.is_empty()); - } - - // ---- Step 8: glob ---- - - #[tokio::test] - async fn glob_finds_files() { - let data = MockSshRunner::new(); - data.queue_response("/home/user/src/main.rs\n/home/user/src/lib.rs\n", "", 0); - let sandbox = sandbox_with_mock_data(data); - - let results = sandbox.glob("*.rs", Some("src")).await.unwrap(); - - assert_eq!(results.len(), 2); - assert!(results[0].contains("main.rs")); - } - - // ---- Step 9: download_file_to_local ---- - - #[tokio::test] - async fn download_file_to_local_writes_bytes() { - let data = MockSshRunner::new(); - data.queue_download(b"binary content".to_vec()); - let sandbox = sandbox_with_mock_data(data); - - let tmp = tempfile::tempdir().unwrap(); - let local = tmp.path().join("downloaded.bin"); - sandbox - .download_file_to_local("artifact.bin", &local) - .await - .unwrap(); - - let bytes = fs::read(&local).await.unwrap(); - assert_eq!(bytes, b"binary content"); - } - - // ---- Step 10: initialize + cleanup (VM lifecycle) ---- - - #[tokio::test] - async fn initialize_creates_vm() { - let mgmt = MockSshRunner::new(); - mgmt.queue_response( - r#"{"vm_name": "my-vm", "ssh_dest": "my-vm.exe.xyz"}"#, - "", - 0, - ); - - let data_for_init = MockSshRunner::new(); - - let mut sandbox = ExeSandbox::new(Box::new(mgmt), ExeConfig::default(), None, None, None); - // Override factory to return our mock data SSH - let data_box: Arc>>> = - Arc::new(Mutex::new(Some(Box::new(data_for_init)))); - sandbox.data_ssh_factory = Box::new(move |_host: &str| { - let data_box = Arc::clone(&data_box); - Box::pin(async move { - data_box - .lock() - .unwrap() - .take() - .ok_or_else(|| "mock data SSH already taken".to_string()) - }) - }); - - sandbox.initialize().await.unwrap(); - - assert_eq!(sandbox.sandbox_info(), "my-vm"); - } - - #[tokio::test] - async fn initialize_emits_events() { - let mgmt = MockSshRunner::new(); - mgmt.queue_response( - r#"{"vm_name": "ev-vm", "ssh_dest": "ev-vm.exe.xyz"}"#, - "", - 0, - ); - - let events: Arc>> = Arc::new(Mutex::new(Vec::new())); - let events_cb = Arc::clone(&events); - - let mut sandbox = ExeSandbox::new(Box::new(mgmt), ExeConfig::default(), None, None, None); - sandbox.set_event_callback(Arc::new(move |event| { - events_cb.lock().unwrap().push(format!("{event:?}")); - })); - - let data_for_init = MockSshRunner::new(); - let data_box: Arc>>> = - Arc::new(Mutex::new(Some(Box::new(data_for_init)))); - sandbox.data_ssh_factory = Box::new(move |_host: &str| { - let data_box = Arc::clone(&data_box); - Box::pin(async move { - data_box - .lock() - .unwrap() - .take() - .ok_or_else(|| "mock data SSH already taken".to_string()) - }) - }); - - sandbox.initialize().await.unwrap(); - - let captured = events.lock().unwrap(); - assert!( - captured.iter().any(|e| e.contains("Initializing")), - "expected Initializing event, got: {captured:?}" - ); - assert!( - captured.iter().any(|e| e.contains("Ready")), - "expected Ready event, got: {captured:?}" - ); - } - - #[tokio::test] - async fn cleanup_destroys_vm() { - let mgmt = MockSshRunner::new(); - let mgmt_commands = mgmt.commands.clone(); - // Response for `rm ` - mgmt.queue_response("", "", 0); - - let sandbox = ExeSandbox::new(Box::new(mgmt), ExeConfig::default(), None, None, None); - let _ = sandbox.vm_name.set("doomed-vm".to_string()); - - sandbox.cleanup().await.unwrap(); - - let recorded = mgmt_commands.lock().unwrap(); - assert_eq!(recorded[0].command, "rm doomed-vm"); - } - - #[tokio::test] - async fn cleanup_before_initialize_is_noop() { - let mgmt = MockSshRunner::new(); - let sandbox = ExeSandbox::new(Box::new(mgmt), ExeConfig::default(), None, None, None); - // Should not error — no VM to destroy - sandbox.cleanup().await.unwrap(); - } - - // ---- clone_repo ---- - - #[tokio::test] - async fn initialize_with_clone_params_clones_repo() { - let mgmt = MockSshRunner::new(); - mgmt.queue_response( - r#"{"vm_name": "clone-vm", "ssh_dest": "clone-vm.exe.xyz"}"#, - "", - 0, - ); - - let data = MockSshRunner::new(); - let data_commands = data.commands.clone(); - // Response for git clone - data.queue_response("", "", 0); - - let data_box: Arc>>> = - Arc::new(Mutex::new(Some(Box::new(data)))); - - let clone_params = GitCloneParams { - url: "https://github.com/org/repo.git".to_string(), - branch: Some("main".to_string()), - }; - let mut sandbox = ExeSandbox::new( - Box::new(mgmt), - ExeConfig::default(), - Some(clone_params), - None, - None, - ); - sandbox.data_ssh_factory = Box::new(move |_host: &str| { - let data_box = Arc::clone(&data_box); - Box::pin(async move { - data_box - .lock() - .unwrap() - .take() - .ok_or_else(|| "mock data SSH already taken".to_string()) - }) - }); - - sandbox.initialize().await.unwrap(); - - let recorded = data_commands.lock().unwrap(); - let clone_inner = decode_bash_payload(&recorded[0].command); - assert!( - clone_inner.contains("git clone"), - "expected git clone, got: {clone_inner}", - ); - assert!( - clone_inner.contains("--branch main"), - "expected branch flag, got: {clone_inner}", - ); - assert_eq!( - sandbox.origin_url(), - Some("https://github.com/org/repo.git"), - ); - } - - #[tokio::test] - async fn initialize_without_clone_params_skips_clone() { - let mgmt = MockSshRunner::new(); - mgmt.queue_response( - r#"{"vm_name": "no-clone-vm", "ssh_dest": "no-clone-vm.exe.xyz"}"#, - "", - 0, - ); - - let data = MockSshRunner::new(); - let data_commands = data.commands.clone(); - - let data_box: Arc>>> = - Arc::new(Mutex::new(Some(Box::new(data)))); - - let mut sandbox = ExeSandbox::new(Box::new(mgmt), ExeConfig::default(), None, None, None); - sandbox.data_ssh_factory = Box::new(move |_host: &str| { - let data_box = Arc::clone(&data_box); - Box::pin(async move { - data_box - .lock() - .unwrap() - .take() - .ok_or_else(|| "mock data SSH already taken".to_string()) - }) - }); - - sandbox.initialize().await.unwrap(); - - let recorded = data_commands.lock().unwrap(); - assert!( - recorded.is_empty(), - "expected no data SSH commands without clone params, got: {recorded:?}", - ); - assert!(sandbox.origin_url().is_none()); - } - - #[tokio::test] - async fn initialize_clone_failure_emits_event_and_errors() { - let mgmt = MockSshRunner::new(); - mgmt.queue_response( - r#"{"vm_name": "fail-vm", "ssh_dest": "fail-vm.exe.xyz"}"#, - "", - 0, - ); - - let data = MockSshRunner::new(); - // git clone fails - data.queue_response("", "auth failed", 128); - - let data_box: Arc>>> = - Arc::new(Mutex::new(Some(Box::new(data)))); - - let events: Arc>> = Arc::new(Mutex::new(Vec::new())); - let events_cb = Arc::clone(&events); - - let clone_params = GitCloneParams { - url: "https://github.com/org/repo.git".to_string(), - branch: None, - }; - let mut sandbox = ExeSandbox::new( - Box::new(mgmt), - ExeConfig::default(), - Some(clone_params), - None, - None, - ); - sandbox.set_event_callback(Arc::new(move |event| { - events_cb.lock().unwrap().push(format!("{event:?}")); - })); - sandbox.data_ssh_factory = Box::new(move |_host: &str| { - let data_box = Arc::clone(&data_box); - Box::pin(async move { - data_box - .lock() - .unwrap() - .take() - .ok_or_else(|| "mock data SSH already taken".to_string()) - }) - }); - - let result = sandbox.initialize().await; - assert!(result.is_err()); - - let captured = events.lock().unwrap(); - assert!( - captured.iter().any(|e| e.contains("GitCloneFailed")), - "expected GitCloneFailed event, got: {captured:?}", - ); - } - - // ---- shell_quote injection tests ---- - - #[tokio::test] - async fn clone_quotes_branch_with_shell_metacharacters() { - let data = MockSshRunner::new(); - let data_commands = data.commands.clone(); - // Response for git clone - data.queue_response("", "", 0); - - let clone_params = GitCloneParams { - url: "https://github.com/org/repo.git".to_string(), - branch: Some("feat;id".to_string()), - }; - let sandbox = sandbox_with_mock_data(data); - sandbox.clone_repo(&clone_params).await.unwrap(); - - let recorded = data_commands.lock().unwrap(); - let clone_inner = decode_bash_payload(&recorded[0].command); - assert!( - clone_inner.contains("--branch 'feat;id'"), - "expected quoted branch, got: {clone_inner}", - ); - } - - #[tokio::test] - async fn clone_quotes_url_with_spaces() { - let data = MockSshRunner::new(); - let data_commands = data.commands.clone(); - data.queue_response("", "", 0); - - let clone_params = GitCloneParams { - url: "https://example.com/has space/repo.git".to_string(), - branch: None, - }; - let sandbox = sandbox_with_mock_data(data); - sandbox.clone_repo(&clone_params).await.unwrap(); - - let recorded = data_commands.lock().unwrap(); - let clone_inner = decode_bash_payload(&recorded[0].command); - assert!( - clone_inner.contains("'https://example.com/has space/repo.git'"), - "expected quoted URL, got: {clone_inner}", - ); - } - - #[tokio::test] - async fn initialize_quotes_image_in_mgmt_command() { - let mgmt = MockSshRunner::new(); - let mgmt_commands = mgmt.commands.clone(); - mgmt.queue_response( - r#"{"vm_name": "img-vm", "ssh_dest": "img-vm.exe.xyz"}"#, - "", - 0, - ); - - let data = MockSshRunner::new(); - let data_box: Arc>>> = - Arc::new(Mutex::new(Some(Box::new(data)))); - - let config = ExeConfig { - image: Some("ubuntu;evil".to_string()), - }; - let mut sandbox = ExeSandbox::new(Box::new(mgmt), config, None, None, None); - sandbox.data_ssh_factory = Box::new(move |_host: &str| { - let data_box = Arc::clone(&data_box); - Box::pin(async move { - data_box - .lock() - .unwrap() - .take() - .ok_or_else(|| "mock data SSH already taken".to_string()) - }) - }); - - sandbox.initialize().await.unwrap(); - - let recorded = mgmt_commands.lock().unwrap(); - assert!( - recorded[0].command.contains("--image 'ubuntu;evil'"), - "expected quoted image, got: {}", - recorded[0].command, - ); - } - - #[tokio::test] - async fn exec_command_quotes_env_values_with_metacharacters() { - let data = MockSshRunner::new(); - let commands = data.commands.clone(); - data.queue_response("", "", 0); - let sandbox = sandbox_with_mock_data(data); - - let mut env = HashMap::new(); - env.insert("KEY".to_string(), "val;rm -rf /".to_string()); - - sandbox - .exec_command("echo $KEY", 5000, None, Some(&env), None) - .await - .unwrap(); - - let recorded = commands.lock().unwrap(); - let inner = decode_bash_payload(&recorded[0].command); - assert!( - inner.contains("export KEY='val;rm -rf /'"), - "expected quoted env value, got: {inner}", - ); - } - - #[tokio::test] - async fn exec_command_quotes_working_dir_with_spaces() { - let data = MockSshRunner::new(); - let commands = data.commands.clone(); - data.queue_response("", "", 0); - let sandbox = sandbox_with_mock_data(data); - - sandbox - .exec_command("ls", 5000, Some("/tmp/my dir"), None, None) - .await - .unwrap(); - - let recorded = commands.lock().unwrap(); - let inner = decode_bash_payload(&recorded[0].command); - assert!( - inner.contains("cd '/tmp/my dir'"), - "expected quoted working dir, got: {inner}", - ); - } - - #[tokio::test] - async fn grep_quotes_glob_filter() { - let data = MockSshRunner::new(); - let commands = data.commands.clone(); - // rg --version - data.queue_response("ripgrep 14.0.0", "", 0); - // grep result - data.queue_response("", "", 1); - let sandbox = sandbox_with_mock_data(data); - - let options = GrepOptions { - glob_filter: Some("*.rs'".to_string()), - ..GrepOptions::default() - }; - sandbox.grep("pattern", ".", &options).await.unwrap(); - - let recorded = commands.lock().unwrap(); - // The grep command goes through exec_command, so decode second command - let inner = decode_bash_payload(&recorded[1].command); - assert!( - !inner.contains("--glob '*.rs''"), - "glob filter should be properly escaped, got: {inner}", - ); - } -} diff --git a/lib/crates/fabro-sandbox/src/exe/openssh_runner.rs b/lib/crates/fabro-sandbox/src/exe/openssh_runner.rs deleted file mode 100644 index 1585dfb6e..000000000 --- a/lib/crates/fabro-sandbox/src/exe/openssh_runner.rs +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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, 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) - } -} diff --git a/lib/crates/fabro-sandbox/src/lib.rs b/lib/crates/fabro-sandbox/src/lib.rs index de694d750..fddcaf92d 100644 --- a/lib/crates/fabro-sandbox/src/lib.rs +++ b/lib/crates/fabro-sandbox/src/lib.rs @@ -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; diff --git a/lib/crates/fabro-sandbox/src/reconnect.rs b/lib/crates/fabro-sandbox/src/reconnect.rs index 3510ac9cf..d991a2934 100644 --- a/lib/crates/fabro-sandbox/src/reconnect.rs +++ b/lib/crates/fabro-sandbox/src/reconnect.rs @@ -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 .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}"), } } diff --git a/lib/crates/fabro-sandbox/src/sandbox.rs b/lib/crates/fabro-sandbox/src/sandbox.rs index fb8aa9299..0688f7561 100644 --- a/lib/crates/fabro-sandbox/src/sandbox.rs +++ b/lib/crates/fabro-sandbox/src/sandbox.rs @@ -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() diff --git a/lib/crates/fabro-sandbox/src/sandbox_provider.rs b/lib/crates/fabro-sandbox/src/sandbox_provider.rs index 215a9a167..941ee6956 100644 --- a/lib/crates/fabro-sandbox/src/sandbox_provider.rs +++ b/lib/crates/fabro-sandbox/src/sandbox_provider.rs @@ -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::().unwrap(), SandboxProvider::Local ); - assert_eq!( - "exe".parse::().unwrap(), - SandboxProvider::Exe - ); - assert_eq!( - "EXE".parse::().unwrap(), - SandboxProvider::Exe - ); - assert_eq!( - "ssh".parse::().unwrap(), - SandboxProvider::Ssh - ); - assert_eq!( - "SSH".parse::().unwrap(), - SandboxProvider::Ssh - ); assert!("invalid".parse::().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"); } } diff --git a/lib/crates/fabro-sandbox/src/sandbox_spec.rs b/lib/crates/fabro-sandbox/src/sandbox_spec.rs index 165f7a600..334c533a5 100644 --- a/lib/crates/fabro-sandbox/src/sandbox_spec.rs +++ b/lib/crates/fabro-sandbox/src/sandbox_spec.rs @@ -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, clone_branch: Option, }, - #[cfg(feature = "exe")] - Exe { - config: ExeConfig, - clone_params: Option, - run_id: Option, - github_app: Option, - mgmt_destination: String, - }, - #[cfg(feature = "ssh")] - Ssh { - config: SshConfig, - clone_params: Option, - run_id: Option, - github_app: Option, - }, } #[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)) - } } } } diff --git a/lib/crates/fabro-sandbox/src/ssh/mod.rs b/lib/crates/fabro-sandbox/src/ssh/mod.rs deleted file mode 100644 index a45d80a99..000000000 --- a/lib/crates/fabro-sandbox/src/ssh/mod.rs +++ /dev/null @@ -1,1231 +0,0 @@ -mod openssh_runner; - -use std::collections::HashMap; -use std::fmt::Write; -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_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::SshSettings as SshConfig; - -const PROVIDER: &str = "ssh"; - -/// Sandbox that runs all operations on a user-provided SSH host. -/// -/// Unlike ExeSandbox, there is no VM lifecycle management -- the host -/// must already be running and accessible via SSH. -pub struct SshSandbox { - ssh: OnceCell>, - config: SshConfig, - clone_params: Option, - run_id: Option, - github_app: Option, - rg_available: OnceCell, - event_callback: Option, - origin_url: OnceCell, -} - -impl SshSandbox { - /// Creates a new `SshSandbox` targeting the given SSH host. - pub fn new( - config: SshConfig, - clone_params: Option, - run_id: Option, - github_app: Option, - ) -> Self { - Self { - ssh: OnceCell::new(), - config, - clone_params, - run_id, - github_app, - rg_available: OnceCell::const_new(), - event_callback: None, - origin_url: OnceCell::new(), - } - } - - /// Create an `SshSandbox` from a pre-connected SSH runner. - /// Used for reconnection (e.g. `fabro cp`) when the host is already known. - pub fn from_existing(ssh: Box, config: SshConfig) -> Self { - let ssh_cell = OnceCell::new(); - let _ = ssh_cell.set(ssh); - Self { - ssh: ssh_cell, - config, - clone_params: None, - run_id: None, - github_app: None, - rg_available: OnceCell::const_new(), - event_callback: None, - origin_url: OnceCell::new(), - } - } - - 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 SSH runner, returning an error if not yet initialized. - fn ssh(&self) -> Result<&dyn SshRunner, String> { - self.ssh - .get() - .map(std::convert::AsRef::as_ref) - .ok_or_else(|| "SSH sandbox not initialized -- call initialize() first".to_string()) - } - - /// Return the SSH command to connect to this host. - pub fn ssh_command(&self) -> String { - format!("ssh {}", self.config.destination) - } - - /// Clone a git repo into the sandbox working directory. - async fn clone_repo(&self, params: &GitCloneParams) -> Result<(), String> { - let ssh = self.ssh()?; - ssh_common::clone_repo( - ssh, - &self.config.working_directory, - params, - self.github_app.as_ref(), - &self.origin_url, - &|event| self.emit(event), - ) - .await - } - - fn resolve_path(&self, path: &str) -> String { - resolve_path(path, &self.config.working_directory) - } -} - -#[async_trait] -impl Sandbox for SshSandbox { - async fn initialize(&self) -> Result<(), String> { - self.emit(SandboxEvent::Initializing { - provider: PROVIDER.into(), - }); - let init_start = Instant::now(); - - // Connect SSH - let runner = - OpensshRunner::connect(&self.config.destination, self.config.config_file.as_deref()) - .await - .map_err(|e| { - let err = format!("Failed to connect to {}: {e}", self.config.destination); - 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.ssh - .set(Box::new(runner)) - .map_err(|_| "SSH sandbox already initialized".to_string())?; - - // Create working directory - let mkdir_cmd = format!("mkdir -p {}", shell_quote(&self.config.working_directory)); - let ssh = self.ssh()?; - let output = ssh.run_command(&mkdir_cmd).await.map_err(|e| { - let err = format!("Failed to create working directory: {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!("mkdir -p 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); - } - - // 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: None, - cpu: None, - memory: None, - url: None, - }); - - Ok(()) - } - - async fn cleanup(&self) -> Result<(), String> { - // No-op: we leave the workspace on the remote host - 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.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 => self.config.working_directory.clone(), - }; - 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.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.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.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.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( - || self.config.working_directory.clone(), - |p| self.resolve_path(p), - ); - - 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.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.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 { - &self.config.working_directory - } - - fn platform(&self) -> &'static str { - "linux" - } - - fn os_version(&self) -> String { - format!("Linux (ssh:{})", self.config.destination) - } - - fn sandbox_info(&self) -> String { - match &self.run_id { - Some(id) => format!("{} (run {id})", self.config.destination), - None => self.config.destination.clone(), - } - } - - 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, String> { - crate::setup_git_via_exec(self, run_id).await.map(Some) - } - - fn resume_setup_commands(&self, run_branch: &str) -> Vec { - vec![format!( - "git fetch origin {run_branch} && git checkout {run_branch}" - )] - } - - async fn git_push_branch(&self, branch: &str) -> bool { - crate::git_push_via_exec(self, branch).await - } - - fn parallel_worktree_path( - &self, - _run_dir: &std::path::Path, - run_id: &str, - node_id: &str, - key: &str, - ) -> String { - format!( - "{}/.fabro/runs/{}/parallel/{}/{}", - self.working_directory(), - run_id, - node_id, - key - ) - } - - async fn ssh_access_command(&self) -> Result, String> { - Ok(Some(self.ssh_command())) - } - - fn data_host(&self) -> Option<&str> { - Some(&self.config.destination) - } - - fn origin_url(&self) -> Option<&str> { - self.origin_url.get().map(String::as_str) - } - - async fn get_preview_url( - &self, - port: u16, - ) -> Result)>, String> { - Ok(self - .config - .preview_url_base - .as_ref() - .map(|base| (format!("{base}:{port}"), HashMap::new()))) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use base64::Engine; - use base64::engine::general_purpose::STANDARD; - use std::sync::{Arc, Mutex}; - use tokio::fs; - - /// A recorded command sent to the mock SSH runner. - #[derive(Debug, Clone)] - struct RecordedCommand { - command: String, - } - - /// A queued response for MockSshRunner. - struct MockResponse { - stdout: Vec, - stderr: Vec, - exit_code: i32, - } - - /// Mock upload record. - #[derive(Debug, Clone)] - struct RecordedUpload { - path: String, - content: Vec, - } - - /// Mock download response. - struct MockDownload { - content: Vec, - } - - /// Mock SSH runner for unit tests. - struct MockSshRunner { - commands: Arc>>, - responses: Arc>>, - uploads: Arc>>, - downloads: Arc>>, - } - - impl MockSshRunner { - fn new() -> Self { - Self { - commands: Arc::new(Mutex::new(Vec::new())), - responses: Arc::new(Mutex::new(Vec::new())), - uploads: Arc::new(Mutex::new(Vec::new())), - downloads: Arc::new(Mutex::new(Vec::new())), - } - } - - fn queue_response(&self, stdout: &str, stderr: &str, exit_code: i32) { - self.responses.lock().unwrap().push(MockResponse { - stdout: stdout.as_bytes().to_vec(), - stderr: stderr.as_bytes().to_vec(), - exit_code, - }); - } - - fn queue_response_bytes(&self, stdout: Vec, stderr: &str, exit_code: i32) { - self.responses.lock().unwrap().push(MockResponse { - stdout, - stderr: stderr.as_bytes().to_vec(), - exit_code, - }); - } - - fn queue_download(&self, content: Vec) { - self.downloads - .lock() - .unwrap() - .push(MockDownload { content }); - } - - fn pop_response(&self) -> MockResponse { - let mut responses = self.responses.lock().unwrap(); - if responses.is_empty() { - MockResponse { - stdout: Vec::new(), - stderr: b"no mock response queued".to_vec(), - exit_code: 1, - } - } else { - responses.remove(0) - } - } - } - - #[async_trait] - impl SshRunner for MockSshRunner { - async fn run_command(&self, command: &str) -> Result { - self.commands.lock().unwrap().push(RecordedCommand { - command: command.to_string(), - }); - let resp = self.pop_response(); - Ok(SshOutput { - stdout: resp.stdout, - stderr: resp.stderr, - exit_code: resp.exit_code, - }) - } - - async fn run_command_with_timeout( - &self, - command: &str, - _timeout: std::time::Duration, - ) -> Result { - self.commands.lock().unwrap().push(RecordedCommand { - command: command.to_string(), - }); - let resp = self.pop_response(); - if resp.exit_code == -99 { - return Err("Command timed out".to_string()); - } - Ok(SshOutput { - stdout: resp.stdout, - stderr: resp.stderr, - exit_code: resp.exit_code, - }) - } - - async fn upload_file(&self, path: &str, content: &[u8]) -> Result<(), String> { - self.uploads.lock().unwrap().push(RecordedUpload { - path: path.to_string(), - content: content.to_vec(), - }); - Ok(()) - } - - async fn download_file(&self, _path: &str) -> Result, String> { - let mut downloads = self.downloads.lock().unwrap(); - if downloads.is_empty() { - Err("no mock download queued".to_string()) - } else { - Ok(downloads.remove(0).content) - } - } - } - - /// Extract and decode the inner command from a base64-wrapped SSH command. - /// The format is: echo '' | base64 -d | sh - fn decode_bash_payload(wrapped: &str) -> String { - let start = wrapped.find("echo '").expect("missing echo prefix") + 6; - let end = wrapped[start..].find('\'').expect("missing closing quote") + start; - let encoded = &wrapped[start..end]; - let bytes = STANDARD.decode(encoded).expect("invalid base64"); - String::from_utf8(bytes).expect("invalid utf8") - } - - fn test_config() -> SshConfig { - SshConfig { - destination: "user@testhost".to_string(), - working_directory: "/home/user/workspace".to_string(), - config_file: None, - preview_url_base: None, - } - } - - /// Helper: create an SshSandbox with mock SSH already initialized (skipping connect). - fn sandbox_with_mock(ssh: impl SshRunner + 'static) -> SshSandbox { - SshSandbox::from_existing(Box::new(ssh), test_config()) - } - - // ---- Metadata accessors ---- - - #[tokio::test] - async fn get_preview_url_returns_none_when_not_configured() { - let sandbox = sandbox_with_mock(MockSshRunner::new()); - assert_eq!(sandbox.get_preview_url(3000).await.unwrap(), None); - } - - #[tokio::test] - async fn get_preview_url_returns_url_when_configured() { - let mut config = test_config(); - config.preview_url_base = Some("http://beast".to_string()); - let sandbox = SshSandbox::from_existing(Box::new(MockSshRunner::new()), config); - let (url, headers) = sandbox.get_preview_url(3000).await.unwrap().unwrap(); - assert_eq!(url, "http://beast:3000"); - assert!(headers.is_empty()); - } - - #[test] - fn working_directory_returns_configured_path() { - let sandbox = sandbox_with_mock(MockSshRunner::new()); - assert_eq!(sandbox.working_directory(), "/home/user/workspace"); - } - - #[test] - fn platform_returns_linux() { - let sandbox = sandbox_with_mock(MockSshRunner::new()); - assert_eq!(sandbox.platform(), "linux"); - } - - #[test] - fn sandbox_info_returns_destination() { - let sandbox = sandbox_with_mock(MockSshRunner::new()); - assert_eq!(sandbox.sandbox_info(), "user@testhost"); - } - - #[test] - fn os_version_returns_ssh_info() { - let sandbox = sandbox_with_mock(MockSshRunner::new()); - assert_eq!(sandbox.os_version(), "Linux (ssh:user@testhost)"); - } - - #[test] - fn ssh_command_returns_destination() { - let sandbox = sandbox_with_mock(MockSshRunner::new()); - assert_eq!(sandbox.ssh_command(), "ssh user@testhost"); - } - - // ---- cleanup ---- - - #[tokio::test] - async fn cleanup_is_noop() { - let sandbox = sandbox_with_mock(MockSshRunner::new()); - sandbox.cleanup().await.unwrap(); - } - - // ---- exec_command ---- - - #[tokio::test] - async fn exec_command_runs_via_ssh() { - let data = MockSshRunner::new(); - data.queue_response("hello world\n", "", 0); - let sandbox = sandbox_with_mock(data); - - let result = sandbox - .exec_command("echo hello world", 5000, None, None, None) - .await - .unwrap(); - - assert_eq!(result.stdout.trim(), "hello world"); - assert_eq!(result.exit_code, 0); - assert!(!result.timed_out); - } - - #[tokio::test] - async fn exec_command_with_working_dir() { - let data = MockSshRunner::new(); - let commands = data.commands.clone(); - data.queue_response("", "", 0); - let sandbox = sandbox_with_mock(data); - - sandbox - .exec_command("ls", 5000, Some("/tmp/work"), None, None) - .await - .unwrap(); - - let recorded = commands.lock().unwrap(); - let inner = decode_bash_payload(&recorded[0].command); - assert!( - inner.contains("cd /tmp/work"), - "expected cd to working dir, got: {inner}", - ); - } - - #[tokio::test] - async fn exec_command_with_env_vars() { - let data = MockSshRunner::new(); - let commands = data.commands.clone(); - data.queue_response("", "", 0); - let sandbox = sandbox_with_mock(data); - - let mut env = HashMap::new(); - env.insert("FOO".to_string(), "bar".to_string()); - - sandbox - .exec_command("echo $FOO", 5000, None, Some(&env), None) - .await - .unwrap(); - - let recorded = commands.lock().unwrap(); - let inner = decode_bash_payload(&recorded[0].command); - assert!( - inner.contains("export FOO=bar"), - "expected env var export, got: {inner}", - ); - } - - #[tokio::test] - async fn exec_command_timeout() { - let data = MockSshRunner::new(); - data.queue_response_bytes(Vec::new(), "", -99); - let sandbox = sandbox_with_mock(data); - - let result = sandbox - .exec_command("sleep 999", 100, None, None, None) - .await - .unwrap(); - - assert!(result.timed_out); - assert_eq!(result.exit_code, -1); - } - - /// SSH runner that never completes -- blocks forever. - struct HangingSshRunner; - - #[async_trait] - impl SshRunner for HangingSshRunner { - async fn run_command(&self, _command: &str) -> Result { - std::future::pending().await - } - - async fn run_command_with_timeout( - &self, - _command: &str, - _timeout: std::time::Duration, - ) -> Result { - std::future::pending().await - } - - async fn upload_file(&self, _path: &str, _content: &[u8]) -> Result<(), String> { - Ok(()) - } - - async fn download_file(&self, _path: &str) -> Result, String> { - Ok(Vec::new()) - } - } - - #[tokio::test] - async fn exec_command_cancelled() { - let sandbox = sandbox_with_mock(HangingSshRunner); - - let token = CancellationToken::new(); - let token_clone = token.clone(); - - // Cancel immediately so the select! picks it up - token_clone.cancel(); - - let result = sandbox - .exec_command("sleep 999", 60_000, None, None, Some(token)) - .await - .unwrap(); - - assert!(result.timed_out); - assert_eq!(result.exit_code, -1); - assert_eq!(result.stderr, "Command cancelled"); - assert!(result.stdout.is_empty()); - } - - // ---- read_file ---- - - #[tokio::test] - async fn read_file_returns_numbered_lines() { - let data = MockSshRunner::new(); - data.queue_response("line one\nline two\nline three\n", "", 0); - let sandbox = sandbox_with_mock(data); - - let content = sandbox.read_file("test.txt", None, None).await.unwrap(); - assert!(content.contains("1 | line one")); - assert!(content.contains("2 | line two")); - assert!(content.contains("3 | line three")); - } - - #[tokio::test] - async fn read_file_with_offset_and_limit() { - let data = MockSshRunner::new(); - data.queue_response("a\nb\nc\nd\ne\n", "", 0); - let sandbox = sandbox_with_mock(data); - - let content = sandbox - .read_file("test.txt", Some(1), Some(2)) - .await - .unwrap(); - assert!(content.contains("2 | b")); - assert!(content.contains("3 | c")); - assert!(!content.contains("1 | a")); - assert!(!content.contains("4 | d")); - } - - #[tokio::test] - async fn read_file_absolute_path() { - let data = MockSshRunner::new(); - let commands = data.commands.clone(); - data.queue_response("content\n", "", 0); - let sandbox = sandbox_with_mock(data); - - sandbox.read_file("/etc/hosts", None, None).await.unwrap(); - - let recorded = commands.lock().unwrap(); - assert!( - recorded[0].command.contains("/etc/hosts"), - "expected absolute path, got: {}", - recorded[0].command, - ); - assert!( - !recorded[0].command.contains("/home/user"), - "should not prepend working dir for absolute path", - ); - } - - // ---- write_file ---- - - #[tokio::test] - async fn write_file_uploads_content() { - let data = MockSshRunner::new(); - let uploads = data.uploads.clone(); - // Response for mkdir -p - data.queue_response("", "", 0); - let sandbox = sandbox_with_mock(data); - - sandbox - .write_file("src/main.rs", "fn main() {}") - .await - .unwrap(); - - let recorded = uploads.lock().unwrap(); - assert_eq!(recorded[0].path, "/home/user/workspace/src/main.rs"); - assert_eq!(recorded[0].content, b"fn main() {}"); - } - - #[tokio::test] - async fn write_file_creates_parent_dirs() { - let data = MockSshRunner::new(); - let commands = data.commands.clone(); - // Response for mkdir -p - data.queue_response("", "", 0); - let sandbox = sandbox_with_mock(data); - - sandbox - .write_file("deep/nested/file.txt", "content") - .await - .unwrap(); - - let recorded = commands.lock().unwrap(); - assert!( - recorded[0].command.contains("mkdir -p"), - "expected mkdir -p, got: {}", - recorded[0].command, - ); - assert!( - recorded[0] - .command - .contains("/home/user/workspace/deep/nested"), - "expected parent path, got: {}", - recorded[0].command, - ); - } - - // ---- delete_file + file_exists ---- - - #[tokio::test] - async fn delete_file_runs_rm() { - let data = MockSshRunner::new(); - let commands = data.commands.clone(); - data.queue_response("", "", 0); - let sandbox = sandbox_with_mock(data); - - sandbox.delete_file("old.txt").await.unwrap(); - - let recorded = commands.lock().unwrap(); - assert!( - recorded[0].command.contains("rm -f"), - "expected rm -f, got: {}", - recorded[0].command, - ); - } - - #[tokio::test] - async fn file_exists_true() { - let data = MockSshRunner::new(); - data.queue_response("", "", 0); - let sandbox = sandbox_with_mock(data); - - assert!(sandbox.file_exists("exists.txt").await.unwrap()); - } - - #[tokio::test] - async fn file_exists_false() { - let data = MockSshRunner::new(); - data.queue_response("", "", 1); - let sandbox = sandbox_with_mock(data); - - assert!(!sandbox.file_exists("missing.txt").await.unwrap()); - } - - // ---- list_directory ---- - - #[tokio::test] - async fn list_directory_parses_find_output() { - let data = MockSshRunner::new(); - data.queue_response( - "f\t1024\tfile.txt\nd\t4096\tsrc\nf\t512\tREADME.md\n", - "", - 0, - ); - let sandbox = sandbox_with_mock(data); - - let entries = sandbox.list_directory(".", None).await.unwrap(); - assert_eq!(entries.len(), 3); - // Sorted alphabetically - assert_eq!(entries[0].name, "README.md"); - assert!(!entries[0].is_dir); - assert_eq!(entries[0].size, Some(512)); - assert_eq!(entries[1].name, "file.txt"); - assert_eq!(entries[2].name, "src"); - assert!(entries[2].is_dir); - assert!(entries[2].size.is_none()); - } - - // ---- grep ---- - - #[tokio::test] - async fn grep_returns_matches() { - let data = MockSshRunner::new(); - // First call: rg --version check (cached) - data.queue_response("ripgrep 14.0.0", "", 0); - // Second call: the actual grep - data.queue_response( - "src/main.rs:1:fn main() {}\nsrc/lib.rs:5:fn helper() {}\n", - "", - 0, - ); - let sandbox = sandbox_with_mock(data); - - let results = sandbox - .grep("fn ", ".", &GrepOptions::default()) - .await - .unwrap(); - - assert_eq!(results.len(), 2); - assert!(results[0].contains("main.rs")); - } - - #[tokio::test] - async fn grep_no_matches_returns_empty() { - let data = MockSshRunner::new(); - // rg --version - data.queue_response("ripgrep 14.0.0", "", 0); - // grep with no matches (exit code 1) - data.queue_response("", "", 1); - let sandbox = sandbox_with_mock(data); - - let results = sandbox - .grep("nonexistent", ".", &GrepOptions::default()) - .await - .unwrap(); - - assert!(results.is_empty()); - } - - // ---- glob ---- - - #[tokio::test] - async fn glob_finds_files() { - let data = MockSshRunner::new(); - data.queue_response( - "/home/user/workspace/src/main.rs\n/home/user/workspace/src/lib.rs\n", - "", - 0, - ); - let sandbox = sandbox_with_mock(data); - - let results = sandbox.glob("*.rs", Some("src")).await.unwrap(); - - assert_eq!(results.len(), 2); - assert!(results[0].contains("main.rs")); - } - - // ---- download_file_to_local ---- - - #[tokio::test] - async fn download_file_to_local_writes_bytes() { - let data = MockSshRunner::new(); - data.queue_download(b"binary content".to_vec()); - let sandbox = sandbox_with_mock(data); - - let tmp = tempfile::tempdir().unwrap(); - let local = tmp.path().join("downloaded.bin"); - sandbox - .download_file_to_local("artifact.bin", &local) - .await - .unwrap(); - - let bytes = fs::read(&local).await.unwrap(); - assert_eq!(bytes, b"binary content"); - } - - // ---- from_existing ---- - - #[tokio::test] - async fn from_existing_reconnects() { - let data = MockSshRunner::new(); - data.queue_response("hello\n", "", 0); - - let config = test_config(); - let sandbox = SshSandbox::from_existing(Box::new(data), config); - - // Should be able to use immediately (no initialize needed) - let result = sandbox - .exec_command("echo hello", 5000, None, None, None) - .await - .unwrap(); - assert_eq!(result.stdout.trim(), "hello"); - } - - // ---- path resolution ---- - - #[test] - fn resolve_path_relative() { - let sandbox = sandbox_with_mock(MockSshRunner::new()); - assert_eq!( - sandbox.resolve_path("src/main.rs"), - "/home/user/workspace/src/main.rs" - ); - } - - #[test] - fn resolve_path_absolute() { - let sandbox = sandbox_with_mock(MockSshRunner::new()); - assert_eq!(sandbox.resolve_path("/tmp/file.txt"), "/tmp/file.txt"); - } -} diff --git a/lib/crates/fabro-sandbox/src/ssh/openssh_runner.rs b/lib/crates/fabro-sandbox/src/ssh/openssh_runner.rs deleted file mode 100644 index 0c823fb5f..000000000 --- a/lib/crates/fabro-sandbox/src/ssh/openssh_runner.rs +++ /dev/null @@ -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 { - 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 { - 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 { - 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, 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) - } -} diff --git a/lib/crates/fabro-sandbox/src/ssh_common.rs b/lib/crates/fabro-sandbox/src/ssh_common.rs deleted file mode 100644 index 26b6d97c7..000000000 --- a/lib/crates/fabro-sandbox/src/ssh_common.rs +++ /dev/null @@ -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, - pub stderr: Vec, - 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; - - async fn run_command_with_timeout( - &self, - command: &str, - timeout: std::time::Duration, - ) -> Result; - - async fn upload_file(&self, path: &str, content: &[u8]) -> Result<(), String>; - - async fn download_file(&self, path: &str) -> Result, 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, -} - -#[cfg(feature = "daytona")] -pub fn detect_clone_params(cwd: &Path) -> Option { - 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 { - 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, - 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(()) -} diff --git a/lib/crates/fabro-sandbox/src/worktree.rs b/lib/crates/fabro-sandbox/src/worktree.rs index d40c8fb26..9e0fa517e 100644 --- a/lib/crates/fabro-sandbox/src/worktree.rs +++ b/lib/crates/fabro-sandbox/src/worktree.rs @@ -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, String> { self.inner.setup_git_for_run(run_id).await } diff --git a/lib/crates/fabro-server/Cargo.toml b/lib/crates/fabro-server/Cargo.toml index 88d8b3f04..bc16abae3 100644 --- a/lib/crates/fabro-server/Cargo.toml +++ b/lib/crates/fabro-server/Cargo.toml @@ -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" } diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 164a7f7a7..e92cbea29 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -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, diff --git a/lib/crates/fabro-server/tests/it/openapi_conformance.rs b/lib/crates/fabro-server/tests/it/openapi_conformance.rs index b21b95df9..37af89db3 100644 --- a/lib/crates/fabro-server/tests/it/openapi_conformance.rs +++ b/lib/crates/fabro-server/tests/it/openapi_conformance.rs @@ -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()), diff --git a/lib/crates/fabro-store/src/disk_projecting.rs b/lib/crates/fabro-store/src/disk_projecting.rs index 73d9e793b..f3ceaaea8 100644 --- a/lib/crates/fabro-store/src/disk_projecting.rs +++ b/lib/crates/fabro-store/src/disk_projecting.rs @@ -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, } } diff --git a/lib/crates/fabro-store/src/memory.rs b/lib/crates/fabro-store/src/memory.rs index 18367c0cf..950308bb1 100644 --- a/lib/crates/fabro-store/src/memory.rs +++ b/lib/crates/fabro-store/src/memory.rs @@ -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, } } diff --git a/lib/crates/fabro-types/Cargo.toml b/lib/crates/fabro-types/Cargo.toml index 5b134afef..3b996e531 100644 --- a/lib/crates/fabro-types/Cargo.toml +++ b/lib/crates/fabro-types/Cargo.toml @@ -11,7 +11,6 @@ doctest = false [features] default = [] -exedev = [] clap = ["dep:clap"] [lints] diff --git a/lib/crates/fabro-types/src/sandbox_record.rs b/lib/crates/fabro-types/src/sandbox_record.rs index 7609e7de9..59741a709 100644 --- a/lib/crates/fabro-types/src/sandbox_record.rs +++ b/lib/crates/fabro-types/src/sandbox_record.rs @@ -10,6 +10,4 @@ pub struct SandboxRecord { pub host_working_directory: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub container_mount_point: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub data_host: Option, } diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index b3943d15a..4e84dac11 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -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, diff --git a/lib/crates/fabro-types/src/settings/sandbox.rs b/lib/crates/fabro-types/src/settings/sandbox.rs index c704865cc..f352f8352 100644 --- a/lib/crates/fabro-types/src/settings/sandbox.rs +++ b/lib/crates/fabro-types/src/settings/sandbox.rs @@ -114,20 +114,6 @@ pub struct DaytonaSnapshotSettings { pub dockerfile: Option, } -#[cfg(feature = "exedev")] -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct ExeSettings { - pub image: Option, -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -pub struct SshSettings { - pub destination: String, - pub working_directory: String, - pub config_file: Option, - pub preview_url_base: Option, -} - #[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, pub local: Option, pub daytona: Option, - #[cfg(feature = "exedev")] - pub exe: Option, - pub ssh: Option, pub env: Option>, } diff --git a/lib/crates/fabro-workflows/Cargo.toml b/lib/crates/fabro-workflows/Cargo.toml index 300f36280..e9eb15e93 100644 --- a/lib/crates/fabro-workflows/Cargo.toml +++ b/lib/crates/fabro-workflows/Cargo.toml @@ -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" } diff --git a/lib/crates/fabro-workflows/src/operations/rebuild_meta.rs b/lib/crates/fabro-workflows/src/operations/rebuild_meta.rs index a92029ce5..4e658f2b9 100644 --- a/lib/crates/fabro-workflows/src/operations/rebuild_meta.rs +++ b/lib/crates/fabro-workflows/src/operations/rebuild_meta.rs @@ -356,7 +356,6 @@ mod tests { identifier: None, host_working_directory: None, container_mount_point: None, - data_host: None, } } diff --git a/lib/crates/fabro-workflows/src/operations/start.rs b/lib/crates/fabro-workflows/src/operations/start.rs index f66dcd219..48aa377d7 100644 --- a/lib/crates/fabro-workflows/src/operations/start.rs +++ b/lib/crates/fabro-workflows/src/operations/start.rs @@ -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 { .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()) -} - fn resolve_fallback_chain( provider: Provider, model: &str, diff --git a/lib/crates/fabro-workflows/tests/it/cp_integration.rs b/lib/crates/fabro-workflows/tests/it/cp_integration.rs index 50620a274..034e5df9b 100644 --- a/lib/crates/fabro-workflows/tests/it/cp_integration.rs +++ b/lib/crates/fabro-workflows/tests/it/cp_integration.rs @@ -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, } } diff --git a/lib/crates/fabro-workflows/tests/it/daytona_integration.rs b/lib/crates/fabro-workflows/tests/it/daytona_integration.rs index 95553f590..ea6ab3e37 100644 --- a/lib/crates/fabro-workflows/tests/it/daytona_integration.rs +++ b/lib/crates/fabro-workflows/tests/it/daytona_integration.rs @@ -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)