Add provider-backed sandbox inventory API and rename SandboxProvider to… (#409)

## Summary

Exposes `GET /api/v1/sandboxes` and `GET /api/v1/sandboxes/{id}`
endpoints that query sandbox inventory directly from configured
providers (Docker, Daytona), independent of run projections. Also
renames the existing `SandboxProvider` enum to `SandboxProviderKind`
throughout the codebase to free the name for the new `SandboxProvider`
trait.

### Plan Summary

- **OpenAPI + types**: New `SandboxInfo`, `SandboxListResponse`,
`SandboxListMeta`, `SandboxProviderLookupError`, and
`SandboxProviderKind` schemas added to the API spec; canonical Rust DTOs
added to `fabro-types`.
- **Provider trait and registry**: `SandboxProvider` trait (`list`,
`get`, `create`, `delete`) and `SandboxProviderRegistry` introduced in
`fabro-sandbox/src/provider.rs`. Registry fans out calls across all
configured providers and implements fail-soft semantics for list and
conflict/unavailable detection for get.
- **Provider implementations**: `DockerSandboxProvider` uses Bollard
label-filtered container listing and per-inspect;
`DaytonaSandboxProvider` uses the SDK with paginated label-filtered
listing. Both verify `sh.fabro.managed=true`.
- **Shared detail mapping**: Docker and Daytona inspect-to-`SandboxInfo`
paths extracted into `docker_info_from_inspect` /
`daytona_info_from_sdk_sandbox` so run-scoped `SandboxDetails` and
inventory `SandboxInfo` share the same normalization logic.
- **Monitoring UI**: `RunsInfo` now exposes `scheduler_slots_used`; the
monitoring panel displays "slots used" instead of the raw active-run
count.

## What changed and why

**`SandboxProvider` → `SandboxProviderKind`** is a mechanical rename
across ~20 call sites so the unqualified name `SandboxProvider` can be
claimed by the new trait without collision.

**Registry lookup semantics** for `get_managed_by_native_id`:

| Outcome | HTTP |
|---|---|
| Exactly one provider matches | `200` |
| All providers succeed, none match | `404` |
| Two or more providers match the same id | `409` |
| No match + at least one provider failed | `502` |

List is always fail-soft: partial results are returned and failing
providers appear in `meta.provider_errors`.

**`DockerFields` / `DaytonaFields` structs** were introduced inside
`details.rs` to hold the shared normalization output. Both
`map_docker_inspect` (run-scoped) and `docker_info_from_inspect`
(inventory) now delegate to `docker_fields_from_inspect`, eliminating
duplicate field-extraction logic. Same pattern for Daytona.

**`futures` moved from optional to unconditional** in
`fabro-sandbox/Cargo.toml` because `join_all` / `try_join_all` are now
used in `provider.rs`, which is not feature-gated.

**`local` provider** intentionally returns an empty list and `None` for
get — it has no provider-managed inventory.


### Fabro Details

<details>
<summary>Ran 8 stages in 102m 54s for $41.81</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 6s | – | 0 |
| preflight_lint | 2m 20s | – | 0 |
| implement | 56m 40s | $10.82 | 0 |
| simplify_opus | 27m 50s | $26.24 | 0 |
| simplify_gpt | 5m 2s | $4.76 | 0 |
| verify | 8m 24s | – | 0 |
| **Total** | **102m 54s** | **$41.81** | **0** |

</details>

<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>

```dot
digraph ImplementPlan {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { model: claude-opus-4-7; }
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
    simplify_opus     [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
    simplify_gpt      [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
    verify            [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=succeeded"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=succeeded"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=succeeded"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify_opus -> simplify_gpt -> verify
    verify -> exit  [condition="outcome=succeeded"]
    verify -> fixup
    fixup -> verify
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
This commit is contained in:
fabro-sh-0530[bot] 2026-05-25 22:41:57 -04:00 • committed by GitHub
parent 62f0b3e7d1
commit bd72570437
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 2379 additions and 228 deletions

View file

@ -15,6 +15,8 @@ tags:
description: Browser authentication
- name: Runs
description: Run management operations
- name: Sandboxes
description: Provider-backed sandbox inventory
- name: Sessions
description: Ask Fabro sessions bound to runs
- name: Human-in-the-Loop
@ -3632,6 +3634,67 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/sandboxes:
get:
operationId: listSandboxes
tags: [Sandboxes]
summary: List Sandboxes
description: Lists Fabro-managed sandboxes directly from configured sandbox providers.
responses:
"200":
description: Provider-backed sandbox inventory
content:
application/json:
schema:
$ref: "#/components/schemas/SandboxListResponse"
/api/v1/sandboxes/{id}:
get:
operationId: retrieveSandbox
tags: [Sandboxes]
summary: Retrieve Sandbox
description: Retrieves a Fabro-managed sandbox by provider-native id by searching all configured sandbox providers.
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
description: Sandbox found
content:
application/json:
schema:
$ref: "#/components/schemas/SandboxInfo"
"404":
description: No provider found a Fabro-managed sandbox with this id
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"409":
description: More than one provider matched this sandbox id
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"502":
description: Provider lookup failed before a definitive result could be determined
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/{id}/sandbox:
get:
operationId: retrieveRunSandbox
@ -7536,7 +7599,7 @@ components:
required: [provider, id]
properties:
provider:
$ref: "#/components/schemas/SandboxProvider"
$ref: "#/components/schemas/SandboxProviderKind"
id:
type: string
@ -10177,8 +10240,8 @@ components:
description: Equals `inference_time_ms + tool_time_ms`.
example: 180000
SandboxProvider:
description: Sandbox execution provider.
SandboxProviderKind:
description: Sandbox provider discriminator.
type: string
enum:
- local
@ -10195,7 +10258,7 @@ components:
- runtime
properties:
provider:
$ref: "#/components/schemas/SandboxProvider"
$ref: "#/components/schemas/SandboxProviderKind"
image:
type: ["string", "null"]
snapshot:
@ -11097,6 +11160,95 @@ components:
timestamps:
$ref: "#/components/schemas/SandboxTimestamps"
SandboxInfo:
description: Provider-backed inventory record for a Fabro-managed sandbox.
type: object
required:
- provider
- id
- state
- resources
- network
- labels
- timestamps
properties:
provider:
$ref: "#/components/schemas/SandboxProviderKind"
id:
type: string
description: Provider-native sandbox id.
display_name:
type: ["string", "null"]
description: Provider display name when distinct from the native id.
state:
$ref: "#/components/schemas/SandboxState"
native_state:
type: ["string", "null"]
description: Original provider state string before normalization. Display/debugging only; UI behavior keys off `state`.
image:
type: ["string", "null"]
description: Provider image when surfaced by the sandbox provider.
snapshot:
type: ["string", "null"]
description: Provider snapshot when surfaced by the sandbox provider.
region:
type: ["string", "null"]
description: Provider region or target. Null for local-style providers.
web_url:
type: ["string", "null"]
description: Provider dashboard URL for this sandbox when available.
working_directory:
type: ["string", "null"]
description: Provider-reported or Fabro-default working directory when available.
resources:
$ref: "#/components/schemas/SandboxResources"
network:
$ref: "#/components/schemas/SandboxNetwork"
labels:
type: object
additionalProperties:
type: string
description: Provider-reported labels.
timestamps:
$ref: "#/components/schemas/SandboxTimestamps"
SandboxProviderLookupError:
description: Provider error captured during fail-soft sandbox inventory lookup.
type: object
required:
- provider
- message
properties:
provider:
$ref: "#/components/schemas/SandboxProviderKind"
message:
type: string
SandboxListMeta:
description: Metadata for provider-backed sandbox inventory.
type: object
required:
- provider_errors
properties:
provider_errors:
type: array
items:
$ref: "#/components/schemas/SandboxProviderLookupError"
SandboxListResponse:
description: Non-paginated provider-backed sandbox inventory response.
type: object
required:
- data
- meta
properties:
data:
type: array
items:
$ref: "#/components/schemas/SandboxInfo"
meta:
$ref: "#/components/schemas/SandboxListMeta"
SandboxFileEntry:
description: A directory entry in a run sandbox.
type: object

View file

@ -530,9 +530,25 @@ fn main() {
("PreRunPushOutcome", "fabro_types::PreRunPushOutcome", &[]),
("DirtyStatus", "fabro_types::DirtyStatus", &[]),
("GitContext", "fabro_types::GitContext", &[]),
("SandboxProvider", "fabro_types::SandboxProvider", &[]),
(
"SandboxProviderKind",
"fabro_types::SandboxProviderKind",
&[],
),
("RunSandbox", "fabro_types::RunSandbox", &[]),
("SandboxDetails", "fabro_types::SandboxDetails", &[]),
("SandboxInfo", "fabro_types::SandboxInfo", &[]),
(
"SandboxProviderLookupError",
"fabro_types::SandboxProviderLookupError",
&[],
),
("SandboxListMeta", "fabro_types::SandboxListMeta", &[]),
(
"SandboxListResponse",
"fabro_types::SandboxListResponse",
&[],
),
("SandboxNetwork", "fabro_types::SandboxNetwork", &[]),
(
"SandboxNetworkPolicy",

View file

@ -47,12 +47,13 @@ pub mod types {
QuestionType, RepositoryRef, Run, RunApproval, RunApprovalState, RunClientProvenance,
RunEvent, RunEventDetailContentKind, RunEventDetailResponse, RunFailure,
RunPairStatusResponse, RunProjection, RunProvenance, RunRunnableSource, RunSandbox,
RunSandboxRuntime, RunServerProvenance, RunSize, SandboxDetails, SandboxNetwork,
SandboxNetworkPolicy, SandboxNetworkPolicyMode, SandboxProvider, SandboxResources,
SandboxService, SandboxServiceListResponse, SandboxState, SandboxTimestamps,
SecretMetadata, SecretType, ServerSettings, SessionDetail, SessionId, SessionMessage,
SessionRecord, SessionStatus, SessionSummary, SessionTurn, SkillsProjection,
StageCompletion, StageContextWindow, StageContextWindowBreakdownItem,
RunSandboxRuntime, RunServerProvenance, RunSize, SandboxDetails, SandboxInfo,
SandboxListMeta, SandboxListResponse, SandboxNetwork, SandboxNetworkPolicy,
SandboxNetworkPolicyMode, SandboxProviderKind, SandboxProviderLookupError,
SandboxResources, SandboxService, SandboxServiceListResponse, SandboxState,
SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, SessionDetail, SessionId,
SessionMessage, SessionRecord, SessionStatus, SessionSummary, SessionTurn,
SkillsProjection, StageCompletion, StageContextWindow, StageContextWindowBreakdownItem,
StageContextWindowCategory, StageContextWindowCountMethod, StageContextWindowProjection,
StageContextWindowStaleness, StageContextWindowUnavailableReason,
StageContextWindowWarning, StageHandler, StageModelUsage, StageOutcome, StageProjection,

View file

@ -1,19 +1,19 @@
use std::any::{TypeId, type_name};
use fabro_api::types::{RunSandbox as ApiRunSandbox, SandboxProvider as ApiSandboxProvider};
use fabro_types::{RunSandbox, RunSandboxRuntime, SandboxProvider};
use fabro_api::types::{RunSandbox as ApiRunSandbox, SandboxProviderKind as ApiSandboxProvider};
use fabro_types::{RunSandbox, RunSandboxRuntime, SandboxProviderKind};
use serde_json::json;
#[test]
fn run_sandbox_reuses_domain_types() {
assert_same_type::<ApiRunSandbox, RunSandbox>();
assert_same_type::<ApiSandboxProvider, SandboxProvider>();
assert_same_type::<ApiSandboxProvider, SandboxProviderKind>();
}
#[test]
fn run_sandbox_json_matches_openapi_shape() {
let sandbox = RunSandbox {
provider: SandboxProvider::Docker,
provider: SandboxProviderKind::Docker,
image: Some("ghcr.io/fabro/sandbox:latest".to_string()),
snapshot: None,
runtime: Some(RunSandboxRuntime {

View file

@ -5,20 +5,21 @@ use chrono::{TimeZone, Utc};
use fabro_api::types::{
SandboxDetails as ApiSandboxDetails, SandboxNetwork as ApiSandboxNetwork,
SandboxNetworkPolicy as ApiSandboxNetworkPolicy,
SandboxNetworkPolicyMode as ApiSandboxNetworkPolicyMode, SandboxProvider as ApiSandboxProvider,
SandboxResources as ApiSandboxResources, SandboxState as ApiSandboxState,
SandboxTimestamps as ApiSandboxTimestamps,
SandboxNetworkPolicyMode as ApiSandboxNetworkPolicyMode,
SandboxProviderKind as ApiSandboxProvider, SandboxResources as ApiSandboxResources,
SandboxState as ApiSandboxState, SandboxTimestamps as ApiSandboxTimestamps,
};
use fabro_types::{
RunSandbox, RunSandboxRuntime, SandboxDetails, SandboxNetwork, SandboxNetworkPolicy,
SandboxNetworkPolicyMode, SandboxProvider, SandboxResources, SandboxState, SandboxTimestamps,
SandboxNetworkPolicyMode, SandboxProviderKind, SandboxResources, SandboxState,
SandboxTimestamps,
};
use serde_json::json;
#[test]
fn sandbox_details_reuses_domain_types() {
assert_same_type::<ApiSandboxDetails, SandboxDetails>();
assert_same_type::<ApiSandboxProvider, SandboxProvider>();
assert_same_type::<ApiSandboxProvider, SandboxProviderKind>();
assert_same_type::<ApiSandboxState, SandboxState>();
assert_same_type::<ApiSandboxResources, SandboxResources>();
assert_same_type::<ApiSandboxTimestamps, SandboxTimestamps>();
@ -32,7 +33,7 @@ fn sandbox_details_json_matches_openapi_shape() {
let created_at = Utc.with_ymd_and_hms(2026, 5, 9, 12, 0, 0).unwrap();
let details = SandboxDetails {
sandbox: RunSandbox {
provider: SandboxProvider::Docker,
provider: SandboxProviderKind::Docker,
image: Some("ghcr.io/fabro/sandbox:latest".to_string()),
snapshot: None,
runtime: Some(RunSandboxRuntime {
@ -129,7 +130,7 @@ fn sandbox_details_deserializes_when_optional_fields_are_absent() {
}))
.unwrap();
assert_eq!(details.sandbox.provider, SandboxProvider::Local);
assert_eq!(details.sandbox.provider, SandboxProviderKind::Local);
assert_eq!(
details
.sandbox

View file

@ -0,0 +1,123 @@
use std::any::{TypeId, type_name};
use std::collections::BTreeMap;
use chrono::{TimeZone, Utc};
use fabro_api::types::{
SandboxInfo as ApiSandboxInfo, SandboxListMeta as ApiSandboxListMeta,
SandboxListResponse as ApiSandboxListResponse, SandboxProviderKind as ApiSandboxProviderKind,
SandboxProviderLookupError as ApiSandboxProviderLookupError,
};
use fabro_types::{
SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxNetwork, SandboxNetworkPolicy,
SandboxProviderKind, SandboxProviderLookupError, SandboxResources, SandboxState,
SandboxTimestamps,
};
use serde_json::json;
#[test]
fn sandbox_inventory_round_trip_reuses_domain_types() {
assert_same_type::<ApiSandboxProviderKind, SandboxProviderKind>();
assert_same_type::<ApiSandboxInfo, SandboxInfo>();
assert_same_type::<ApiSandboxProviderLookupError, SandboxProviderLookupError>();
assert_same_type::<ApiSandboxListMeta, SandboxListMeta>();
assert_same_type::<ApiSandboxListResponse, SandboxListResponse>();
}
#[test]
fn sandbox_inventory_round_trip_json_matches_openapi_shape() {
let created_at = Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap();
let response = SandboxListResponse {
data: vec![SandboxInfo {
provider: SandboxProviderKind::Daytona,
id: "sandbox-abc123".to_string(),
display_name: Some("fabro-01KSGHGMCFM8W2FHXNMJ7MVY65".to_string()),
state: SandboxState::Running,
native_state: Some("started".to_string()),
image: None,
snapshot: Some("daytona-medium".to_string()),
region: Some("us".to_string()),
web_url: Some(
"https://app.daytona.io/dashboard/sandboxes?sandboxId=sandbox-abc123".to_string(),
),
working_directory: Some("/home/daytona/workspace".to_string()),
resources: SandboxResources {
cpu_cores: Some(2.0),
memory_bytes: Some(4 * 1024 * 1024 * 1024),
disk_bytes: Some(20 * 1024 * 1024 * 1024),
},
network: SandboxNetwork {
egress: SandboxNetworkPolicy::open(),
ingress: SandboxNetworkPolicy::blocked(),
},
labels: BTreeMap::from([(
"sh.fabro.managed".to_string(),
"true".to_string(),
)]),
timestamps: SandboxTimestamps {
created_at: Some(created_at),
last_activity_at: Some(created_at),
},
}],
meta: SandboxListMeta {
provider_errors: vec![SandboxProviderLookupError {
provider: SandboxProviderKind::Docker,
message: "Failed to connect to Docker daemon".to_string(),
}],
},
};
assert_eq!(
serde_json::to_value(&response).unwrap(),
json!({
"data": [{
"provider": "daytona",
"id": "sandbox-abc123",
"display_name": "fabro-01KSGHGMCFM8W2FHXNMJ7MVY65",
"state": "running",
"native_state": "started",
"snapshot": "daytona-medium",
"region": "us",
"web_url": "https://app.daytona.io/dashboard/sandboxes?sandboxId=sandbox-abc123",
"working_directory": "/home/daytona/workspace",
"resources": {
"cpu_cores": 2.0,
"memory_bytes": 4_294_967_296_u64,
"disk_bytes": 21_474_836_480_u64
},
"network": {
"egress": {
"mode": "open",
"cidrs": []
},
"ingress": {
"mode": "blocked",
"cidrs": []
}
},
"labels": {
"sh.fabro.managed": "true"
},
"timestamps": {
"created_at": "2026-05-25T12:00:00Z",
"last_activity_at": "2026-05-25T12:00:00Z"
}
}],
"meta": {
"provider_errors": [{
"provider": "docker",
"message": "Failed to connect to Docker daemon"
}]
}
})
);
}
fn assert_same_type<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<U>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<U>()
);
}

View file

@ -474,7 +474,7 @@ mod tests {
use fabro_model::{Catalog, ModelRef, ProviderId};
use fabro_types::run_event::CliEnsureCompletedProps;
use fabro_types::{
MetadataSnapshotFailureKind, MetadataSnapshotPhase, ParallelBranchId, SandboxProvider,
MetadataSnapshotFailureKind, MetadataSnapshotPhase, ParallelBranchId, SandboxProviderKind,
StageId, fixtures,
};
use fabro_workflow::event::{Event, RunNoticeLevel, to_run_event, to_run_event_at};
@ -737,7 +737,7 @@ mod tests {
stage_started("code", "Code"),
Event::SandboxInitialized {
working_directory: "/home/daytona/workspace".into(),
provider: SandboxProvider::Daytona,
provider: SandboxProviderKind::Daytona,
id: "daytona:sandbox-id".into(),
repo_cloned: None,
clone_origin_url: None,
@ -1119,7 +1119,7 @@ mod tests {
emit(&mut ui, stage_started("code", "Code"));
emit(&mut ui, Event::SandboxInitialized {
working_directory: "/home/daytona/workspace".into(),
provider: SandboxProvider::Daytona,
provider: SandboxProviderKind::Daytona,
id: "daytona:sandbox-id".into(),
repo_cloned: None,
clone_origin_url: None,

View file

@ -473,9 +473,9 @@ mod tests {
use fabro_types::graph::Graph;
use fabro_types::run::RunSpec;
use fabro_types::{
Checkpoint, CheckpointRecord, Conclusion, RunDiff, RunSandbox, RunStatus, SandboxProvider,
StageCompletion, StageModelUsage, StageOutcome, StartRecord, SuccessReason,
WorkflowSettings, first_event_seq, fixtures,
Checkpoint, CheckpointRecord, Conclusion, RunDiff, RunSandbox, RunStatus,
SandboxProviderKind, StageCompletion, StageModelUsage, StageOutcome, StartRecord,
SuccessReason, WorkflowSettings, first_event_seq, fixtures,
};
use futures::executor;
@ -558,7 +558,7 @@ mod tests {
diff: RunDiff::default(),
});
projection.sandbox = Some(RunSandbox {
provider: SandboxProvider::Local,
provider: SandboxProviderKind::Local,
image: None,
snapshot: None,
runtime: Some(fabro_types::RunSandboxRuntime {

View file

@ -461,13 +461,13 @@ pub fn write_object_store_settings(
}
fn write_sandbox_provider_policy(server: &mut toml::Table) -> Result<()> {
use fabro_types::SandboxProvider;
use fabro_types::SandboxProviderKind;
let sandbox = ensure_table(server, "sandbox")?;
let providers = ensure_table(sandbox, "providers")?;
for provider in [
SandboxProvider::Local,
SandboxProvider::Docker,
SandboxProvider::Daytona,
SandboxProviderKind::Local,
SandboxProviderKind::Docker,
SandboxProviderKind::Daytona,
] {
let entry = ensure_table(providers, &provider.to_string())?;
entry.insert("enabled".to_string(), toml::Value::Boolean(true));

View file

@ -9,7 +9,7 @@ description = "Sandbox trait and implementations for Fabro agent execution envir
[features]
default = ["local"]
local = []
docker = ["dep:bollard", "dep:tar", "dep:futures", "dep:fabro-github"]
docker = ["dep:bollard", "dep:tar", "dep:fabro-github"]
daytona = ["dep:daytona-sdk", "dep:daytona-api-client", "dep:git2", "dep:fabro-github", "dep:fabro-config", "dep:fabro-http", "dep:reqwest-middleware", "dep:rand", "dep:tokio-tungstenite", "dep:futures-util", "dep:rustls"]
test-support = []
@ -39,10 +39,11 @@ shlex = "1"
# local
glob = { version = "0.3" }
futures = { workspace = true }
# docker
bollard = { workspace = true, optional = true }
tar = { workspace = true, optional = true }
futures = { workspace = true, optional = true }
# daytona
fabro-config = { path = "../fabro-config", optional = true }

View file

@ -128,7 +128,7 @@ async fn build_daytona_client(
build_daytona_client_with(api_key, None, None, None).await
}
async fn build_daytona_client_with(
pub(crate) async fn build_daytona_client_with(
api_key: Option<String>,
api_url: Option<String>,
organization_id: Option<String>,

View file

@ -4,7 +4,7 @@ use anyhow::Result;
#[cfg(any(feature = "docker", feature = "daytona"))]
use chrono::{DateTime, Utc};
use fabro_types::{
RunId, RunSandbox, SandboxDetails, SandboxNetwork, SandboxProvider, SandboxResources,
RunId, RunSandbox, SandboxDetails, SandboxNetwork, SandboxProviderKind, SandboxResources,
SandboxState, SandboxTimestamps,
};
@ -26,18 +26,18 @@ pub async fn sandbox_details(
run_id: Option<RunId>,
) -> Result<SandboxDetails> {
match record.provider {
SandboxProvider::Local => Ok(local_details(record)),
SandboxProviderKind::Local => Ok(local_details(record)),
#[cfg(feature = "docker")]
SandboxProvider::Docker => docker::docker_details(record, run_id).await,
SandboxProviderKind::Docker => docker::docker_details(record, run_id).await,
#[cfg(not(feature = "docker"))]
SandboxProvider::Docker => Err(anyhow::anyhow!(
SandboxProviderKind::Docker => Err(anyhow::anyhow!(
"Sandbox provider '{}' has no details implementation",
record.provider
)),
#[cfg(feature = "daytona")]
SandboxProvider::Daytona => daytona::daytona_details(record, daytona_api_key).await,
SandboxProviderKind::Daytona => daytona::daytona_details(record, daytona_api_key).await,
#[cfg(not(feature = "daytona"))]
SandboxProvider::Daytona => Err(anyhow::anyhow!(
SandboxProviderKind::Daytona => Err(anyhow::anyhow!(
"Sandbox provider '{}' has no details implementation",
record.provider
)),
@ -66,7 +66,7 @@ fn parse_rfc3339_utc(value: &str) -> Option<DateTime<Utc>> {
}
#[cfg(feature = "docker")]
mod docker {
pub(crate) mod docker {
use std::collections::BTreeMap;
use anyhow::{Context, Result, anyhow};
@ -74,11 +74,12 @@ mod docker {
use bollard::container::InspectContainerOptions;
use bollard::models::{ContainerInspectResponse, ContainerStateStatusEnum, HostConfig};
use fabro_types::{
RunId, RunSandbox, SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, SandboxResources,
SandboxState, SandboxTimestamps,
RunId, RunSandbox, SandboxDetails, SandboxInfo, SandboxNetwork, SandboxNetworkPolicy,
SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps,
};
use super::parse_rfc3339_utc;
use crate::docker::WORKING_DIRECTORY;
pub(super) async fn docker_details(
record: &RunSandbox,
@ -94,13 +95,66 @@ mod docker {
.inspect_container(&runtime.id, None::<InspectContainerOptions>)
.await
.map_err(|err| anyhow!("Failed to inspect Docker container '{}': {err}", runtime.id))?;
Ok(map_docker_inspect(inspect, record))
Ok(map_docker_inspect(&inspect, record))
}
fn map_docker_inspect(
inspect: ContainerInspectResponse,
pub(crate) fn docker_info_from_inspect(inspect: &ContainerInspectResponse) -> SandboxInfo {
let fields = docker_fields_from_inspect(inspect);
SandboxInfo {
provider: SandboxProviderKind::Docker,
id: fields.id,
display_name: fields.display_name,
state: fields.state,
native_state: fields.native_state,
image: fields.image,
snapshot: None,
region: None,
web_url: None,
working_directory: fields.working_directory,
resources: fields.resources,
network: fields.network,
labels: fields.labels,
timestamps: fields.timestamps,
}
}
pub(super) fn map_docker_inspect(
inspect: &ContainerInspectResponse,
record: &RunSandbox,
) -> SandboxDetails {
let fields = docker_fields_from_inspect(inspect);
let image = fields.image.clone().or_else(|| record.image.clone());
SandboxDetails {
sandbox: RunSandbox {
image,
..record.clone()
},
state: fields.state,
native_state: fields.native_state,
region: None,
web_url: None,
resources: fields.resources,
network: fields.network,
labels: fields.labels,
timestamps: fields.timestamps,
}
}
struct DockerFields {
id: String,
display_name: Option<String>,
state: SandboxState,
native_state: Option<String>,
image: Option<String>,
working_directory: Option<String>,
resources: SandboxResources,
network: SandboxNetwork,
labels: BTreeMap<String, String>,
timestamps: SandboxTimestamps,
}
fn docker_fields_from_inspect(inspect: &ContainerInspectResponse) -> DockerFields {
let status_enum = inspect
.state
.as_ref()
@ -124,23 +178,44 @@ mod docker {
let labels: BTreeMap<String, String> = inspect
.config
.and_then(|config| config.labels)
.as_ref()
.and_then(|config| config.labels.clone())
.map(|map| map.into_iter().collect())
.unwrap_or_default();
let image = inspect.image;
let image = inspect
.config
.as_ref()
.and_then(|config| config.image.clone())
.or_else(|| inspect.image.clone())
.filter(|value| !value.is_empty());
let working_directory = inspect
.config
.as_ref()
.and_then(|config| config.working_dir.clone())
.filter(|value| !value.is_empty())
.or_else(|| Some(WORKING_DIRECTORY.to_string()));
let id = inspect
.id
.clone()
.or_else(|| inspect.name.as_ref().map(|name| trim_container_name(name)))
.unwrap_or_default();
let display_name = inspect
.name
.as_ref()
.map(|name| trim_container_name(name))
.filter(|name| !name.is_empty());
let created_at = inspect.created.as_deref().and_then(parse_rfc3339_utc);
SandboxDetails {
sandbox: RunSandbox {
image: image.or_else(|| record.image.clone()),
..record.clone()
},
DockerFields {
id,
display_name,
state: normalized_state,
native_state,
region: None,
web_url: None,
image,
working_directory,
resources,
network,
labels,
@ -151,6 +226,10 @@ mod docker {
}
}
fn trim_container_name(name: &str) -> String {
name.strip_prefix('/').unwrap_or(name).to_string()
}
fn docker_network(host_config: Option<&HostConfig>) -> SandboxNetwork {
match host_config.and_then(|host| host.network_mode.as_deref()) {
Some("none") => {
@ -195,14 +274,15 @@ mod docker {
mod tests {
use bollard::models::HostConfig;
use fabro_types::{
RunSandbox, RunSandboxRuntime, SandboxNetwork, SandboxNetworkPolicy, SandboxProvider,
RunSandbox, RunSandboxRuntime, SandboxNetwork, SandboxNetworkPolicy,
SandboxProviderKind,
};
use super::*;
fn record() -> RunSandbox {
RunSandbox {
provider: SandboxProvider::Docker,
provider: SandboxProviderKind::Docker,
image: None,
snapshot: None,
runtime: Some(RunSandboxRuntime {
@ -258,7 +338,7 @@ mod docker {
}),
..Default::default()
};
let details = map_docker_inspect(inspect, &record());
let details = map_docker_inspect(&inspect, &record());
assert_eq!(details.resources.memory_bytes, None);
}
@ -271,7 +351,7 @@ mod docker {
}),
..Default::default()
};
let details = map_docker_inspect(inspect, &record());
let details = map_docker_inspect(&inspect, &record());
assert_eq!(details.resources.memory_bytes, Some(2_147_483_648));
}
@ -284,7 +364,7 @@ mod docker {
}),
..Default::default()
};
let details = map_docker_inspect(inspect, &record());
let details = map_docker_inspect(&inspect, &record());
assert_eq!(details.network.egress, SandboxNetworkPolicy::blocked());
assert_eq!(details.network.ingress, SandboxNetworkPolicy::blocked());
}
@ -298,7 +378,7 @@ mod docker {
}),
..Default::default()
};
let details = map_docker_inspect(inspect, &record());
let details = map_docker_inspect(&inspect, &record());
assert_eq!(details.network, SandboxNetwork::unknown());
}
@ -308,12 +388,31 @@ mod docker {
name: Some("/fabro-run-abc".to_string()),
..Default::default()
};
let details = map_docker_inspect(inspect, &record());
let details = map_docker_inspect(&inspect, &record());
let runtime = details.sandbox.runtime.expect("runtime");
assert_eq!(runtime.id, "container-abc123");
assert_eq!(runtime.working_directory, "/workspace");
}
#[test]
fn inventory_identity_uses_native_id_and_display_name() {
let inspect = ContainerInspectResponse {
id: Some("container-abc123".to_string()),
name: Some("/fabro-run-abc".to_string()),
..Default::default()
};
let info = docker_info_from_inspect(&inspect);
assert_eq!(info.id, "container-abc123");
assert_eq!(info.display_name.as_deref(), Some("fabro-run-abc"));
}
#[test]
fn inventory_working_directory_defaults_to_fabro_workspace() {
let inspect = ContainerInspectResponse::default();
let info = docker_info_from_inspect(&inspect);
assert_eq!(info.working_directory.as_deref(), Some("/workspace"));
}
#[test]
fn empty_status_is_unknown() {
assert_eq!(
@ -392,18 +491,18 @@ mod docker {
}
#[cfg(feature = "daytona")]
mod daytona {
pub(crate) mod daytona {
use std::collections::BTreeMap;
use anyhow::{Context, Result, anyhow};
use daytona_api_client::models::SandboxState as DaytonaState;
use fabro_types::{
RunSandbox, SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, SandboxResources,
SandboxState, SandboxTimestamps,
RunSandbox, SandboxDetails, SandboxInfo, SandboxNetwork, SandboxNetworkPolicy,
SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps,
};
use super::parse_rfc3339_utc;
use crate::daytona::{DAYTONA_DASHBOARD_SANDBOXES_URL, DaytonaSandbox};
use crate::daytona::{DAYTONA_DASHBOARD_SANDBOXES_URL, DaytonaSandbox, WORKING_DIRECTORY};
pub(super) async fn daytona_details(
record: &RunSandbox,
@ -434,7 +533,58 @@ mod daytona {
Ok(map_daytona_sandbox(sdk_sandbox, record))
}
fn map_daytona_sandbox(sandbox: &daytona_sdk::Sandbox, record: &RunSandbox) -> SandboxDetails {
pub(crate) fn daytona_info_from_sdk_sandbox(sandbox: &daytona_sdk::Sandbox) -> SandboxInfo {
let fields = daytona_fields_from_sdk_sandbox(sandbox);
SandboxInfo {
provider: SandboxProviderKind::Daytona,
id: sandbox.id.clone(),
display_name: Some(sandbox.name.clone()).filter(|name| !name.is_empty()),
state: fields.state,
native_state: fields.native_state,
image: None,
snapshot: sandbox.snapshot.clone(),
region: fields.region,
web_url: Some(daytona_dashboard_url(&sandbox.id)),
working_directory: Some(WORKING_DIRECTORY.to_string()),
resources: fields.resources,
network: fields.network,
labels: fields.labels,
timestamps: fields.timestamps,
}
}
pub(super) fn map_daytona_sandbox(
sandbox: &daytona_sdk::Sandbox,
record: &RunSandbox,
) -> SandboxDetails {
let fields = daytona_fields_from_sdk_sandbox(sandbox);
SandboxDetails {
sandbox: RunSandbox {
snapshot: sandbox.snapshot.clone().or_else(|| record.snapshot.clone()),
..record.clone()
},
state: fields.state,
native_state: fields.native_state,
region: fields.region,
web_url: Some(daytona_dashboard_url(&sandbox.id)),
resources: fields.resources,
network: fields.network,
labels: fields.labels,
timestamps: fields.timestamps,
}
}
struct DaytonaFields {
state: SandboxState,
native_state: Option<String>,
region: Option<String>,
resources: SandboxResources,
network: SandboxNetwork,
labels: BTreeMap<String, String>,
timestamps: SandboxTimestamps,
}
fn daytona_fields_from_sdk_sandbox(sandbox: &daytona_sdk::Sandbox) -> DaytonaFields {
let normalized_state = sandbox
.state
.map_or(SandboxState::Unknown, normalize_daytona_state);
@ -459,15 +609,10 @@ mod daytona {
Some(target)
};
SandboxDetails {
sandbox: RunSandbox {
snapshot: sandbox.snapshot.clone().or_else(|| record.snapshot.clone()),
..record.clone()
},
DaytonaFields {
state: normalized_state,
native_state,
region,
web_url: Some(daytona_dashboard_url(&sandbox.id)),
resources,
network: daytona_network(
sandbox.network_block_all,
@ -673,7 +818,7 @@ mod tests {
#[test]
fn local_details_returns_running_with_no_metadata() {
let record = RunSandbox {
provider: SandboxProvider::Local,
provider: SandboxProviderKind::Local,
image: None,
snapshot: None,
runtime: Some(fabro_types::RunSandboxRuntime {
@ -689,7 +834,7 @@ mod tests {
}),
};
let details = local_details(&record);
assert_eq!(details.sandbox.provider, SandboxProvider::Local);
assert_eq!(details.sandbox.provider, SandboxProviderKind::Local);
assert_eq!(details.state, SandboxState::Running);
let runtime = details.sandbox.runtime.as_ref().unwrap();
assert_eq!(runtime.id, "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z");

View file

@ -2,6 +2,7 @@ pub mod config;
pub mod error;
#[cfg(any(feature = "docker", feature = "daytona"))]
pub mod from_environment;
pub mod provider;
pub mod sandbox;
pub mod sandbox_spec;
@ -39,8 +40,16 @@ pub use details::sandbox_details;
#[cfg(feature = "docker")]
pub use docker::{DockerSandbox, DockerSandboxOptions};
pub use error::{Error, Result, default_redacted_output_tail, display_for_log};
pub use fabro_types::{RunSandbox, SandboxProvider};
pub use fabro_types::{RunSandbox, SandboxProviderKind};
pub use local::LocalSandbox;
#[cfg(feature = "daytona")]
pub use provider::daytona::DaytonaSandboxProvider;
#[cfg(feature = "docker")]
pub use provider::docker::DockerSandboxProvider;
pub use provider::{
LocalSandboxProvider, SandboxCreateSpec, SandboxLookupError, SandboxProvider,
SandboxProviderRegistry,
};
pub use read_guard::ReadBeforeWriteSandbox;
pub use reconnect::{reconnect, reconnect_for_run, reconnect_for_run_with_callback};
pub use sandbox::{

View file

@ -3,8 +3,15 @@ use std::collections::HashMap;
use fabro_types::RunId;
pub(crate) const MANAGED_LABEL: &str = "sh.fabro.managed";
pub(crate) const MANAGED_LABEL_VALUE: &str = "true";
pub(crate) const RUN_ID_LABEL: &str = "sh.fabro.run_id";
/// True when the provided label map carries the Fabro managed sentinel.
#[cfg(any(feature = "docker", feature = "daytona", test))]
pub(crate) fn is_managed(labels: &HashMap<String, String>) -> bool {
labels.get(MANAGED_LABEL).map(String::as_str) == Some(MANAGED_LABEL_VALUE)
}
#[cfg(any(feature = "docker", test))]
pub(crate) fn for_run(run_id: Option<&RunId>) -> HashMap<String, String> {
let mut labels = HashMap::new();
@ -23,7 +30,7 @@ pub(crate) fn merge_for_run(
}
fn insert_for_run(labels: &mut HashMap<String, String>, run_id: Option<&RunId>) {
labels.insert(MANAGED_LABEL.to_string(), "true".to_string());
labels.insert(MANAGED_LABEL.to_string(), MANAGED_LABEL_VALUE.to_string());
if let Some(run_id) = run_id {
labels.insert(RUN_ID_LABEL.to_string(), run_id.to_string());
}
@ -60,6 +67,7 @@ mod tests {
labels.get(RUN_ID_LABEL).map(String::as_str),
Some("01HY0000000000000000000000")
);
assert!(is_managed(&labels));
}
#[test]

View file

@ -0,0 +1,360 @@
#[cfg(feature = "daytona")]
pub mod daytona;
#[cfg(feature = "docker")]
pub mod docker;
use std::sync::Arc;
use async_trait::async_trait;
#[cfg(any(feature = "docker", feature = "daytona"))]
use fabro_github::GitHubCredentials;
#[cfg(any(feature = "docker", feature = "daytona"))]
use fabro_types::RunId;
use fabro_types::{
SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxProviderKind,
SandboxProviderLookupError,
};
use fabro_util::error::collect_chain;
use futures::future::join_all;
#[cfg(feature = "daytona")]
use crate::daytona::DaytonaConfig;
#[cfg(feature = "docker")]
use crate::docker::DockerSandboxOptions;
pub enum SandboxCreateSpec {
Local,
#[cfg(feature = "docker")]
Docker {
config: DockerSandboxOptions,
github_app: Option<GitHubCredentials>,
run_id: Option<RunId>,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
},
#[cfg(feature = "daytona")]
Daytona {
config: Box<DaytonaConfig>,
github_app: Option<GitHubCredentials>,
run_id: Option<RunId>,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
api_key: Option<String>,
},
}
#[async_trait]
pub trait SandboxProvider: Send + Sync {
fn kind(&self) -> SandboxProviderKind;
async fn list(&self) -> crate::Result<Vec<SandboxInfo>>;
async fn get(&self, id: &str) -> crate::Result<Option<SandboxInfo>>;
async fn create(&self, spec: SandboxCreateSpec) -> crate::Result<SandboxInfo>;
async fn delete(&self, id: &str) -> crate::Result<()>;
}
#[derive(Clone, Default)]
pub struct SandboxProviderRegistry {
providers: Vec<Arc<dyn SandboxProvider>>,
}
impl SandboxProviderRegistry {
pub fn new(providers: Vec<Arc<dyn SandboxProvider>>) -> Self {
Self { providers }
}
pub fn empty() -> Self {
Self::default()
}
pub fn providers(&self) -> &[Arc<dyn SandboxProvider>] {
&self.providers
}
pub async fn list_managed(&self) -> SandboxListResponse {
let results = join_all(
self.providers
.iter()
.map(|provider| async move { (provider.kind(), provider.list().await) }),
)
.await;
let mut data = Vec::new();
let mut provider_errors = Vec::new();
for (kind, result) in results {
match result {
Ok(mut sandboxes) => data.append(&mut sandboxes),
Err(err) => provider_errors.push(provider_error(kind, &err)),
}
}
SandboxListResponse {
data,
meta: SandboxListMeta { provider_errors },
}
}
pub async fn get_managed_by_native_id(
&self,
id: &str,
) -> Result<SandboxInfo, SandboxLookupError> {
let results = join_all(
self.providers
.iter()
.map(|provider| async move { (provider.kind(), provider.get(id).await) }),
)
.await;
let mut matches = Vec::new();
let mut provider_errors = Vec::new();
for (kind, result) in results {
match result {
Ok(Some(sandbox)) => matches.push(sandbox),
Ok(None) => {}
Err(err) => provider_errors.push(provider_error(kind, &err)),
}
}
match matches.len() {
1 => Ok(matches.remove(0)),
0 if provider_errors.is_empty() => {
Err(SandboxLookupError::NotFound { id: id.to_string() })
}
0 => Err(SandboxLookupError::ProviderUnavailable {
id: id.to_string(),
provider_errors,
}),
_ => Err(SandboxLookupError::Conflict {
id: id.to_string(),
providers: matches
.into_iter()
.map(|sandbox| sandbox.provider)
.collect(),
}),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum SandboxLookupError {
#[error("sandbox '{id}' was not found by any configured provider")]
NotFound { id: String },
#[error("sandbox '{id}' matched more than one configured provider")]
Conflict {
id: String,
providers: Vec<SandboxProviderKind>,
},
#[error("sandbox '{id}' could not be found definitively because one or more providers failed")]
ProviderUnavailable {
id: String,
provider_errors: Vec<SandboxProviderLookupError>,
},
}
#[derive(Debug, Clone, Copy, Default)]
pub struct LocalSandboxProvider;
#[async_trait]
impl SandboxProvider for LocalSandboxProvider {
fn kind(&self) -> SandboxProviderKind {
SandboxProviderKind::Local
}
async fn list(&self) -> crate::Result<Vec<SandboxInfo>> {
Ok(Vec::new())
}
async fn get(&self, _id: &str) -> crate::Result<Option<SandboxInfo>> {
Ok(None)
}
async fn create(&self, _spec: SandboxCreateSpec) -> crate::Result<SandboxInfo> {
Err(crate::Error::message(
"local sandbox provider has no provider-managed inventory",
))
}
async fn delete(&self, _id: &str) -> crate::Result<()> {
Ok(())
}
}
fn provider_error(
provider: SandboxProviderKind,
err: &(dyn std::error::Error + 'static),
) -> SandboxProviderLookupError {
SandboxProviderLookupError {
provider,
message: collect_chain(err).join(": "),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{
FakeGet, FakeList, FakeSandboxProvider, fake_registry, fake_sandbox_info,
};
#[tokio::test]
async fn list_returns_aggregate_data_from_successful_providers() {
let docker = fake_sandbox_info(SandboxProviderKind::Docker, "docker-1");
let daytona = fake_sandbox_info(SandboxProviderKind::Daytona, "daytona-1");
let registry = fake_registry(vec![
FakeSandboxProvider::new(
SandboxProviderKind::Docker,
FakeList::Ok(vec![docker.clone()]),
FakeGet::Missing,
),
FakeSandboxProvider::new(
SandboxProviderKind::Daytona,
FakeList::Ok(vec![daytona.clone()]),
FakeGet::Missing,
),
]);
let response = registry.list_managed().await;
assert_eq!(response.data, vec![docker, daytona]);
assert!(response.meta.provider_errors.is_empty());
}
#[tokio::test]
async fn list_includes_provider_error_metadata_when_one_provider_fails() {
let docker = fake_sandbox_info(SandboxProviderKind::Docker, "docker-1");
let registry = fake_registry(vec![
FakeSandboxProvider::new(
SandboxProviderKind::Docker,
FakeList::Ok(vec![docker.clone()]),
FakeGet::Missing,
),
FakeSandboxProvider::new(
SandboxProviderKind::Daytona,
FakeList::Err("daytona unavailable"),
FakeGet::Missing,
),
]);
let response = registry.list_managed().await;
assert_eq!(response.data, vec![docker]);
assert_eq!(response.meta.provider_errors, vec![
SandboxProviderLookupError {
provider: SandboxProviderKind::Daytona,
message: "daytona unavailable".to_string(),
}
]);
}
#[tokio::test]
async fn get_returns_one_matching_sandbox() {
let docker = fake_sandbox_info(SandboxProviderKind::Docker, "same-id");
let registry = fake_registry(vec![
FakeSandboxProvider::new(
SandboxProviderKind::Docker,
FakeList::Ok(Vec::new()),
FakeGet::Found(Box::new(docker.clone())),
),
FakeSandboxProvider::new(
SandboxProviderKind::Daytona,
FakeList::Ok(Vec::new()),
FakeGet::Missing,
),
]);
assert_eq!(
registry.get_managed_by_native_id("same-id").await.unwrap(),
docker
);
}
#[tokio::test]
async fn get_returns_not_found_when_all_providers_miss() {
let registry = fake_registry(vec![
FakeSandboxProvider::new(
SandboxProviderKind::Docker,
FakeList::Ok(Vec::new()),
FakeGet::Missing,
),
FakeSandboxProvider::new(
SandboxProviderKind::Daytona,
FakeList::Ok(Vec::new()),
FakeGet::Missing,
),
]);
let err = registry
.get_managed_by_native_id("missing")
.await
.unwrap_err();
assert!(matches!(err, SandboxLookupError::NotFound { id } if id == "missing"));
}
#[tokio::test]
async fn get_returns_conflict_when_two_providers_match() {
let registry = fake_registry(vec![
FakeSandboxProvider::new(
SandboxProviderKind::Docker,
FakeList::Ok(Vec::new()),
FakeGet::Found(Box::new(fake_sandbox_info(
SandboxProviderKind::Docker,
"same-id",
))),
),
FakeSandboxProvider::new(
SandboxProviderKind::Daytona,
FakeList::Ok(Vec::new()),
FakeGet::Found(Box::new(fake_sandbox_info(
SandboxProviderKind::Daytona,
"same-id",
))),
),
]);
let err = registry
.get_managed_by_native_id("same-id")
.await
.unwrap_err();
assert!(matches!(
err,
SandboxLookupError::Conflict { id, providers }
if id == "same-id"
&& providers == vec![SandboxProviderKind::Docker, SandboxProviderKind::Daytona]
));
}
#[tokio::test]
async fn get_returns_provider_unavailable_when_no_match_and_one_provider_fails() {
let registry = fake_registry(vec![
FakeSandboxProvider::new(
SandboxProviderKind::Docker,
FakeList::Ok(Vec::new()),
FakeGet::Missing,
),
FakeSandboxProvider::new(
SandboxProviderKind::Daytona,
FakeList::Ok(Vec::new()),
FakeGet::Err("daytona unavailable"),
),
]);
let err = registry
.get_managed_by_native_id("maybe-missing")
.await
.unwrap_err();
assert!(matches!(
err,
SandboxLookupError::ProviderUnavailable {
id,
provider_errors
} if id == "maybe-missing"
&& provider_errors == vec![SandboxProviderLookupError {
provider: SandboxProviderKind::Daytona,
message: "daytona unavailable".to_string(),
}]
));
}
}

View file

@ -0,0 +1,172 @@
use std::collections::HashMap;
use async_trait::async_trait;
use daytona_sdk::DaytonaError;
use fabro_static::EnvVars;
use fabro_types::{SandboxInfo, SandboxProviderKind};
use super::{SandboxCreateSpec, SandboxProvider};
use crate::daytona::{self, DaytonaSandbox};
use crate::managed_labels::{self, MANAGED_LABEL, MANAGED_LABEL_VALUE};
use crate::{Sandbox, details};
const DAYTONA_LIST_PAGE_SIZE: i32 = 100;
#[derive(Clone)]
pub struct DaytonaSandboxProvider {
api_key: Option<String>,
api_url: Option<String>,
organization_id: Option<String>,
http_client: Option<fabro_http::HttpClient>,
}
impl DaytonaSandboxProvider {
pub fn new(
api_key: Option<String>,
api_url: Option<String>,
organization_id: Option<String>,
http_client: Option<fabro_http::HttpClient>,
) -> Self {
Self {
api_key,
api_url,
organization_id,
http_client,
}
}
async fn client(&self) -> crate::Result<daytona_sdk::Client> {
let api_key = self.api_key.clone().ok_or_else(|| {
crate::Error::message(format!("{} is not configured", EnvVars::DAYTONA_API_KEY))
})?;
daytona::build_daytona_client_with(
Some(api_key),
self.api_url.clone(),
self.organization_id.clone(),
self.http_client.clone(),
)
.await
.map_err(|err| crate::Error::context("Failed to create Daytona client", err))
}
}
#[async_trait]
impl SandboxProvider for DaytonaSandboxProvider {
fn kind(&self) -> SandboxProviderKind {
SandboxProviderKind::Daytona
}
async fn list(&self) -> crate::Result<Vec<SandboxInfo>> {
let client = self.client().await?;
let labels = HashMap::from([(MANAGED_LABEL.to_string(), MANAGED_LABEL_VALUE.to_string())]);
let mut page = 1;
let mut sandboxes = Vec::new();
loop {
let result = client
.list(Some(&labels), Some(page), Some(DAYTONA_LIST_PAGE_SIZE))
.await
.map_err(|err| crate::Error::context("Failed to list Daytona sandboxes", err))?;
// The Daytona API already filters by the managed label above; map every
// returned sandbox without re-checking the label client-side.
sandboxes.extend(
result
.items
.iter()
.map(details::daytona::daytona_info_from_sdk_sandbox),
);
if result.total_pages <= i64::from(page) {
break;
}
page += 1;
}
Ok(sandboxes)
}
async fn get(&self, id: &str) -> crate::Result<Option<SandboxInfo>> {
let client = self.client().await?;
let sandbox = match client.get(id).await {
Ok(sandbox) => sandbox,
Err(err) if daytona_not_found(&err) => return Ok(None),
Err(err) => {
return Err(crate::Error::context(
format!("Failed to get Daytona sandbox '{id}'"),
err,
));
}
};
if !managed_from_sdk_sandbox(&sandbox) {
return Ok(None);
}
Ok(Some(details::daytona::daytona_info_from_sdk_sandbox(
&sandbox,
)))
}
async fn create(&self, spec: SandboxCreateSpec) -> crate::Result<SandboxInfo> {
let SandboxCreateSpec::Daytona {
config,
github_app,
run_id,
clone_origin_url,
clone_branch,
api_key,
} = spec
else {
return Err(crate::Error::message(
"Daytona sandbox provider can only create Daytona sandboxes",
));
};
let api_key = api_key.or_else(|| self.api_key.clone()).ok_or_else(|| {
crate::Error::message(format!("{} is not configured", EnvVars::DAYTONA_API_KEY))
})?;
let sandbox = DaytonaSandbox::new(
config.as_ref().clone(),
github_app,
run_id,
clone_origin_url,
clone_branch,
Some(api_key),
)
.await?;
sandbox.initialize().await?;
let sdk_sandbox = sandbox.sandbox_handle().ok_or_else(|| {
crate::Error::message("Daytona sandbox was created but no SDK handle is available")
})?;
Ok(details::daytona::daytona_info_from_sdk_sandbox(sdk_sandbox))
}
async fn delete(&self, id: &str) -> crate::Result<()> {
let client = self.client().await?;
let sandbox = match client.get(id).await {
Ok(sandbox) => sandbox,
Err(err) if daytona_not_found(&err) => return Ok(()),
Err(err) => {
return Err(crate::Error::context(
format!("Failed to get Daytona sandbox '{id}' before delete"),
err,
));
}
};
if !managed_from_sdk_sandbox(&sandbox) {
return Err(crate::Error::message(format!(
"Refusing to delete Daytona sandbox '{id}' because it is missing label {MANAGED_LABEL}={MANAGED_LABEL_VALUE}"
)));
}
client.delete(&sandbox.id).await.map_err(|err| {
crate::Error::context(format!("Failed to delete Daytona sandbox '{id}'"), err)
})
}
}
fn managed_from_sdk_sandbox(sandbox: &daytona_sdk::Sandbox) -> bool {
managed_labels::is_managed(&sandbox.labels)
}
fn daytona_not_found(err: &DaytonaError) -> bool {
matches!(err, DaytonaError::NotFound { .. }) || err.status_code() == Some(404)
}

View file

@ -0,0 +1,163 @@
use std::collections::HashMap;
use async_trait::async_trait;
use bollard::Docker;
use bollard::container::{InspectContainerOptions, ListContainersOptions, RemoveContainerOptions};
use bollard::errors::Error as DockerError;
use bollard::models::ContainerInspectResponse;
use fabro_types::{SandboxInfo, SandboxProviderKind};
use futures::future::try_join_all;
use super::{SandboxCreateSpec, SandboxProvider};
use crate::docker::DockerSandbox;
use crate::managed_labels::{self, MANAGED_LABEL, MANAGED_LABEL_VALUE};
use crate::{Sandbox, details};
#[derive(Debug, Clone, Default)]
pub struct DockerSandboxProvider;
impl DockerSandboxProvider {
pub fn new() -> Self {
Self
}
fn docker_client() -> crate::Result<Docker> {
Docker::connect_with_local_defaults().map_err(crate::Error::docker_connect)
}
}
#[async_trait]
impl SandboxProvider for DockerSandboxProvider {
fn kind(&self) -> SandboxProviderKind {
SandboxProviderKind::Docker
}
async fn list(&self) -> crate::Result<Vec<SandboxInfo>> {
let docker = Self::docker_client()?;
let mut filters = HashMap::new();
filters.insert("label".to_string(), vec![format!(
"{MANAGED_LABEL}={MANAGED_LABEL_VALUE}"
)]);
let options = ListContainersOptions::<String> {
all: true,
filters,
..Default::default()
};
let containers = docker
.list_containers(Some(options))
.await
.map_err(|err| crate::Error::context("Failed to list Docker containers", err))?;
let ids: Vec<String> = containers.into_iter().filter_map(|c| c.id).collect();
// Daemon-side label filter already restricts to managed containers, so we
// can skip the per-inspect managed re-check. Run inspects concurrently on
// the shared Docker client to avoid a serial N+1 round-trip.
let inspects = try_join_all(
ids.iter()
.map(|id| docker.inspect_container(id, None::<InspectContainerOptions>)),
)
.await
.map_err(|err| crate::Error::context("Failed to inspect Docker container", err))?;
Ok(inspects
.iter()
.map(details::docker::docker_info_from_inspect)
.collect())
}
async fn get(&self, id: &str) -> crate::Result<Option<SandboxInfo>> {
let docker = Self::docker_client()?;
let Some(inspect) = inspect_container(&docker, id).await? else {
return Ok(None);
};
if !managed_from_inspect(&inspect) {
return Ok(None);
}
Ok(Some(details::docker::docker_info_from_inspect(&inspect)))
}
async fn create(&self, spec: SandboxCreateSpec) -> crate::Result<SandboxInfo> {
let SandboxCreateSpec::Docker {
config,
github_app,
run_id,
clone_origin_url,
clone_branch,
} = spec
else {
return Err(crate::Error::message(
"Docker sandbox provider can only create Docker sandboxes",
));
};
let sandbox =
DockerSandbox::new(config, github_app, run_id, clone_origin_url, clone_branch)?;
sandbox.initialize().await?;
let container_id = sandbox.container_identifier()?.to_string();
self.get(&container_id).await?.ok_or_else(|| {
crate::Error::message(format!(
"Docker sandbox '{container_id}' was created but is not visible in provider inventory"
))
})
}
async fn delete(&self, id: &str) -> crate::Result<()> {
let docker = Self::docker_client()?;
let Some(inspect) = inspect_container(&docker, id).await? else {
return Ok(());
};
if !managed_from_inspect(&inspect) {
return Err(crate::Error::message(format!(
"Refusing to delete Docker container '{id}' because it is missing label {MANAGED_LABEL}={MANAGED_LABEL_VALUE}"
)));
}
let container_id = inspect.id.as_deref().unwrap_or(id);
docker
.remove_container(
container_id,
Some(RemoveContainerOptions {
force: true,
..Default::default()
}),
)
.await
.map_err(|err| {
crate::Error::context(
format!("Failed to remove Docker container '{container_id}'"),
err,
)
})
}
}
async fn inspect_container(
docker: &Docker,
id: &str,
) -> crate::Result<Option<ContainerInspectResponse>> {
match docker
.inspect_container(id, None::<InspectContainerOptions>)
.await
{
Ok(inspect) => Ok(Some(inspect)),
Err(err) if docker_not_found(&err) => Ok(None),
Err(err) => Err(crate::Error::context(
format!("Failed to inspect Docker container '{id}'"),
err,
)),
}
}
fn managed_from_inspect(inspect: &ContainerInspectResponse) -> bool {
inspect
.config
.as_ref()
.and_then(|config| config.labels.as_ref())
.is_some_and(managed_labels::is_managed)
}
fn docker_not_found(error: &DockerError) -> bool {
matches!(error, DockerError::DockerResponseServerError {
status_code: 404,
..
})
}

View file

@ -5,7 +5,7 @@ use std::path::PathBuf;
reason = "Feature-gated branches consume these imports when optional backends are enabled."
)]
use anyhow::{Context, Result, bail};
use fabro_types::{RunId, RunSandbox, SandboxProvider};
use fabro_types::{RunId, RunSandbox, SandboxProviderKind};
use crate::SandboxEventCallback;
#[cfg(feature = "daytona")]
@ -57,7 +57,7 @@ pub async fn reconnect_for_run_with_callback(
.as_ref()
.context("run sandbox missing runtime metadata")?;
match record.provider {
SandboxProvider::Local => {
SandboxProviderKind::Local => {
let mut sandbox = LocalSandbox::new(PathBuf::from(&runtime.working_directory));
if let Some(callback) = event_callback {
sandbox.set_event_callback(callback);
@ -65,7 +65,7 @@ pub async fn reconnect_for_run_with_callback(
Ok(Box::new(sandbox))
}
#[cfg(feature = "docker")]
SandboxProvider::Docker => {
SandboxProviderKind::Docker => {
let repo_cloned = runtime
.repo_cloned
.context("Docker run sandbox missing repo_cloned metadata")?;
@ -85,9 +85,9 @@ pub async fn reconnect_for_run_with_callback(
Ok(Box::new(sandbox))
}
#[cfg(not(feature = "docker"))]
SandboxProvider::Docker => bail!("Docker sandbox support is not enabled"),
SandboxProviderKind::Docker => bail!("Docker sandbox support is not enabled"),
#[cfg(feature = "daytona")]
SandboxProvider::Daytona => {
SandboxProviderKind::Daytona => {
let repo_cloned = runtime
.repo_cloned
.context("Daytona run sandbox missing repo_cloned metadata")?;
@ -108,6 +108,6 @@ pub async fn reconnect_for_run_with_callback(
Ok(Box::new(sandbox))
}
#[cfg(not(feature = "daytona"))]
SandboxProvider::Daytona => bail!("Daytona sandbox support is not enabled"),
SandboxProviderKind::Daytona => bail!("Daytona sandbox support is not enabled"),
}
}

View file

@ -9,7 +9,7 @@ use fabro_github::GitHubCredentials;
unused_imports,
reason = "Daytona-enabled builds persist RunId in the sandbox spec."
)]
use fabro_types::{RunId, RunSandbox, RunSandboxRuntime, SandboxProvider};
use fabro_types::{RunId, RunSandbox, RunSandboxRuntime, SandboxProviderKind};
#[cfg(any(feature = "docker", feature = "daytona"))]
use crate::clone_source;
@ -45,21 +45,21 @@ pub enum SandboxSpec {
}
impl SandboxSpec {
pub fn provider(&self) -> SandboxProvider {
pub fn provider(&self) -> SandboxProviderKind {
match self {
Self::Local { .. } => SandboxProvider::Local,
Self::Local { .. } => SandboxProviderKind::Local,
#[cfg(feature = "docker")]
Self::Docker { .. } => SandboxProvider::Docker,
Self::Docker { .. } => SandboxProviderKind::Docker,
#[cfg(feature = "daytona")]
Self::Daytona { .. } => SandboxProvider::Daytona,
Self::Daytona { .. } => SandboxProviderKind::Daytona,
}
}
pub fn provider_name(&self) -> &'static str {
match self.provider() {
SandboxProvider::Local => "local",
SandboxProvider::Docker => "docker",
SandboxProvider::Daytona => "daytona",
SandboxProviderKind::Local => "local",
SandboxProviderKind::Docker => "docker",
SandboxProviderKind::Daytona => "daytona",
}
}
@ -266,9 +266,12 @@ fn runtime_layout_metadata(
#[cfg(test)]
mod tests {
#[cfg(feature = "docker")]
use fabro_types::RunId;
#[cfg(feature = "docker")]
use super::*;
#[cfg(feature = "docker")]
use crate::test_support::MockSandbox;
#[cfg(feature = "docker")]

View file

@ -1,15 +1,15 @@
use async_trait::async_trait;
#[cfg(feature = "daytona")]
use fabro_static::EnvVars;
use fabro_types::RunId;
use fabro_types::{RunId, SandboxProviderKind};
use crate::RunSandbox;
#[cfg(any(feature = "daytona", feature = "docker"))]
use crate::Sandbox;
#[cfg(feature = "daytona")]
use crate::daytona::{DEFAULT_DAYTONA_API_URL, DaytonaSandbox};
#[cfg(feature = "docker")]
use crate::docker::DockerSandbox;
use crate::{RunSandbox, SandboxProvider};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TerminalSize {
@ -55,7 +55,7 @@ pub async fn open_terminal_for_run(
match record.provider {
#[cfg(feature = "daytona")]
SandboxProvider::Daytona => {
SandboxProviderKind::Daytona => {
let repo_cloned = runtime.repo_cloned.ok_or_else(|| {
crate::Error::message("Daytona run sandbox is missing clone metadata")
})?;
@ -82,11 +82,11 @@ pub async fn open_terminal_for_run(
Ok(Box::new(session))
}
#[cfg(not(feature = "daytona"))]
SandboxProvider::Daytona => Err(crate::Error::message(
SandboxProviderKind::Daytona => Err(crate::Error::message(
"Daytona sandbox support is not enabled",
)),
#[cfg(feature = "docker")]
SandboxProvider::Docker => {
SandboxProviderKind::Docker => {
let repo_cloned = runtime.repo_cloned.ok_or_else(|| {
crate::Error::message("Docker run sandbox is missing clone metadata")
})?;
@ -104,10 +104,10 @@ pub async fn open_terminal_for_run(
Ok(Box::new(session))
}
#[cfg(not(feature = "docker"))]
SandboxProvider::Docker => Err(crate::Error::message(
SandboxProviderKind::Docker => Err(crate::Error::message(
"Docker sandbox support is not enabled",
)),
SandboxProvider::Local => Err(crate::Error::message(
SandboxProviderKind::Local => Err(crate::Error::message(
"Local sandboxes do not support embedded terminals",
)),
}

View file

@ -560,3 +560,103 @@ impl Sandbox for MutableMockSandbox {
"Linux 6.1.0".into()
}
}
// --- FakeSandboxProvider ---
pub use fake_provider::{FakeGet, FakeList, FakeSandboxProvider, fake_registry, fake_sandbox_info};
mod fake_provider {
use std::collections::BTreeMap;
use std::sync::Arc;
use async_trait::async_trait;
use fabro_types::{
SandboxInfo, SandboxNetwork, SandboxProviderKind, SandboxResources, SandboxState,
SandboxTimestamps,
};
use crate::provider::{SandboxCreateSpec, SandboxProvider, SandboxProviderRegistry};
#[derive(Clone)]
pub enum FakeList {
Ok(Vec<SandboxInfo>),
Err(&'static str),
}
#[derive(Clone)]
pub enum FakeGet {
Found(Box<SandboxInfo>),
Missing,
Err(&'static str),
}
pub struct FakeSandboxProvider {
kind: SandboxProviderKind,
list: FakeList,
get: FakeGet,
}
impl FakeSandboxProvider {
pub fn new(kind: SandboxProviderKind, list: FakeList, get: FakeGet) -> Self {
Self { kind, list, get }
}
}
#[async_trait]
impl SandboxProvider for FakeSandboxProvider {
fn kind(&self) -> SandboxProviderKind {
self.kind
}
async fn list(&self) -> crate::Result<Vec<SandboxInfo>> {
match &self.list {
FakeList::Ok(sandboxes) => Ok(sandboxes.clone()),
FakeList::Err(message) => Err(crate::Error::message(*message)),
}
}
async fn get(&self, _id: &str) -> crate::Result<Option<SandboxInfo>> {
match &self.get {
FakeGet::Found(sandbox) => Ok(Some((**sandbox).clone())),
FakeGet::Missing => Ok(None),
FakeGet::Err(message) => Err(crate::Error::message(*message)),
}
}
async fn create(&self, _spec: SandboxCreateSpec) -> crate::Result<SandboxInfo> {
Err(crate::Error::message("not implemented"))
}
async fn delete(&self, _id: &str) -> crate::Result<()> {
Ok(())
}
}
pub fn fake_registry(providers: Vec<FakeSandboxProvider>) -> SandboxProviderRegistry {
SandboxProviderRegistry::new(
providers
.into_iter()
.map(|provider| Arc::new(provider) as Arc<dyn SandboxProvider>)
.collect(),
)
}
pub fn fake_sandbox_info(provider: SandboxProviderKind, id: &str) -> SandboxInfo {
SandboxInfo {
provider,
id: id.to_string(),
display_name: None,
state: SandboxState::Running,
native_state: None,
image: None,
snapshot: None,
region: None,
web_url: None,
working_directory: None,
resources: SandboxResources::default(),
network: SandboxNetwork::unknown(),
labels: BTreeMap::new(),
timestamps: SandboxTimestamps::default(),
}
}
}

View file

@ -21,12 +21,12 @@ use fabro_sandbox::from_environment::{
daytona_config_from_environment, docker_config_from_environment,
};
use fabro_sandbox::redact::redact_auth_url;
use fabro_sandbox::{DockerSandboxOptions, Sandbox, SandboxProvider, SandboxSpec};
use fabro_sandbox::{DockerSandboxOptions, Sandbox, SandboxSpec};
use fabro_static::EnvVars;
use fabro_types::settings::cli::OutputVerbosity;
use fabro_types::settings::interp::InterpString;
use fabro_types::settings::run::{EnvironmentProvider, RunGoal, RunNamespace};
use fabro_types::{ManifestPath, RunId, ServerSettings, WorkflowSettings};
use fabro_types::{ManifestPath, RunId, SandboxProviderKind, ServerSettings, WorkflowSettings};
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
use fabro_validate::Severity;
use fabro_workflow::Error as WorkflowError;
@ -632,7 +632,7 @@ fn base_preflight_checks(prepared: &PreparedManifest, graph: &Graph) -> Vec<Chec
pub(crate) fn sandbox_provider_policy_error(
server_settings: &ServerSettings,
provider: SandboxProvider,
provider: SandboxProviderKind,
) -> Option<String> {
let enabled = server_settings
.server
@ -647,8 +647,8 @@ pub(crate) fn sandbox_provider_policy_error(
})
}
pub(crate) fn effective_sandbox_provider(settings: &RunNamespace) -> SandboxProvider {
SandboxProvider::from(settings.environment.provider).effective_for(settings.execution.mode)
pub(crate) fn effective_sandbox_provider(settings: &RunNamespace) -> SandboxProviderKind {
SandboxProviderKind::from(settings.environment.provider).effective_for(settings.execution.mode)
}
fn resolve_daytona_config(settings: &RunNamespace) -> DaytonaConfig {
@ -665,10 +665,10 @@ struct GitRemoteRefCheck {
branch: Option<String>,
}
fn clone_disabled_for_provider(provider: SandboxProvider, resolved_run: &RunNamespace) -> bool {
fn clone_disabled_for_provider(provider: SandboxProviderKind, resolved_run: &RunNamespace) -> bool {
match provider {
SandboxProvider::Docker | SandboxProvider::Daytona => !resolved_run.clone.enabled,
SandboxProvider::Local => false,
SandboxProviderKind::Docker | SandboxProviderKind::Daytona => !resolved_run.clone.enabled,
SandboxProviderKind::Local => false,
}
}
@ -742,7 +742,7 @@ fn repository_access_details(request: &GitRemoteRefCheck) -> Vec<CheckDetail> {
async fn run_repository_access_check(
checks: &mut Vec<CheckResult>,
sandbox_provider: SandboxProvider,
sandbox_provider: SandboxProviderKind,
prepared: &PreparedManifest,
resolved_run: &RunNamespace,
github_app: Option<fabro_github::GitHubCredentials>,
@ -760,7 +760,7 @@ async fn run_repository_access_check(
async fn run_repository_access_check_with<F, Fut>(
checks: &mut Vec<CheckResult>,
sandbox_provider: SandboxProvider,
sandbox_provider: SandboxProviderKind,
prepared: &PreparedManifest,
resolved_run: &RunNamespace,
github_app: Option<fabro_github::GitHubCredentials>,
@ -876,7 +876,7 @@ async fn check_git_remote_ref(
}
fn preflight_sandbox_spec(
sandbox_provider: SandboxProvider,
sandbox_provider: SandboxProviderKind,
prepared: &PreparedManifest,
resolved_run: &RunNamespace,
github_app: Option<fabro_github::GitHubCredentials>,
@ -889,10 +889,10 @@ fn preflight_sandbox_spec(
let clone_branch = prepared.git.as_ref().map(|git| git.branch.clone());
match sandbox_provider {
SandboxProvider::Local => SandboxSpec::Local {
SandboxProviderKind::Local => SandboxSpec::Local {
working_directory: prepared.source_directory.clone(),
},
SandboxProvider::Docker => {
SandboxProviderKind::Docker => {
let mut config = resolve_docker_config(resolved_run);
config.skip_clone = true;
SandboxSpec::Docker {
@ -903,7 +903,7 @@ fn preflight_sandbox_spec(
clone_branch,
}
}
SandboxProvider::Daytona => {
SandboxProviderKind::Daytona => {
let mut config = resolve_daytona_config(resolved_run);
config.skip_clone = true;
SandboxSpec::Daytona {
@ -920,7 +920,7 @@ fn preflight_sandbox_spec(
async fn run_sandbox_check(
checks: &mut Vec<CheckResult>,
sandbox_provider: SandboxProvider,
sandbox_provider: SandboxProviderKind,
prepared: &PreparedManifest,
resolved_run: &RunNamespace,
github_app: Option<fabro_github::GitHubCredentials>,
@ -934,7 +934,7 @@ async fn run_sandbox_check(
daytona_api_key,
);
let sandbox_result: Result<Arc<dyn Sandbox>, String> = spec.build(None).await.map_err(|err| {
if matches!(sandbox_provider, SandboxProvider::Daytona) {
if matches!(sandbox_provider, SandboxProviderKind::Daytona) {
format!("Daytona sandbox creation failed: {err}")
} else {
err.to_string()
@ -1468,7 +1468,7 @@ mod tests {
}
fn prepared_and_resolved_for_sandbox(
provider: SandboxProvider,
provider: SandboxProviderKind,
clone_enabled: bool,
git: Option<types::GitContext>,
) -> (PreparedManifest, RunNamespace) {
@ -1630,7 +1630,7 @@ dockerfile = { path = "Dockerfile" }
#[tokio::test]
async fn repository_access_check_skips_when_clone_is_disabled() {
let (prepared, resolved) = prepared_and_resolved_for_sandbox(
SandboxProvider::Docker,
SandboxProviderKind::Docker,
false,
Some(git_context("https://github.com/acme/widgets", "main")),
);
@ -1640,7 +1640,7 @@ dockerfile = { path = "Dockerfile" }
let ok = run_repository_access_check_with(
&mut checks,
SandboxProvider::Docker,
SandboxProviderKind::Docker,
&prepared,
&resolved,
None,
@ -1659,7 +1659,7 @@ dockerfile = { path = "Dockerfile" }
#[tokio::test]
async fn repository_access_check_rejects_non_github_origins_before_remote_probe() {
let (prepared, resolved) = prepared_and_resolved_for_sandbox(
SandboxProvider::Docker,
SandboxProviderKind::Docker,
true,
Some(git_context("https://gitlab.com/acme/widgets", "main")),
);
@ -1669,7 +1669,7 @@ dockerfile = { path = "Dockerfile" }
let ok = run_repository_access_check_with(
&mut checks,
SandboxProvider::Docker,
SandboxProviderKind::Docker,
&prepared,
&resolved,
None,
@ -1697,7 +1697,7 @@ dockerfile = { path = "Dockerfile" }
#[tokio::test]
async fn repository_access_check_probes_normalized_github_branch() {
let (prepared, resolved) = prepared_and_resolved_for_sandbox(
SandboxProvider::Docker,
SandboxProviderKind::Docker,
true,
Some(git_context(
"git@github.com:acme/widgets.git",
@ -1710,7 +1710,7 @@ dockerfile = { path = "Dockerfile" }
let ok = run_repository_access_check_with(
&mut checks,
SandboxProvider::Docker,
SandboxProviderKind::Docker,
&prepared,
&resolved,
None,
@ -1734,7 +1734,7 @@ dockerfile = { path = "Dockerfile" }
#[tokio::test]
async fn repository_access_check_surfaces_remote_probe_failure() {
let (prepared, resolved) = prepared_and_resolved_for_sandbox(
SandboxProvider::Docker,
SandboxProviderKind::Docker,
true,
Some(git_context("https://github.com/acme/widgets", "missing")),
);
@ -1742,7 +1742,7 @@ dockerfile = { path = "Dockerfile" }
let ok = run_repository_access_check_with(
&mut checks,
SandboxProvider::Docker,
SandboxProviderKind::Docker,
&prepared,
&resolved,
None,
@ -1766,13 +1766,18 @@ dockerfile = { path = "Dockerfile" }
#[test]
fn preflight_sandbox_spec_disables_docker_clone_but_preserves_clone_metadata() {
let (prepared, resolved) = prepared_and_resolved_for_sandbox(
SandboxProvider::Docker,
SandboxProviderKind::Docker,
true,
Some(git_context("https://github.com/acme/widgets", "main")),
);
let spec =
preflight_sandbox_spec(SandboxProvider::Docker, &prepared, &resolved, None, None);
let spec = preflight_sandbox_spec(
SandboxProviderKind::Docker,
&prepared,
&resolved,
None,
None,
);
match spec {
SandboxSpec::Docker {

View file

@ -810,6 +810,7 @@ where
github_api_base_url: None,
active_config_path,
http_client: None,
sandbox_provider_registry: None,
shutdown: shutdown.clone(),
})?;
let reconciled = reconcile_incomplete_runs_on_startup(&state).await?;

View file

@ -65,7 +65,10 @@ use fabro_redact::redact_jsonl_line;
use fabro_sandbox::daytona::{self, DaytonaSandbox};
use fabro_sandbox::details::sandbox_details;
use fabro_sandbox::reconnect::reconnect_for_run;
use fabro_sandbox::{Sandbox, SandboxProvider};
use fabro_sandbox::{
DaytonaSandboxProvider, DockerSandboxProvider, LocalSandboxProvider, Sandbox, SandboxProvider,
SandboxProviderRegistry,
};
use fabro_slack::client::{PostedMessage as SlackPostedMessage, SlackClient};
use fabro_slack::config::{
SlackCredentialResolution,
@ -89,8 +92,8 @@ use fabro_types::settings::{InterpString, RunNamespace};
use fabro_types::{
AgentBackend, AskFabro, AskFabroUnavailableReason, EventBody, InterviewQuestionRecord, PairId,
PairMessageId, PairTarget, PendingReason, Principal, PullRequestLink, QuestionType, RunBlobId,
RunControlAction, RunEvent, RunId, RunRunnableSource, ServerSettings, SessionCapability,
StageModelUsage,
RunControlAction, RunEvent, RunId, RunRunnableSource, SandboxProviderKind, ServerSettings,
SessionCapability, StageModelUsage,
};
use fabro_util::error::{
SharedError, collect_causes, render_compact_with_causes, render_with_causes,
@ -960,6 +963,7 @@ pub struct AppState {
pub(crate) github_api_base_url: String,
active_config_path: PathBuf,
http_client: Option<fabro_http::HttpClient>,
sandbox_provider_registry: SandboxProviderRegistry,
shutdown: CancellationToken,
shutting_down: AtomicBool,
registry_factory_override: Option<Box<RegistryFactoryOverride>>,
@ -1060,6 +1064,7 @@ pub(crate) struct AppStateConfig {
pub(crate) github_api_base_url: Option<String>,
pub(crate) active_config_path: PathBuf,
pub(crate) http_client: Option<fabro_http::HttpClient>,
pub(crate) sandbox_provider_registry: Option<SandboxProviderRegistry>,
pub(crate) shutdown: CancellationToken,
}
@ -1260,6 +1265,10 @@ impl AppState {
&self.session_runtimes
}
pub(crate) fn sandbox_provider_registry(&self) -> &SandboxProviderRegistry {
&self.sandbox_provider_registry
}
pub(crate) fn server_secret(&self, name: &str) -> Option<String> {
self.server_secrets.get(name)
}
@ -2002,7 +2011,7 @@ fn system_sandbox_provider(
manifest_run_settings: &std::result::Result<RunNamespace, SharedError>,
) -> String {
manifest_run_settings.as_ref().map_or_else(
|_| SandboxProvider::default().to_string(),
|_| SandboxProviderKind::default().to_string(),
|settings| settings.environment.provider.to_string(),
)
}
@ -2050,6 +2059,38 @@ fn worker_token_keys_from_server_secrets(
.map_err(|err| jwt_auth::session_secret_key_error(&err))
}
fn build_sandbox_provider_registry(
server_settings: &ServerSettings,
daytona_api_key: Option<String>,
env_lookup: &EnvLookup,
http_client: Option<fabro_http::HttpClient>,
) -> SandboxProviderRegistry {
let provider_settings = &server_settings.server.sandbox.providers;
let mut providers: Vec<Arc<dyn SandboxProvider>> = Vec::new();
if provider_settings.local.enabled {
providers.push(Arc::new(LocalSandboxProvider));
}
if provider_settings.docker.enabled {
providers.push(Arc::new(DockerSandboxProvider::new()));
}
if provider_settings.daytona.enabled && daytona_api_key.is_some() {
let api_url = env_lookup(EnvVars::DAYTONA_API_URL)
.or_else(|| env_lookup(EnvVars::DAYTONA_SERVER_URL));
let organization_id = env_lookup(EnvVars::DAYTONA_ORGANIZATION_ID);
providers.push(Arc::new(DaytonaSandboxProvider::new(
daytona_api_key,
api_url,
organization_id,
http_client,
)));
}
SandboxProviderRegistry::new(providers)
}
pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppState>> {
let AppStateConfig {
resolved_settings,
@ -2064,6 +2105,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
github_api_base_url,
active_config_path,
http_client,
sandbox_provider_registry,
shutdown,
} = config;
@ -2071,6 +2113,9 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
Some(vault) => vault,
None => load_startup_vault(&vault_path)?,
};
// Read vault secrets needed for synchronous setup before we wrap the vault in
// an async lock for the rest of AppState.
let daytona_api_key = vault.get(EnvVars::DAYTONA_API_KEY).map(str::to_string);
let vault = Arc::new(AsyncRwLock::new(vault));
let llm_source: Arc<dyn CredentialSource> =
Arc::new(VaultCredentialSource::vault_only(Arc::clone(&vault)));
@ -2084,6 +2129,14 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
Catalog::from_builtin_with_overrides(&resolved_settings.llm_catalog_settings)
.context("building LLM model catalog")?,
);
let sandbox_provider_registry = sandbox_provider_registry.unwrap_or_else(|| {
build_sandbox_provider_registry(
current_server_settings.as_ref(),
daytona_api_key,
&env_lookup,
http_client.clone(),
)
});
let slack_service = {
let default_channel = current_server_settings
.server
@ -2153,6 +2206,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
github_api_base_url,
active_config_path,
http_client,
sandbox_provider_registry,
shutdown,
shutting_down: AtomicBool::new(false),
registry_factory_override,

View file

@ -16,6 +16,7 @@ mod pair;
mod pull_requests;
mod runs;
mod sandbox;
mod sandboxes;
mod secrets;
mod sessions;
mod steer;
@ -153,6 +154,7 @@ pub(super) fn real_routes() -> Router<Arc<AppState>> {
.merge(pull_requests::routes())
.merge(artifacts::routes())
.merge(sandbox::routes())
.merge(sandboxes::routes())
.merge(lifecycle::routes())
.merge(steer::routes())
.merge(pair::routes())

View file

@ -5,7 +5,7 @@ use std::sync::Arc;
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use fabro_sandbox::{TerminalSize, open_terminal_for_run};
use fabro_types::{SandboxServiceDiscoverySource, SandboxServiceListMeta};
use fabro_types::{SandboxProviderKind, SandboxServiceDiscoverySource, SandboxServiceListMeta};
use futures_util::FutureExt;
use futures_util::future::BoxFuture;
@ -13,10 +13,10 @@ use super::super::{
ApiError, AppState, Bytes, DaytonaSandbox, EnvVars, HeaderMap, IntoResponse, Json,
NamedTempFile, Path, PreviewUrlRequest, PreviewUrlResponse, Query, RequiredUser, Response,
Router, RunId, Sandbox, SandboxDetails, SandboxFileEntry, SandboxFileListResponse,
SandboxProvider, SandboxService, SandboxServiceListResponse, SshAccessRequest,
SshAccessResponse, State, StatusCode, VncPreviewResponse, collect_causes, fs, get,
octet_stream_response, parse_run_id_path, post, reconnect_for_run, reject_if_archived,
render_with_causes, sandbox_details,
SandboxService, SandboxServiceListResponse, SshAccessRequest, SshAccessResponse, State,
StatusCode, VncPreviewResponse, collect_causes, fs, get, octet_stream_response,
parse_run_id_path, post, reconnect_for_run, reject_if_archived, render_with_causes,
sandbox_details,
};
const MAX_TERMINAL_CONTROL_BYTES: usize = 4096;
@ -400,7 +400,7 @@ async fn create_ssh_access(
};
match record.provider {
SandboxProvider::Daytona => {
SandboxProviderKind::Daytona => {
let sandbox = match reconnect_daytona_sandbox(&state, &id).await {
Ok(sandbox) => sandbox,
Err(response) => return response,
@ -414,7 +414,7 @@ async fn create_ssh_access(
}
}
}
SandboxProvider::Docker => {
SandboxProviderKind::Docker => {
let sandbox = match reconnect_run_sandbox(&state, &id).await {
Ok(sandbox) => sandbox,
Err(response) => return response,
@ -433,7 +433,7 @@ async fn create_ssh_access(
}
}
}
SandboxProvider::Local => ApiError::new(
SandboxProviderKind::Local => ApiError::new(
StatusCode::CONFLICT,
"Sandbox provider does not support access commands.",
)
@ -454,7 +454,7 @@ async fn create_sandbox_vnc_preview(
Ok(record) => record,
Err(response) => return response,
};
if record.provider != SandboxProvider::Daytona {
if record.provider != SandboxProviderKind::Daytona {
return ApiError::new(
StatusCode::NOT_IMPLEMENTED,
"Sandbox provider does not support VNC previews.",
@ -613,7 +613,7 @@ struct SandboxServiceDiscovery {
source: SandboxServiceDiscoverySource,
}
fn parse_sandbox_services(output: &str, provider: SandboxProvider) -> SandboxServiceDiscovery {
fn parse_sandbox_services(output: &str, provider: SandboxProviderKind) -> SandboxServiceDiscovery {
if output
.lines()
.any(|line| line.trim_start().starts_with("FABRO_PROC_NET_TCP "))
@ -630,7 +630,7 @@ fn parse_sandbox_services(output: &str, provider: SandboxProvider) -> SandboxSer
}
}
fn parse_ss_listening_services(output: &str, provider: SandboxProvider) -> Vec<SandboxService> {
fn parse_ss_listening_services(output: &str, provider: SandboxProviderKind) -> Vec<SandboxService> {
let mut services = BTreeMap::<u16, SandboxService>::new();
for line in output
.lines()
@ -663,7 +663,7 @@ enum ProcNetFamily {
fn parse_proc_net_listening_services(
output: &str,
provider: SandboxProvider,
provider: SandboxProviderKind,
) -> Vec<SandboxService> {
let mut services = BTreeMap::<u16, SandboxService>::new();
let mut family = None;
@ -743,7 +743,7 @@ fn parse_proc_net_ipv6(value: &str) -> Option<Ipv6Addr> {
fn push_service(
services: &mut BTreeMap<u16, SandboxService>,
provider: SandboxProvider,
provider: SandboxProviderKind,
port: u16,
address: String,
process: Option<String>,
@ -760,8 +760,8 @@ fn push_service(
}
}
fn preview_supported(provider: SandboxProvider, port: u16) -> bool {
provider == SandboxProvider::Daytona && (3000..=9999).contains(&port)
fn preview_supported(provider: SandboxProviderKind, port: u16) -> bool {
provider == SandboxProviderKind::Daytona && (3000..=9999).contains(&port)
}
fn push_unique(values: &mut Vec<String>, value: String) {
@ -866,7 +866,7 @@ async fn reconnect_daytona_sandbox(
run_id: &RunId,
) -> Result<DaytonaSandbox, Response> {
let record = load_run_sandbox(state, run_id).await?;
if record.provider != SandboxProvider::Daytona {
if record.provider != SandboxProviderKind::Daytona {
return Err(ApiError::new(
StatusCode::CONFLICT,
"Sandbox provider does not support this capability.",
@ -1001,7 +1001,7 @@ LISTEN 0 4096 0.0.0.0:5173 0.0.0.0:* users:(("vite",pid=84,fd=19))
LISTEN 0 4096 [::]:8080 [::]:* users:(("server",pid=126,fd=9))
LISTEN 0 4096 [::1]:2500 [::]:* users:(("debug",pid=168,fd=7))
"#,
SandboxProvider::Daytona,
SandboxProviderKind::Daytona,
);
assert_eq!(services.len(), 4);
@ -1035,7 +1035,7 @@ not enough fields
LISTEN 0 4096 127.0.0.1:0 0.0.0.0:* users:(("zero",pid=1,fd=2))
LISTEN 0 4096 127.0.0.1:65536 0.0.0.0:* users:(("large",pid=1,fd=2))
"#,
SandboxProvider::Daytona,
SandboxProviderKind::Daytona,
);
assert!(services.is_empty());
@ -1050,7 +1050,7 @@ LISTEN 0 4096 0.0.0.0:3000 0.0.0.0:* users:(("node",pid=42,fd=23))
LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("node",pid=42,fd=23))
LISTEN 0 4096 [::]:3000 [::]:* users:(("vite",pid=84,fd=19))
"#,
SandboxProvider::Daytona,
SandboxProviderKind::Daytona,
);
assert_eq!(services, vec![SandboxService {
@ -1082,7 +1082,7 @@ FABRO_PROC_NET_TCP /proc/net/tcp6
0: 00000000000000000000000000000000:1F90 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 501 0 44444
1: 00000000000000000000000001000000:09C4 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 501 0 55555
",
SandboxProvider::Daytona,
SandboxProviderKind::Daytona,
);
assert_eq!(discovery.source, SandboxServiceDiscoverySource::Procfs);
@ -1116,11 +1116,11 @@ FABRO_PROC_NET_TCP /proc/net/tcp6
#[test]
fn preview_support_is_daytona_only_for_documented_range() {
assert!(!preview_supported(SandboxProvider::Daytona, 2500));
assert!(preview_supported(SandboxProvider::Daytona, 3000));
assert!(preview_supported(SandboxProvider::Daytona, 9999));
assert!(!preview_supported(SandboxProvider::Daytona, 10000));
assert!(!preview_supported(SandboxProvider::Docker, 3000));
assert!(!preview_supported(SandboxProviderKind::Daytona, 2500));
assert!(preview_supported(SandboxProviderKind::Daytona, 3000));
assert!(preview_supported(SandboxProviderKind::Daytona, 9999));
assert!(!preview_supported(SandboxProviderKind::Daytona, 10000));
assert!(!preview_supported(SandboxProviderKind::Docker, 3000));
}
#[test]

View file

@ -0,0 +1,247 @@
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::get;
use axum::{Json, Router};
use fabro_sandbox::SandboxLookupError;
use fabro_types::{SandboxInfo, SandboxListResponse, SandboxProviderKind};
use super::super::AppState;
use crate::error::ApiError;
use crate::principal_middleware::RequiredRunManagementActor;
pub(super) fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/sandboxes", get(list_sandboxes))
.route("/sandboxes/{id}", get(retrieve_sandbox))
}
async fn list_sandboxes(
State(state): State<Arc<AppState>>,
_auth: RequiredRunManagementActor,
) -> Json<SandboxListResponse> {
Json(state.sandbox_provider_registry().list_managed().await)
}
async fn retrieve_sandbox(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
_auth: RequiredRunManagementActor,
) -> Result<Json<SandboxInfo>, ApiError> {
state
.sandbox_provider_registry()
.get_managed_by_native_id(&id)
.await
.map(Json)
.map_err(sandbox_lookup_error)
}
fn sandbox_lookup_error(err: SandboxLookupError) -> ApiError {
match err {
SandboxLookupError::NotFound { id } => ApiError::new(
StatusCode::NOT_FOUND,
format!("No provider found a Fabro-managed sandbox with id '{id}'."),
),
SandboxLookupError::Conflict { id, providers } => ApiError::new(
StatusCode::CONFLICT,
format!(
"More than one provider matched sandbox id '{id}': {}.",
provider_list(&providers)
),
),
SandboxLookupError::ProviderUnavailable {
id,
provider_errors,
} => ApiError::new(
StatusCode::BAD_GATEWAY,
format!(
"Provider lookup for sandbox id '{id}' failed before a definitive result could be determined: {}.",
provider_errors
.iter()
.map(|error| format!("{}: {}", error.provider, error.message))
.collect::<Vec<_>>()
.join("; ")
),
),
}
}
fn provider_list(providers: &[SandboxProviderKind]) -> String {
providers
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
mod tests {
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode};
use fabro_sandbox::SandboxProviderRegistry;
use fabro_sandbox::test_support::{
FakeGet, FakeList, FakeSandboxProvider, fake_registry, fake_sandbox_info,
};
use fabro_types::SandboxProviderKind;
use serde_json::{Value, json};
use tower::ServiceExt;
use crate::test_support::{TestAppStateBuilder, build_test_router};
fn app_with_registry(registry: SandboxProviderRegistry) -> axum::Router {
let state = TestAppStateBuilder::new()
.sandbox_provider_registry(registry)
.build();
build_test_router(state)
}
fn req_get(uri: &str) -> Request<Body> {
Request::builder()
.method("GET")
.uri(uri)
.body(Body::empty())
.expect("sandbox inventory GET request should build")
}
async fn body_json(response: axum::response::Response) -> Value {
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("response body should fit in memory");
serde_json::from_slice(&bytes).expect("response body should be valid JSON")
}
#[tokio::test]
async fn list_returns_provider_backed_data_without_run_projection_state() {
let docker = fake_sandbox_info(SandboxProviderKind::Docker, "docker-native-id");
let app = app_with_registry(fake_registry(vec![FakeSandboxProvider::new(
SandboxProviderKind::Docker,
FakeList::Ok(vec![docker]),
FakeGet::Missing,
)]));
let response = app.oneshot(req_get("/api/v1/sandboxes")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
assert_eq!(body["data"][0]["id"], "docker-native-id");
assert_eq!(body["data"][0]["provider"], "docker");
assert_eq!(body["meta"]["provider_errors"], json!([]));
}
#[tokio::test]
async fn retrieve_searches_all_configured_providers() {
let daytona = fake_sandbox_info(SandboxProviderKind::Daytona, "native-id");
let app = app_with_registry(fake_registry(vec![
FakeSandboxProvider::new(
SandboxProviderKind::Docker,
FakeList::Ok(Vec::new()),
FakeGet::Missing,
),
FakeSandboxProvider::new(
SandboxProviderKind::Daytona,
FakeList::Ok(Vec::new()),
FakeGet::Found(Box::new(daytona)),
),
]));
let response = app
.oneshot(req_get("/api/v1/sandboxes/native-id"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
assert_eq!(body["id"], "native-id");
assert_eq!(body["provider"], "daytona");
}
#[tokio::test]
async fn no_matching_sandbox_returns_404() {
let app = app_with_registry(fake_registry(vec![
FakeSandboxProvider::new(
SandboxProviderKind::Docker,
FakeList::Ok(Vec::new()),
FakeGet::Missing,
),
FakeSandboxProvider::new(
SandboxProviderKind::Daytona,
FakeList::Ok(Vec::new()),
FakeGet::Missing,
),
]));
let response = app
.oneshot(req_get("/api/v1/sandboxes/missing"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn duplicate_native_ids_return_409() {
let app = app_with_registry(fake_registry(vec![
FakeSandboxProvider::new(
SandboxProviderKind::Docker,
FakeList::Ok(Vec::new()),
FakeGet::Found(Box::new(fake_sandbox_info(
SandboxProviderKind::Docker,
"same-id",
))),
),
FakeSandboxProvider::new(
SandboxProviderKind::Daytona,
FakeList::Ok(Vec::new()),
FakeGet::Found(Box::new(fake_sandbox_info(
SandboxProviderKind::Daytona,
"same-id",
))),
),
]));
let response = app
.oneshot(req_get("/api/v1/sandboxes/same-id"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT);
let body = body_json(response).await;
assert!(
body["errors"][0]["detail"]
.as_str()
.unwrap_or_default()
.contains("More than one provider matched")
);
}
#[tokio::test]
async fn provider_lookup_uncertainty_returns_502() {
let app = app_with_registry(fake_registry(vec![
FakeSandboxProvider::new(
SandboxProviderKind::Docker,
FakeList::Ok(Vec::new()),
FakeGet::Missing,
),
FakeSandboxProvider::new(
SandboxProviderKind::Daytona,
FakeList::Ok(Vec::new()),
FakeGet::Err("daytona unavailable"),
),
]));
let response = app
.oneshot(req_get("/api/v1/sandboxes/maybe-missing"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = body_json(response).await;
assert!(
body["errors"][0]["detail"]
.as_str()
.unwrap_or_default()
.contains("daytona unavailable")
);
}
}

View file

@ -21,7 +21,7 @@ use fabro_types::settings::ServerAuthMethod;
use fabro_types::{
AgentBackend, AttrValue, AuthMethod, CommandTermination, FailureCategory, FailureDetail, Graph,
InterviewQuestionRecord, Node, Outcome, QuestionType, RunBlobId, RunId, RunSpec,
SandboxProvider, StageContextWindowBreakdownItem, StageContextWindowCategory,
SandboxProviderKind, StageContextWindowBreakdownItem, StageContextWindowCategory,
StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness,
StageContextWindowWarning, StageModelUsage, StageTiming, SuccessReason, SystemActorKind,
WorkflowSettings, fixtures,
@ -961,7 +961,7 @@ id = "missing"
assert_eq!(
system_sandbox_provider(&manifest_run_settings),
SandboxProvider::default().to_string()
SandboxProviderKind::default().to_string()
);
}
@ -980,7 +980,7 @@ enabled = false
);
assert_eq!(
crate::run_manifest::sandbox_provider_policy_error(&settings, SandboxProvider::Daytona)
crate::run_manifest::sandbox_provider_policy_error(&settings, SandboxProviderKind::Daytona)
.as_deref(),
Some(
"sandbox provider \"daytona\" is disabled by server.sandbox.providers.daytona.enabled"
@ -1659,6 +1659,7 @@ fn slack_app_state_with_secret_sources(
github_api_base_url: None,
active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"),
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
sandbox_provider_registry: None,
shutdown: tokio_util::sync::CancellationToken::new(),
})
.expect("slack test app state should build")
@ -1999,6 +2000,7 @@ methods = ["dev-token"]
github_api_base_url: None,
active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"),
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
sandbox_provider_registry: None,
shutdown: tokio_util::sync::CancellationToken::new(),
}) else {
panic!("build_app_state should require SESSION_SECRET")
@ -2124,6 +2126,7 @@ fn build_test_app_state_with_vault_path(vault_path: &Path) -> anyhow::Result<Arc
github_api_base_url: None,
active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"),
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
sandbox_provider_registry: None,
shutdown: tokio_util::sync::CancellationToken::new(),
})
}
@ -5215,6 +5218,7 @@ fn create_github_token_app_state_with_env_lookup_and_llm_catalog_settings(
github_api_base_url,
active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"),
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
sandbox_provider_registry: None,
shutdown: tokio_util::sync::CancellationToken::new(),
};
let state = build_app_state(config).expect("test app state should build");
@ -11575,7 +11579,7 @@ async fn create_preserved_local_sandbox_run(state: &Arc<AppState>, run_id: RunId
definition_blob: None,
},
workflow_event::Event::SandboxInitialized {
provider: SandboxProvider::Local,
provider: SandboxProviderKind::Local,
id: "sandbox-preserve-1".to_string(),
working_directory: "/tmp/fabro-preserved-sandbox".to_string(),
repo_cloned: None,
@ -12326,7 +12330,7 @@ async fn delete_run_retry_after_missing_provider_resource_removes_metadata() {
workflow_event::Event::RunStarting,
workflow_event::Event::RunRunning,
workflow_event::Event::SandboxInitialized {
provider: SandboxProvider::Docker,
provider: SandboxProviderKind::Docker,
id: "missing-sandbox".to_string(),
working_directory: "/tmp/fabro-missing-sandbox".to_string(),
repo_cloned: Some(false),
@ -14220,7 +14224,7 @@ async fn list_runs_includes_live_metadata_from_run_state() {
workflow_event::Event::RunStarting,
workflow_event::Event::RunRunning,
workflow_event::Event::SandboxInitialized {
provider: SandboxProvider::Local,
provider: SandboxProviderKind::Local,
id: "sb-test".to_string(),
working_directory: "/sandbox/workdir".to_string(),
repo_cloned: None,
@ -14299,7 +14303,7 @@ async fn list_runs_page_limit_preserves_metadata_for_paged_items() {
workflow_event::Event::RunStarting,
workflow_event::Event::RunRunning,
workflow_event::Event::SandboxInitialized {
provider: SandboxProvider::Local,
provider: SandboxProviderKind::Local,
id: sandbox_id.to_string(),
working_directory: "/sandbox/workdir".to_string(),
repo_cloned: None,

View file

@ -16,6 +16,7 @@ use chrono::Duration as ChronoDuration;
use fabro_config::{RunLayer, RunSettingsBuilder, ServerSettingsBuilder, envfile};
use fabro_interview::Interviewer;
use fabro_model::catalog::{LlmCatalogSettings, ProviderCatalogSettings};
use fabro_sandbox::SandboxProviderRegistry;
use fabro_static::EnvVars;
use fabro_store::{ArtifactStore, Database};
use fabro_types::settings::ServerAuthMethod;
@ -61,6 +62,7 @@ pub struct TestAppStateBuilder {
manifest_run_defaults: RunLayer,
max_concurrent_runs: usize,
registry_factory_override: Option<Box<RegistryFactoryOverride>>,
sandbox_provider_registry: Option<SandboxProviderRegistry>,
store_bundle: Option<(Arc<Database>, ArtifactStore)>,
vault_path: Option<PathBuf>,
vault_entries: Vec<(String, String)>,
@ -78,6 +80,7 @@ impl Default for TestAppStateBuilder {
manifest_run_defaults: RunLayer::default(),
max_concurrent_runs: 5,
registry_factory_override: None,
sandbox_provider_registry: None,
store_bundle: None,
vault_path: None,
vault_entries: Vec::new(),
@ -121,6 +124,14 @@ impl TestAppStateBuilder {
self
}
pub fn sandbox_provider_registry(
mut self,
sandbox_provider_registry: SandboxProviderRegistry,
) -> Self {
self.sandbox_provider_registry = Some(sandbox_provider_registry);
self
}
pub fn env_lookup(
mut self,
env_lookup: impl Fn(&str) -> Option<String> + Send + Sync + 'static,
@ -222,6 +233,7 @@ impl TestAppStateBuilder {
http_client: Some(
fabro_http::test_http_client().expect("test HTTP client should build"),
),
sandbox_provider_registry: self.sandbox_provider_registry,
shutdown: CancellationToken::new(),
})
.expect("test app state should build")

View file

@ -14,7 +14,7 @@ use fabro_types::{
PullRequestLink, RepositoryRef, Run, RunApproval, RunApprovalState, RunBillingSummary,
RunControlAction, RunDiff, RunEvent, RunId, RunLifecycle, RunLinks, RunModel, RunOrigin,
RunProjection, RunSandbox, RunSandboxRuntime, RunSize, RunSpec, RunStatus, RunTimestamps,
SandboxProvider, StageCompletion, StageHandler, StageId, StageModelUsage, StageOutcome,
SandboxProviderKind, StageCompletion, StageHandler, StageId, StageModelUsage, StageOutcome,
StageProjection, StageState, StartRecord, SubAgentProjection, SubAgentStatus, TodoListKind,
TodoListProjection, TodoProjection, WorkflowRef, first_event_seq,
};
@ -796,7 +796,7 @@ fn projection_from_created(event: &EventEnvelope) -> Result<RunProjection> {
}
fn planned_sandbox(settings: &RunEnvironmentSettings) -> RunSandbox {
let provider = SandboxProvider::from(settings.provider);
let provider = SandboxProviderKind::from(settings.provider);
RunSandbox {
provider,
image: (settings.provider == EnvironmentProvider::Docker)

View file

@ -6,7 +6,7 @@ use fabro_types::graph::Graph;
use fabro_types::run::RunSpec;
use fabro_types::{
BilledModelUsage, BilledTokenCounts, Checkpoint, CheckpointRecord, InterviewQuestionRecord,
QuestionType, RunDiff, RunSandbox, RunSandboxRuntime, RunStatus, SandboxProvider,
QuestionType, RunDiff, RunSandbox, RunSandboxRuntime, RunStatus, SandboxProviderKind,
StageCompletion, StageModelUsage, StageOutcome, StartRecord, WorkflowSettings, first_event_seq,
fixtures,
};
@ -99,7 +99,7 @@ fn serializable_projection_round_trips_and_trims_bulky_node_fields() {
diff: RunDiff::default(),
});
projection.sandbox = Some(RunSandbox {
provider: SandboxProvider::Local,
provider: SandboxProviderKind::Local,
image: None,
snapshot: None,
runtime: Some(RunSandboxRuntime {

View file

@ -31,6 +31,7 @@ pub mod run_sandbox;
pub mod run_summary;
pub mod run_title;
pub mod sandbox_details;
pub mod sandbox_inventory;
pub mod sandbox_provider;
pub mod sandbox_services;
pub mod secret;
@ -123,7 +124,10 @@ pub use sandbox_details::{
SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, SandboxNetworkPolicyMode,
SandboxResources, SandboxState, SandboxTimestamps,
};
pub use sandbox_provider::SandboxProvider;
pub use sandbox_inventory::{
SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxProviderLookupError,
};
pub use sandbox_provider::SandboxProviderKind;
pub use sandbox_services::{
SandboxService, SandboxServiceDiscoverySource, SandboxServiceListMeta,
SandboxServiceListResponse,

View file

@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
use crate::SandboxProvider;
use crate::SandboxProviderKind;
#[derive(
Debug,
@ -329,7 +329,7 @@ pub struct GitCloneFailedProps {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SandboxInitializedProps {
pub working_directory: String,
pub provider: SandboxProvider,
pub provider: SandboxProviderKind,
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo_cloned: Option<bool>,

View file

@ -1,10 +1,10 @@
use serde::{Deserialize, Serialize};
use crate::SandboxProvider;
use crate::SandboxProviderKind;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunSandbox {
pub provider: SandboxProvider,
pub provider: SandboxProviderKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]

View file

@ -188,7 +188,7 @@ mod tests {
fn serializes_with_snake_case_state() {
let details = SandboxDetails {
sandbox: RunSandbox {
provider: crate::SandboxProvider::Docker,
provider: crate::SandboxProviderKind::Docker,
image: Some("ghcr.io/fabro/sandbox:latest".to_string()),
snapshot: None,
runtime: Some(crate::RunSandboxRuntime {
@ -282,7 +282,7 @@ mod tests {
}))
.unwrap();
assert_eq!(details.sandbox.provider, crate::SandboxProvider::Local);
assert_eq!(details.sandbox.provider, crate::SandboxProviderKind::Local);
assert_eq!(
details
.sandbox

View file

@ -0,0 +1,52 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::{
SandboxNetwork, SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SandboxInfo {
pub provider: SandboxProviderKind,
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
pub state: SandboxState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub native_state: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snapshot: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub web_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory: Option<String>,
pub resources: SandboxResources,
#[serde(default)]
pub network: SandboxNetwork,
#[serde(default)]
pub labels: BTreeMap<String, String>,
pub timestamps: SandboxTimestamps,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SandboxProviderLookupError {
pub provider: SandboxProviderKind,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct SandboxListMeta {
#[serde(default)]
pub provider_errors: Vec<SandboxProviderLookupError>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SandboxListResponse {
pub data: Vec<SandboxInfo>,
pub meta: SandboxListMeta,
}

View file

@ -3,13 +3,13 @@ use strum::{Display, EnumString};
use crate::settings::run::RunMode;
/// Sandbox provider for agent tool operations.
/// Sandbox provider discriminator for agent tool operations.
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, Display, EnumString,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
pub enum SandboxProvider {
pub enum SandboxProviderKind {
/// Run tools on the local host.
#[default]
Local,
@ -19,7 +19,7 @@ pub enum SandboxProvider {
Daytona,
}
impl SandboxProvider {
impl SandboxProviderKind {
/// True only for Local. Used by dry-run to force local execution.
/// NOT the same as "runs on the host" (Docker is host-adjacent but not
/// dry-run compatible).
@ -48,38 +48,38 @@ impl SandboxProvider {
#[cfg(test)]
mod tests {
use super::SandboxProvider;
use super::SandboxProviderKind;
#[test]
fn sandbox_provider_default_is_local() {
assert_eq!(SandboxProvider::default(), SandboxProvider::Local);
assert_eq!(SandboxProviderKind::default(), SandboxProviderKind::Local);
}
#[test]
fn sandbox_provider_from_str() {
assert_eq!(
"local".parse::<SandboxProvider>().unwrap(),
SandboxProvider::Local
"local".parse::<SandboxProviderKind>().unwrap(),
SandboxProviderKind::Local
);
assert_eq!(
"docker".parse::<SandboxProvider>().unwrap(),
SandboxProvider::Docker
"docker".parse::<SandboxProviderKind>().unwrap(),
SandboxProviderKind::Docker
);
assert_eq!(
"daytona".parse::<SandboxProvider>().unwrap(),
SandboxProvider::Daytona
"daytona".parse::<SandboxProviderKind>().unwrap(),
SandboxProviderKind::Daytona
);
assert_eq!(
"LOCAL".parse::<SandboxProvider>().unwrap(),
SandboxProvider::Local
"LOCAL".parse::<SandboxProviderKind>().unwrap(),
SandboxProviderKind::Local
);
assert!("invalid".parse::<SandboxProvider>().is_err());
assert!("invalid".parse::<SandboxProviderKind>().is_err());
}
#[test]
fn sandbox_provider_display() {
assert_eq!(SandboxProvider::Local.to_string(), "local");
assert_eq!(SandboxProvider::Docker.to_string(), "docker");
assert_eq!(SandboxProvider::Daytona.to_string(), "daytona");
assert_eq!(SandboxProviderKind::Local.to_string(), "local");
assert_eq!(SandboxProviderKind::Docker.to_string(), "docker");
assert_eq!(SandboxProviderKind::Daytona.to_string(), "daytona");
}
}

View file

@ -323,7 +323,7 @@ impl EnvironmentProvider {
}
}
impl From<EnvironmentProvider> for crate::SandboxProvider {
impl From<EnvironmentProvider> for crate::SandboxProviderKind {
fn from(value: EnvironmentProvider) -> Self {
match value {
EnvironmentProvider::Local => Self::Local,

View file

@ -150,11 +150,14 @@ pub struct ServerSandboxProvidersSettings {
impl ServerSandboxProvidersSettings {
/// Per-provider policy entry.
#[must_use]
pub fn for_provider(&self, provider: crate::SandboxProvider) -> &ServerSandboxProviderSettings {
pub fn for_provider(
&self,
provider: crate::SandboxProviderKind,
) -> &ServerSandboxProviderSettings {
match provider {
crate::SandboxProvider::Local => &self.local,
crate::SandboxProvider::Docker => &self.docker,
crate::SandboxProvider::Daytona => &self.daytona,
crate::SandboxProviderKind::Local => &self.local,
crate::SandboxProviderKind::Docker => &self.docker,
crate::SandboxProviderKind::Daytona => &self.daytona,
}
}
}

View file

@ -0,0 +1,119 @@
use std::collections::BTreeMap;
use chrono::{TimeZone, Utc};
use fabro_types::{
SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxNetwork, SandboxNetworkPolicy,
SandboxProviderKind, SandboxProviderLookupError, SandboxResources, SandboxState,
SandboxTimestamps,
};
use serde_json::json;
#[test]
fn sandbox_inventory_serializes_provider_backed_shape() {
let created_at = Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap();
let response = SandboxListResponse {
data: vec![SandboxInfo {
provider: SandboxProviderKind::Docker,
id: "container-abc123".to_string(),
display_name: Some("fabro-run-abc".to_string()),
state: SandboxState::Running,
native_state: Some("running".to_string()),
image: Some("buildpack-deps:noble".to_string()),
snapshot: None,
region: None,
web_url: None,
working_directory: Some("/workspace".to_string()),
resources: SandboxResources {
cpu_cores: Some(2.0),
memory_bytes: Some(4 * 1024 * 1024 * 1024),
disk_bytes: None,
},
network: SandboxNetwork {
egress: SandboxNetworkPolicy::open(),
ingress: SandboxNetworkPolicy::blocked(),
},
labels: BTreeMap::from([(
"sh.fabro.managed".to_string(),
"true".to_string(),
)]),
timestamps: SandboxTimestamps {
created_at: Some(created_at),
last_activity_at: None,
},
}],
meta: SandboxListMeta {
provider_errors: vec![SandboxProviderLookupError {
provider: SandboxProviderKind::Daytona,
message: "Daytona API key is not configured".to_string(),
}],
},
};
assert_eq!(
serde_json::to_value(&response).unwrap(),
json!({
"data": [{
"provider": "docker",
"id": "container-abc123",
"display_name": "fabro-run-abc",
"state": "running",
"native_state": "running",
"image": "buildpack-deps:noble",
"working_directory": "/workspace",
"resources": {
"cpu_cores": 2.0,
"memory_bytes": 4_294_967_296_u64
},
"network": {
"egress": {
"mode": "open",
"cidrs": []
},
"ingress": {
"mode": "blocked",
"cidrs": []
}
},
"labels": {
"sh.fabro.managed": "true"
},
"timestamps": {
"created_at": "2026-05-25T12:00:00Z"
}
}],
"meta": {
"provider_errors": [{
"provider": "daytona",
"message": "Daytona API key is not configured"
}]
}
})
);
}
#[test]
fn sandbox_inventory_deserializes_when_optional_fields_are_absent() {
let info: SandboxInfo = serde_json::from_value(json!({
"provider": "local",
"id": "local:01KSGHGMCFM8W2FHXNMJ7MVY65",
"state": "unknown",
"resources": {},
"timestamps": {}
}))
.unwrap();
assert_eq!(info.provider, SandboxProviderKind::Local);
assert_eq!(info.id, "local:01KSGHGMCFM8W2FHXNMJ7MVY65");
assert_eq!(info.state, SandboxState::Unknown);
assert!(info.display_name.is_none());
assert!(info.native_state.is_none());
assert!(info.image.is_none());
assert!(info.snapshot.is_none());
assert!(info.region.is_none());
assert!(info.web_url.is_none());
assert!(info.working_directory.is_none());
assert_eq!(info.resources, SandboxResources::default());
assert_eq!(info.network, SandboxNetwork::unknown());
assert!(info.labels.is_empty());
assert_eq!(info.timestamps, SandboxTimestamps::default());
}

View file

@ -2,7 +2,7 @@ use std::collections::BTreeMap;
use chrono::{TimeZone, Utc};
use fabro_types::{
RunSandbox, RunSandboxRuntime, SandboxDetails, SandboxNetwork, SandboxProvider,
RunSandbox, RunSandboxRuntime, SandboxDetails, SandboxNetwork, SandboxProviderKind,
SandboxResources, SandboxState, SandboxTimestamps,
};
use serde_json::json;
@ -10,7 +10,7 @@ use serde_json::json;
#[test]
fn run_sandbox_serializes_canonical_identity_without_identifier() {
let sandbox = RunSandbox {
provider: SandboxProvider::Docker,
provider: SandboxProviderKind::Docker,
image: None,
snapshot: None,
runtime: Some(RunSandboxRuntime {
@ -52,7 +52,7 @@ fn run_sandbox_serializes_canonical_identity_without_identifier() {
fn sandbox_details_requires_canonical_id_and_working_directory() {
let details = SandboxDetails {
sandbox: RunSandbox {
provider: SandboxProvider::Daytona,
provider: SandboxProviderKind::Daytona,
image: Some("ubuntu:24.04".to_string()),
snapshot: None,
runtime: Some(RunSandboxRuntime {
@ -116,16 +116,16 @@ fn sandbox_details_requires_canonical_id_and_working_directory() {
#[test]
fn sandbox_provider_rejects_unknown_values() {
assert_eq!(
serde_json::from_value::<SandboxProvider>(json!("local")).unwrap(),
SandboxProvider::Local
serde_json::from_value::<SandboxProviderKind>(json!("local")).unwrap(),
SandboxProviderKind::Local
);
assert_eq!(
serde_json::from_value::<SandboxProvider>(json!("docker")).unwrap(),
SandboxProvider::Docker
serde_json::from_value::<SandboxProviderKind>(json!("docker")).unwrap(),
SandboxProviderKind::Docker
);
assert_eq!(
serde_json::from_value::<SandboxProvider>(json!("daytona")).unwrap(),
SandboxProvider::Daytona
serde_json::from_value::<SandboxProviderKind>(json!("daytona")).unwrap(),
SandboxProviderKind::Daytona
);
assert!(serde_json::from_value::<SandboxProvider>(json!("other")).is_err());
assert!(serde_json::from_value::<SandboxProviderKind>(json!("other")).is_err());
}

View file

@ -5,7 +5,7 @@ use ::fabro_types::{
ForkSourceRef, GitContext, PairId, PairMessageId, PairSystemMessageKind, PairTarget,
ParallelBranchId, PendingReason, PermissionLevel, Principal, PullRequestLink, RunBlobId,
RunFailure, RunId, RunNoticeLevel, RunPairEndedReason, RunPairFailedReason, RunProvenance,
RunRunnableSource, RunTiming, SandboxProvider, StageId, StageTiming, SuccessReason,
RunRunnableSource, RunTiming, SandboxProviderKind, StageId, StageTiming, SuccessReason,
run_event as fabro_types,
};
use fabro_agent::{AgentEvent, SandboxEvent};
@ -506,7 +506,7 @@ pub enum Event {
/// Emitted after the sandbox has been initialized (by engine lifecycle).
SandboxInitialized {
working_directory: String,
provider: SandboxProvider,
provider: SandboxProviderKind,
id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
repo_cloned: Option<bool>,

View file

@ -301,7 +301,7 @@ mod tests {
.await
.unwrap();
event::append_event(&source_store, &source_run_id, &Event::SandboxInitialized {
provider: fabro_types::SandboxProvider::Local,
provider: fabro_types::SandboxProviderKind::Local,
id: "sandbox-source".to_string(),
working_directory: "/tmp/source".to_string(),
repo_cloned: None,

View file

@ -12,7 +12,7 @@ use fabro_sandbox::daytona::DaytonaConfig;
use fabro_sandbox::from_environment::{
daytona_config_from_environment, docker_config_from_environment,
};
use fabro_sandbox::{DockerSandboxOptions, SandboxProvider, SandboxSpec};
use fabro_sandbox::{DockerSandboxOptions, SandboxSpec};
use fabro_static::EnvVars;
use fabro_types::settings::run::{
ApprovalMode, HookDefinition as ResolvedHookDefinition, HookEvent as ResolvedHookEvent,
@ -22,7 +22,7 @@ use fabro_types::settings::run::{
TlsMode as ResolvedTlsMode,
};
use fabro_types::settings::{InterpString, ModelRegistry, ResolvedModelRef};
use fabro_types::{ManifestPath, RunId, RunRunnableSource};
use fabro_types::{ManifestPath, RunId, RunRunnableSource, SandboxProviderKind};
use fabro_vault::Vault;
use tokio::runtime::Handle;
use tokio::sync::RwLock as AsyncRwLock;
@ -387,17 +387,17 @@ impl RunSession {
.collect();
let sandbox = match sandbox_provider {
SandboxProvider::Local => SandboxSpec::Local {
SandboxProviderKind::Local => SandboxSpec::Local {
working_directory: working_directory.clone(),
},
SandboxProvider::Docker => SandboxSpec::Docker {
SandboxProviderKind::Docker => SandboxSpec::Docker {
config: resolve_docker_config(resolved),
github_app: services.github_app.clone(),
run_id: Some(record.run_id),
clone_origin_url: record.repo_origin_url().map(str::to_string),
clone_branch: record.base_branch().map(str::to_string),
},
SandboxProvider::Daytona => {
SandboxProviderKind::Daytona => {
let api_key = match &services.vault {
Some(v) => v
.read()
@ -553,8 +553,8 @@ async fn load_accepted_run_definition(
serde_json::from_slice(&bytes).map_err(|err| Error::Parse(err.to_string()))
}
fn resolve_sandbox_provider(settings: &ResolvedRunSettings) -> SandboxProvider {
SandboxProvider::from(settings.environment.provider)
fn resolve_sandbox_provider(settings: &ResolvedRunSettings) -> SandboxProviderKind {
SandboxProviderKind::from(settings.environment.provider)
}
fn resolve_daytona_config(settings: &ResolvedRunSettings) -> DaytonaConfig {

View file

@ -15,7 +15,7 @@
)]
use fabro_sandbox::reconnect::reconnect;
use fabro_types::{RunSandbox, RunSandboxRuntime, SandboxProvider};
use fabro_types::{RunSandbox, RunSandboxRuntime, SandboxProviderKind};
const DOCKER_MANAGED_LABEL: &str = "sh.fabro.managed";
const DOCKER_CP_IMAGE: &str = "buildpack-deps:noble";
@ -26,7 +26,7 @@ const DOCKER_CP_IMAGE: &str = "buildpack-deps:noble";
fn local_record(working_directory: &std::path::Path) -> RunSandbox {
RunSandbox {
provider: SandboxProvider::Local,
provider: SandboxProviderKind::Local,
image: None,
snapshot: None,
runtime: Some(RunSandboxRuntime {
@ -137,7 +137,7 @@ async fn local_cp_creates_parent_dirs() {
fn docker_record(container_id: &str) -> RunSandbox {
RunSandbox {
provider: SandboxProvider::Docker,
provider: SandboxProviderKind::Docker,
image: None,
snapshot: None,
runtime: Some(RunSandboxRuntime {

View file

@ -1704,7 +1704,7 @@ async fn daytona_toolbox_idle_diagnostic() {
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
async fn daytona_cp_upload_download_round_trip() {
use fabro_sandbox::reconnect::reconnect;
use fabro_types::{RunSandbox, SandboxProvider};
use fabro_types::{RunSandbox, SandboxProviderKind};
// 1. Create and initialize a real Daytona sandbox
let env = create_env().await;
@ -1718,7 +1718,7 @@ async fn daytona_cp_upload_download_round_trip() {
// 2. Build a RunSandbox (same as `fabro run` would persist)
let record = RunSandbox {
provider: SandboxProvider::Daytona,
provider: SandboxProviderKind::Daytona,
image: None,
snapshot: None,
runtime: Some(fabro_types::RunSandboxRuntime {

View file

@ -12,6 +12,7 @@ api/repos-api.ts
api/run-internals-api.ts
api/run-outputs-api.ts
api/runs-api.ts
api/sandboxes-api.ts
api/secrets-api.ts
api/sessions-api.ts
api/settings-api.ts
@ -370,10 +371,14 @@ models/run.ts
models/sandbox-details.ts
models/sandbox-file-entry.ts
models/sandbox-file-list-response.ts
models/sandbox-info.ts
models/sandbox-list-meta.ts
models/sandbox-list-response.ts
models/sandbox-network-policy-mode.ts
models/sandbox-network-policy.ts
models/sandbox-network.ts
models/sandbox-provider.ts
models/sandbox-provider-kind.ts
models/sandbox-provider-lookup-error.ts
models/sandbox-resources.ts
models/sandbox-service-discovery-source.ts
models/sandbox-service-list-meta.ts

View file

@ -27,6 +27,7 @@ export * from './api/repos-api';
export * from './api/run-internals-api';
export * from './api/run-outputs-api';
export * from './api/runs-api';
export * from './api/sandboxes-api';
export * from './api/secrets-api';
export * from './api/sessions-api';
export * from './api/settings-api';

View file

@ -0,0 +1,200 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from '../configuration';
import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios';
import globalAxios from 'axios';
// Some imports not used depending on template conditions
// @ts-ignore
import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction, replaceWithSerializableTypeIfNeeded } from '../common';
// @ts-ignore
import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base';
// @ts-ignore
import type { ErrorResponse } from '../models';
// @ts-ignore
import type { SandboxInfo } from '../models';
// @ts-ignore
import type { SandboxListResponse } from '../models';
/**
* SandboxesApi - axios parameter creator
*/
export const SandboxesApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
* Lists Fabro-managed sandboxes directly from configured sandbox providers.
* @summary List Sandboxes
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listSandboxes: async (options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/api/v1/sandboxes`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Retrieves a Fabro-managed sandbox by provider-native id by searching all configured sandbox providers.
* @summary Retrieve Sandbox
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
retrieveSandbox: async (id: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('retrieveSandbox', 'id', id)
const localVarPath = `/api/v1/sandboxes/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* SandboxesApi - functional programming interface
*/
export const SandboxesApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = SandboxesApiAxiosParamCreator(configuration)
return {
/**
* Lists Fabro-managed sandboxes directly from configured sandbox providers.
* @summary List Sandboxes
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listSandboxes(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SandboxListResponse>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listSandboxes(options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['SandboxesApi.listSandboxes']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Retrieves a Fabro-managed sandbox by provider-native id by searching all configured sandbox providers.
* @summary Retrieve Sandbox
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async retrieveSandbox(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SandboxInfo>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveSandbox(id, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['SandboxesApi.retrieveSandbox']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
}
};
/**
* SandboxesApi - factory interface
*/
export const SandboxesApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = SandboxesApiFp(configuration)
return {
/**
* Lists Fabro-managed sandboxes directly from configured sandbox providers.
* @summary List Sandboxes
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listSandboxes(options?: RawAxiosRequestConfig): AxiosPromise<SandboxListResponse> {
return localVarFp.listSandboxes(options).then((request) => request(axios, basePath));
},
/**
* Retrieves a Fabro-managed sandbox by provider-native id by searching all configured sandbox providers.
* @summary Retrieve Sandbox
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
retrieveSandbox(id: string, options?: RawAxiosRequestConfig): AxiosPromise<SandboxInfo> {
return localVarFp.retrieveSandbox(id, options).then((request) => request(axios, basePath));
},
};
};
/**
* SandboxesApi - object-oriented interface
*/
export class SandboxesApi extends BaseAPI {
/**
* Lists Fabro-managed sandboxes directly from configured sandbox providers.
* @summary List Sandboxes
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public listSandboxes(options?: RawAxiosRequestConfig) {
return SandboxesApiFp(this.configuration).listSandboxes(options).then((request) => request(this.axios, this.basePath));
}
/**
* Retrieves a Fabro-managed sandbox by provider-native id by searching all configured sandbox providers.
* @summary Retrieve Sandbox
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public retrieveSandbox(id: string, options?: RawAxiosRequestConfig) {
return SandboxesApiFp(this.configuration).retrieveSandbox(id, options).then((request) => request(this.axios, this.basePath));
}
}

View file

@ -15,9 +15,9 @@
// May contain unused imports in some cases
// @ts-ignore
import type { SandboxProvider } from './sandbox-provider';
import type { SandboxProviderKind } from './sandbox-provider-kind';
export interface DeleteRunSandbox {
'provider': SandboxProvider;
'provider': SandboxProviderKind;
'id': string;
}

View file

@ -346,10 +346,14 @@ export * from './run-timing';
export * from './sandbox-details';
export * from './sandbox-file-entry';
export * from './sandbox-file-list-response';
export * from './sandbox-info';
export * from './sandbox-list-meta';
export * from './sandbox-list-response';
export * from './sandbox-network';
export * from './sandbox-network-policy';
export * from './sandbox-network-policy-mode';
export * from './sandbox-provider';
export * from './sandbox-provider-kind';
export * from './sandbox-provider-lookup-error';
export * from './sandbox-resources';
export * from './sandbox-service';
export * from './sandbox-service-discovery-source';

View file

@ -18,13 +18,13 @@
import type { RunSandboxRuntime } from './run-sandbox-runtime';
// May contain unused imports in some cases
// @ts-ignore
import type { SandboxProvider } from './sandbox-provider';
import type { SandboxProviderKind } from './sandbox-provider-kind';
/**
* Canonical sandbox environment record for a run.
*/
export interface RunSandbox {
'provider': SandboxProvider;
'provider': SandboxProviderKind;
'image': string | null;
'snapshot': string | null;
'runtime': RunSandboxRuntime | null;

View file

@ -0,0 +1,77 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { SandboxNetwork } from './sandbox-network';
// May contain unused imports in some cases
// @ts-ignore
import type { SandboxProviderKind } from './sandbox-provider-kind';
// May contain unused imports in some cases
// @ts-ignore
import type { SandboxResources } from './sandbox-resources';
// May contain unused imports in some cases
// @ts-ignore
import type { SandboxState } from './sandbox-state';
// May contain unused imports in some cases
// @ts-ignore
import type { SandboxTimestamps } from './sandbox-timestamps';
/**
* Provider-backed inventory record for a Fabro-managed sandbox.
*/
export interface SandboxInfo {
'provider': SandboxProviderKind;
/**
* Provider-native sandbox id.
*/
'id': string;
/**
* Provider display name when distinct from the native id.
*/
'display_name'?: string | null;
'state': SandboxState;
/**
* Original provider state string before normalization. Display/debugging only; UI behavior keys off `state`.
*/
'native_state'?: string | null;
/**
* Provider image when surfaced by the sandbox provider.
*/
'image'?: string | null;
/**
* Provider snapshot when surfaced by the sandbox provider.
*/
'snapshot'?: string | null;
/**
* Provider region or target. Null for local-style providers.
*/
'region'?: string | null;
/**
* Provider dashboard URL for this sandbox when available.
*/
'web_url'?: string | null;
/**
* Provider-reported or Fabro-default working directory when available.
*/
'working_directory'?: string | null;
'resources': SandboxResources;
'network': SandboxNetwork;
/**
* Provider-reported labels.
*/
'labels': { [key: string]: string; };
'timestamps': SandboxTimestamps;
}

View file

@ -0,0 +1,25 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { SandboxProviderLookupError } from './sandbox-provider-lookup-error';
/**
* Metadata for provider-backed sandbox inventory.
*/
export interface SandboxListMeta {
'provider_errors': Array<SandboxProviderLookupError>;
}

View file

@ -0,0 +1,29 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { SandboxInfo } from './sandbox-info';
// May contain unused imports in some cases
// @ts-ignore
import type { SandboxListMeta } from './sandbox-list-meta';
/**
* Non-paginated provider-backed sandbox inventory response.
*/
export interface SandboxListResponse {
'data': Array<SandboxInfo>;
'meta': SandboxListMeta;
}

View file

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

View file

@ -0,0 +1,26 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { SandboxProviderKind } from './sandbox-provider-kind';
/**
* Provider error captured during fail-soft sandbox inventory lookup.
*/
export interface SandboxProviderLookupError {
'provider': SandboxProviderKind;
'message': string;
}