feat(server): add Daytona VNC preview endpoint

Adds the sandbox VNC API contract, Daytona Computer Use startup flow, signed noVNC preview response, and generated TypeScript client support.
This commit is contained in:
Bryan Helmkamp 2026-05-10 00:06:43 -04:00
parent 36c5a86005
commit ac90bb199c
No known key found for this signature in database
9 changed files with 504 additions and 34 deletions

View file

@ -2653,6 +2653,49 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/{id}/sandbox/vnc:
post:
operationId: createSandboxVncPreview
tags: [Human-in-the-Loop]
summary: Create Sandbox VNC Preview
description: Starts or ensures Daytona Computer Use for the run sandbox and returns a signed noVNC preview URL.
parameters:
- $ref: "#/components/parameters/RunId"
responses:
"201":
description: Signed noVNC preview URL created
content:
application/json:
schema:
$ref: "#/components/schemas/VncPreviewResponse"
"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, Computer Use startup failed, or signed preview generation failed
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"501":
description: Sandbox provider does not support VNC previews
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/{id}/sandbox/files:
get:
operationId: listSandboxFiles
@ -7910,6 +7953,35 @@ components:
items:
$ref: "#/components/schemas/SandboxFileEntry"
VncPreviewResponse:
description: Response containing a signed noVNC preview URL for a Daytona sandbox.
type: object
required:
- url
- provider
- port
- expires_in_secs
properties:
url:
type: string
description: Signed noVNC preview URL.
example: "https://preview.example.com/sb-a1b2c3d4/6080?token=..."
provider:
type: string
description: Sandbox provider that produced the VNC preview.
example: daytona
port:
type: integer
minimum: 1
maximum: 65535
description: noVNC port exposed by the sandbox.
example: 6080
expires_in_secs:
type: integer
minimum: 1
description: Signed URL time-to-live in seconds.
example: 3600
# ── Insights Schemas ─────────────────────────────────────────────────
SavedQuery:

View file

@ -36,7 +36,8 @@ pub use fabro_api::types::{
RunError, RunManifest, RunStage, RunStatusResponse, SandboxDetails, SandboxFileEntry,
SandboxFileListResponse, SshAccessRequest, SshAccessResponse, StageHandler, StageState,
StartRunRequest, SubmitAnswerRequest, SystemFeatures, SystemInfoResponse, SystemRepairRunIssue,
SystemRepairRunsResponse, SystemRunCounts, TimelineEntryResponse, WriteBlobResponse,
SystemRepairRunsResponse, SystemRunCounts, TimelineEntryResponse, VncPreviewResponse,
WriteBlobResponse,
};
use fabro_auth::{
CredentialSource, VaultCredentialSource, auth_issue_message, parse_credential_secret,

View file

@ -1,18 +1,59 @@
use std::num::NonZeroU64;
use std::sync::Arc;
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use fabro_sandbox::{TerminalSize, open_terminal_for_run};
use futures_util::FutureExt;
use futures_util::future::BoxFuture;
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, collect_causes, fs,
get, octet_stream_response, parse_run_id_path, post, reconnect_for_run, reject_if_archived,
render_with_causes, sandbox_details,
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,
};
const MAX_TERMINAL_CONTROL_BYTES: usize = 4096;
const DEFAULT_VNC_NO_VNC_PORT: u16 = 6080;
const DEFAULT_VNC_TTL_SECS: i32 = 3600;
trait VncSandbox {
fn start_computer_use(&self) -> BoxFuture<'_, fabro_sandbox::Result<()>>;
fn signed_preview_url(
&self,
port: u16,
expires_in_secs: i32,
) -> BoxFuture<'_, fabro_sandbox::Result<String>>;
}
impl VncSandbox for DaytonaSandbox {
fn start_computer_use(&self) -> BoxFuture<'_, fabro_sandbox::Result<()>> {
async move {
let computer_use = self.computer_use().await?;
computer_use
.start()
.await
.map_err(|err| fabro_sandbox::Error::context("Failed to start Computer Use", err))
.map(|_| ())
}
.boxed()
}
fn signed_preview_url(
&self,
port: u16,
expires_in_secs: i32,
) -> BoxFuture<'_, fabro_sandbox::Result<String>> {
async move {
self.get_signed_preview_url(port, Some(expires_in_secs))
.await
.map(|preview| preview.url)
}
.boxed()
}
}
pub(super) fn routes() -> Router<Arc<AppState>> {
Router::new()
@ -20,6 +61,7 @@ pub(super) fn routes() -> Router<Arc<AppState>> {
.route("/runs/{id}/ssh", post(create_ssh_access))
.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/files", get(list_sandbox_files))
.route(
"/runs/{id}/sandbox/file",
@ -374,6 +416,60 @@ async fn create_ssh_access(
}
}
async fn create_sandbox_vnc_preview(
_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,
};
if record.provider != SandboxProvider::Daytona.to_string() {
return ApiError::new(
StatusCode::NOT_IMPLEMENTED,
"Sandbox provider does not support VNC previews.",
)
.into_response();
}
let sandbox = match reconnect_daytona_sandbox(&state, &id).await {
Ok(sandbox) => sandbox,
Err(response) => return response,
};
match build_vnc_preview_response(&sandbox).await {
Ok(response) => (StatusCode::CREATED, Json(response)).into_response(),
Err(response) => response,
}
}
async fn build_vnc_preview_response(
sandbox: &impl VncSandbox,
) -> Result<VncPreviewResponse, Response> {
sandbox.start_computer_use().await.map_err(|err| {
ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response()
})?;
let url = sandbox
.signed_preview_url(DEFAULT_VNC_NO_VNC_PORT, DEFAULT_VNC_TTL_SECS)
.await
.map_err(|err| {
ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response()
})?;
Ok(VncPreviewResponse {
expires_in_secs: NonZeroU64::new(
u64::try_from(DEFAULT_VNC_TTL_SECS).expect("default VNC TTL should fit in u64"),
)
.expect("default VNC TTL should be nonzero"),
port: NonZeroU64::new(u64::from(DEFAULT_VNC_NO_VNC_PORT))
.expect("default VNC port should be nonzero"),
provider: "daytona".to_string(),
url,
})
}
async fn list_sandbox_files(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
@ -579,6 +675,7 @@ async fn load_run_sandbox_record_or_not_found(
#[cfg(test)]
mod tests {
use axum::http::{HeaderMap, HeaderValue};
use futures_util::FutureExt;
use super::*;
@ -623,6 +720,87 @@ mod tests {
headers.insert("origin", HeaderValue::from_static("https://evil.example"));
assert!(!origin_allowed(&headers));
}
struct FakeVncSandbox {
start_error: Option<&'static str>,
signed_url_error: Option<&'static str>,
signed_url: &'static str,
}
impl VncSandbox for FakeVncSandbox {
fn start_computer_use(
&self,
) -> futures_util::future::BoxFuture<'_, fabro_sandbox::Result<()>> {
async move {
match self.start_error {
Some(message) => Err(fabro_sandbox::Error::message(message)),
None => Ok(()),
}
}
.boxed()
}
fn signed_preview_url(
&self,
port: u16,
expires_in_secs: i32,
) -> futures_util::future::BoxFuture<'_, fabro_sandbox::Result<String>> {
async move {
assert_eq!(port, DEFAULT_VNC_NO_VNC_PORT);
assert_eq!(expires_in_secs, DEFAULT_VNC_TTL_SECS);
match self.signed_url_error {
Some(message) => Err(fabro_sandbox::Error::message(message)),
None => Ok(self.signed_url.to_string()),
}
}
.boxed()
}
}
#[tokio::test]
async fn vnc_preview_response_uses_daytona_defaults() {
let sandbox = FakeVncSandbox {
start_error: None,
signed_url_error: None,
signed_url: "https://preview.example.test/sandbox/6080",
};
let response = build_vnc_preview_response(&sandbox).await.unwrap();
assert_eq!(response.url, "https://preview.example.test/sandbox/6080");
assert_eq!(response.provider, "daytona");
assert_eq!(response.port.get(), u64::from(DEFAULT_VNC_NO_VNC_PORT));
assert_eq!(
response.expires_in_secs.get(),
u64::try_from(DEFAULT_VNC_TTL_SECS).unwrap()
);
}
#[tokio::test]
async fn vnc_preview_response_maps_computer_use_start_failure_to_conflict() {
let sandbox = FakeVncSandbox {
start_error: Some("computer use failed"),
signed_url_error: None,
signed_url: "https://preview.example.test/sandbox/6080",
};
let response = build_vnc_preview_response(&sandbox).await.unwrap_err();
assert_eq!(response.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn vnc_preview_response_maps_signed_preview_failure_to_conflict() {
let sandbox = FakeVncSandbox {
start_error: None,
signed_url_error: Some("preview failed"),
signed_url: "https://preview.example.test/sandbox/6080",
};
let response = build_vnc_preview_response(&sandbox).await.unwrap_err();
assert_eq!(response.status(), StatusCode::CONFLICT);
}
}
#[cfg(test)]
@ -643,6 +821,14 @@ mod retrieve_sandbox_tests {
.expect("sandbox details GET request should build")
}
fn req_post(uri: &str) -> Request<Body> {
Request::builder()
.method("POST")
.uri(uri)
.body(Body::empty())
.expect("sandbox POST request should build")
}
async fn body_json(response: axum::response::Response) -> Value {
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
@ -669,6 +855,28 @@ mod retrieve_sandbox_tests {
run_store.append_event(&payload).await.unwrap();
}
async fn append_sandbox_initialized(
run_store: &fabro_store::RunDatabase,
run_id: &RunId,
provider: &str,
) {
let payload = fabro_store::EventPayload::new(
json!({
"id": "evt-sandbox-init",
"ts": "2026-05-09T12:00:00Z",
"run_id": run_id,
"event": "sandbox.initialized",
"properties": {
"provider": provider,
"working_directory": "/workspace",
},
}),
run_id,
)
.expect("sandbox.initialized payload should validate");
run_store.append_event(&payload).await.unwrap();
}
#[tokio::test]
async fn missing_run_returns_404() {
let app = build_test_router(test_app_state());
@ -725,21 +933,7 @@ mod retrieve_sandbox_tests {
.await
.expect("test run should be creatable");
append_run_created(&run_store, &run_id).await;
let payload = fabro_store::EventPayload::new(
json!({
"id": "evt-sandbox-init",
"ts": "2026-05-09T12:00:00Z",
"run_id": run_id,
"event": "sandbox.initialized",
"properties": {
"provider": "local",
"working_directory": "/workspace",
},
}),
&run_id,
)
.expect("sandbox.initialized payload should validate");
run_store.append_event(&payload).await.unwrap();
append_sandbox_initialized(&run_store, &run_id, "local").await;
let response = app
.oneshot(req_get(&format!("/api/v1/runs/{run_id}/sandbox")))
@ -764,21 +958,7 @@ mod retrieve_sandbox_tests {
.await
.expect("test run should be creatable");
append_run_created(&run_store, &run_id).await;
let payload = fabro_store::EventPayload::new(
json!({
"id": "evt-sandbox-init",
"ts": "2026-05-09T12:00:00Z",
"run_id": run_id,
"event": "sandbox.initialized",
"properties": {
"provider": "ephemeral-mystery-cloud",
"working_directory": "/workspace",
},
}),
&run_id,
)
.expect("sandbox.initialized payload should validate");
run_store.append_event(&payload).await.unwrap();
append_sandbox_initialized(&run_store, &run_id, "ephemeral-mystery-cloud").await;
let response = app
.oneshot(req_get(&format!("/api/v1/runs/{run_id}/sandbox")))
@ -786,4 +966,46 @@ mod retrieve_sandbox_tests {
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
}
#[tokio::test]
async fn local_sandbox_vnc_returns_501() {
let state = test_app_state();
let app = build_test_router(state.clone());
let run_id = RunId::new();
let run_store = state
.store_ref()
.create_run(&run_id)
.await
.expect("test run should be creatable");
append_run_created(&run_store, &run_id).await;
append_sandbox_initialized(&run_store, &run_id, "local").await;
let response = app
.oneshot(req_post(&format!("/api/v1/runs/{run_id}/sandbox/vnc")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
}
#[tokio::test]
async fn docker_sandbox_vnc_returns_501_without_reconnect() {
let state = test_app_state();
let app = build_test_router(state.clone());
let run_id = RunId::new();
let run_store = state
.store_ref()
.create_run(&run_id)
.await
.expect("test run should be creatable");
append_run_created(&run_store, &run_id).await;
append_sandbox_initialized(&run_store, &run_id, "docker").await;
let response = app
.oneshot(req_post(&format!("/api/v1/runs/{run_id}/sandbox/vnc")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
}
}

View file

@ -5,6 +5,7 @@ mod install_openai_compatible;
mod routing;
mod run_files;
mod runs;
mod sandbox_vnc;
mod settings;
mod system;
mod tcp;

View file

@ -0,0 +1,58 @@
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
use crate::helpers::{
MINIMAL_DOT, api, minimal_manifest_json, response_json, response_status, test_app_state,
};
#[tokio::test]
async fn vnc_for_missing_run_returns_not_found() {
let app = fabro_server::test_support::build_test_router(test_app_state());
let fake = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
let req = Request::builder()
.method("POST")
.uri(api(&format!("/runs/{fake}/sandbox/vnc")))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
response_status(
response,
StatusCode::NOT_FOUND,
"POST /api/v1/runs/{id}/sandbox/vnc",
)
.await;
}
#[tokio::test]
async fn vnc_for_run_without_sandbox_returns_conflict() {
let app = fabro_server::test_support::build_test_router(test_app_state());
let create_req = Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&minimal_manifest_json(MINIMAL_DOT)).unwrap(),
))
.unwrap();
let create_response = app.clone().oneshot(create_req).await.unwrap();
let create_body =
response_json(create_response, StatusCode::CREATED, "POST /api/v1/runs").await;
let run_id = create_body["id"].as_str().unwrap();
let req = Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/sandbox/vnc")))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
response_status(
response,
StatusCode::CONFLICT,
format!("POST /api/v1/runs/{run_id}/sandbox/vnc"),
)
.await;
}

View file

@ -346,6 +346,7 @@ models/tls-mode.ts
models/update-run-request.ts
models/user-response.ts
models/validate-response.ts
models/vnc-preview-response.ts
models/webhook-strategy.ts
models/workflow-detail-response.ts
models/workflow-diagnostic.ts

View file

@ -41,6 +41,8 @@ import type { SshAccessResponse } from '../models';
import type { SteerRunRequest } from '../models';
// @ts-ignore
import type { SubmitAnswerRequest } from '../models';
// @ts-ignore
import type { VncPreviewResponse } from '../models';
/**
* HumanInTheLoopApi - axios parameter creator
*/
@ -91,6 +93,46 @@ export const HumanInTheLoopApiAxiosParamCreator = function (configuration?: Conf
options: localVarRequestOptions,
};
},
/**
* Starts or ensures Daytona Computer Use for the run sandbox and returns a signed noVNC preview URL.
* @summary Create Sandbox VNC Preview
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createSandboxVncPreview: async (id: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('createSandboxVncPreview', 'id', id)
const localVarPath = `/api/v1/runs/{id}/sandbox/vnc`
.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: 'POST', ...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,
};
},
/**
* Generates a preview URL for a port exposed by the run\'s sandbox environment.
* @summary Preview URL
@ -534,6 +576,19 @@ export const HumanInTheLoopApiFp = function(configuration?: Configuration) {
const localVarOperationServerBasePath = operationServerMap['HumanInTheLoopApi.createRunSshAccess']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Starts or ensures Daytona Computer Use for the run sandbox and returns a signed noVNC preview URL.
* @summary Create Sandbox VNC Preview
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async createSandboxVncPreview(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<VncPreviewResponse>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.createSandboxVncPreview(id, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['HumanInTheLoopApi.createSandboxVncPreview']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Generates a preview URL for a port exposed by the run\'s sandbox environment.
* @summary Preview URL
@ -682,6 +737,16 @@ export const HumanInTheLoopApiFactory = function (configuration?: Configuration,
createRunSshAccess(id: string, sshAccessRequest: SshAccessRequest, options?: RawAxiosRequestConfig): AxiosPromise<SshAccessResponse> {
return localVarFp.createRunSshAccess(id, sshAccessRequest, options).then((request) => request(axios, basePath));
},
/**
* Starts or ensures Daytona Computer Use for the run sandbox and returns a signed noVNC preview URL.
* @summary Create Sandbox VNC Preview
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createSandboxVncPreview(id: string, options?: RawAxiosRequestConfig): AxiosPromise<VncPreviewResponse> {
return localVarFp.createSandboxVncPreview(id, options).then((request) => request(axios, basePath));
},
/**
* Generates a preview URL for a port exposed by the run\'s sandbox environment.
* @summary Preview URL
@ -802,6 +867,17 @@ export class HumanInTheLoopApi extends BaseAPI {
return HumanInTheLoopApiFp(this.configuration).createRunSshAccess(id, sshAccessRequest, options).then((request) => request(this.axios, this.basePath));
}
/**
* Starts or ensures Daytona Computer Use for the run sandbox and returns a signed noVNC preview URL.
* @summary Create Sandbox VNC Preview
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public createSandboxVncPreview(id: string, options?: RawAxiosRequestConfig) {
return HumanInTheLoopApiFp(this.configuration).createSandboxVncPreview(id, options).then((request) => request(this.axios, this.basePath));
}
/**
* Generates a preview URL for a port exposed by the run\'s sandbox environment.
* @summary Preview URL

View file

@ -323,6 +323,7 @@ export * from './tls-mode';
export * from './update-run-request';
export * from './user-response';
export * from './validate-response';
export * from './vnc-preview-response';
export * from './webhook-strategy';
export * from './workflow-detail-response';
export * from './workflow-diagnostic';

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.
*/
/**
* Response containing a signed noVNC preview URL for a Daytona sandbox.
*/
export interface VncPreviewResponse {
/**
* Signed noVNC preview URL.
*/
'url': string;
/**
* Sandbox provider that produced the VNC preview.
*/
'provider': string;
/**
* noVNC port exposed by the sandbox.
*/
'port': number;
/**
* Signed URL time-to-live in seconds.
*/
'expires_in_secs': number;
}