feat(server): list sandbox services

This commit is contained in:
Bryan Helmkamp 2026-05-10 11:24:39 -04:00
parent 1f6965386c
commit 9c08653228
No known key found for this signature in database
17 changed files with 703 additions and 11 deletions

View file

@ -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

View file

@ -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<String>, processes: Vec<String>, preview_supported: bool }`
- `SandboxServiceListResponse { data: Vec<SandboxService> }`
- [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<SandboxService>`
- `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.

View file

@ -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", &[]),

View file

@ -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::*;

View file

@ -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::<ApiSandboxService, SandboxService>();
assert_same_type::<ApiSandboxServiceListResponse, SandboxServiceListResponse>();
}
#[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<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<U>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<U>()
);
}

View file

@ -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<Arc<AppState>>,
Path(_id): Path<String>,
) -> 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<Arc<AppState>>,

View file

@ -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,

View file

@ -78,6 +78,10 @@ pub(super) fn demo_routes() -> Router<Arc<AppState>> {
"/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),

View file

@ -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<Arc<AppState>> {
.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<Arc<AppState>>,
Path(id): Path<String>,
) -> 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<SandboxService> {
let mut services = BTreeMap::<u16, SandboxService>::new();
for line in output
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
{
let fields = line.split_whitespace().collect::<Vec<_>>();
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<u16> {
let port = address.rsplit_once(':')?.1.parse::<u16>().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<String>, value: String) {
if !values.contains(&value) {
values.push(value);
}
}
async fn get_sandbox_file(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
@ -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>,

View file

@ -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")),
];

View file

@ -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;

View file

@ -0,0 +1,12 @@
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SandboxService {
pub port: u16,
pub addresses: Vec<String>,
pub processes: Vec<String>,
pub preview_supported: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SandboxServiceListResponse {
pub data: Vec<SandboxService>,
}

View file

@ -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

View file

@ -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<RequestArgs> => {
// 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<SandboxServiceListResponse>> {
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<SandboxFileListResponse> {
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<SandboxServiceListResponse> {
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

View file

@ -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';

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 { SandboxService } from './sandbox-service';
/**
* Non-paginated list of listening TCP services in a run sandbox.
*/
export interface SandboxServiceListResponse {
'data': Array<SandboxService>;
}

View file

@ -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<string>;
/**
* Visible process summaries reported by `ss`.
*/
'processes': Array<string>;
/**
* Whether the provider supports an external preview URL for this port.
*/
'preview_supported': boolean;
}