From f967c1b3920fa7801420de14ef3e997f99996bf1 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 18 Sep 2026 02:31:37 +0900 Subject: [PATCH] Close runtime contract acceptance gaps --- crates/runtime-api/src/lib.rs | 12 +- crates/worker-runtime/src/http_server.rs | 175 ++++++++++++++++++ .../src/http_server/runtime_management_api.rs | 6 +- 3 files changed, 189 insertions(+), 4 deletions(-) diff --git a/crates/runtime-api/src/lib.rs b/crates/runtime-api/src/lib.rs index 91c905ab6..189a1c615 100644 --- a/crates/runtime-api/src/lib.rs +++ b/crates/runtime-api/src/lib.rs @@ -716,6 +716,16 @@ pub const REMAINING_RUNTIME_ROUTES: &[RemainingRuntimeRoute] = &[ path: "/v1/working-directories/{working_directory_id}", reason: "Workdir transport", }, + RemainingRuntimeRoute { + method: "GET", + path: "/v1/protocol/ws", + reason: "WebSocket protocol transport (ws-server feature)", + }, + RemainingRuntimeRoute { + method: "GET", + path: "/v1/workers/{worker_id}/protocol/ws", + reason: "WebSocket protocol transport (ws-server feature)", + }, RemainingRuntimeRoute { method: "POST", path: "/v1/workers/{worker_id}/attachments", @@ -901,7 +911,7 @@ mod tests { #[test] fn remaining_route_inventory_does_not_overlap_contract() { - assert_eq!(REMAINING_RUNTIME_ROUTES.len(), 18); + assert_eq!(REMAINING_RUNTIME_ROUTES.len(), 20); for remaining in REMAINING_RUNTIME_ROUTES { assert!(!RuntimeApiMetadata::OPERATIONS.iter().any(|operation| { format!("{:?}", operation.method).eq_ignore_ascii_case(remaining.method) diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index c6f9d8af0..9f2582417 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -2460,6 +2460,11 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode { { StatusCode::NOT_FOUND } + RuntimeError::InvalidRequest(message) + if message.contains("already created with a different fingerprint") => + { + StatusCode::CONFLICT + } RuntimeError::RuntimeStopped | RuntimeError::RuntimeStoreAlreadyOpen { .. } | RuntimeError::WorkerExecutionUnavailable { .. } @@ -2498,6 +2503,11 @@ fn code_for_runtime_error(error: &RuntimeError) -> String { RuntimeError::WorkerExecutionRejected { .. } => "worker_execution_rejected".to_string(), RuntimeError::WorkspaceOwnerMismatch { .. } => "workspace_owner_mismatch".to_string(), RuntimeError::LimitTooLarge { .. } => "limit_too_large".to_string(), + RuntimeError::InvalidRequest(message) + if message.contains("already created with a different fingerprint") => + { + "worker_create_conflict".to_string() + } RuntimeError::InvalidRequest(_) => "invalid_request".to_string(), RuntimeError::WorkingDirectory(diagnostic) => diagnostic.code.clone(), RuntimeError::InvalidInitialInputKind { .. } => "invalid_initial_input_kind".to_string(), @@ -2552,6 +2562,29 @@ mod tests { use manifest::{Scope, SharedScope}; use sha2::Digest as _; use tower::ServiceExt; + + #[derive(Clone)] + struct TestRuntimeAuthorizer(String); + + impl runtime_api::client_support::RequestAuthorizer for TestRuntimeAuthorizer { + fn authorize( + &self, + _request: runtime_api::client_support::AuthorizerRequest<'_>, + ) -> Result< + runtime_api::client_support::framework::header::HeaderMap, + runtime_api::client_support::AuthorizationError, + > { + let mut headers = runtime_api::client_support::framework::header::HeaderMap::new(); + headers.insert( + runtime_api::client_support::framework::header::AUTHORIZATION, + format!("Bearer {}", self.0) + .parse() + .map_err(|_| runtime_api::client_support::AuthorizationError::new())?, + ); + Ok(headers) + } + } + use workdir::{ GrepOutputMode, GrepRequest, LocalWorkdirSession, StatRequest, Workdir, WorkdirPath, WorkdirSessionCapabilities, @@ -2589,6 +2622,38 @@ mod tests { } } + #[test] + fn remaining_route_inventory_covers_every_handwritten_router_path() { + let inventoried = runtime_api::REMAINING_RUNTIME_ROUTES + .iter() + .map(|route| route.path) + .collect::>(); + for path in [ + WORKSPACE_VERIFICATION_CHALLENGE_PATH, + WORKSPACE_VERIFICATION_ACK_PATH, + "/v1/config-bundles", + "/v1/config-bundles/{bundle_id}/availability", + "/v1/workspace-prompt-projections", + "/v1/working-directories", + "/v1/working-directories/repository-access", + SSH_HOST_KEY_PROBE_PATH, + "/v1/repository-refs/observe", + "/v1/working-directories/{working_directory_id}/sessions", + "/v1/workdir-sessions/{session_id}/operations", + "/v1/workdir-sessions/{session_id}", + "/v1/working-directories/{working_directory_id}", + "/v1/workers/{worker_id}/attachments", + "/v1/workers/{worker_id}/attachments/{artifact_id}", + "/v1/protocol/ws", + "/v1/workers/{worker_id}/protocol/ws", + ] { + assert!( + inventoried.contains(path), + "missing handwritten route inventory for {path}" + ); + } + } + #[tokio::test] async fn workspace_signed_verification_requires_exact_request_and_acknowledges_response() { let runtime = Runtime::new_memory(); @@ -3284,6 +3349,116 @@ mod tests { let malformed: RuntimeHttpErrorResponse = read_json(malformed_response).await; assert_eq!(malformed.error.code, "invalid_json"); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server_app = app.clone(); + let server = tokio::spawn(async move { + axum::serve(listener, server_app).await.unwrap(); + }); + let client = runtime_api::RuntimeApiClient::builder(&format!("http://{address}")) + .unwrap() + .authorizer(TestRuntimeAuthorizer(token.to_string())) + .build() + .unwrap(); + let generated_request: runtime_api::CreateWorkerRequest = serde_json::from_value( + serde_json::to_value(task_request("generated-roundtrip")).unwrap(), + ) + .unwrap(); + let generated_worker_id = generated_request.worker_id.to_string(); + let mut conflicting_request = generated_request.clone(); + conflicting_request.create_fingerprint = "different-fingerprint".to_string(); + let generated = client.create_worker(generated_request).await.unwrap(); + assert_eq!(generated.worker.worker_id.to_string(), generated_worker_id); + let generated_input: runtime_api::WorkerInput = serde_json::from_value( + serde_json::to_value(WorkerInput::user("generated client input")).unwrap(), + ) + .unwrap(); + client + .send_worker_input(generated_worker_id.clone(), generated_input) + .await + .unwrap(); + client + .stop_worker( + generated_worker_id.clone(), + runtime_api::WorkerLifecycleRequest::default(), + ) + .await + .unwrap(); + client + .restore_worker( + generated_worker_id.clone(), + runtime_api::EmptyObjectRequest::default(), + ) + .await + .unwrap(); + let conflict = client.create_worker(conflicting_request).await.unwrap_err(); + assert!( + matches!( + &conflict, + runtime_api::client_support::ClientError::Public { status, .. } + if *status == StatusCode::CONFLICT + ), + "{conflict:?}" + ); + client + .stop_worker( + generated_worker_id.clone(), + runtime_api::WorkerLifecycleRequest::default(), + ) + .await + .unwrap(); + client + .delete_worker(generated_worker_id.clone()) + .await + .unwrap(); + let missing = client + .get_worker(generated_worker_id.clone()) + .await + .unwrap_err(); + assert!(matches!( + missing, + runtime_api::client_support::ClientError::Public { status, .. } + if status == StatusCode::NOT_FOUND + )); + let unauthorized = runtime_api::RuntimeApiClient::builder(&format!("http://{address}")) + .unwrap() + .build() + .unwrap() + .list_workers(runtime_api::WorkerListQuery::default()) + .await + .unwrap_err(); + assert!(matches!( + unauthorized, + runtime_api::client_support::ClientError::Public { status, .. } + if status == StatusCode::UNAUTHORIZED + )); + let forbidden_response = app + .clone() + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/v1/ping") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(forbidden_response.status(), StatusCode::FORBIDDEN); + let bounded_client = runtime_api::RuntimeApiClient::builder(&format!("http://{address}")) + .unwrap() + .authorizer(TestRuntimeAuthorizer(token.to_string())) + .response_body_limit(1) + .build() + .unwrap(); + assert!(matches!( + bounded_client.runtime_summary().await.unwrap_err(), + runtime_api::client_support::ClientError::Failure( + runtime_api::client_support::ClientFailure::ResponseTooLarge { .. } + ) + )); + server.abort(); + let response = authed_json_request( app.clone(), Method::POST, diff --git a/crates/worker-runtime/src/http_server/runtime_management_api.rs b/crates/worker-runtime/src/http_server/runtime_management_api.rs index 969a2119f..0e92c43bc 100644 --- a/crates/worker-runtime/src/http_server/runtime_management_api.rs +++ b/crates/worker-runtime/src/http_server/runtime_management_api.rs @@ -449,9 +449,9 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi { let request: WorkerRetentionExecutionRequest = request(value)?; if request.workspace_id != scope.workspace_id { return Err(runtime_api::RuntimeApiError::new( - StatusCode::FORBIDDEN.as_u16(), - "workspace_scope_mismatch", - "Worker retention request workspace does not match the authenticated workspace scope", + StatusCode::NOT_FOUND.as_u16(), + "worker_not_found", + "worker does not belong to the authenticated Workspace", )); } if request.worker_id != worker_ref.worker_id {