diff --git a/crates/sidecar/src/execution.rs b/crates/sidecar/src/execution.rs index 29a1a4087..3e5aa0b3c 100644 --- a/crates/sidecar/src/execution.rs +++ b/crates/sidecar/src/execution.rs @@ -14,8 +14,9 @@ use crate::protocol::{ KillProcessRequest, ListenerSnapshotResponse, OwnershipScope, ProcessExitedEvent, ProcessKilledResponse, ProcessOutputEvent, ProcessSnapshotEntry, ProcessSnapshotResponse, ProcessSnapshotStatus, ProcessStartedResponse, RequestFrame, ResponsePayload, - SidecarRequestPayload, SignalDispositionAction, SignalHandlerRegistration, SignalStateResponse, - SocketStateEntry, StdinClosedResponse, StdinWrittenResponse, StreamChannel, WasmPermissionTier, + SidecarRequestPayload, SidecarResponsePayload, SignalDispositionAction, + SignalHandlerRegistration, SignalStateResponse, SocketStateEntry, StdinClosedResponse, + StdinWrittenResponse, StreamChannel, TransformHttpRequest, WasmPermissionTier, WriteStdinRequest, ZombieTimerCountResponse, }; use crate::service::{ @@ -5323,6 +5324,17 @@ where let resource_limits = vm.kernel.resource_limits().clone(); let network_counts = vm_network_resource_counts(vm); let socket_paths = build_javascript_socket_path_context(vm)?; + let enable_transform = vm.configuration.enable_http_request_transform; + let ownership = OwnershipScope::vm( + vm.connection_id.clone(), + vm.session_id.clone(), + vm_id.to_owned(), + ); + let http_transform = if enable_transform { + Some((&self.sidecar_requests, &ownership)) + } else { + None + }; let root = vm .active_processes .get_mut(process_id) @@ -5349,6 +5361,7 @@ where &request, &resource_limits, network_counts, + http_transform, ) }; @@ -10256,6 +10269,7 @@ pub(crate) fn service_javascript_sync_rpc( request: &JavascriptSyncRpcRequest, resource_limits: &ResourceLimits, network_counts: NetworkResourceCounts, + http_transform: Option<(&SharedSidecarRequestClient, &OwnershipScope)>, ) -> Result where B: NativeSidecarBridge + Send + 'static, @@ -10302,6 +10316,7 @@ where request, resource_limits, network_counts, + http_transform, ), "net.http2_server_listen" | "net.http2_server_poll" @@ -10365,6 +10380,7 @@ where request, resource_limits, network_counts, + http_transform, ), "dgram.createSocket" | "dgram.bind" @@ -12682,6 +12698,7 @@ where &request, resource_limits, network_counts, + None, ); match response { Ok(result) => process @@ -15345,6 +15362,7 @@ pub(crate) fn service_javascript_net_sync_rpc( request: &JavascriptSyncRpcRequest, resource_limits: &ResourceLimits, network_counts: NetworkResourceCounts, + http_transform: Option<(&SharedSidecarRequestClient, &OwnershipScope)>, ) -> Result where B: NativeSidecarBridge + Send + 'static, @@ -15352,7 +15370,7 @@ where { match request.method.as_str() { "net.http_request" => { - let (url, options, headers) = parse_http_request_options(request)?; + let (mut url, mut options, mut headers) = parse_http_request_options(request)?; let host = url.host_str().ok_or_else(|| { SidecarError::Execution(String::from("ERR_INVALID_URL: missing host")) })?; @@ -15403,6 +15421,79 @@ where } } + if let Some((sidecar_requests, ownership)) = &http_transform { + let header_json = json!(headers.normalized); + let transform_request = TransformHttpRequest { + request_id: format!("http-{}", request.id), + url: url.to_string(), + method: options.method.clone().unwrap_or_else(|| String::from("GET")), + headers: header_json, + body: options.body.clone(), + }; + let transform_response = sidecar_requests.invoke( + (*ownership).clone(), + SidecarRequestPayload::TransformHttpRequest(transform_request), + Duration::from_secs(10), + ); + match transform_response { + Ok(SidecarResponsePayload::TransformHttpResult(result)) => { + if let Some(error) = result.error { + return Err(SidecarError::Execution(format!( + "ERR_HTTP_TRANSFORM_FAILED: {error}" + ))); + } + if let Some(new_url) = result.url { + url = Url::parse(&new_url).map_err(|error| { + SidecarError::Execution(format!( + "ERR_HTTP_TRANSFORM_INVALID_URL: {error}" + )) + })?; + } + if let Some(new_method) = result.method { + options.method = Some(new_method); + } + if let Some(new_headers) = result.headers { + if let Some(map) = new_headers.as_object() { + let mut normalized = BTreeMap::new(); + let mut raw_pairs = Vec::new(); + for (key, value) in map { + let values: Vec = match value { + Value::Array(arr) => arr + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(), + Value::String(s) => vec![s.clone()], + _ => continue, + }; + for v in &values { + raw_pairs.push((key.clone(), v.clone())); + } + normalized.insert(key.clone(), values); + } + headers = HttpHeaderCollection { + normalized, + raw_pairs, + }; + } + } + if result.body.is_some() { + options.body = result.body; + } + } + Ok(unexpected) => { + return Err(SidecarError::Execution(format!( + "ERR_HTTP_TRANSFORM_FAILED: unexpected response type: {}", + unexpected.kind_name() + ))); + } + Err(error) => { + return Err(SidecarError::Execution(format!( + "ERR_HTTP_TRANSFORM_FAILED: {error}" + ))); + } + } + } + issue_outbound_http_request(&url, &options, &headers) } "net.http_listen" => { diff --git a/crates/sidecar/src/protocol.rs b/crates/sidecar/src/protocol.rs index 48400f940..ec45a12c2 100644 --- a/crates/sidecar/src/protocol.rs +++ b/crates/sidecar/src/protocol.rs @@ -542,6 +542,7 @@ pub enum SidecarRequestPayload { ToolInvocation(ToolInvocationRequest), PermissionRequest(SidecarPermissionRequest), JsBridgeCall(JsBridgeCallRequest), + TransformHttpRequest(TransformHttpRequest), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -549,6 +550,7 @@ pub enum SidecarResponsePayload { ToolInvocationResult(ToolInvocationResultResponse), PermissionRequestResult(SidecarPermissionResultResponse), JsBridgeResult(JsBridgeResultResponse), + TransformHttpResult(TransformHttpResultResponse), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -981,6 +983,8 @@ pub struct ConfigureVmRequest { pub allowed_node_builtins: Vec, #[serde(default)] pub loopback_exempt_ports: Vec, + #[serde(default)] + pub enable_http_request_transform: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -1215,6 +1219,32 @@ pub struct JsBridgeCallRequest { pub args: Value, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransformHttpRequest { + pub request_id: String, + pub url: String, + pub method: String, + #[serde(with = "json_utf8_value")] + pub headers: Value, + #[serde(default)] + pub body: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransformHttpResultResponse { + pub request_id: String, + #[serde(default)] + pub url: Option, + #[serde(default)] + pub method: Option, + #[serde(default, with = "json_utf8_option")] + pub headers: Option, + #[serde(default)] + pub body: Option, + #[serde(default)] + pub error: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AuthenticatedResponse { pub sidecar_id: String, @@ -1757,6 +1787,7 @@ impl_bare_newtype_union_enum!( ToolInvocation(ToolInvocationRequest) = 1, PermissionRequest(SidecarPermissionRequest) = 2, JsBridgeCall(JsBridgeCallRequest) = 3, + TransformHttpRequest(TransformHttpRequest) = 4, } ); @@ -1768,6 +1799,7 @@ impl_bare_newtype_union_enum!( ToolInvocationResult(ToolInvocationResultResponse) = 1, PermissionRequestResult(SidecarPermissionResultResponse) = 2, JsBridgeResult(JsBridgeResultResponse) = 3, + TransformHttpResult(TransformHttpResultResponse) = 4, } ); @@ -2808,6 +2840,7 @@ enum ExpectedSidecarResponseKind { ToolInvocationResult, PermissionRequestResult, JsBridgeResult, + TransformHttpResult, } impl ExpectedResponseKind { @@ -2861,6 +2894,7 @@ impl ExpectedSidecarResponseKind { Self::ToolInvocationResult => "tool_invocation_result", Self::PermissionRequestResult => "permission_request_result", Self::JsBridgeResult => "js_bridge_result", + Self::TransformHttpResult => "transform_http_result", } } @@ -2952,6 +2986,7 @@ impl SidecarRequestPayload { Self::ToolInvocation(_) => ExpectedSidecarResponseKind::ToolInvocationResult, Self::PermissionRequest(_) => ExpectedSidecarResponseKind::PermissionRequestResult, Self::JsBridgeCall(_) => ExpectedSidecarResponseKind::JsBridgeResult, + Self::TransformHttpRequest(_) => ExpectedSidecarResponseKind::TransformHttpResult, } } } @@ -3036,11 +3071,12 @@ impl SidecarResponsePayload { OwnershipRequirement::Vm } - fn kind_name(&self) -> &'static str { + pub(crate) fn kind_name(&self) -> &'static str { match self { Self::ToolInvocationResult(_) => "tool_invocation_result", Self::PermissionRequestResult(_) => "permission_request_result", Self::JsBridgeResult(_) => "js_bridge_result", + Self::TransformHttpResult(_) => "transform_http_result", } } } diff --git a/crates/sidecar/src/service.rs b/crates/sidecar/src/service.rs index e1f30d52d..926611f12 100644 --- a/crates/sidecar/src/service.rs +++ b/crates/sidecar/src/service.rs @@ -1858,6 +1858,17 @@ where let resource_limits = vm.kernel.resource_limits().clone(); let network_counts = vm_network_resource_counts(vm); let socket_paths = build_javascript_socket_path_context(vm)?; + let enable_transform = vm.configuration.enable_http_request_transform; + let ownership = OwnershipScope::vm( + vm.connection_id.clone(), + vm.session_id.clone(), + vm_id.to_owned(), + ); + let http_transform = if enable_transform { + Some((&self.sidecar_requests, &ownership)) + } else { + None + }; let process = vm .active_processes .get_mut(process_id) @@ -1872,6 +1883,7 @@ where &request, &resource_limits, network_counts, + http_transform, ) } }; diff --git a/crates/sidecar/src/state.rs b/crates/sidecar/src/state.rs index 98b7bcee8..d95945202 100644 --- a/crates/sidecar/src/state.rs +++ b/crates/sidecar/src/state.rs @@ -234,6 +234,7 @@ pub(crate) struct VmConfiguration { pub(crate) command_permissions: BTreeMap, pub(crate) allowed_node_builtins: Vec, pub(crate) loopback_exempt_ports: Vec, + pub(crate) enable_http_request_transform: bool, } #[allow(dead_code)] diff --git a/crates/sidecar/src/vm.rs b/crates/sidecar/src/vm.rs index d90c05d9e..cdc7ce8fa 100644 --- a/crates/sidecar/src/vm.rs +++ b/crates/sidecar/src/vm.rs @@ -318,6 +318,7 @@ where command_permissions: payload.command_permissions.clone(), allowed_node_builtins: payload.allowed_node_builtins.clone(), loopback_exempt_ports: payload.loopback_exempt_ports.clone(), + enable_http_request_transform: payload.enable_http_request_transform, }; if let Some(permissions) = payload.permissions.as_ref() { self.bridge.set_vm_permissions(&vm_id, permissions)?; diff --git a/crates/sidecar/tests/protocol.rs b/crates/sidecar/tests/protocol.rs index 7d3c36128..279fe1a27 100644 --- a/crates/sidecar/tests/protocol.rs +++ b/crates/sidecar/tests/protocol.rs @@ -8,7 +8,8 @@ use agent_os_sidecar::protocol::{ RootFilesystemLowerDescriptor, SidecarPlacement, SidecarRequestFrame, SidecarRequestPayload, SidecarResponseFrame, SidecarResponsePayload, SidecarResponseTracker, SidecarResponseTrackerError, SoftwareDescriptor, StructuredEvent, ToolInvocationRequest, - ToolInvocationResultResponse, VmLifecycleEvent, VmLifecycleState, WriteStdinRequest, + ToolInvocationResultResponse, TransformHttpRequest, TransformHttpResultResponse, + VmLifecycleEvent, VmLifecycleState, WriteStdinRequest, }; use serde_json::json; use std::collections::BTreeMap; @@ -119,6 +120,108 @@ fn codec_round_trips_sidecar_request_and_response_frames() { ); } +#[test] +fn codec_round_trips_transform_http_request_and_response() { + let codec = NativeFrameCodec::default(); + let request = ProtocolFrame::SidecarRequest(SidecarRequestFrame::new( + -20, + OwnershipScope::vm("conn-1", "session-1", "vm-1"), + SidecarRequestPayload::TransformHttpRequest(TransformHttpRequest { + request_id: "http-42".to_string(), + url: "https://api.example.com/v1/data".to_string(), + method: "POST".to_string(), + headers: json!({ + "authorization": ["Bearer __CREDENTIAL_REF_abc__"], + "content-type": ["application/json"], + }), + body: Some(r#"{"key":"value"}"#.to_string()), + }), + )); + let response = ProtocolFrame::SidecarResponse(SidecarResponseFrame::new( + -20, + OwnershipScope::vm("conn-1", "session-1", "vm-1"), + SidecarResponsePayload::TransformHttpResult(TransformHttpResultResponse { + request_id: "http-42".to_string(), + url: None, + method: None, + headers: Some(json!({ + "authorization": ["Bearer real-secret-token"], + "content-type": ["application/json"], + })), + body: None, + error: None, + }), + )); + + assert_eq!( + codec.decode(&codec.encode(&request).unwrap()).unwrap(), + request + ); + assert_eq!( + codec.decode(&codec.encode(&response).unwrap()).unwrap(), + response + ); +} + +#[test] +fn bare_codec_round_trips_transform_http_request_frames() { + let codec = NativeFrameCodec::with_payload_codec(1024 * 1024, NativePayloadCodec::Bare); + let request = ProtocolFrame::SidecarRequest(SidecarRequestFrame::new( + -21, + OwnershipScope::vm("conn-1", "session-1", "vm-1"), + SidecarRequestPayload::TransformHttpRequest(TransformHttpRequest { + request_id: "http-99".to_string(), + url: "https://api.stripe.com/v1/charges".to_string(), + method: "GET".to_string(), + headers: json!({"accept": ["application/json"]}), + body: None, + }), + )); + + let encoded = codec.encode(&request).expect("encode bare transform request"); + let decoded = codec.decode(&encoded).expect("decode bare transform request"); + assert_eq!(decoded, request); + + let response = ProtocolFrame::SidecarResponse(SidecarResponseFrame::new( + -21, + OwnershipScope::vm("conn-1", "session-1", "vm-1"), + SidecarResponsePayload::TransformHttpResult(TransformHttpResultResponse { + request_id: "http-99".to_string(), + url: Some("https://api.stripe.com/v1/charges".to_string()), + method: Some("GET".to_string()), + headers: Some(json!({"accept": ["application/json"], "authorization": ["Bearer sk_live_xxx"]})), + body: None, + error: None, + }), + )); + + let encoded = codec.encode(&response).expect("encode bare transform response"); + let decoded = codec.decode(&encoded).expect("decode bare transform response"); + assert_eq!(decoded, response); +} + +#[test] +fn transform_http_result_with_error_round_trips() { + let codec = NativeFrameCodec::default(); + let response = ProtocolFrame::SidecarResponse(SidecarResponseFrame::new( + -22, + OwnershipScope::vm("conn-1", "session-1", "vm-1"), + SidecarResponsePayload::TransformHttpResult(TransformHttpResultResponse { + request_id: "http-fail".to_string(), + url: None, + method: None, + headers: None, + body: None, + error: Some("credential resolver unavailable".to_string()), + }), + )); + + assert_eq!( + codec.decode(&codec.encode(&response).unwrap()).unwrap(), + response + ); +} + #[test] fn bare_codec_round_trips_frames_with_json_utf8_fields() { let codec = NativeFrameCodec::with_payload_codec(1024 * 1024, NativePayloadCodec::Bare); diff --git a/packages/core/src/agent-os.ts b/packages/core/src/agent-os.ts index 371c9d245..eec7785cd 100644 --- a/packages/core/src/agent-os.ts +++ b/packages/core/src/agent-os.ts @@ -374,6 +374,13 @@ export interface AgentOsOptions { * Pass an explicit sidecar handle to pin the VM to a caller-managed sidecar. */ sidecar?: AgentOsSidecarConfig; + /** + * When enabled, the sidecar sends a `transform_http_request` callback to the + * host before every outbound HTTP request. The host can modify the URL, + * headers, and body (e.g. for credential injection) before the request is + * issued. Handle the callback via `setSidecarRequestHandler` on the client. + */ + enableHttpRequestTransform?: boolean; } /** Configuration for a local MCP server (spawned as a child process). */ @@ -1691,6 +1698,7 @@ export class AgentOs { commandPermissions: processed.commandPermissions, allowedNodeBuiltins: options?.allowedNodeBuiltins, loopbackExemptPorts: options?.loopbackExemptPorts, + enableHttpRequestTransform: options?.enableHttpRequestTransform, }); if (toolKits && toolKits.length > 0) { toolReference = await registerToolkitsOnSidecar( @@ -3027,6 +3035,11 @@ export class AgentOs { call_id: request.payload.call_id, error: `unsupported sidecar request type: ${request.payload.type}`, }); + case "transform_http_request": + return { + type: "transform_http_result" as const, + request_id: request.payload.request_id, + }; } }); } diff --git a/packages/core/src/runtime-compat.ts b/packages/core/src/runtime-compat.ts index d0deb75f6..15e81ff30 100644 --- a/packages/core/src/runtime-compat.ts +++ b/packages/core/src/runtime-compat.ts @@ -1988,6 +1988,7 @@ class NativeKernel implements Kernel { fs: VirtualFileSystem; readOnly?: boolean; }>; + enableHttpRequestTransform?: boolean; }, ) { this.env = { ...(options.env ?? {}) }; @@ -2243,7 +2244,8 @@ class NativeKernel implements Kernel { ); if ( this.pendingLocalMounts.length > 0 || - this.loopbackExemptPorts.length > 0 + this.loopbackExemptPorts.length > 0 || + this.options.enableHttpRequestTransform ) { await client.configureVm(session, vm, { mounts: this.pendingLocalMounts.map((mount) => @@ -2266,6 +2268,7 @@ class NativeKernel implements Kernel { }), ), loopbackExemptPorts: this.loopbackExemptPorts, + enableHttpRequestTransform: this.options.enableHttpRequestTransform, }); } @@ -2297,6 +2300,7 @@ export function createKernel(options: { loopbackExemptPorts?: number[]; logger?: unknown; mounts?: Array<{ path: string; fs: VirtualFileSystem; readOnly?: boolean }>; + enableHttpRequestTransform?: boolean; }): Kernel { return new NativeKernel(options); } diff --git a/packages/core/src/sidecar/native-process-client.ts b/packages/core/src/sidecar/native-process-client.ts index ff296ed62..ad9c15593 100644 --- a/packages/core/src/sidecar/native-process-client.ts +++ b/packages/core/src/sidecar/native-process-client.ts @@ -216,6 +216,7 @@ type RequestPayload = command_permissions: Record; allowed_node_builtins?: string[]; loopback_exempt_ports?: number[]; + enable_http_request_transform?: boolean; } | { type: "register_toolkit"; @@ -346,6 +347,14 @@ export type SidecarRequestPayload = mount_id: string; operation: string; args: unknown; + } + | { + type: "transform_http_request"; + request_id: string; + url: string; + method: string; + headers: Record; + body?: string; }; export type SidecarResponsePayload = @@ -366,6 +375,15 @@ export type SidecarResponsePayload = call_id: string; result?: unknown; error?: string; + } + | { + type: "transform_http_result"; + request_id: string; + url?: string; + method?: string; + headers?: Record; + body?: string; + error?: string; }; interface RequestFrame { @@ -1062,6 +1080,7 @@ export class NativeSidecarProcessClient { commandPermissions?: Record; allowedNodeBuiltins?: string[]; loopbackExemptPorts?: number[]; + enableHttpRequestTransform?: boolean; }, ): Promise { const response = await this.sendRequest({ @@ -1088,6 +1107,9 @@ export class NativeSidecarProcessClient { ...(options.loopbackExemptPorts ? { loopback_exempt_ports: options.loopbackExemptPorts } : {}), + ...(options.enableHttpRequestTransform + ? { enable_http_request_transform: true } + : {}), }, }); if (response.payload.type !== "vm_configured") { @@ -2812,6 +2834,7 @@ function encodeRequestPayload(writer: BareWriter, payload: RequestPayload): void writer.writeList(payload.loopback_exempt_ports ?? [], (value) => writer.writeU16(value), ); + writer.writeBool(payload.enable_http_request_transform ?? false); return; case "register_toolkit": writer.writeVarUint(11); @@ -2983,6 +3006,19 @@ function encodeSidecarResponsePayload( ); writer.writeOptional(payload.error, (value) => writer.writeString(value)); return; + case "transform_http_result": + writer.writeVarUint(4); + writer.writeString(payload.request_id); + writer.writeOptional(payload.url, (value) => writer.writeString(value)); + writer.writeOptional(payload.method, (value) => writer.writeString(value)); + writer.writeOptional(payload.headers, (value) => + writer.writeString( + stringifyJsonUtf8(value, "transform_http_result.headers"), + ), + ); + writer.writeOptional(payload.body, (value) => writer.writeString(value)); + writer.writeOptional(payload.error, (value) => writer.writeString(value)); + return; } } @@ -3383,6 +3419,20 @@ function decodeSidecarRequestPayload( "js bridge call args", ), }; + case 4: + return { + type: "transform_http_request", + request_id: reader.readString("transform_http_request.request_id"), + url: reader.readString("transform_http_request.url"), + method: reader.readString("transform_http_request.method"), + headers: parseJsonUtf8( + reader.readString("transform_http_request.headers"), + "transform_http_request headers", + ) as Record, + body: reader.readOptional(() => + reader.readString("transform_http_request.body"), + ), + }; default: throw new Error("unsupported sidecar request payload tag"); } @@ -3739,6 +3789,8 @@ function isMatchingSidecarResponsePayload( return response.type === "permission_request_result"; case "js_bridge_call": return response.type === "js_bridge_result"; + case "transform_http_request": + return response.type === "transform_http_result"; } } @@ -3766,6 +3818,12 @@ function errorSidecarResponsePayload( call_id: request.call_id, error: message, }; + case "transform_http_request": + return { + type: "transform_http_result", + request_id: request.request_id, + error: message, + }; } } diff --git a/packages/core/tests/http-request-transform.test.ts b/packages/core/tests/http-request-transform.test.ts new file mode 100644 index 000000000..ce207b8a7 --- /dev/null +++ b/packages/core/tests/http-request-transform.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "vitest"; +import type { + SidecarRequestPayload, + SidecarResponsePayload, +} from "../src/sidecar/native-process-client.js"; + +describe("HTTP request transform types", () => { + test("transform_http_request payload shape matches expected contract", () => { + const request: Extract< + SidecarRequestPayload, + { type: "transform_http_request" } + > = { + type: "transform_http_request", + request_id: "http-42", + url: "https://api.example.com/v1/data", + method: "POST", + headers: { + authorization: ["Bearer __CREDENTIAL_REF_abc__"], + "content-type": ["application/json"], + }, + body: '{"key":"value"}', + }; + + expect(request.type).toBe("transform_http_request"); + expect(request.url).toBe("https://api.example.com/v1/data"); + expect(request.method).toBe("POST"); + expect(request.headers.authorization).toEqual([ + "Bearer __CREDENTIAL_REF_abc__", + ]); + expect(request.body).toBe('{"key":"value"}'); + }); + + test("transform_http_result response can return modified headers only", () => { + const response: Extract< + SidecarResponsePayload, + { type: "transform_http_result" } + > = { + type: "transform_http_result", + request_id: "http-42", + headers: { + authorization: ["Bearer real-secret-token"], + "content-type": ["application/json"], + }, + }; + + expect(response.url).toBeUndefined(); + expect(response.method).toBeUndefined(); + expect(response.body).toBeUndefined(); + expect(response.error).toBeUndefined(); + expect(response.headers?.authorization).toEqual([ + "Bearer real-secret-token", + ]); + }); + + test("transform_http_result response can return an error", () => { + const response: Extract< + SidecarResponsePayload, + { type: "transform_http_result" } + > = { + type: "transform_http_result", + request_id: "http-fail", + error: "credential resolver unavailable", + }; + + expect(response.error).toBe("credential resolver unavailable"); + expect(response.url).toBeUndefined(); + expect(response.headers).toBeUndefined(); + }); + + test("transform_http_result response can return all fields", () => { + const response: Extract< + SidecarResponsePayload, + { type: "transform_http_result" } + > = { + type: "transform_http_result", + request_id: "http-99", + url: "https://proxy.internal.com/v1/charges", + method: "PUT", + headers: { + authorization: ["Bearer sk_live_xxx"], + "x-proxy-target": ["api.stripe.com"], + }, + body: '{"amount":100}', + }; + + expect(response.url).toBe("https://proxy.internal.com/v1/charges"); + expect(response.method).toBe("PUT"); + expect(response.body).toBe('{"amount":100}'); + expect(response.headers?.["x-proxy-target"]).toEqual(["api.stripe.com"]); + }); + + test("SidecarRequestHandler dispatch matches transform_http_request to transform_http_result", () => { + const request: SidecarRequestPayload = { + type: "transform_http_request", + request_id: "http-1", + url: "https://example.com", + method: "GET", + headers: {}, + }; + + const matchesResult = ( + req: SidecarRequestPayload, + res: SidecarResponsePayload, + ): boolean => { + if (req.type === "transform_http_request") { + return res.type === "transform_http_result"; + } + return false; + }; + + const response: SidecarResponsePayload = { + type: "transform_http_result", + request_id: "http-1", + }; + + expect(matchesResult(request, response)).toBe(true); + expect( + matchesResult(request, { + type: "tool_invocation_result", + invocation_id: "x", + }), + ).toBe(false); + }); +});