From 9fc5d13a3e834c7a7cce3437e63f09d9da70a61c Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Sun, 16 Aug 2026 21:50:17 +0100 Subject: [PATCH 1/2] feat(supervisor): expose sandbox name to middleware request context Signed-off-by: Artem Lytvyn --- .../src/lib.rs | 9 +++- .../src/l7/middleware.rs | 52 ++++++++++++++++++- .../src/l7/relay.rs | 1 + docs/extensibility/supervisor-middleware.mdx | 2 + proto/supervisor_middleware.proto | 2 + 5 files changed, 63 insertions(+), 3 deletions(-) diff --git a/crates/openshell-supervisor-middleware/src/lib.rs b/crates/openshell-supervisor-middleware/src/lib.rs index 0125f8e5a2..7efa513f5d 100644 --- a/crates/openshell-supervisor-middleware/src/lib.rs +++ b/crates/openshell-supervisor-middleware/src/lib.rs @@ -200,6 +200,7 @@ pub enum TransformedBodyPolicy<'a> { pub struct HttpRequestInput { pub request_id: String, pub sandbox_id: String, + pub sandbox_name: String, pub scheme: String, pub host: String, pub port: u16, @@ -1087,6 +1088,7 @@ impl ChainRunner { let HttpRequestInput { request_id, sandbox_id, + sandbox_name, scheme, host, port, @@ -1103,6 +1105,7 @@ impl ChainRunner { let context = RequestContext { request_id, sandbox_id, + sandbox_name, originating_process: None, }; let target = HttpRequestTarget { @@ -1505,7 +1508,8 @@ mod tests { fn input(body: &str) -> HttpRequestInput { HttpRequestInput { request_id: "req".into(), - sandbox_id: "sbx".into(), + sandbox_id: "sbx-id".into(), + sandbox_name: "sbx-name".into(), scheme: "https".into(), host: "api.example.com".into(), port: 443, @@ -2615,7 +2619,8 @@ mod tests { assert_eq!(received[0].config.as_ref(), Some(&evaluation_config)); let context = received[0].context.as_ref().expect("request context"); assert_eq!(context.request_id, "req"); - assert_eq!(context.sandbox_id, "sbx"); + assert_eq!(context.sandbox_id, "sbx-id"); + assert_eq!(context.sandbox_name, "sbx-name"); assert!(context.originating_process.is_none()); let target = received[0].target.as_ref().expect("request target"); assert_eq!(target.scheme, "https"); diff --git a/crates/openshell-supervisor-network/src/l7/middleware.rs b/crates/openshell-supervisor-network/src/l7/middleware.rs index 28f91c3bb7..57e8c5bd5f 100644 --- a/crates/openshell-supervisor-network/src/l7/middleware.rs +++ b/crates/openshell-supervisor-network/src/l7/middleware.rs @@ -145,6 +145,7 @@ pub async fn apply_middleware_chain_for_scheme openshell_supervisor_middleware::HttpRequestInput { openshell_supervisor_middleware::HttpRequestInput { request_id: uuid::Uuid::new_v4().to_string(), - sandbox_id: openshell_ocsf::ctx::ctx().sandbox_id.clone(), + sandbox_id: sandbox.sandbox_id.clone(), + sandbox_name: sandbox.sandbox_name.clone(), scheme: scheme.into(), host: ctx.host.clone(), port: ctx.port, @@ -594,6 +599,51 @@ mod tests { assert!(!body.to_string().contains("secret-value")); } + #[test] + fn middleware_input_carries_real_sandbox_name() { + let sandbox = openshell_ocsf::SandboxContext { + sandbox_id: "sbx-123".into(), + sandbox_name: "nightly-build".into(), + container_image: String::new(), + hostname: "h".into(), + product_version: "0".into(), + proxy_ip: [127, 0, 0, 1].into(), + proxy_port: 3128, + }; + + let eval = L7EvalContext { + host: "api.example.test".into(), + port: 443, + policy_name: "api-policy".into(), + binary_path: "/usr/bin/curl".into(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + secret_resolver: None, + ..Default::default() + }; + let req = crate::l7::provider::L7Request { + action: "POST".into(), + target: "/v1/messages".into(), + query_params: std::collections::HashMap::new(), + raw_header: Vec::new(), + body_length: crate::l7::provider::BodyLength::None, + }; + + let input = super::middleware_request_input( + &sandbox, + "https", + &req, + &eval, + Vec::new(), + Vec::new(), + String::new(), + Vec::new(), + ); + + assert_eq!(input.sandbox_name, "nightly-build"); + assert_eq!(input.sandbox_id, "sbx-123"); + } + #[tokio::test] async fn middleware_failure_uses_platform_response_without_policy_guidance() { let ctx = L7EvalContext { diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index aae3c042b9..84839f9536 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -5065,6 +5065,7 @@ network_policies: }; let input = middleware_request_input( + openshell_ocsf::ctx::ctx(), "http", &req, &ctx, diff --git a/docs/extensibility/supervisor-middleware.mdx b/docs/extensibility/supervisor-middleware.mdx index f3bdcac9bb..9525e09e50 100644 --- a/docs/extensibility/supervisor-middleware.mdx +++ b/docs/extensibility/supervisor-middleware.mdx @@ -28,6 +28,8 @@ If post-transformation policy evaluation itself fails, OpenShell denies the requ Middleware receives the request before credential injection. Operator-run services cannot inspect OpenShell-managed credentials. Middleware-visible request headers are delivered in wire order and repeated header names are preserved as separate entries. OpenShell filters credential, routing, framing, and hop-by-hop headers before invoking middleware. It rejects malformed request headers and unsupported transfer-coding sequences before middleware or policy dispatch. Headers named by a request's `Connection` field are omitted from middleware input and removed before forwarding, except for the validated WebSocket upgrade pair. +The request context identifies the originating sandbox to operator-run services. It carries the sandbox ID (`sandbox_id`) and the sandbox name (`sandbox_name`), letting audit and approval interfaces show a human-readable name instead of an opaque ID. The name is best-effort: a supervisor that cannot resolve it, or an older supervisor that predates the field, sends an empty string. Services should fall back to the sandbox ID when the name is empty. + ## Choose a Middleware Type | Type | Registration | Body limit | Deployment | diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index dbde411c9f..1325c2e0a5 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -120,6 +120,8 @@ message RequestContext { string sandbox_id = 2; // Workload process that originated the request, when available. Process originating_process = 3; + // Sandbox name that originated the request. + string sandbox_name = 4; } // HttpRequestTarget describes the admitted HTTP destination and request target. From 803dc62b5bfd6c57e11bdf5b6891456e1611b1bf Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Thu, 20 Aug 2026 12:06:16 +0100 Subject: [PATCH 2/2] feat(supervisor): add workspace to middleware request context Signed-off-by: Artem Lytvyn --- .../src/lib.rs | 12 +++ .../src/websocket.rs | 2 + .../src/l7/middleware.rs | 3 + .../src/l7/relay.rs | 90 ++++++++++++++---- .../src/l7/websocket.rs | 93 ++++++++++++++++++- .../openshell-supervisor-network/src/opa.rs | 1 + .../src/policy_local.rs | 4 + .../openshell-supervisor-network/src/proxy.rs | 10 ++ .../src/proxy/relay.rs | 3 + docs/extensibility/supervisor-middleware.mdx | 2 +- proto/supervisor_middleware.proto | 8 +- 11 files changed, 209 insertions(+), 19 deletions(-) diff --git a/crates/openshell-supervisor-middleware/src/lib.rs b/crates/openshell-supervisor-middleware/src/lib.rs index 5351bbdd96..902b5165c2 100644 --- a/crates/openshell-supervisor-middleware/src/lib.rs +++ b/crates/openshell-supervisor-middleware/src/lib.rs @@ -438,6 +438,7 @@ pub struct HttpRequestInput { pub request_id: String, pub sandbox_id: String, pub sandbox_name: String, + pub workspace: String, pub scheme: String, pub host: String, pub port: u16, @@ -1583,6 +1584,7 @@ impl ChainRunner { request_id, sandbox_id, sandbox_name, + workspace, scheme, host, port, @@ -1600,6 +1602,7 @@ impl ChainRunner { request_id, sandbox_id, sandbox_name, + workspace, originating_process: None, }; let target = HttpRequestTarget { @@ -2052,6 +2055,7 @@ mod tests { request_id: "req".into(), sandbox_id: "sbx-id".into(), sandbox_name: "sbx-name".into(), + workspace: "wrks-default".into(), scheme: "https".into(), host: "api.example.com".into(), port: 443, @@ -3221,6 +3225,7 @@ mod tests { assert_eq!(context.request_id, "req"); assert_eq!(context.sandbox_id, "sbx-id"); assert_eq!(context.sandbox_name, "sbx-name"); + assert_eq!(context.workspace, "wrks-default"); assert!(context.originating_process.is_none()); let target = received[0].target.as_ref().expect("request target"); assert_eq!(target.scheme, "https"); @@ -4684,6 +4689,7 @@ mod tests { request_id: "request".into(), sandbox_id: "sandbox".into(), sandbox_name: "sandbox-name".into(), + workspace: "wrks-default".into(), scheme: "wss".into(), host: "api.openai.com".into(), port: 443, @@ -5118,6 +5124,7 @@ mod tests { request_id: "request".into(), sandbox_id: "sandbox".into(), sandbox_name: "sandbox-name".into(), + workspace: "wrks-default".into(), scheme: "wss".into(), host: "api.openai.com".into(), port: 443, @@ -5185,6 +5192,7 @@ mod tests { request_id: "request".into(), sandbox_id: "sandbox".into(), sandbox_name: "sandbox-name".into(), + workspace: "wrks-default".into(), scheme: "wss".into(), host: "api.openai.com".into(), port: 443, @@ -5248,6 +5256,7 @@ mod tests { request_id: "request".into(), sandbox_id: "sandbox".into(), sandbox_name: "sandbox-name".into(), + workspace: "wrks-default".into(), scheme: "wss".into(), host: "api.openai.com".into(), port: 443, @@ -5320,6 +5329,7 @@ mod tests { request_id: "request".into(), sandbox_id: "sandbox".into(), sandbox_name: "sandbox-name".into(), + workspace: "wrks-default".into(), scheme: "wss".into(), host: "api.openai.com".into(), port: 443, @@ -5405,6 +5415,7 @@ mod tests { request_id: "request".into(), sandbox_id: "sandbox".into(), sandbox_name: "sandbox-name".into(), + workspace: "wrks-default".into(), scheme: "ws".into(), host: "api.openai.com".into(), port: 80, @@ -5701,6 +5712,7 @@ mod tests { request_id: "request".into(), sandbox_id: "sandbox".into(), sandbox_name: "sandbox-name".into(), + workspace: "wrks-default".into(), scheme: "wss".into(), host: "api.openai.com".into(), port: 443, diff --git a/crates/openshell-supervisor-middleware/src/websocket.rs b/crates/openshell-supervisor-middleware/src/websocket.rs index 48891dd3fa..1fd95021a8 100644 --- a/crates/openshell-supervisor-middleware/src/websocket.rs +++ b/crates/openshell-supervisor-middleware/src/websocket.rs @@ -39,6 +39,7 @@ pub struct WebSocketPreflightInput { pub request_id: String, pub sandbox_id: String, pub sandbox_name: String, + pub workspace: String, pub scheme: String, pub host: String, pub port: u16, @@ -922,6 +923,7 @@ async fn open_stage(entry: DescribedChainEntry, input: WebSocketPreflightInput) request_id: input.request_id, sandbox_id: input.sandbox_id, sandbox_name: input.sandbox_name, + workspace: input.workspace, originating_process: None, }), target: Some(HttpRequestTarget { diff --git a/crates/openshell-supervisor-network/src/l7/middleware.rs b/crates/openshell-supervisor-network/src/l7/middleware.rs index 56f518fbf6..6305653f6a 100644 --- a/crates/openshell-supervisor-network/src/l7/middleware.rs +++ b/crates/openshell-supervisor-network/src/l7/middleware.rs @@ -624,6 +624,7 @@ pub(super) fn middleware_request_input( request_id: uuid::Uuid::new_v4().to_string(), sandbox_id: sandbox.sandbox_id.clone(), sandbox_name: sandbox.sandbox_name.clone(), + workspace: ctx.workspace.clone(), scheme: scheme.into(), host: ctx.host.clone(), port: ctx.port, @@ -1099,6 +1100,7 @@ mod tests { let eval = L7EvalContext { host: "api.example.test".into(), port: 443, + workspace: "wrks-default".into(), policy_name: "api-policy".into(), binary_path: "/usr/bin/curl".into(), ancestors: Vec::new(), @@ -1127,6 +1129,7 @@ mod tests { assert_eq!(input.sandbox_name, "nightly-build"); assert_eq!(input.sandbox_id, "sbx-123"); + assert_eq!(input.workspace, "wrks-default"); } #[tokio::test] diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 46a4b370bf..c9cbcb9bd0 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -42,6 +42,8 @@ pub struct L7EvalContext { pub host: String, /// Port from the CONNECT request. pub port: u16, + /// Workspace the sandbox belongs to, learned from `GetSandboxConfigResponse`. + pub workspace: String, /// Default authority port for the inspected HTTP transport (80 for /// plaintext, 443 after TLS termination). pub(crate) request_default_port: Option, @@ -983,22 +985,39 @@ pub(crate) async fn websocket_middleware_preflight( .map_or(req.raw_header.len(), |position| position + 4); let requested_subprotocols = crate::l7::rest::websocket_requested_subprotocols(&req.raw_header[..header_end])?; - runner - .preflight_websocket( - chain, - openshell_supervisor_middleware::WebSocketPreflightInput { - session_id: uuid::Uuid::new_v4().to_string(), - request_id: uuid::Uuid::new_v4().to_string(), - sandbox_id: openshell_ocsf::ctx::ctx().sandbox_id.clone(), - sandbox_name: openshell_ocsf::ctx::ctx().sandbox_name.clone(), - scheme: scheme.to_string(), - host: ctx.host.clone(), - port: ctx.port, - path: req.target.clone(), - requested_subprotocols, - }, - ) - .await + let input = websocket_preflight_input( + openshell_ocsf::ctx::ctx(), + ctx, + req, + scheme, + requested_subprotocols, + ); + runner.preflight_websocket(chain, input).await +} + +/// Build the WebSocket preflight input from the sandbox and evaluation +/// contexts. Kept separate from `websocket_middleware_preflight` (and taking an +/// explicit `SandboxContext`) so the identifier copy is unit-testable with a +/// real sandbox name, mirroring `middleware_request_input` on the HTTP path. +fn websocket_preflight_input( + sandbox: &openshell_ocsf::SandboxContext, + ctx: &L7EvalContext, + req: &crate::l7::provider::L7Request, + scheme: &str, + requested_subprotocols: Vec, +) -> openshell_supervisor_middleware::WebSocketPreflightInput { + openshell_supervisor_middleware::WebSocketPreflightInput { + session_id: uuid::Uuid::new_v4().to_string(), + request_id: uuid::Uuid::new_v4().to_string(), + sandbox_id: sandbox.sandbox_id.clone(), + sandbox_name: sandbox.sandbox_name.clone(), + workspace: ctx.workspace.clone(), + scheme: scheme.to_string(), + host: ctx.host.clone(), + port: ctx.port, + path: req.target.clone(), + requested_subprotocols, + } } /// Handle an upgraded connection (101 Switching Protocols). @@ -2833,6 +2852,45 @@ mod tests { (state, resolver) } + #[test] + fn websocket_preflight_input_carries_real_sandbox_name() { + let sandbox = openshell_ocsf::SandboxContext { + sandbox_id: "sbx-123".into(), + sandbox_name: "nightly-build".into(), + container_image: String::new(), + hostname: "h".into(), + product_version: "0".into(), + proxy_ip: [127, 0, 0, 1].into(), + proxy_port: 3128, + }; + + let eval = L7EvalContext { + host: "api.example.test".into(), + port: 443, + workspace: "team-a".into(), + policy_name: "api-policy".into(), + binary_path: "/usr/bin/curl".into(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + secret_resolver: None, + ..Default::default() + }; + let req = crate::l7::provider::L7Request { + action: "GET".into(), + target: "/v1/stream".into(), + query_params: std::collections::HashMap::new(), + raw_header: Vec::new(), + body_length: crate::l7::provider::BodyLength::None, + }; + + let input = + websocket_preflight_input(&sandbox, &eval, &req, "wss", vec!["chat".to_string()]); + + assert_eq!(input.sandbox_id, "sbx-123"); + assert_eq!(input.sandbox_name, "nightly-build"); + assert_eq!(input.workspace, "team-a"); + } + #[test] fn scoped_context_captures_endpoint_resolver_and_revision_together() { let state = ProviderCredentialState::from_bound_environment( diff --git a/crates/openshell-supervisor-network/src/l7/websocket.rs b/crates/openshell-supervisor-network/src/l7/websocket.rs index bdb54a9bc0..bd83ac0731 100644 --- a/crates/openshell-supervisor-network/src/l7/websocket.rs +++ b/crates/openshell-supervisor-network/src/l7/websocket.rs @@ -3367,6 +3367,10 @@ network_policies: close_on_first_message: bool, message_received: Option>, release_message: Option>, + // Opt-in capture of the preflight request context's workspace. Kept + // separate from `observed` so it does not perturb the session-event + // ordering the other tests assert on. + preflight_observed: Option>, } #[tonic::async_trait] @@ -3433,11 +3437,20 @@ network_policies: let close_on_first_message = self.close_on_first_message; let message_received = self.message_received.clone(); let release_message = self.release_message.clone(); + let preflight_observed = self.preflight_observed.clone(); let (responses_tx, responses_rx) = tokio::sync::mpsc::channel(4); tokio::spawn(async move { while let Ok(Some(request)) = requests.message().await { let response = match request.event { - Some(web_socket_session_event::Event::Preflight(_)) => { + Some(web_socket_session_event::Event::Preflight(preflight)) => { + if let Some(preflight_observed) = &preflight_observed { + let workspace = preflight + .context + .as_ref() + .map(|context| context.workspace.clone()) + .unwrap_or_default(); + let _ = preflight_observed.send(workspace); + } Some(WebSocketSessionEventResult { result: Some( web_socket_session_event_result::Result::PreflightDecision( @@ -3599,6 +3612,7 @@ network_policies: request_id: "request".into(), sandbox_id: "sandbox".into(), sandbox_name: "sandbox-name".into(), + workspace: "workspace".into(), scheme: scheme.into(), host: "api.openai.com".into(), port: if scheme == "wss" { 443 } else { 80 }, @@ -3617,6 +3631,80 @@ network_policies: ) } + // Companion to the HTTP-path assertion in openshell-supervisor-middleware: + // proves the WebSocket preflight forwards the request context's workspace to + // the middleware service. + #[tokio::test] + async fn websocket_preflight_forwards_workspace_to_middleware() { + use openshell_core::proto::SupervisorMiddlewareService; + use openshell_supervisor_middleware::{ChainEntry, MiddlewareRegistry, OnError}; + + let (preflight_tx, mut preflight_rx) = tokio::sync::mpsc::unbounded_channel(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind WebSocket middleware"); + let address = listener.local_addr().expect("middleware address"); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = tonic::transport::Server::builder() + .add_service(SupervisorMiddlewareServer::new(OpenAiWebSocketRedactor { + preflight_observed: Some(preflight_tx), + ..Default::default() + })) + .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { + let _ = shutdown_rx.await; + }); + let server_task = tokio::spawn(server); + let registry = MiddlewareRegistry::connect_services( + Vec::new(), + vec![SupervisorMiddlewareService { + name: "openai-redactor".into(), + grpc_endpoint: format!("http://{address}"), + max_payload_bytes: openshell_supervisor_middleware::MAX_MIDDLEWARE_PAYLOAD_BYTES + as u64, + timeout: "2s".into(), + tls_ca_cert_pem: Vec::new(), + audience: String::new(), + allow_insecure_transport: false, + }], + ) + .await + .expect("connect middleware"); + let runner = openshell_supervisor_middleware::ChainRunner::from_registry(registry); + runner + .preflight_websocket( + &[ChainEntry { + name: "redact-openai".into(), + implementation: "openai-redactor".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }], + openshell_supervisor_middleware::WebSocketPreflightInput { + session_id: "session".into(), + request_id: "request".into(), + sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "team-a".into(), + scheme: "wss".into(), + host: "api.openai.com".into(), + port: 443, + path: "/v1/responses".into(), + requested_subprotocols: Vec::new(), + }, + ) + .await + .expect("preflight"); + + assert_eq!( + preflight_rx.recv().await, + Some("team-a".to_string()), + "WebSocket preflight must forward the workspace to the middleware" + ); + + let _ = shutdown_tx.send(()); + let _ = server_task.await; + } + async fn assert_invalid_close_termination(payload: Vec, expected_close_code: u16) { let (mut session, mut observed, shutdown_tx, server_task) = recording_middleware_session("wss").await; @@ -4484,6 +4572,7 @@ network_policies: request_id: "request".into(), sandbox_id: "sandbox".into(), sandbox_name: "sandbox-name".into(), + workspace: "workspace".into(), scheme: "wss".into(), host: "api.openai.com".into(), port: 443, @@ -4632,6 +4721,7 @@ network_policies: request_id: "request".into(), sandbox_id: "sandbox".into(), sandbox_name: "sandbox-name".into(), + workspace: "workspace".into(), scheme: "wss".into(), host: "api.openai.com".into(), port: 443, @@ -4752,6 +4842,7 @@ network_policies: request_id: "request".into(), sandbox_id: "sandbox".into(), sandbox_name: "sandbox-name".into(), + workspace: "workspace".into(), scheme: "wss".into(), host: "api.openai.com".into(), port: 443, diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index f3278d60f1..b169d476b3 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -7124,6 +7124,7 @@ network_policies: request_id: "request".into(), sandbox_id: "sandbox".into(), sandbox_name: "sandbox-name".into(), + workspace: "workspace".into(), scheme: "wss".into(), host: "api.openai.com".into(), port: 443, diff --git a/crates/openshell-supervisor-network/src/policy_local.rs b/crates/openshell-supervisor-network/src/policy_local.rs index 13744a63f9..d3757ae494 100644 --- a/crates/openshell-supervisor-network/src/policy_local.rs +++ b/crates/openshell-supervisor-network/src/policy_local.rs @@ -133,6 +133,10 @@ impl PolicyLocalContext { *self.current_policy.write().await = Some(policy); } + pub fn workspace(&self) -> String { + self.workspace_rx.borrow().clone() + } + #[must_use] pub fn agent_proposals(&self) -> AgentProposals { self.agent_proposals.clone() diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index b4da286bf8..542bf46ed5 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -1541,6 +1541,10 @@ async fn handle_tcp_connection( // gate needs it) and drives the raw-tunnel branch below. // Build request-processing context shared by CONNECT and forward HTTP. + let workspace = policy_local_ctx + .as_ref() + .map(|ctx| ctx.workspace()) + .unwrap_or_default(); let mut ctx = relay::http_context( &decision, provider_credentials, @@ -1548,6 +1552,7 @@ async fn handle_tcp_connection( activity_tx.clone(), dynamic_credentials.clone(), agent_proposals, + workspace, ); if effective_tls_skip { @@ -4339,6 +4344,10 @@ async fn handle_forward_proxy( .map_or_else(std::collections::HashMap::new, |query| { crate::l7::rest::parse_query_params(query).unwrap_or_default() }); + let workspace = policy_local_ctx + .as_ref() + .map(|ctx| ctx.workspace()) + .unwrap_or_default(); let mut l7_ctx = relay::http_context( &decision, provider_credentials, @@ -4346,6 +4355,7 @@ async fn handle_forward_proxy( activity_tx.cloned(), dynamic_credentials.clone(), agent_proposals, + workspace, ); l7_ctx.request_default_port = match scheme.as_str() { "http" => Some(80), diff --git a/crates/openshell-supervisor-network/src/proxy/relay.rs b/crates/openshell-supervisor-network/src/proxy/relay.rs index 314ab53312..bc8f5f39d2 100644 --- a/crates/openshell-supervisor-network/src/proxy/relay.rs +++ b/crates/openshell-supervisor-network/src/proxy/relay.rs @@ -45,6 +45,7 @@ pub(super) fn http_context( activity_tx: Option, dynamic_credentials: Option, agent_proposals: openshell_core::proposals::AgentProposals, + workspace: String, ) -> L7EvalContext { // Provider-backed credentials must be acquired from the live state for // each request after middleware/token-grant awaits. Keep only the legacy @@ -87,6 +88,7 @@ pub(super) fn http_context( .as_ref() .map(|_| crate::l7::token_grant_injection::default_resolver()), agent_proposals, + workspace, } } @@ -356,6 +358,7 @@ mod tests { dynamic_credentials: None, token_grant_resolver: None, agent_proposals: openshell_core::proposals::AgentProposals::default(), + workspace: String::new(), } } diff --git a/docs/extensibility/supervisor-middleware.mdx b/docs/extensibility/supervisor-middleware.mdx index db529a1474..978c21e0bf 100644 --- a/docs/extensibility/supervisor-middleware.mdx +++ b/docs/extensibility/supervisor-middleware.mdx @@ -41,7 +41,7 @@ If post-transformation policy evaluation itself fails, OpenShell denies the requ Middleware receives the request before credential injection. Operator-run services cannot inspect OpenShell-managed credentials. Middleware-visible request headers are delivered in wire order and repeated header names are preserved as separate entries. OpenShell filters credential, routing, framing, and hop-by-hop headers before invoking middleware. It rejects malformed request headers and unsupported transfer-coding sequences before middleware or policy dispatch. Headers named by a request's `Connection` field are omitted from middleware input and removed before forwarding, except for the validated WebSocket upgrade pair. -The request context identifies the originating sandbox to operator-run services. It carries the sandbox ID (`sandbox_id`) and the sandbox name (`sandbox_name`), letting audit and approval interfaces show a human-readable name instead of an opaque ID. The name is best-effort: a supervisor that cannot resolve it, or an older supervisor that predates the field, sends an empty string. Services should fall back to the sandbox ID when the name is empty. +The request context identifies the originating sandbox to operator-run services. It carries the sandbox ID (`sandbox_id`), the sandbox name (`sandbox_name`), and the workspace (`workspace`), letting audit and approval interfaces show a human-readable name and its workspace instead of an opaque ID. `sandbox_name` and `workspace` are for display and logging only: names are workspace-scoped and may be reused for different sandbox instances, so services must use `sandbox_id` for authorization, persistence, durable correlation, and identity. All three are best-effort: a supervisor that cannot resolve a value, or an older supervisor that predates a field, sends an empty string. Services should fall back to the sandbox ID when the name or workspace is empty. ## Choose a Middleware Type diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 61ea442475..27fd804bdf 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -280,8 +280,14 @@ message RequestContext { string sandbox_id = 2; // Workload process that originated the request, when available. Process originating_process = 3; - // Sandbox name that originated the request. + // Sandbox name that originated the request. For display and logging only. + // Names are workspace-scoped and may be reused for different sandbox + // instances, so consumers must use sandbox_id for authorization, persistence, + // durable correlation, and identity. string sandbox_name = 4; + // Workspace the sandbox belongs to. For display and logging only; see the + // sandbox_name guidance above. + string workspace = 5; } // HttpRequestTarget describes the admitted HTTP destination and request target.