diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 1be456f61..4971f3f8b 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -2653,6 +2653,40 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /api/v1/runs/{id}/sandbox/services: + get: + operationId: listSandboxServices + tags: [Human-in-the-Loop] + summary: List Sandbox Services + description: Lists listening TCP services discovered inside the run sandbox. + parameters: + - $ref: "#/components/parameters/RunId" + responses: + "200": + description: Listening TCP services + content: + application/json: + schema: + $ref: "#/components/schemas/SandboxServiceListResponse" + "404": + description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "409": + description: Run has no active sandbox or service discovery failed + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /api/v1/runs/{id}/sandbox/vnc: post: operationId: createSandboxVncPreview @@ -7953,6 +7987,49 @@ components: items: $ref: "#/components/schemas/SandboxFileEntry" + SandboxService: + description: A listening TCP service discovered inside a run sandbox. + type: object + required: + - port + - addresses + - processes + - preview_supported + properties: + port: + type: integer + minimum: 1 + maximum: 65535 + description: Listening TCP port. + example: 3000 + addresses: + type: array + description: Local bind addresses reported by `ss`. + items: + type: string + example: ["127.0.0.1:3000", "[::]:3000"] + processes: + type: array + description: Visible process summaries reported by `ss`. + items: + type: string + example: ['users:(("node",pid=42,fd=23))'] + preview_supported: + type: boolean + description: Whether the provider supports an external preview URL for this port. + example: true + + SandboxServiceListResponse: + description: Non-paginated list of listening TCP services in a run sandbox. + type: object + required: + - data + properties: + data: + type: array + items: + $ref: "#/components/schemas/SandboxService" + VncPreviewResponse: description: Response containing a signed noVNC preview URL for a Daytona sandbox. type: object diff --git a/docs/superpowers/plans/2026-05-10-sandbox-services-backend.md b/docs/superpowers/plans/2026-05-10-sandbox-services-backend.md new file mode 100644 index 000000000..81e4ae550 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-sandbox-services-backend.md @@ -0,0 +1,135 @@ +# Sandbox Services Backend 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 a backend API that lists listening TCP services inside a run sandbox and marks Daytona-previewable ports. + +**Architecture:** The server reconnects to the run-owned sandbox, starts it if needed, runs `ss -H -ltnp`, parses the raw output outside the sandbox command, groups services by port, and returns a provider-neutral JSON response. This phase owns the API contract, server implementation, parser tests, and generated clients; the frontend is implemented separately. + +**Tech Stack:** Rust, Axum, OpenAPI, progenitor, fabro-sandbox `Sandbox::exec_command`, generated TypeScript Axios client. + +--- + +## Scope + +- Add `GET /api/v1/runs/{id}/sandbox/services`. +- Use only `ss -H -ltnp` for v1; do not add `netstat` or `lsof` fallback. +- Return all parsed listening TCP ports, including ports outside Daytona's preview range. +- Compute preview support outside the sandbox command: `provider == "daytona" && 3000 <= port <= 9999`. +- Do not probe HTTP readiness and do not read `devcontainer.json`. + +## Files + +- Modify: `docs/public/api-reference/fabro-api.yaml` +- Modify: `lib/crates/fabro-types/src/lib.rs` +- Create or modify: `lib/crates/fabro-types/src/sandbox_services.rs` +- Modify: `lib/crates/fabro-api/src/lib.rs` +- Modify: `lib/crates/fabro-api/build.rs` +- Create: `lib/crates/fabro-api/tests/sandbox_services_round_trip.rs` +- Modify: `lib/crates/fabro-server/src/server/handler/sandbox.rs` +- Modify: `lib/crates/fabro-server/src/server/handler/mod.rs` +- Modify: `lib/crates/fabro-server/src/demo/mod.rs` +- Regenerate: generated Rust API code via `cargo build -p fabro-api` +- Regenerate: `lib/packages/fabro-api-client/src/**` via `cd lib/packages/fabro-api-client && bun run generate` + +## Tasks + +### Task 1: Define the API Contract and Shared Types + +- [x] Add schemas to `docs/public/api-reference/fabro-api.yaml`: + - `SandboxService` with required fields `port`, `addresses`, `processes`, `preview_supported`. + - `SandboxServiceListResponse` with required field `data`. + - `port` is an integer with `minimum: 1`, `maximum: 65535`. + - `addresses` is an array of strings, preserving bind addresses from `ss`. + - `processes` is an array of strings, preserving visible process summaries from `ss`. + - `preview_supported` is a boolean. +- [x] Add `GET /api/v1/runs/{id}/sandbox/services` under the Human-in-the-Loop tag: + - Summary: `List Sandbox Services`. + - Description: lists listening TCP services discovered inside the run sandbox. + - `200` returns `SandboxServiceListResponse`. + - `404` returns `ErrorResponse` for missing run. + - `409` returns `ErrorResponse` when the run has no active sandbox or service discovery fails. +- [x] Add shared Rust types in `fabro-types`: + - `SandboxService { port: u16, addresses: Vec, processes: Vec, preview_supported: bool }` + - `SandboxServiceListResponse { data: Vec }` +- [x] Re-export the shared types from `fabro-types/src/lib.rs`. +- [x] Reuse the shared types from `fabro-api` with `with_replacement(...)` entries in `build.rs`. +- [x] Add `fabro-api/tests/sandbox_services_round_trip.rs` proving: + - `fabro_api::types::SandboxService` is the same type as `fabro_types::SandboxService`. + - JSON shape matches the OpenAPI contract. + - Minimal response with an empty `data` array deserializes. + +### Task 2: Implement Service Discovery in the Server + +- [x] Add a route in `lib/crates/fabro-server/src/server/handler/sandbox.rs`: + - `.route("/runs/{id}/sandbox/services", get(list_sandbox_services))` +- [x] Implement `list_sandbox_services` with the same auth and run-id parsing style as `list_sandbox_files`. +- [x] Use `reconnect_run_sandbox(&state, &id).await` so the sandbox is reconnected and started before service discovery. +- [x] Execute `ss -H -ltnp` with: + - timeout `5_000`. + - no custom working directory. + - no custom environment variables. + - no cancellation token. +- [x] If `exec_command` returns an error, return `409` with the cause chain. +- [x] If the command exits non-zero, return `409` with stderr when present, otherwise stdout, otherwise `ss -H -ltnp failed`. +- [x] Parse only stdout on success. +- [x] Determine provider from the loaded sandbox record, not from parsed command output. +- [x] Sort response rows by ascending port. + +### Task 3: Add a Focused Parser + +- [x] Add private parser helpers in `sandbox.rs`, near the route handler or in a small private module inside the file: + - `parse_ss_listening_services(output: &str, provider: &str) -> Vec` + - `preview_supported(provider: &str, port: u16) -> bool` +- [x] Parser rules: + - Input lines are from `ss -H -ltnp`. + - Ignore blank lines and malformed lines. + - The local address field is the fourth whitespace-delimited field for standard `ss -H -ltnp` output. + - Extract the port from the last colon-separated segment. + - Handle IPv4, IPv6 bracketed addresses, wildcard binds, and loopback binds. + - Keep the full local address string in `addresses`. + - Keep the process field and any remaining trailing text in `processes` when present. + - Group duplicate rows by port, deduplicating `addresses` and `processes`. +- [x] Parser tests should cover: + - `127.0.0.1:3000` + - `0.0.0.0:5173` + - `[::]:8080` + - `[::1]:2500` + - malformed and non-numeric ports ignored. + - same port with multiple addresses grouped. + - Daytona `2500` is not previewable. + - Daytona `3000` and `9999` are previewable. + - Docker `3000` is not previewable. + +### Task 4: Demo and Auth Coverage + +- [x] Add `/runs/{id}/sandbox/services` to `demo_routes`. +- [x] Add `list_sandbox_services_stub` returning at least: + - port `3000`, preview-supported `true`. + - port `2500`, preview-supported `false`. +- [x] Add the route to the server user-only route auth test so worker tokens cannot call it. +- [x] Add a server test for non-zero `ss` output if an existing fake sandbox setup can cover it cleanly; otherwise keep the failure behavior covered by parser/unit tests and the handler implementation review. + +### Task 5: Generate and Verify + +- [x] Run `cargo build -p fabro-api`. +- [x] Run `cd lib/packages/fabro-api-client && bun run generate`. +- [x] Run `cargo nextest run -p fabro-api`. +- [x] Run `cargo nextest run -p fabro-server`. +- [x] Run `cargo +nightly-2026-04-14 fmt --check --all`. +- [x] Run `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`. + +## Acceptance Criteria + +- `GET /api/v1/runs/{id}/sandbox/services` returns all parsed listening TCP ports. +- Preview support is true only for Daytona ports `3000..=9999`. +- The sandbox command performs no filtering beyond `ss -H -ltnp`. +- Non-Daytona providers can list services but never mark rows previewable. +- Missing `ss` or a failing `ss` command returns a clear `409`. +- Generated Rust and TypeScript clients expose the new endpoint and response types. + +## Assumptions + +- The intended preview range is `3000..=9999`. +- `ss` is available often enough for v1; fallback commands are deferred. +- Process names from `ss` are useful display hints only and are not parsed into structured PID/name fields in this phase. diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs index cd274b4d2..f8896b9d6 100644 --- a/lib/crates/fabro-api/build.rs +++ b/lib/crates/fabro-api/build.rs @@ -385,6 +385,12 @@ fn main() { ("DirtyStatus", "fabro_types::DirtyStatus", &[]), ("GitContext", "fabro_types::GitContext", &[]), ("SandboxDetails", "fabro_types::SandboxDetails", &[]), + ("SandboxService", "fabro_types::SandboxService", &[]), + ( + "SandboxServiceListResponse", + "fabro_types::SandboxServiceListResponse", + &[], + ), ("SandboxState", "fabro_types::SandboxState", &[]), ("SandboxResources", "fabro_types::SandboxResources", &[]), ("SandboxTimestamps", "fabro_types::SandboxTimestamps", &[]), diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index b2691ab01..380e978f2 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -33,9 +33,10 @@ pub mod types { EventEnvelope, GitContext, IdpIdentity, InterviewOption, InterviewQuestionRecord, PendingInterviewRecord, PreRunPushOutcome, Principal, QuestionType, RepositoryReference, RunClientProvenance, RunEvent, RunProjection, RunProvenance, RunServerProvenance, - RunSummary, SandboxDetails, SandboxResources, SandboxState, SandboxTimestamps, - SecretMetadata, SecretType, ServerSettings, StageCompletion, StageHandler, StageOutcome, - StageProjection, StageState, SystemActorKind, UserPrincipal, WorkflowSettings, + RunSummary, SandboxDetails, SandboxResources, SandboxService, SandboxServiceListResponse, + SandboxState, SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, + StageCompletion, StageHandler, StageOutcome, StageProjection, StageState, SystemActorKind, + UserPrincipal, WorkflowSettings, }; pub use crate::generated::types::*; diff --git a/lib/crates/fabro-api/tests/sandbox_services_round_trip.rs b/lib/crates/fabro-api/tests/sandbox_services_round_trip.rs new file mode 100644 index 000000000..32387fd2a --- /dev/null +++ b/lib/crates/fabro-api/tests/sandbox_services_round_trip.rs @@ -0,0 +1,62 @@ +use std::any::{TypeId, type_name}; + +use fabro_api::types::{ + SandboxService as ApiSandboxService, + SandboxServiceListResponse as ApiSandboxServiceListResponse, +}; +use fabro_types::{SandboxService, SandboxServiceListResponse}; +use serde_json::json; + +#[test] +fn sandbox_services_reuse_domain_types() { + assert_same_type::(); + assert_same_type::(); +} + +#[test] +fn sandbox_services_json_matches_openapi_shape() { + let response = SandboxServiceListResponse { + data: vec![SandboxService { + port: 3000, + addresses: vec!["127.0.0.1:3000".to_string(), "[::]:3000".to_string()], + processes: vec![ + r#"users:(("node",pid=42,fd=23))"#.to_string(), + r#"users:(("vite",pid=84,fd=19))"#.to_string(), + ], + preview_supported: true, + }], + }; + + assert_eq!( + serde_json::to_value(&response).unwrap(), + json!({ + "data": [{ + "port": 3000, + "addresses": ["127.0.0.1:3000", "[::]:3000"], + "processes": [ + r#"users:(("node",pid=42,fd=23))"#, + r#"users:(("vite",pid=84,fd=19))"#, + ], + "preview_supported": true + }] + }) + ); +} + +#[test] +fn sandbox_services_deserializes_empty_response() { + let response: SandboxServiceListResponse = serde_json::from_value(json!({ "data": [] })) + .expect("empty service response should deserialize"); + + assert!(response.data.is_empty()); +} + +fn assert_same_type() { + assert_eq!( + TypeId::of::(), + TypeId::of::(), + "{} should be the same type as {}", + type_name::(), + type_name::() + ); +} diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 7e5a31a4a..261302691 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -20,7 +20,8 @@ use fabro_api::types::{ PaginationMeta, RunArtifactListResponse, RunCommit, RunCommitParent, RunCommitParentSha, RunCommitParentShortSha, RunCommitPerson, RunCommitSha, RunCommitShortSha, RunCommitTreeSha, RunCommitsMeta, RunCommitsMetaBaseSha, RunCommitsMetaHeadSha, RunCommitsMetaSource, - RunFilesMeta, RunFilesMetaScope, RunFilesMetaSource, + RunFilesMeta, RunFilesMetaScope, RunFilesMetaSource, SandboxService, + SandboxServiceListResponse, }; use serde_json::json; @@ -400,6 +401,33 @@ pub(crate) async fn list_sandbox_files_stub( .into_response() } +pub(crate) async fn list_sandbox_services_stub( + _auth: RequiredUser, + State(_state): State>, + Path(_id): Path, +) -> Response { + ( + StatusCode::OK, + Json(SandboxServiceListResponse { + data: vec![ + SandboxService { + port: 3000, + addresses: vec!["0.0.0.0:3000".to_string()], + processes: vec![r#"users:(("node",pid=42,fd=23))"#.to_string()], + preview_supported: true, + }, + SandboxService { + port: 2500, + addresses: vec!["127.0.0.1:2500".to_string()], + processes: vec![r#"users:(("debug",pid=84,fd=19))"#.to_string()], + preview_supported: false, + }, + ], + }), + ) + .into_response() +} + pub(crate) async fn get_sandbox_file_stub( _auth: RequiredUser, State(_state): State>, diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 4f553601a..2553e6fc8 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -34,10 +34,10 @@ pub use fabro_api::types::{ RenderWorkflowGraphDirection, RenderWorkflowGraphRequest, RewindRequest, RewindResponse, RunArtifactEntry, RunArtifactListResponse, RunBilling, RunBillingStage, RunBillingTotals, RunError, RunManifest, RunStage, RunStatusResponse, SandboxDetails, SandboxFileEntry, - SandboxFileListResponse, SshAccessRequest, SshAccessResponse, StageHandler, StageState, - StartRunRequest, SubmitAnswerRequest, SystemFeatures, SystemInfoResponse, SystemRepairRunIssue, - SystemRepairRunsResponse, SystemRunCounts, TimelineEntryResponse, VncPreviewResponse, - WriteBlobResponse, + SandboxFileListResponse, SandboxService, SandboxServiceListResponse, SshAccessRequest, + SshAccessResponse, StageHandler, StageState, StartRunRequest, SubmitAnswerRequest, + SystemFeatures, SystemInfoResponse, SystemRepairRunIssue, SystemRepairRunsResponse, + SystemRunCounts, TimelineEntryResponse, VncPreviewResponse, WriteBlobResponse, }; use fabro_auth::{ CredentialSource, VaultCredentialSource, auth_issue_message, parse_credential_secret, diff --git a/lib/crates/fabro-server/src/server/handler/mod.rs b/lib/crates/fabro-server/src/server/handler/mod.rs index b7ce2243c..83f2181c4 100644 --- a/lib/crates/fabro-server/src/server/handler/mod.rs +++ b/lib/crates/fabro-server/src/server/handler/mod.rs @@ -78,6 +78,10 @@ pub(super) fn demo_routes() -> Router> { "/runs/{id}/sandbox/files", get(demo::list_sandbox_files_stub), ) + .route( + "/runs/{id}/sandbox/services", + get(demo::list_sandbox_services_stub), + ) .route( "/runs/{id}/sandbox/file", get(demo::get_sandbox_file_stub).put(demo::put_sandbox_file_stub), diff --git a/lib/crates/fabro-server/src/server/handler/sandbox.rs b/lib/crates/fabro-server/src/server/handler/sandbox.rs index 522430b16..1f447cde5 100644 --- a/lib/crates/fabro-server/src/server/handler/sandbox.rs +++ b/lib/crates/fabro-server/src/server/handler/sandbox.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::num::NonZeroU64; use std::sync::Arc; @@ -10,14 +11,17 @@ 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, 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, + 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, }; const MAX_TERMINAL_CONTROL_BYTES: usize = 4096; const DEFAULT_VNC_NO_VNC_PORT: u16 = 6080; const DEFAULT_VNC_TTL_SECS: i32 = 3600; +const LIST_SANDBOX_SERVICES_COMMAND: &str = "ss -H -ltnp"; +const LIST_SANDBOX_SERVICES_TIMEOUT_MS: u64 = 5_000; // Daytona's signed preview points at the noVNC service root, which serves a // directory listing. Force the iframe to the actual viewer page with // autoconnect+scale so the user lands on the desktop, not a file index. @@ -68,6 +72,7 @@ pub(super) fn routes() -> Router> { .route("/runs/{id}/terminal", get(run_terminal)) .route("/runs/{id}/sandbox", get(retrieve_run_sandbox)) .route("/runs/{id}/sandbox/vnc", post(create_sandbox_vnc_preview)) + .route("/runs/{id}/sandbox/services", get(list_sandbox_services)) .route("/runs/{id}/sandbox/files", get(list_sandbox_files)) .route( "/runs/{id}/sandbox/file", @@ -526,6 +531,109 @@ async fn list_sandbox_files( } } +async fn list_sandbox_services( + _auth: RequiredUser, + State(state): State>, + Path(id): Path, +) -> Response { + let id = match parse_run_id_path(&id) { + Ok(id) => id, + Err(response) => return response, + }; + let record = match load_run_sandbox_record(&state, &id).await { + Ok(record) => record, + Err(response) => return response, + }; + let provider = record.provider; + let sandbox = match reconnect_run_sandbox(&state, &id).await { + Ok(sandbox) => sandbox, + Err(response) => return response, + }; + let result = match sandbox + .exec_command( + LIST_SANDBOX_SERVICES_COMMAND, + LIST_SANDBOX_SERVICES_TIMEOUT_MS, + None, + None, + None, + ) + .await + { + Ok(result) => result, + Err(err) => { + return ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response(); + } + }; + if !result.is_success() { + return ApiError::new( + StatusCode::CONFLICT, + sandbox_service_command_failure_detail(&result), + ) + .into_response(); + } + + Json(SandboxServiceListResponse { + data: parse_ss_listening_services(&result.stdout, &provider), + }) + .into_response() +} + +fn sandbox_service_command_failure_detail(result: &fabro_sandbox::ExecResult) -> String { + let stderr = result.stderr.trim(); + if !stderr.is_empty() { + return stderr.to_string(); + } + let stdout = result.stdout.trim(); + if !stdout.is_empty() { + return stdout.to_string(); + } + format!("{LIST_SANDBOX_SERVICES_COMMAND} failed") +} + +fn parse_ss_listening_services(output: &str, provider: &str) -> Vec { + let mut services = BTreeMap::::new(); + for line in output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + { + let fields = line.split_whitespace().collect::>(); + let Some(address) = fields.get(3).copied() else { + continue; + }; + let Some(port) = parse_ss_local_port(address) else { + continue; + }; + let process = (fields.len() > 5).then(|| fields[5..].join(" ")); + let service = services.entry(port).or_insert_with(|| SandboxService { + port, + addresses: Vec::new(), + processes: Vec::new(), + preview_supported: preview_supported(provider, port), + }); + push_unique(&mut service.addresses, address.to_string()); + if let Some(process) = process { + push_unique(&mut service.processes, process); + } + } + services.into_values().collect() +} + +fn parse_ss_local_port(address: &str) -> Option { + let port = address.rsplit_once(':')?.1.parse::().ok()?; + (port > 0).then_some(port) +} + +fn preview_supported(provider: &str, port: u16) -> bool { + provider == SandboxProvider::Daytona.to_string() && (3000..=9999).contains(&port) +} + +fn push_unique(values: &mut Vec, value: String) { + if !values.contains(&value) { + values.push(value); + } +} + async fn get_sandbox_file( _auth: RequiredUser, State(state): State>, @@ -747,6 +855,118 @@ mod tests { assert!(!origin_allowed(&headers)); } + #[test] + fn ss_parser_extracts_addresses_processes_and_preview_support() { + let services = parse_ss_listening_services( + r#" +LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("node",pid=42,fd=23)) +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)) +"#, + "daytona", + ); + + assert_eq!(services.len(), 4); + assert_eq!(services[0].port, 2500); + assert_eq!(services[0].addresses, vec!["[::1]:2500"]); + assert_eq!(services[0].processes, vec![ + r#"users:(("debug",pid=168,fd=7))"# + ]); + assert!(!services[0].preview_supported); + assert_eq!(services[1].port, 3000); + assert_eq!(services[1].addresses, vec!["127.0.0.1:3000"]); + assert_eq!(services[1].processes, vec![ + r#"users:(("node",pid=42,fd=23))"# + ]); + assert!(services[1].preview_supported); + assert_eq!(services[2].port, 5173); + assert_eq!(services[2].addresses, vec!["0.0.0.0:5173"]); + assert!(services[2].preview_supported); + assert_eq!(services[3].port, 8080); + assert_eq!(services[3].addresses, vec!["[::]:8080"]); + assert!(services[3].preview_supported); + } + + #[test] + fn ss_parser_ignores_malformed_and_non_numeric_ports() { + let services = parse_ss_listening_services( + r#" +LISTEN 0 4096 127.0.0.1:not-a-port 0.0.0.0:* users:(("node",pid=42,fd=23)) +LISTEN 0 4096 missing-peer +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)) +"#, + "daytona", + ); + + assert!(services.is_empty()); + } + + #[test] + fn ss_parser_groups_duplicate_ports_and_deduplicates_values() { + let services = parse_ss_listening_services( + r#" +LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("node",pid=42,fd=23)) +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)) +"#, + "daytona", + ); + + assert_eq!(services, vec![SandboxService { + port: 3000, + addresses: vec![ + "127.0.0.1:3000".to_string(), + "0.0.0.0:3000".to_string(), + "[::]:3000".to_string(), + ], + processes: vec![ + r#"users:(("node",pid=42,fd=23))"#.to_string(), + r#"users:(("vite",pid=84,fd=19))"#.to_string(), + ], + preview_supported: true, + }]); + } + + #[test] + fn preview_support_is_daytona_only_for_documented_range() { + assert!(!preview_supported("daytona", 2500)); + assert!(preview_supported("daytona", 3000)); + assert!(preview_supported("daytona", 9999)); + assert!(!preview_supported("daytona", 10000)); + assert!(!preview_supported("docker", 3000)); + } + + #[test] + fn sandbox_service_command_failure_prefers_stderr_then_stdout() { + let mut result = fabro_sandbox::ExecResult { + stdout: "stdout detail".to_string(), + stderr: "stderr detail".to_string(), + exit_code: Some(127), + termination: fabro_types::CommandTermination::Exited, + duration_ms: 10, + }; + assert_eq!( + sandbox_service_command_failure_detail(&result), + "stderr detail" + ); + + result.stderr.clear(); + assert_eq!( + sandbox_service_command_failure_detail(&result), + "stdout detail" + ); + + result.stdout.clear(); + assert_eq!( + sandbox_service_command_failure_detail(&result), + "ss -H -ltnp failed" + ); + } + struct FakeVncSandbox { start_error: Option<&'static str>, signed_url_error: Option<&'static str>, diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index b14a4ae85..e239c8a57 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -6321,6 +6321,7 @@ async fn worker_token_is_rejected_on_user_only_routes() { (Method::POST, format!("/runs/{run_id}/preview")), (Method::POST, format!("/runs/{run_id}/ssh")), (Method::GET, format!("/runs/{run_id}/sandbox/files")), + (Method::GET, format!("/runs/{run_id}/sandbox/services")), (Method::GET, format!("/runs/{run_id}/sandbox/file")), (Method::PUT, format!("/runs/{run_id}/sandbox/file")), ]; diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index cf4585978..9e7e38c86 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -26,6 +26,7 @@ pub mod run_summary; pub mod run_title; pub mod sandbox_details; pub mod sandbox_record; +pub mod sandbox_services; pub mod secret; pub mod settings; pub mod stage_completion; @@ -80,6 +81,7 @@ pub use run_summary::RunSummary; pub use run_title::{RunTitleError, infer_run_title, normalize_explicit_run_title}; pub use sandbox_details::{SandboxDetails, SandboxResources, SandboxState, SandboxTimestamps}; pub use sandbox_record::SandboxRecord; +pub use sandbox_services::{SandboxService, SandboxServiceListResponse}; pub use secret::{SecretMetadata, SecretType}; pub use stage_completion::StageCompletion; pub use stage_handler::StageHandler; diff --git a/lib/crates/fabro-types/src/sandbox_services.rs b/lib/crates/fabro-types/src/sandbox_services.rs new file mode 100644 index 000000000..7212c174b --- /dev/null +++ b/lib/crates/fabro-types/src/sandbox_services.rs @@ -0,0 +1,12 @@ +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct SandboxService { + pub port: u16, + pub addresses: Vec, + pub processes: Vec, + pub preview_supported: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct SandboxServiceListResponse { + pub data: Vec, +} diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index c9607975e..5cf2ab09d 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -290,6 +290,8 @@ models/sandbox-details.ts models/sandbox-file-entry.ts models/sandbox-file-list-response.ts models/sandbox-resources.ts +models/sandbox-service-list-response.ts +models/sandbox-service.ts models/sandbox-state.ts models/sandbox-timestamps.ts models/save-query-request.ts diff --git a/lib/packages/fabro-api-client/src/api/human-in-the-loop-api.ts b/lib/packages/fabro-api-client/src/api/human-in-the-loop-api.ts index 6ce17da88..1b299db54 100644 --- a/lib/packages/fabro-api-client/src/api/human-in-the-loop-api.ts +++ b/lib/packages/fabro-api-client/src/api/human-in-the-loop-api.ts @@ -34,6 +34,8 @@ import type { SandboxDetails } from '../models'; // @ts-ignore import type { SandboxFileListResponse } from '../models'; // @ts-ignore +import type { SandboxServiceListResponse } from '../models'; +// @ts-ignore import type { SshAccessRequest } from '../models'; // @ts-ignore import type { SshAccessResponse } from '../models'; @@ -367,6 +369,46 @@ export const HumanInTheLoopApiAxiosParamCreator = function (configuration?: Conf options: localVarRequestOptions, }; }, + /** + * Lists listening TCP services discovered inside the run sandbox. + * @summary List Sandbox Services + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listSandboxServices: async (id: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('listSandboxServices', 'id', id) + const localVarPath = `/api/v1/runs/{id}/sandbox/services` + .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, + }; + }, /** * Uploads a file into the run\'s sandbox environment. * @summary Upload Sandbox File @@ -660,6 +702,19 @@ export const HumanInTheLoopApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['HumanInTheLoopApi.listSandboxFiles']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Lists listening TCP services discovered inside the run sandbox. + * @summary List Sandbox Services + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async listSandboxServices(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listSandboxServices(id, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['HumanInTheLoopApi.listSandboxServices']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Uploads a file into the run\'s sandbox environment. * @summary Upload Sandbox File @@ -803,6 +858,16 @@ export const HumanInTheLoopApiFactory = function (configuration?: Configuration, listSandboxFiles(id: string, path: string, depth?: number, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.listSandboxFiles(id, path, depth, options).then((request) => request(axios, basePath)); }, + /** + * Lists listening TCP services discovered inside the run sandbox. + * @summary List Sandbox Services + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listSandboxServices(id: string, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.listSandboxServices(id, options).then((request) => request(axios, basePath)); + }, /** * Uploads a file into the run\'s sandbox environment. * @summary Upload Sandbox File @@ -939,6 +1004,17 @@ export class HumanInTheLoopApi extends BaseAPI { return HumanInTheLoopApiFp(this.configuration).listSandboxFiles(id, path, depth, options).then((request) => request(this.axios, this.basePath)); } + /** + * Lists listening TCP services discovered inside the run sandbox. + * @summary List Sandbox Services + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public listSandboxServices(id: string, options?: RawAxiosRequestConfig) { + return HumanInTheLoopApiFp(this.configuration).listSandboxServices(id, options).then((request) => request(this.axios, this.basePath)); + } + /** * Uploads a file into the run\'s sandbox environment. * @summary Upload Sandbox File diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 8ed99846f..083763307 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -267,6 +267,8 @@ export * from './sandbox-details'; export * from './sandbox-file-entry'; export * from './sandbox-file-list-response'; export * from './sandbox-resources'; +export * from './sandbox-service'; +export * from './sandbox-service-list-response'; export * from './sandbox-state'; export * from './sandbox-timestamps'; export * from './save-query-request'; diff --git a/lib/packages/fabro-api-client/src/models/sandbox-service-list-response.ts b/lib/packages/fabro-api-client/src/models/sandbox-service-list-response.ts new file mode 100644 index 000000000..15a6c634c --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/sandbox-service-list-response.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 { SandboxService } from './sandbox-service'; + +/** + * Non-paginated list of listening TCP services in a run sandbox. + */ +export interface SandboxServiceListResponse { + 'data': Array; +} + diff --git a/lib/packages/fabro-api-client/src/models/sandbox-service.ts b/lib/packages/fabro-api-client/src/models/sandbox-service.ts new file mode 100644 index 000000000..0550d38ec --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/sandbox-service.ts @@ -0,0 +1,38 @@ +/* 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. + */ + + + +/** + * A listening TCP service discovered inside a run sandbox. + */ +export interface SandboxService { + /** + * Listening TCP port. + */ + 'port': number; + /** + * Local bind addresses reported by `ss`. + */ + 'addresses': Array; + /** + * Visible process summaries reported by `ss`. + */ + 'processes': Array; + /** + * Whether the provider supports an external preview URL for this port. + */ + 'preview_supported': boolean; +} +