feat(sandbox): surface provider links and network policy (#275)

## Summary
- Add provider dashboard URL reporting to `SandboxDetails`, including
Daytona dashboard links and a Sandbox tab provider link.
- Extend `SandboxDetails` with required provider-neutral public network
policy for egress and ingress allow/block rules.
- Populate local, Docker, and Daytona network policies from provider
details when Fabro can assert them, otherwise default to explicit
`unknown` policy.
- Update OpenAPI, Rust API replacements, generated TypeScript client
models, server/API tests, and the Sandbox tab `Network` panel.

## Notes
- This reports policy only; it does not probe live connectivity.
- The network model intentionally excludes ports, previews, IP
addresses, DNS, routes, Docker network IDs, and service discovery.
- Older persisted/API JSON still deserializes through the Rust serde
default for `network`.

## Verification
- `cargo build -p fabro-api`
- `cargo nextest run -p fabro-types -p fabro-api -p fabro-sandbox -p
fabro-server sandbox_details`
- `cargo test -p fabro-sandbox details --features docker,daytona`
- `cargo +nightly-2026-04-14 fmt --check --all`
- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D
warnings`
- `cd apps/fabro-web && bun test app/routes/run-sandbox.test.tsx`
- `cd apps/fabro-web && bun run typecheck`
- `cd apps/fabro-web && bun run build`
- `cd lib/packages/fabro-api-client && bun run typecheck`
- `git diff --check`

## Post-Deploy Monitoring & Validation
- Log queries/search terms: `Failed to inspect Docker container`,
`Daytona sandbox is not initialized`, `missing runtime metadata`, `GET
/api/v1/runs/*/sandbox`, `Sandbox unavailable`.
- Metrics/dashboards to watch: API 5xx/error rate for `GET
/api/v1/runs/{id}/sandbox`, frontend error reporting for the Sandbox
tab, and provider reconnect/inspect failures.
- Expected healthy signals: Sandbox tab loads with Overview, Resources,
Network, Labels, and Timestamps; Daytona sandboxes show provider links;
local/ambiguous network policy shows `Unknown`; Docker `network_mode =
none` shows `Blocked`.
- Failure signals and rollback trigger: sandbox details deserialization
errors, missing `network` crashes, sustained sandbox endpoint 5xx
increase, or blank Sandbox tab after deploy. Roll back this PR or hide
the Network panel if API/client shape issues appear.
- Validation window and owner: first 24 hours after deploy, release
owner/on-call.

## Compound Engineering
- Implemented with OpenAI Codex CLI on GPT-5.
This commit is contained in:
Bryan Helmkamp 2026-05-16 07:16:18 -07:00 • committed by GitHub
parent be993cb608
commit f790a47da4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 801 additions and 21 deletions

View file

@ -113,12 +113,38 @@ function sandboxDetails(
native_state: null,
region: null,
resources: { cpu_cores: null, memory_bytes: null, disk_bytes: null },
network: networkDetails(),
labels: {},
timestamps: { created_at: null, last_activity_at: null },
...overrides,
};
}
function networkDetails(
overrides: Partial<SandboxDetails["network"]> = {},
): SandboxDetails["network"] {
return {
egress: networkPolicy("unknown"),
ingress: networkPolicy("unknown"),
...overrides,
};
}
function networkPolicy(
mode: SandboxDetails["network"]["egress"]["mode"],
cidrs: string[] = [],
): SandboxDetails["network"]["egress"] {
return { mode, cidrs };
}
function textContent(renderer: TestRenderer.ReactTestRenderer): string {
return renderer.root
.findAll((node) => typeof node.type === "string")
.flatMap((node) => node.children)
.filter((child): child is string => typeof child === "string")
.join(" ");
}
function renderRoute(initialPath: string = "/runs/run_1/sandbox") {
let renderer!: TestRenderer.ReactTestRenderer;
act(() => {
@ -176,6 +202,10 @@ describe("RunSandbox route", () => {
memory_bytes: 4 * 1024 * 1024 * 1024,
disk_bytes: undefined,
},
network: networkDetails({
egress: networkPolicy("open"),
ingress: networkPolicy("blocked"),
}),
labels: { run: "abc" },
timestamps: {
created_at: "2026-05-09T12:00:00Z",
@ -188,7 +218,37 @@ describe("RunSandbox route", () => {
.findAll((node) => node.type === "h3")
.map((node) => node.children.find((child) => typeof child === "string"))
.filter((text): text is string => typeof text === "string");
expect(panelHeadings).toEqual(["Overview", "Resources", "Labels", "Timestamps"]);
expect(panelHeadings).toEqual(["Overview", "Resources", "Network", "Labels", "Timestamps"]);
const copy = textContent(renderer);
expect(copy).toContain("Open");
expect(copy).toContain("Blocked");
});
test("links to the provider dashboard when a sandbox web URL is present", () => {
currentDetails = sandboxDetails({
sandbox: {
provider: "daytona",
runtime: {
id: "ad65029a-2d01-421e-8936-49451653fcd9",
working_directory: "/workspace",
},
},
web_url:
"https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9",
});
const renderer = renderRoute();
const providerLinks = renderer.root.findAll(
(node) =>
node.type === "a" &&
node.props.href ===
"https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9",
);
expect(providerLinks).toHaveLength(1);
expect(providerLinks[0]?.props.target).toBe("_blank");
expect(providerLinks[0]?.props.rel).toBe("noopener noreferrer");
const linkText = providerLinks[0]?.findByType("span");
expect(linkText?.children).toContain("Open in Daytona");
});
test("renders without crashing when most fields are null", () => {
@ -232,6 +292,37 @@ describe("RunSandbox route", () => {
expect(noLabelsCopy).toHaveLength(1);
});
test("renders unknown network policies", () => {
currentDetails = sandboxDetails({
network: networkDetails({
egress: networkPolicy("unknown"),
ingress: networkPolicy("unknown"),
}),
});
const renderer = renderRoute();
const copy = textContent(renderer);
expect(copy).toContain("Network");
expect(copy).toContain("Egress");
expect(copy).toContain("Ingress");
expect(copy).toContain("Unknown");
});
test("renders blocked, essentials, and CIDR network policies", () => {
currentDetails = sandboxDetails({
network: networkDetails({
egress: networkPolicy("cidr_allow_list", ["10.0.0.0/8", "192.168.0.0/16"]),
ingress: networkPolicy("essentials_only"),
}),
});
const renderer = renderRoute();
const copy = textContent(renderer);
expect(copy).toContain("CIDR allow list");
expect(copy).toContain("10.0.0.0/8, 192.168.0.0/16");
expect(copy).toContain("Essentials only");
});
test("shows the empty state when no sandbox is reported", () => {
currentDetails = null;
const renderer = renderRoute();
@ -300,7 +391,7 @@ describe("RunSandbox route", () => {
.findAll((node) => node.type === "h3")
.map((node) => node.children.find((child) => typeof child === "string"))
.filter((text): text is string => typeof text === "string");
expect(panelHeadings).toEqual(["Overview", "Resources", "Labels", "Timestamps"]);
expect(panelHeadings).toEqual(["Overview", "Resources", "Network", "Labels", "Timestamps"]);
const tabs = renderer.root.findAll(
(node) => node.type === "button" && node.props.role === "tab",

View file

@ -1,11 +1,17 @@
import { useMemo } from "react";
import { useSearchParams } from "react-router";
import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid";
import TerminalView, { TERMINAL_DOCK_CLEARANCE_CLASS } from "../components/terminal-view";
import { EmptyState, ErrorState } from "../components/state";
import { formatAbsoluteTs } from "../lib/format";
import { useRunSandboxDetails } from "../lib/queries";
import type { SandboxDetails, SandboxResources, SandboxState } from "@qltysh/fabro-api-client";
import type {
SandboxDetails,
SandboxNetwork,
SandboxResources,
SandboxState,
} from "@qltysh/fabro-api-client";
import FilesystemPanel from "./run-sandbox/filesystem-panel";
import ServicesPanel from "./run-sandbox/services-panel";
import VncPanel from "./run-sandbox/vnc-panel";
@ -81,16 +87,39 @@ function nullableCpu(cores: number | null | undefined): string {
return cores != null ? formatCpuCores(cores) : EMPTY_VALUE;
}
type SandboxNetworkPolicy = SandboxNetwork["egress"];
type SandboxNetworkPolicyMode = SandboxNetworkPolicy["mode"];
const NETWORK_POLICY_DISPLAY: Record<SandboxNetworkPolicyMode, string> = {
unknown: "Unknown",
open: "Open",
blocked: "Blocked",
cidr_allow_list: "CIDR allow list",
essentials_only: "Essentials only",
};
function networkPolicySummary(policy: SandboxNetworkPolicy): string {
return NETWORK_POLICY_DISPLAY[policy.mode] ?? policy.mode;
}
interface RowProps {
label: string;
value: string;
value: React.ReactNode;
valueClassName?: string;
}
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-4 px-4 py-2.5 text-sm">
<span className="text-fg-3">{label}</span>
{children}
</div>
);
}
function Row({ label, value, valueClassName }: RowProps) {
return (
<div className="flex items-center justify-between gap-4 px-4 py-2.5 text-sm">
<span className="text-fg-3">{label}</span>
<DetailRow label={label}>
<span
className={`text-right font-mono text-xs text-fg-2 ${
valueClassName ?? ""
@ -98,7 +127,23 @@ function Row({ label, value, valueClassName }: RowProps) {
>
{value}
</span>
</div>
</DetailRow>
);
}
function LinkRow({ label, href, text }: { label: string; href: string; text: string }) {
return (
<DetailRow label={label}>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex min-w-0 items-center gap-1.5 text-right font-mono text-xs text-teal-500 transition-colors hover:text-teal-300 focus-visible:rounded-sm focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500"
>
<span className="truncate">{text}</span>
<ArrowTopRightOnSquareIcon className="size-3.5 shrink-0" aria-hidden="true" />
</a>
</DetailRow>
);
}
@ -154,6 +199,17 @@ function OverviewPanel({ details }: { details: SandboxDetails }) {
value={details.region ? details.region : sandbox.provider === "docker" ? "local" : EMPTY_VALUE}
/>
<Row label="Image" value={nullable(sandbox.image ?? sandbox.snapshot)} />
{details.web_url && (
<LinkRow
label="Provider"
href={details.web_url}
text={
sandbox.provider === "daytona"
? "Open in Daytona"
: `Open in ${sandbox.provider}`
}
/>
)}
</Panel>
);
}
@ -168,6 +224,23 @@ function ResourcesPanel({ resources }: { resources: SandboxResources }) {
);
}
function NetworkPanel({ network }: { network: SandboxNetwork }) {
const cidrRows: Array<{ label: string; policy: SandboxNetworkPolicy }> = [
{ label: "Egress CIDRs", policy: network.egress },
{ label: "Ingress CIDRs", policy: network.ingress },
].filter(({ policy }) => policy.mode === "cidr_allow_list");
return (
<Panel title="Network">
<Row label="Egress" value={networkPolicySummary(network.egress)} />
<Row label="Ingress" value={networkPolicySummary(network.ingress)} />
{cidrRows.map(({ label, policy }) => (
<Row key={label} label={label} value={policy.cidrs.join(", ") || EMPTY_VALUE} />
))}
</Panel>
);
}
function LabelsPanel({ labels }: { labels: { [key: string]: string } | null | undefined }) {
const entries = labels ? Object.entries(labels) : [];
return (
@ -209,6 +282,7 @@ function DetailsColumn({ details }: { details: SandboxDetails | null }) {
<StatusStrip details={details} />
<OverviewPanel details={details} />
<ResourcesPanel resources={details.resources} />
<NetworkPanel network={details.network} />
<LabelsPanel labels={details.labels} />
<TimestampsPanel details={details} />
</div>

View file

@ -8098,6 +8098,43 @@ components:
minimum: 0
description: Disk size in bytes. Null when unavailable.
SandboxNetworkPolicyMode:
description: Provider-neutral public-network policy for one direction.
type: string
enum:
- unknown
- open
- blocked
- cidr_allow_list
- essentials_only
SandboxNetworkPolicy:
description: Public-network policy for one direction.
type: object
required:
- mode
- cidrs
properties:
mode:
$ref: "#/components/schemas/SandboxNetworkPolicyMode"
cidrs:
type: array
items:
type: string
description: CIDR entries when `mode` is `cidr_allow_list`; empty for other modes.
SandboxNetwork:
description: Provider-neutral public-network policy for sandbox egress and ingress.
type: object
required:
- egress
- ingress
properties:
egress:
$ref: "#/components/schemas/SandboxNetworkPolicy"
ingress:
$ref: "#/components/schemas/SandboxNetworkPolicy"
SandboxTimestamps:
description: Lifecycle timestamps for a sandbox. Fields are nullable when the provider does not surface a value.
type: object
@ -8118,6 +8155,7 @@ components:
- sandbox
- state
- resources
- network
- labels
- timestamps
properties:
@ -8131,8 +8169,13 @@ components:
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.
resources:
$ref: "#/components/schemas/SandboxResources"
network:
$ref: "#/components/schemas/SandboxNetwork"
labels:
type: object
additionalProperties:

View file

@ -0,0 +1,109 @@
# Sandbox Network Details Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add required provider-neutral public network policy data to `SandboxDetails` and show it clearly on the Sandbox tab.
**Architecture:** Extend the shared `fabro-types` sandbox-details model with a required `network` object that separates egress and ingress policy. Populate it from provider inspection where available, default to explicit `unknown` policies when Fabro cannot assert the policy, and reuse the shared types through OpenAPI, Rust API generation, and the generated TypeScript client.
**Tech Stack:** Rust, serde, OpenAPI/progenitor, TypeScript Axios client, React, Bun tests, cargo nextest.
---
## Summary
Add public-network policy reporting to `SandboxDetails`. The model reports only high-level public-network policy for egress and ingress; it does not include ports, previews, IP addresses, DNS, routes, Docker network IDs, or service discovery.
## Key Changes
- Add shared domain/API types in `fabro-types` and re-export them from `fabro-api`:
```rust
pub struct SandboxNetwork {
pub egress: SandboxNetworkPolicy,
pub ingress: SandboxNetworkPolicy,
}
pub struct SandboxNetworkPolicy {
pub mode: SandboxNetworkPolicyMode,
pub cidrs: Vec<String>,
}
#[serde(rename_all = "snake_case")]
pub enum SandboxNetworkPolicyMode {
Unknown,
Open,
Blocked,
CidrAllowList,
EssentialsOnly,
}
```
- Add required `network: SandboxNetwork` to `SandboxDetails`.
- Serialize `network` always.
- Give `network` a serde default of egress/ingress `unknown` so older persisted/API JSON can still deserialize.
- Update OpenAPI with required `network` and schemas for the new types.
- Add `fabro-api` replacement mappings for the new shared types.
- Regenerate the TypeScript API client after the OpenAPI/Rust type changes.
## Provider Mapping
- Daytona:
- `network_block_all == true` maps egress to `blocked`.
- Non-empty `network_allow_list` maps egress to `cidr_allow_list`, with comma-separated CIDRs trimmed.
- `network_block_all == false` and no allow list maps egress to `unknown`, because current SDK fields do not distinguish full default access from Daytona tier-managed essentials-only behavior.
- Ingress maps to `unknown`.
- Docker:
- If `inspect.host_config.network_mode == "none"`, map egress and ingress to `blocked`.
- Other Docker modes map to `unknown`.
- Local:
- Egress and ingress map to `unknown`.
## Web UI
- Add a compact `Network` panel to the Sandbox tab after `Resources`.
- Show two primary rows: `Egress` and `Ingress`.
- Use concise summaries:
- `Unknown`
- `Open`
- `Blocked`
- `Essentials only`
- `CIDR allow list`
- When a direction has `mode == "cidr_allow_list"`, add one extra row for that CIDR list, comma-separated and styled consistently with existing detail rows.
## Implementation Tasks
- [x] Add `SandboxNetwork`, `SandboxNetworkPolicy`, and `SandboxNetworkPolicyMode` to `lib/crates/fabro-types/src/sandbox_details.rs`.
- [x] Add constructors/default helpers for `unknown`, `open`, `blocked`, `allow_cidrs`, and `essentials_only` policies to keep provider mapping code readable.
- [x] Add `network: SandboxNetwork` to every `SandboxDetails` construction site.
- [x] Update local sandbox details to return `SandboxNetwork::unknown()`.
- [x] Update Docker sandbox details to inspect `host_config.network_mode` and emit blocked policy only for `"none"`, otherwise unknown.
- [x] Update Daytona sandbox details to derive egress policy from `network_block_all` and `network_allow_list`, and ingress as unknown.
- [x] Update OpenAPI schemas and `fabro-api/build.rs` replacements for the new shared types.
- [x] Run `cargo build -p fabro-api` so Rust API generation validates the schema.
- [x] Regenerate the TypeScript client with `cd lib/packages/fabro-api-client && bun run generate`.
- [x] Add the Sandbox tab `Network` panel and formatting helpers in `apps/fabro-web/app/routes/run-sandbox.tsx`.
- [x] Update tests and snapshots affected by the now-required `network` field.
## Test Plan
- Update `fabro-types` serde tests to prove `network` serializes, defaults to unknown when absent, and supports `open`, `blocked`, `cidr_allow_list`, and `essentials_only`.
- Update `fabro-api` round-trip/type-identity tests for the new shared types and OpenAPI JSON shape.
- Add `fabro-sandbox` mapper tests for Daytona block-all, Daytona CIDR allow list, Daytona ambiguous default, local unknown, Docker `network_mode = none`, and Docker non-`none` unknown.
- Update server sandbox-details tests for the required `network` field.
- Update `apps/fabro-web` Sandbox tab tests to expect the `Network` panel and verify unknown, blocked, essentials-only, and CIDR allow-list display.
- Run:
```sh
cargo nextest run -p fabro-types -p fabro-api -p fabro-sandbox -p fabro-server sandbox_details
cd apps/fabro-web && bun test app/routes/run-sandbox.test.tsx
cd apps/fabro-web && bun run typecheck
```
## Assumptions
- This is policy/reporting data, not live connectivity probing.
- No IPs, listening services, port mappings, preview URLs, DNS, routes, or Docker network IDs are added to `SandboxDetails`.
- The `essentials_only` mode is included in the API now, but v1 only emits it when Fabro can assert it from provider data.
- Current Daytona SDK fields do not distinguish full default access from org-tier essentials-only access.
- Daytona behavior follows the current docs: `networkBlockAll` takes precedence, `networkAllowList` is IPv4 CIDR-only, and essential services are Daytona-managed.

View file

@ -395,6 +395,17 @@ fn main() {
("SandboxProvider", "fabro_types::SandboxProvider", &[]),
("RunSandbox", "fabro_types::RunSandbox", &[]),
("SandboxDetails", "fabro_types::SandboxDetails", &[]),
("SandboxNetwork", "fabro_types::SandboxNetwork", &[]),
(
"SandboxNetworkPolicy",
"fabro_types::SandboxNetworkPolicy",
&[],
),
(
"SandboxNetworkPolicyMode",
"fabro_types::SandboxNetworkPolicyMode",
&[],
),
("SandboxService", "fabro_types::SandboxService", &[]),
(
"SandboxServiceDiscoverySource",

View file

@ -37,7 +37,8 @@ pub mod types {
IdpIdentity, InterviewOption, InterviewQuestionRecord, PendingInterviewRecord,
PreRunPushOutcome, Principal, PullRequest, PullRequestDetails, QuestionType, RepositoryRef,
Run, RunClientProvenance, RunEvent, RunFailure, RunProjection, RunProvenance, RunSandbox,
RunSandboxRuntime, RunServerProvenance, SandboxDetails, SandboxProvider, SandboxResources,
RunSandboxRuntime, RunServerProvenance, SandboxDetails, SandboxNetwork,
SandboxNetworkPolicy, SandboxNetworkPolicyMode, SandboxProvider, SandboxResources,
SandboxService, SandboxServiceListResponse, SandboxState, SandboxTimestamps,
SecretMetadata, SecretType, ServerSettings, StageCompletion, StageHandler, StageOutcome,
StageProjection, StageState, SystemActorKind, UserPrincipal, WorkflowSettings,

View file

@ -3,13 +3,15 @@ use std::collections::BTreeMap;
use chrono::{TimeZone, Utc};
use fabro_api::types::{
SandboxDetails as ApiSandboxDetails, SandboxProvider as ApiSandboxProvider,
SandboxDetails as ApiSandboxDetails, SandboxNetwork as ApiSandboxNetwork,
SandboxNetworkPolicy as ApiSandboxNetworkPolicy,
SandboxNetworkPolicyMode as ApiSandboxNetworkPolicyMode, SandboxProvider as ApiSandboxProvider,
SandboxResources as ApiSandboxResources, SandboxState as ApiSandboxState,
SandboxTimestamps as ApiSandboxTimestamps,
};
use fabro_types::{
RunSandbox, RunSandboxRuntime, SandboxDetails, SandboxProvider, SandboxResources, SandboxState,
SandboxTimestamps,
RunSandbox, RunSandboxRuntime, SandboxDetails, SandboxNetwork, SandboxNetworkPolicy,
SandboxNetworkPolicyMode, SandboxProvider, SandboxResources, SandboxState, SandboxTimestamps,
};
use serde_json::json;
@ -20,6 +22,9 @@ fn sandbox_details_reuses_domain_types() {
assert_same_type::<ApiSandboxState, SandboxState>();
assert_same_type::<ApiSandboxResources, SandboxResources>();
assert_same_type::<ApiSandboxTimestamps, SandboxTimestamps>();
assert_same_type::<ApiSandboxNetwork, SandboxNetwork>();
assert_same_type::<ApiSandboxNetworkPolicy, SandboxNetworkPolicy>();
assert_same_type::<ApiSandboxNetworkPolicyMode, SandboxNetworkPolicyMode>();
}
#[test]
@ -45,11 +50,19 @@ fn sandbox_details_json_matches_openapi_shape() {
state: SandboxState::Running,
native_state: Some("running".to_string()),
region: None,
web_url: Some(
"https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9"
.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([("run".to_string(), "abc".to_string())]),
timestamps: SandboxTimestamps {
created_at: Some(created_at),
@ -74,10 +87,21 @@ fn sandbox_details_json_matches_openapi_shape() {
},
"state": "running",
"native_state": "running",
"web_url": "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9",
"resources": {
"cpu_cores": 2.0,
"memory_bytes": 4_294_967_296_u64,
},
"network": {
"egress": {
"mode": "open",
"cidrs": []
},
"ingress": {
"mode": "blocked",
"cidrs": []
}
},
"labels": {
"run": "abc"
},
@ -128,6 +152,7 @@ fn sandbox_details_deserializes_when_optional_fields_are_absent() {
assert!(details.native_state.is_none());
assert!(details.labels.is_empty());
assert_eq!(details.resources, SandboxResources::default());
assert_eq!(details.network, SandboxNetwork::unknown());
assert_eq!(details.timestamps, SandboxTimestamps::default());
}

View file

@ -36,6 +36,8 @@ pub(crate) const WORKING_DIRECTORY: &str = "/home/daytona/workspace";
pub(crate) const REPOS_ROOT: &str = "/repos";
const DEFAULT_SNAPSHOT: &str = "daytona-medium";
pub const DEFAULT_DAYTONA_API_URL: &str = "https://app.daytona.io/api";
pub(crate) const DAYTONA_DASHBOARD_SANDBOXES_URL: &str =
"https://app.daytona.io/dashboard/sandboxes";
const FABRO_SANDBOX_USER_AGENT: &str = concat!("fabro-sandbox/", env!("CARGO_PKG_VERSION"));
const DAYTONA_PROBE_TIMEOUT: Duration = Duration::from_secs(20);
/// Upper bound on `DaytonaSession::close` so a stalled Daytona REST call cannot
@ -1033,7 +1035,7 @@ impl Sandbox for DaytonaSandbox {
name: Some(sandbox_name),
cpu: Some(sandbox_cpu),
memory: Some(sandbox_memory),
url: Some("https://app.daytona.io/dashboard/sandboxes".into()),
url: Some(DAYTONA_DASHBOARD_SANDBOXES_URL.into()),
});
Ok(())

View file

@ -4,8 +4,8 @@ use anyhow::Result;
#[cfg(any(feature = "docker", feature = "daytona"))]
use chrono::{DateTime, Utc};
use fabro_types::{
RunId, RunSandbox, SandboxDetails, SandboxProvider, SandboxResources, SandboxState,
SandboxTimestamps,
RunId, RunSandbox, SandboxDetails, SandboxNetwork, SandboxProvider, SandboxResources,
SandboxState, SandboxTimestamps,
};
/// Inspect the sandbox identified by `record` and return provider-neutral
@ -50,7 +50,9 @@ fn local_details(record: &RunSandbox) -> SandboxDetails {
state: SandboxState::Running,
native_state: None,
region: None,
web_url: None,
resources: SandboxResources::default(),
network: SandboxNetwork::unknown(),
labels: BTreeMap::new(),
timestamps: SandboxTimestamps::default(),
}
@ -72,7 +74,8 @@ mod docker {
use bollard::container::InspectContainerOptions;
use bollard::models::{ContainerInspectResponse, ContainerStateStatusEnum, HostConfig};
use fabro_types::{
RunId, RunSandbox, SandboxDetails, SandboxResources, SandboxState, SandboxTimestamps,
RunId, RunSandbox, SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, SandboxResources,
SandboxState, SandboxTimestamps,
};
use super::parse_rfc3339_utc;
@ -117,6 +120,7 @@ mod docker {
.and_then(|bytes| u64::try_from(bytes).ok()),
disk_bytes: None,
};
let network = docker_network(host_config);
let labels: BTreeMap<String, String> = inspect
.config
@ -136,7 +140,9 @@ mod docker {
state: normalized_state,
native_state,
region: None,
web_url: None,
resources,
network,
labels,
timestamps: SandboxTimestamps {
created_at,
@ -145,6 +151,19 @@ mod docker {
}
}
fn docker_network(host_config: Option<&HostConfig>) -> SandboxNetwork {
match host_config.and_then(|host| host.network_mode.as_deref()) {
Some("none") => {
let blocked = SandboxNetworkPolicy::blocked();
SandboxNetwork {
egress: blocked.clone(),
ingress: blocked,
}
}
_ => SandboxNetwork::unknown(),
}
}
pub(super) fn docker_cpu_cores(host_config: &HostConfig) -> Option<f64> {
let quota = host_config.cpu_quota?;
let period = host_config.cpu_period?;
@ -175,7 +194,9 @@ mod docker {
#[cfg(test)]
mod tests {
use bollard::models::HostConfig;
use fabro_types::{RunSandbox, RunSandboxRuntime, SandboxProvider};
use fabro_types::{
RunSandbox, RunSandboxRuntime, SandboxNetwork, SandboxNetworkPolicy, SandboxProvider,
};
use super::*;
@ -254,6 +275,33 @@ mod docker {
assert_eq!(details.resources.memory_bytes, Some(2_147_483_648));
}
#[test]
fn network_mode_none_blocks_ingress_and_egress() {
let inspect = ContainerInspectResponse {
host_config: Some(HostConfig {
network_mode: Some("none".to_string()),
..Default::default()
}),
..Default::default()
};
let details = map_docker_inspect(inspect, &record());
assert_eq!(details.network.egress, SandboxNetworkPolicy::blocked());
assert_eq!(details.network.ingress, SandboxNetworkPolicy::blocked());
}
#[test]
fn non_none_network_mode_is_unknown() {
let inspect = ContainerInspectResponse {
host_config: Some(HostConfig {
network_mode: Some("bridge".to_string()),
..Default::default()
}),
..Default::default()
};
let details = map_docker_inspect(inspect, &record());
assert_eq!(details.network, SandboxNetwork::unknown());
}
#[test]
fn record_identity_is_carried_through() {
let inspect = ContainerInspectResponse {
@ -350,11 +398,12 @@ mod daytona {
use anyhow::{Context, Result, anyhow};
use daytona_api_client::models::SandboxState as DaytonaState;
use fabro_types::{
RunSandbox, SandboxDetails, SandboxResources, SandboxState, SandboxTimestamps,
RunSandbox, SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, SandboxResources,
SandboxState, SandboxTimestamps,
};
use super::parse_rfc3339_utc;
use crate::daytona::DaytonaSandbox;
use crate::daytona::{DAYTONA_DASHBOARD_SANDBOXES_URL, DaytonaSandbox};
pub(super) async fn daytona_details(
record: &RunSandbox,
@ -418,7 +467,12 @@ mod daytona {
state: normalized_state,
native_state,
region,
web_url: Some(daytona_dashboard_url(&sandbox.id)),
resources,
network: daytona_network(
sandbox.network_block_all,
sandbox.network_allow_list.as_deref(),
),
labels,
timestamps: SandboxTimestamps {
created_at: sandbox.created_at.as_deref().and_then(parse_rfc3339_utc),
@ -443,6 +497,36 @@ mod daytona {
Some(bytes)
}
fn daytona_dashboard_url(sandbox_id: &str) -> String {
format!("{DAYTONA_DASHBOARD_SANDBOXES_URL}?sandboxId={sandbox_id}")
}
fn daytona_network(
network_block_all: bool,
network_allow_list: Option<&str>,
) -> SandboxNetwork {
let egress = if network_block_all {
SandboxNetworkPolicy::blocked()
} else {
let cidrs = network_allow_list
.into_iter()
.flat_map(|allow_list| allow_list.split(','))
.map(str::trim)
.filter(|cidr| !cidr.is_empty());
let cidrs: Vec<_> = cidrs.collect();
if cidrs.is_empty() {
SandboxNetworkPolicy::unknown()
} else {
SandboxNetworkPolicy::allow_cidrs(cidrs)
}
};
SandboxNetwork {
egress,
ingress: SandboxNetworkPolicy::unknown(),
}
}
pub(super) fn normalize_daytona_state(state: DaytonaState) -> SandboxState {
match state {
DaytonaState::Creating
@ -540,6 +624,43 @@ mod daytona {
fn gibibytes_to_bytes_returns_none_for_zero() {
assert_eq!(gibibytes_to_bytes(0.0), None);
}
#[test]
fn daytona_dashboard_url_uses_sandbox_id_query_param() {
assert_eq!(
daytona_dashboard_url("ad65029a-2d01-421e-8936-49451653fcd9"),
"https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9",
);
}
#[test]
fn network_block_all_blocks_egress_and_leaves_ingress_unknown() {
let network = daytona_network(true, Some("10.0.0.0/8"));
assert_eq!(network.egress, SandboxNetworkPolicy::blocked());
assert_eq!(network.ingress, SandboxNetworkPolicy::unknown());
}
#[test]
fn network_allow_list_maps_to_cidr_allow_list() {
let network = daytona_network(false, Some("10.0.0.0/8, 192.168.0.0/16 "));
assert_eq!(
network.egress,
SandboxNetworkPolicy::allow_cidrs(["10.0.0.0/8", "192.168.0.0/16"])
);
assert_eq!(network.ingress, SandboxNetworkPolicy::unknown());
}
#[test]
fn empty_network_allow_list_is_unknown() {
let network = daytona_network(false, Some(" , "));
assert_eq!(network, SandboxNetwork::unknown());
}
#[test]
fn default_daytona_network_is_unknown() {
let network = daytona_network(false, None);
assert_eq!(network, SandboxNetwork::unknown());
}
}
}
@ -575,6 +696,7 @@ mod tests {
assert!(details.sandbox.image.is_none());
assert!(details.labels.is_empty());
assert_eq!(details.resources, SandboxResources::default());
assert_eq!(details.network, SandboxNetwork::unknown());
assert_eq!(details.timestamps, SandboxTimestamps::default());
}
}

View file

@ -1405,6 +1405,8 @@ mod retrieve_sandbox_tests {
assert!(body.get("name").is_none());
assert!(body.get("identifier").is_none());
assert!(body["resources"].is_object());
assert_eq!(body["network"]["egress"]["mode"], "unknown");
assert_eq!(body["network"]["ingress"]["mode"], "unknown");
assert!(body["timestamps"].is_object());
}

View file

@ -93,7 +93,10 @@ pub type RunSummary = Run;
pub type PullRequestRecord = PullRequest;
pub type PullRequestDetail = PullRequestDetails;
pub use run_title::{RunTitleError, infer_run_title, normalize_explicit_run_title};
pub use sandbox_details::{SandboxDetails, SandboxResources, SandboxState, SandboxTimestamps};
pub use sandbox_details::{
SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, SandboxNetworkPolicyMode,
SandboxResources, SandboxState, SandboxTimestamps,
};
pub use sandbox_provider::SandboxProvider;
pub use sandbox_services::{
SandboxService, SandboxServiceDiscoverySource, SandboxServiceListMeta,

View file

@ -1,6 +1,7 @@
use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use serde::de::Error as _;
use serde::{Deserialize, Serialize};
use crate::RunSandbox;
@ -13,7 +14,11 @@ pub struct SandboxDetails {
pub native_state: 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>,
pub resources: SandboxResources,
#[serde(default)]
pub network: SandboxNetwork,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
pub timestamps: SandboxTimestamps,
@ -47,6 +52,123 @@ pub struct SandboxResources {
pub disk_bytes: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct SandboxNetwork {
pub egress: SandboxNetworkPolicy,
pub ingress: SandboxNetworkPolicy,
}
impl SandboxNetwork {
pub fn unknown() -> Self {
Self::default()
}
}
#[derive(Debug, Clone, PartialEq, Default, Serialize)]
pub struct SandboxNetworkPolicy {
mode: SandboxNetworkPolicyMode,
cidrs: Vec<String>,
}
impl SandboxNetworkPolicy {
pub fn unknown() -> Self {
Self::default()
}
pub fn mode(&self) -> SandboxNetworkPolicyMode {
self.mode
}
pub fn cidrs(&self) -> &[String] {
&self.cidrs
}
pub fn open() -> Self {
Self {
mode: SandboxNetworkPolicyMode::Open,
cidrs: Vec::new(),
}
}
pub fn blocked() -> Self {
Self {
mode: SandboxNetworkPolicyMode::Blocked,
cidrs: Vec::new(),
}
}
pub fn allow_cidrs<I, S>(cidrs: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let cidrs: Vec<String> = cidrs.into_iter().map(Into::into).collect();
if cidrs.is_empty() {
return Self::unknown();
}
Self {
mode: SandboxNetworkPolicyMode::CidrAllowList,
cidrs,
}
}
pub fn essentials_only() -> Self {
Self {
mode: SandboxNetworkPolicyMode::EssentialsOnly,
cidrs: Vec::new(),
}
}
}
impl<'de> Deserialize<'de> for SandboxNetworkPolicy {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct Wire {
#[serde(default)]
mode: SandboxNetworkPolicyMode,
#[serde(default)]
cidrs: Vec<String>,
}
let wire = Wire::deserialize(deserializer)?;
match wire.mode {
SandboxNetworkPolicyMode::CidrAllowList => {
if wire.cidrs.is_empty() {
return Err(D::Error::custom(
"cidr_allow_list network policy requires at least one CIDR",
));
}
Ok(Self::allow_cidrs(wire.cidrs))
}
mode => {
if !wire.cidrs.is_empty() {
return Err(D::Error::custom(
"network policy CIDRs are only valid for cidr_allow_list mode",
));
}
Ok(Self {
mode,
cidrs: Vec::new(),
})
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SandboxNetworkPolicyMode {
#[default]
Unknown,
Open,
Blocked,
CidrAllowList,
EssentialsOnly,
}
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
pub struct SandboxTimestamps {
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -84,11 +206,19 @@ mod tests {
state: SandboxState::Running,
native_state: Some("running".to_string()),
region: None,
web_url: Some(
"https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9"
.to_string(),
),
resources: SandboxResources {
cpu_cores: Some(2.0),
memory_bytes: Some(4 * 1024 * 1024 * 1024),
disk_bytes: None,
},
network: SandboxNetwork {
egress: SandboxNetworkPolicy::allow_cidrs(["10.0.0.0/8"]),
ingress: SandboxNetworkPolicy::unknown(),
},
labels: BTreeMap::from([("run".to_string(), "abc".to_string())]),
timestamps: SandboxTimestamps {
created_at: Some(Utc.with_ymd_and_hms(2026, 5, 9, 12, 0, 0).unwrap()),
@ -109,10 +239,21 @@ mod tests {
},
"state": "running",
"native_state": "running",
"web_url": "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9",
"resources": {
"cpu_cores": 2.0,
"memory_bytes": 4_294_967_296_u64,
},
"network": {
"egress": {
"mode": "cidr_allow_list",
"cidrs": ["10.0.0.0/8"]
},
"ingress": {
"mode": "unknown",
"cidrs": []
}
},
"labels": {
"run": "abc"
},
@ -162,9 +303,56 @@ mod tests {
assert!(details.sandbox.image.is_none());
assert!(details.labels.is_empty());
assert_eq!(details.resources, SandboxResources::default());
assert_eq!(details.network, SandboxNetwork::unknown());
assert_eq!(details.timestamps, SandboxTimestamps::default());
}
#[test]
fn network_policy_helpers_cover_supported_modes() {
assert_eq!(
SandboxNetworkPolicy::unknown().mode(),
SandboxNetworkPolicyMode::Unknown
);
assert_eq!(
SandboxNetworkPolicy::open().mode(),
SandboxNetworkPolicyMode::Open
);
assert_eq!(
SandboxNetworkPolicy::blocked().mode(),
SandboxNetworkPolicyMode::Blocked
);
assert_eq!(
SandboxNetworkPolicy::allow_cidrs(["192.168.0.0/16", "10.0.0.0/8"]).cidrs(),
["192.168.0.0/16".to_string(), "10.0.0.0/8".to_string()]
);
assert_eq!(
SandboxNetworkPolicy::essentials_only().mode(),
SandboxNetworkPolicyMode::EssentialsOnly,
);
}
#[test]
fn network_policy_deserialization_rejects_empty_cidr_allow_list() {
assert!(
serde_json::from_value::<SandboxNetworkPolicy>(json!({
"mode": "cidr_allow_list",
"cidrs": []
}))
.is_err()
);
}
#[test]
fn network_policy_deserialization_rejects_cidrs_for_non_cidr_mode() {
assert!(
serde_json::from_value::<SandboxNetworkPolicy>(json!({
"mode": "open",
"cidrs": ["10.0.0.0/8"]
}))
.is_err()
);
}
#[test]
fn state_serializes_each_variant_in_snake_case() {
fn check(state: SandboxState, expected: &str) {

View file

@ -2,8 +2,8 @@ use std::collections::BTreeMap;
use chrono::{TimeZone, Utc};
use fabro_types::{
RunSandbox, RunSandboxRuntime, SandboxDetails, SandboxProvider, SandboxResources, SandboxState,
SandboxTimestamps,
RunSandbox, RunSandboxRuntime, SandboxDetails, SandboxNetwork, SandboxProvider,
SandboxResources, SandboxState, SandboxTimestamps,
};
use serde_json::json;
@ -70,11 +70,16 @@ fn sandbox_details_requires_canonical_id_and_working_directory() {
state: SandboxState::Running,
native_state: Some("started".to_string()),
region: Some("us".to_string()),
web_url: Some(
"https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9"
.to_string(),
),
resources: SandboxResources {
cpu_cores: Some(2.0),
memory_bytes: Some(4 * 1024 * 1024 * 1024),
disk_bytes: None,
},
network: SandboxNetwork::unknown(),
labels: BTreeMap::from([("run".to_string(), "abc".to_string())]),
timestamps: SandboxTimestamps {
created_at: Some(Utc.with_ymd_and_hms(2026, 5, 9, 12, 0, 0).unwrap()),
@ -95,6 +100,12 @@ fn sandbox_details_requires_canonical_id_and_working_directory() {
"/home/daytona/workspace"
);
assert_eq!(value["sandbox"]["runtime"]["repos_root"], "/repos");
assert_eq!(
value["web_url"],
"https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9"
);
assert_eq!(value["network"]["egress"]["mode"], "unknown");
assert_eq!(value["network"]["ingress"]["mode"], "unknown");
assert!(value.get("name").is_none());
assert!(value.get("identifier").is_none());
}

View file

@ -302,6 +302,9 @@ models/run.ts
models/sandbox-details.ts
models/sandbox-file-entry.ts
models/sandbox-file-list-response.ts
models/sandbox-network-policy-mode.ts
models/sandbox-network-policy.ts
models/sandbox-network.ts
models/sandbox-provider.ts
models/sandbox-resources.ts
models/sandbox-service-discovery-source.ts

View file

@ -279,6 +279,9 @@ export * from './run-timings';
export * from './sandbox-details';
export * from './sandbox-file-entry';
export * from './sandbox-file-list-response';
export * from './sandbox-network';
export * from './sandbox-network-policy';
export * from './sandbox-network-policy-mode';
export * from './sandbox-provider';
export * from './sandbox-resources';
export * from './sandbox-service';

View file

@ -18,6 +18,9 @@
import type { RunSandbox } from './run-sandbox';
// 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 { SandboxResources } from './sandbox-resources';
// May contain unused imports in some cases
// @ts-ignore
@ -40,7 +43,12 @@ export interface SandboxDetails {
* Provider region or target. Null for local-style providers.
*/
'region'?: string | null;
/**
* Provider dashboard URL for this sandbox when available.
*/
'web_url'?: string | null;
'resources': SandboxResources;
'network': SandboxNetwork;
/**
* Provider-reported labels.
*/

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.
*/
/**
* Provider-neutral public-network policy for one direction.
*/
export const SandboxNetworkPolicyMode = {
UNKNOWN: 'unknown',
OPEN: 'open',
BLOCKED: 'blocked',
CIDR_ALLOW_LIST: 'cidr_allow_list',
ESSENTIALS_ONLY: 'essentials_only'
} as const;
export type SandboxNetworkPolicyMode = typeof SandboxNetworkPolicyMode[keyof typeof SandboxNetworkPolicyMode];

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 { SandboxNetworkPolicyMode } from './sandbox-network-policy-mode';
/**
* Public-network policy for one direction.
*/
export interface SandboxNetworkPolicy {
'mode': SandboxNetworkPolicyMode;
/**
* CIDR entries when `mode` is `cidr_allow_list`; empty for other modes.
*/
'cidrs': Array<string>;
}

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 { SandboxNetworkPolicy } from './sandbox-network-policy';
/**
* Provider-neutral public-network policy for sandbox egress and ingress.
*/
export interface SandboxNetwork {
'egress': SandboxNetworkPolicy;
'ingress': SandboxNetworkPolicy;
}