fix: preserve auth and response bounds for session reads

This commit is contained in:
2026-09-18 05:16:20 +09:00
parent 74981f8217
commit 96e04bbc32
7 changed files with 127 additions and 14 deletions
+98
View File
@@ -278,6 +278,7 @@ impl BackendRuntimeListTarget {
pub enum BackendRuntimeClientError { pub enum BackendRuntimeClientError {
InvalidTarget(String), InvalidTarget(String),
Api(BackendApiClientError), Api(BackendApiClientError),
SessionApi(server_api::ServerApiClientError),
Http(reqwest::Error), Http(reqwest::Error),
Protocol(String), Protocol(String),
} }
@@ -287,6 +288,7 @@ impl fmt::Display for BackendRuntimeClientError {
match self { match self {
Self::InvalidTarget(message) => f.write_str(message), Self::InvalidTarget(message) => f.write_str(message),
Self::Api(error) => write!(f, "{error}"), Self::Api(error) => write!(f, "{error}"),
Self::SessionApi(error) => write!(f, "{error}"),
Self::Http(error) => write!(f, "{error}"), Self::Http(error) => write!(f, "{error}"),
Self::Protocol(message) => f.write_str(message), Self::Protocol(message) => f.write_str(message),
} }
@@ -295,6 +297,12 @@ impl fmt::Display for BackendRuntimeClientError {
impl std::error::Error for BackendRuntimeClientError {} impl std::error::Error for BackendRuntimeClientError {}
impl From<server_api::ServerApiClientError> for BackendRuntimeClientError {
fn from(value: server_api::ServerApiClientError) -> Self {
Self::SessionApi(value)
}
}
impl From<BackendApiClientError> for BackendRuntimeClientError { impl From<BackendApiClientError> for BackendRuntimeClientError {
fn from(error: BackendApiClientError) -> Self { fn from(error: BackendApiClientError) -> Self {
Self::Api(error) Self::Api(error)
@@ -307,6 +315,59 @@ impl From<reqwest::Error> 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<reqwest::header::HeaderMap, server_api::client_support::AuthorizationError> {
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<server_api::WorkspaceWorkerSessionResponse, BackendRuntimeClientError> {
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<server_api::WorkspaceWorkerSessionResponse, BackendRuntimeClientError> {
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( pub async fn get_backend_worker_launch_options(
target: &BackendWorkerLaunchTarget, target: &BackendWorkerLaunchTarget,
) -> Result<BackendWorkerLaunchOptions, BackendRuntimeClientError> { ) -> Result<BackendWorkerLaunchOptions, BackendRuntimeClientError> {
@@ -765,6 +826,43 @@ mod tests {
(base_url, task) (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] #[tokio::test]
async fn launch_options_request_uses_workspace_path_and_bearer_auth() { async fn launch_options_request_uses_workspace_path_and_bearer_auth() {
let (base_url, server) = serve_json_once(serde_json::json!({ let (base_url, server) = serve_json_once(serde_json::json!({
+1 -1
View File
@@ -30,7 +30,7 @@ pub use backend_runtime::{
BackendWorkerRestoreResult, BackendWorkerRestoreState, BackendWorkerSummary, BackendWorkerRestoreResult, BackendWorkerRestoreState, BackendWorkerSummary,
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, connect_backend_runtime, BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, connect_backend_runtime,
create_backend_worker, get_backend_worker_launch_options, list_backend_stopped_workers, 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::{ pub use backend_workspace::{
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError, BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
+1
View File
@@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
use workspace_api::WorkspaceWorkerSubject; use workspace_api::WorkspaceWorkerSubject;
pub type ServerApiError = runtime_api::RuntimeApiError; pub type ServerApiError = runtime_api::RuntimeApiError;
pub type ServerApiClientError = client_support::ClientError<ServerApiError>;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct WorkspaceWorkerSessionResponse { pub struct WorkspaceWorkerSessionResponse {
+3 -12
View File
@@ -3,8 +3,8 @@ use std::io;
use std::time::Duration; use std::time::Duration;
use client::{ use client::{
BackendRuntimeListTarget, BackendWorkerSummary, ServerApiClient, WorkerSessionAvailability, BackendRuntimeListTarget, BackendWorkerSummary, WorkerSessionAvailability,
list_backend_stopped_workers, list_backend_workers, list_backend_stopped_workers, list_backend_workers, observe_backend_worker_session,
}; };
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::Frame; use ratatui::Frame;
@@ -83,16 +83,7 @@ pub(crate) async fn run(
let mut attach_target = target let mut attach_target = target
.runtime_target(selected.runtime_id.clone(), selected.worker_id.clone()) .runtime_target(selected.runtime_id.clone(), selected.worker_id.clone())
.map_err(|error| io::Error::other(error.to_string()))?; .map_err(|error| io::Error::other(error.to_string()))?;
let api = ServerApiClient::builder(&target.base_url) let response = observe_backend_worker_session(&attach_target)
.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(),
)
.await .await
.map_err(|error| { .map_err(|error| {
io::Error::other(format!( io::Error::other(format!(
@@ -2,6 +2,7 @@ import {
isCurrentWorkerSessionRequest, isCurrentWorkerSessionRequest,
workerSessionAction, workerSessionAction,
type WorkerSessionRequestIdentity, type WorkerSessionRequestIdentity,
workerSessionRequestInit,
} from "./session-observation"; } from "./session-observation";
import type { SessionSnapshot } from "$lib/generated/protocol"; import type { SessionSnapshot } from "$lib/generated/protocol";
@@ -23,6 +24,19 @@ const snapshot: SessionSnapshot = {
entries: [], 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", () => { Deno.test("worker session observation selects live, retained, and unavailable behavior", () => {
assert( assert(
workerSessionAction({ availability: "live_protocol" }).kind === workerSessionAction({ availability: "live_protocol" }).kind ===
@@ -17,6 +17,14 @@ export interface WorkerSessionRequestIdentity {
workerId: string; workerId: string;
} }
export function workerSessionRequestInit(signal: AbortSignal): RequestInit {
return {
signal,
credentials: "same-origin",
headers: { accept: "application/json" },
};
}
export function workerSessionAction( export function workerSessionAction(
observation: WorkerSessionObservation, observation: WorkerSessionObservation,
): WorkerSessionAction { ): WorkerSessionAction {
@@ -55,6 +55,7 @@
import { import {
isCurrentWorkerSessionRequest, isCurrentWorkerSessionRequest,
workerSessionAction, workerSessionAction,
workerSessionRequestInit,
type WorkerSessionObservation, type WorkerSessionObservation,
type WorkerSessionRequestIdentity, type WorkerSessionRequestIdentity,
} from "$lib/workspace/session-observation"; } from "$lib/workspace/session-observation";
@@ -951,7 +952,7 @@
workerApiPath( workerApiPath(
`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}/session`, `/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}/session`,
), ),
{ signal: controller.signal, headers: { accept: "application/json" } }, workerSessionRequestInit(controller.signal),
) )
.then(async (response) => { .then(async (response) => {
if (!response.ok) { if (!response.ok) {