diff --git a/crates/client/src/backend_runtime.rs b/crates/client/src/backend_runtime.rs index ff6c0fb27..3d61f8966 100644 --- a/crates/client/src/backend_runtime.rs +++ b/crates/client/src/backend_runtime.rs @@ -278,6 +278,7 @@ impl BackendRuntimeListTarget { pub enum BackendRuntimeClientError { InvalidTarget(String), Api(BackendApiClientError), + SessionApi(server_api::ServerApiClientError), Http(reqwest::Error), Protocol(String), } @@ -287,6 +288,7 @@ impl fmt::Display for BackendRuntimeClientError { match self { Self::InvalidTarget(message) => f.write_str(message), Self::Api(error) => write!(f, "{error}"), + Self::SessionApi(error) => write!(f, "{error}"), Self::Http(error) => write!(f, "{error}"), Self::Protocol(message) => f.write_str(message), } @@ -295,6 +297,12 @@ impl fmt::Display for BackendRuntimeClientError { impl std::error::Error for BackendRuntimeClientError {} +impl From for BackendRuntimeClientError { + fn from(value: server_api::ServerApiClientError) -> Self { + Self::SessionApi(value) + } +} + impl From for BackendRuntimeClientError { fn from(error: BackendApiClientError) -> Self { Self::Api(error) @@ -307,6 +315,59 @@ impl From for BackendRuntimeClientError { } } +const WORKER_SESSION_RESPONSE_LIMIT: usize = 16 * 1024 * 1024; + +#[derive(Clone, Debug)] +struct StoredBearerAuthorizer { + authorization: String, +} + +impl server_api::client_support::RequestAuthorizer for StoredBearerAuthorizer { + fn authorize( + &self, + _request: server_api::client_support::AuthorizerRequest<'_>, + ) -> Result { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + self.authorization + .parse() + .map_err(|_| server_api::client_support::AuthorizationError::new())?, + ); + Ok(headers) + } +} + +pub async fn observe_backend_worker_session( + target: &BackendRuntimeTarget, +) -> Result { + validate_target(target)?; + let backend = BackendApiClient::from_stored_token(&target.base_url)?; + observe_backend_worker_session_with_client(target, &backend).await +} + +async fn observe_backend_worker_session_with_client( + target: &BackendRuntimeTarget, + backend: &BackendApiClient, +) -> Result { + let client = server_api::ServerApiClient::builder(&target.base_url) + .map_err(|error| BackendRuntimeClientError::Protocol(error.to_string()))? + .authorizer(StoredBearerAuthorizer { + authorization: backend.authorization_header_value(), + }) + .response_body_limit(WORKER_SESSION_RESPONSE_LIMIT) + .build() + .map_err(|error| BackendRuntimeClientError::Protocol(error.to_string()))?; + client + .worker_session( + target.workspace_id.clone(), + target.runtime_id.clone(), + target.worker_id.clone(), + ) + .await + .map_err(Into::into) +} + pub async fn get_backend_worker_launch_options( target: &BackendWorkerLaunchTarget, ) -> Result { @@ -765,6 +826,43 @@ mod tests { (base_url, task) } + #[tokio::test] + async fn worker_session_request_uses_bearer_and_sixteen_mib_response_limit() { + let large_message = "x".repeat(2 * 1024 * 1024); + let (base_url, server) = serve_json_once(serde_json::json!({ + "subject": { + "kind": "runtime_worker", + "runtime_id": "runtime-a", + "worker_id": "worker-a" + }, + "availability": "unavailable", + "reason": "storage_unavailable", + "message": large_message + })) + .await; + let target = BackendRuntimeTarget::new(&base_url, "workspace-a", "runtime-a", "worker-a"); + let api = + BackendApiClient::from_access_token_for_test(&base_url, "session-secret").unwrap(); + + let response = observe_backend_worker_session_with_client(&target, &api) + .await + .unwrap(); + assert!(matches!( + response.observation, + runtime_api::WorkerSessionAvailability::Unavailable { ref message, .. } + if message.len() == 2 * 1024 * 1024 + )); + let request = server.await.unwrap(); + assert!(request.starts_with( + "GET /api/w/workspace-a/runtimes/runtime-a/workers/worker-a/session HTTP/1.1\r\n" + )); + assert!( + request + .to_ascii_lowercase() + .contains("authorization: bearer session-secret\r\n") + ); + } + #[tokio::test] async fn launch_options_request_uses_workspace_path_and_bearer_auth() { let (base_url, server) = serve_json_once(serde_json::json!({ diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index ad44a2083..64d2b25f1 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -30,7 +30,7 @@ pub use backend_runtime::{ BackendWorkerRestoreResult, BackendWorkerRestoreState, BackendWorkerSummary, BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, connect_backend_runtime, create_backend_worker, get_backend_worker_launch_options, list_backend_stopped_workers, - list_backend_workers, restore_backend_worker, + list_backend_workers, observe_backend_worker_session, restore_backend_worker, }; pub use backend_workspace::{ BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError, diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 88e782fd3..4e9bc6026 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use workspace_api::WorkspaceWorkerSubject; pub type ServerApiError = runtime_api::RuntimeApiError; +pub type ServerApiClientError = client_support::ClientError; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct WorkspaceWorkerSessionResponse { diff --git a/crates/tui/src/backend_worker_picker.rs b/crates/tui/src/backend_worker_picker.rs index 1717184b0..1768334b2 100644 --- a/crates/tui/src/backend_worker_picker.rs +++ b/crates/tui/src/backend_worker_picker.rs @@ -3,8 +3,8 @@ use std::io; use std::time::Duration; use client::{ - BackendRuntimeListTarget, BackendWorkerSummary, ServerApiClient, WorkerSessionAvailability, - list_backend_stopped_workers, list_backend_workers, + BackendRuntimeListTarget, BackendWorkerSummary, WorkerSessionAvailability, + list_backend_stopped_workers, list_backend_workers, observe_backend_worker_session, }; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; use ratatui::Frame; @@ -83,16 +83,7 @@ pub(crate) async fn run( let mut attach_target = target .runtime_target(selected.runtime_id.clone(), selected.worker_id.clone()) .map_err(|error| io::Error::other(error.to_string()))?; - let api = ServerApiClient::builder(&target.base_url) - .map_err(|error| io::Error::other(error.to_string()))? - .build() - .map_err(|error| io::Error::other(error.to_string()))?; - let response = api - .worker_session( - attach_target.workspace_id.clone(), - selected.runtime_id.clone(), - selected.worker_id.clone(), - ) + let response = observe_backend_worker_session(&attach_target) .await .map_err(|error| { io::Error::other(format!( diff --git a/web/workspace/src/lib/workspace/session-observation.test.ts b/web/workspace/src/lib/workspace/session-observation.test.ts index c6450ec2d..51e31cf02 100644 --- a/web/workspace/src/lib/workspace/session-observation.test.ts +++ b/web/workspace/src/lib/workspace/session-observation.test.ts @@ -2,6 +2,7 @@ import { isCurrentWorkerSessionRequest, workerSessionAction, type WorkerSessionRequestIdentity, + workerSessionRequestInit, } from "./session-observation"; import type { SessionSnapshot } from "$lib/generated/protocol"; @@ -23,6 +24,19 @@ const snapshot: SessionSnapshot = { entries: [], }; +Deno.test("worker session request includes same-origin credentials", () => { + const controller = new AbortController(); + const init = workerSessionRequestInit(controller.signal); + assert( + init.credentials === "same-origin", + "session observation must use the Workspace auth cookie boundary", + ); + assert( + init.signal === controller.signal, + "request must retain its abort fence", + ); +}); + Deno.test("worker session observation selects live, retained, and unavailable behavior", () => { assert( workerSessionAction({ availability: "live_protocol" }).kind === diff --git a/web/workspace/src/lib/workspace/session-observation.ts b/web/workspace/src/lib/workspace/session-observation.ts index 4b5309a10..9c5894507 100644 --- a/web/workspace/src/lib/workspace/session-observation.ts +++ b/web/workspace/src/lib/workspace/session-observation.ts @@ -17,6 +17,14 @@ export interface WorkerSessionRequestIdentity { workerId: string; } +export function workerSessionRequestInit(signal: AbortSignal): RequestInit { + return { + signal, + credentials: "same-origin", + headers: { accept: "application/json" }, + }; +} + export function workerSessionAction( observation: WorkerSessionObservation, ): WorkerSessionAction { diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte index d8dd3d5b7..6ff2e33d2 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte @@ -55,6 +55,7 @@ import { isCurrentWorkerSessionRequest, workerSessionAction, + workerSessionRequestInit, type WorkerSessionObservation, type WorkerSessionRequestIdentity, } from "$lib/workspace/session-observation"; @@ -951,7 +952,7 @@ workerApiPath( `/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}/session`, ), - { signal: controller.signal, headers: { accept: "application/json" } }, + workerSessionRequestInit(controller.signal), ) .then(async (response) => { if (!response.ok) {