diff --git a/apps/fabro-web/app/routes/run-sandbox.test.tsx b/apps/fabro-web/app/routes/run-sandbox.test.tsx index cde374135..1ae03e0e6 100644 --- a/apps/fabro-web/app/routes/run-sandbox.test.tsx +++ b/apps/fabro-web/app/routes/run-sandbox.test.tsx @@ -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"] { + 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", diff --git a/apps/fabro-web/app/routes/run-sandbox.tsx b/apps/fabro-web/app/routes/run-sandbox.tsx index f660bf65d..8cc0dbc24 100644 --- a/apps/fabro-web/app/routes/run-sandbox.tsx +++ b/apps/fabro-web/app/routes/run-sandbox.tsx @@ -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 = { + 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 ( +
+ {label} + {children} +
+ ); +} + function Row({ label, value, valueClassName }: RowProps) { return ( -
- {label} + {value} -
+ + ); +} + +function LinkRow({ label, href, text }: { label: string; href: string; text: string }) { + return ( + + + {text} + + ); } @@ -154,6 +199,17 @@ function OverviewPanel({ details }: { details: SandboxDetails }) { value={details.region ? details.region : sandbox.provider === "docker" ? "local" : EMPTY_VALUE} /> + {details.web_url && ( + + )} ); } @@ -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 ( + + + + {cidrRows.map(({ label, policy }) => ( + + ))} + + ); +} + 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 }) { + diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index d17032350..89455db4a 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -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: diff --git a/docs/superpowers/plans/2026-05-15-sandbox-network-details.md b/docs/superpowers/plans/2026-05-15-sandbox-network-details.md new file mode 100644 index 000000000..6715c59e7 --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-sandbox-network-details.md @@ -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, + } + + #[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. diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs index 1ecf7aaa1..eb0e53dd3 100644 --- a/lib/crates/fabro-api/build.rs +++ b/lib/crates/fabro-api/build.rs @@ -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", diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index c72597ac5..c2312ae11 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -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, diff --git a/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs b/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs index 88919dc29..97485b4c1 100644 --- a/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs +++ b/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs @@ -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::(); assert_same_type::(); assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); } #[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()); } diff --git a/lib/crates/fabro-sandbox/src/daytona/mod.rs b/lib/crates/fabro-sandbox/src/daytona/mod.rs index c3f1e550b..1b794c413 100644 --- a/lib/crates/fabro-sandbox/src/daytona/mod.rs +++ b/lib/crates/fabro-sandbox/src/daytona/mod.rs @@ -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(()) diff --git a/lib/crates/fabro-sandbox/src/details.rs b/lib/crates/fabro-sandbox/src/details.rs index 6b158b262..fe5612691 100644 --- a/lib/crates/fabro-sandbox/src/details.rs +++ b/lib/crates/fabro-sandbox/src/details.rs @@ -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 = 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 { 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()); } } diff --git a/lib/crates/fabro-server/src/server/handler/sandbox.rs b/lib/crates/fabro-server/src/server/handler/sandbox.rs index cbe882fff..ca192c550 100644 --- a/lib/crates/fabro-server/src/server/handler/sandbox.rs +++ b/lib/crates/fabro-server/src/server/handler/sandbox.rs @@ -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()); } diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index 7b11683b5..57154fd44 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -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, diff --git a/lib/crates/fabro-types/src/sandbox_details.rs b/lib/crates/fabro-types/src/sandbox_details.rs index eb9e8f04c..ecbf21a12 100644 --- a/lib/crates/fabro-types/src/sandbox_details.rs +++ b/lib/crates/fabro-types/src/sandbox_details.rs @@ -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, #[serde(default, skip_serializing_if = "Option::is_none")] pub region: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web_url: Option, pub resources: SandboxResources, + #[serde(default)] + pub network: SandboxNetwork, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub labels: BTreeMap, pub timestamps: SandboxTimestamps, @@ -47,6 +52,123 @@ pub struct SandboxResources { pub disk_bytes: Option, } +#[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, +} + +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(cidrs: I) -> Self + where + I: IntoIterator, + S: Into, + { + let cidrs: Vec = 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(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + #[serde(default)] + mode: SandboxNetworkPolicyMode, + #[serde(default)] + cidrs: Vec, + } + + 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::(json!({ + "mode": "cidr_allow_list", + "cidrs": [] + })) + .is_err() + ); + } + + #[test] + fn network_policy_deserialization_rejects_cidrs_for_non_cidr_mode() { + assert!( + serde_json::from_value::(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) { diff --git a/lib/crates/fabro-types/tests/sandbox_model_serde.rs b/lib/crates/fabro-types/tests/sandbox_model_serde.rs index 306e308ab..a6609aa5a 100644 --- a/lib/crates/fabro-types/tests/sandbox_model_serde.rs +++ b/lib/crates/fabro-types/tests/sandbox_model_serde.rs @@ -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()); } diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index b99bcfd58..8a8f087bd 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -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 diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index f4b7cd730..209d99af7 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -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'; diff --git a/lib/packages/fabro-api-client/src/models/sandbox-details.ts b/lib/packages/fabro-api-client/src/models/sandbox-details.ts index c3a917272..d1a5ef41d 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-details.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-details.ts @@ -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. */ diff --git a/lib/packages/fabro-api-client/src/models/sandbox-network-policy-mode.ts b/lib/packages/fabro-api-client/src/models/sandbox-network-policy-mode.ts new file mode 100644 index 000000000..0579ee087 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/sandbox-network-policy-mode.ts @@ -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]; diff --git a/lib/packages/fabro-api-client/src/models/sandbox-network-policy.ts b/lib/packages/fabro-api-client/src/models/sandbox-network-policy.ts new file mode 100644 index 000000000..da49f0397 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/sandbox-network-policy.ts @@ -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; +} diff --git a/lib/packages/fabro-api-client/src/models/sandbox-network.ts b/lib/packages/fabro-api-client/src/models/sandbox-network.ts new file mode 100644 index 000000000..91b5d8e1d --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/sandbox-network.ts @@ -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; +}