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 {
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<server_api::ServerApiClientError> for BackendRuntimeClientError {
fn from(value: server_api::ServerApiClientError) -> Self {
Self::SessionApi(value)
}
}
impl From<BackendApiClientError> for BackendRuntimeClientError {
fn from(error: BackendApiClientError) -> Self {
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(
target: &BackendWorkerLaunchTarget,
) -> Result<BackendWorkerLaunchOptions, BackendRuntimeClientError> {
@@ -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!({
+1 -1
View File
@@ -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,
+1
View File
@@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
use workspace_api::WorkspaceWorkerSubject;
pub type ServerApiError = runtime_api::RuntimeApiError;
pub type ServerApiClientError = client_support::ClientError<ServerApiError>;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct WorkspaceWorkerSessionResponse {
+3 -12
View File
@@ -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!(
@@ -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 ===
@@ -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 {
@@ -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) {