mirror of
https://github.com/openai/codex.git
synced 2026-09-06 15:29:32 +00:00
## What changed - Add client-side handling for exec-server network policy requests, including request validation, per-process decision routing, and allow, deny, or ask responses. - Bound concurrent callbacks and fail closed when requests are invalid, exceed capacity, time out, or outlive their process session. - Replace the callback opt-in flag with a launch-level `policyDecisionTimeoutMs` value and include transport overhead in the executor timeout. - Preserve callback admission limits across connection recovery and clean up policy state when process startup is abandoned or a session ends. ## Testing - Cover decision forwarding, invalid and excess requests, cancellation during process cleanup, abandoned starts, timeout propagation, and launch-config serialization. GitOrigin-RevId: 6ee2a0139e0f8170cf1a5cdd646bd811b7d3c1f6
226 lines
7.4 KiB
Rust
226 lines
7.4 KiB
Rust
use std::sync::Arc;
|
|
use std::sync::RwLock;
|
|
use std::time::Duration;
|
|
|
|
use codex_network_proxy::NetworkDecision;
|
|
use codex_network_proxy::NetworkPolicyRequest;
|
|
use codex_network_proxy::NetworkPolicyRequestArgs;
|
|
use codex_network_proxy::NetworkProtocol;
|
|
use pretty_assertions::assert_eq;
|
|
use tokio::sync::mpsc;
|
|
use tokio::task::JoinHandle;
|
|
use tokio::time::timeout;
|
|
|
|
use super::*;
|
|
use crate::protocol::ExecServerNetworkPolicyDecision;
|
|
use crate::protocol::NetworkPolicyRequestParams;
|
|
use crate::protocol::NetworkPolicyRequestResponse;
|
|
use crate::rpc::RpcServerOutboundMessage;
|
|
|
|
struct DeciderHarness {
|
|
requests: RpcServerRequestSender,
|
|
outgoing: mpsc::Receiver<RpcServerOutboundMessage>,
|
|
controller_timeout: Duration,
|
|
process_shutdown: CancellationToken,
|
|
}
|
|
|
|
impl DeciderHarness {
|
|
fn new() -> Self {
|
|
let (outgoing_tx, outgoing) = mpsc::channel(/*buffer*/ 8);
|
|
Self {
|
|
requests: RpcServerRequestSender::new(outgoing_tx),
|
|
outgoing,
|
|
controller_timeout: Duration::from_secs(60),
|
|
process_shutdown: CancellationToken::new(),
|
|
}
|
|
}
|
|
|
|
fn request(&self, host: &str) -> JoinHandle<NetworkDecision> {
|
|
let decider = network_policy_decider(
|
|
ProcessId::from("process"),
|
|
Arc::new(RwLock::new(Some(self.requests.clone()))),
|
|
self.controller_timeout,
|
|
self.process_shutdown.clone(),
|
|
);
|
|
let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
|
|
protocol: NetworkProtocol::HttpsConnect,
|
|
host: host.to_string(),
|
|
port: 443,
|
|
environment_id: None,
|
|
client_addr: None,
|
|
method: None,
|
|
command: None,
|
|
exec_policy_hint: None,
|
|
});
|
|
tokio::spawn(async move { decider.decide(request).await })
|
|
}
|
|
|
|
async fn next_request(
|
|
&mut self,
|
|
) -> (
|
|
codex_exec_server_protocol::RequestId,
|
|
NetworkPolicyRequestParams,
|
|
) {
|
|
let outbound = timeout(Duration::from_secs(1), self.outgoing.recv())
|
|
.await
|
|
.expect("policy request should arrive")
|
|
.expect("policy request");
|
|
let RpcServerOutboundMessage::Request(request) = outbound else {
|
|
panic!("expected policy request");
|
|
};
|
|
assert_eq!(request.method, NETWORK_POLICY_REQUEST_METHOD);
|
|
let params = serde_json::from_value(request.params.expect("request params"))
|
|
.expect("deserialize policy request");
|
|
(request.id, params)
|
|
}
|
|
}
|
|
|
|
async fn await_decision(decision: JoinHandle<NetworkDecision>) -> NetworkDecision {
|
|
timeout(Duration::from_secs(1), decision)
|
|
.await
|
|
.expect("network policy decision should resolve")
|
|
.expect("network policy decision task")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn returns_client_policy_decision() {
|
|
let mut harness = DeciderHarness::new();
|
|
let decision = harness.request("example.com");
|
|
let (request_id, params) = harness.next_request().await;
|
|
assert_eq!(params.process_id, ProcessId::from("process"));
|
|
assert_eq!(params.request.host, "example.com");
|
|
harness.requests.complete(
|
|
request_id,
|
|
Ok(serde_json::to_value(NetworkPolicyRequestResponse {
|
|
decision: ExecServerNetworkPolicyDecision::Allow,
|
|
})
|
|
.expect("serialize policy response")),
|
|
);
|
|
|
|
assert_eq!(await_decision(decision).await, NetworkDecision::Allow);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn policy_response_reasons_are_bounded_and_fail_closed() {
|
|
let mut harness = DeciderHarness::new();
|
|
let boundary_reason = "d".repeat(MAX_NETWORK_POLICY_REASON_BYTES);
|
|
let cases = [
|
|
(
|
|
ExecServerNetworkPolicyDecision::Deny {
|
|
reason: boundary_reason.clone(),
|
|
},
|
|
NetworkDecision::deny(boundary_reason),
|
|
),
|
|
(
|
|
ExecServerNetworkPolicyDecision::Deny {
|
|
reason: "d".repeat(MAX_NETWORK_POLICY_REASON_BYTES + 1),
|
|
},
|
|
NetworkDecision::deny("not_allowed"),
|
|
),
|
|
(
|
|
ExecServerNetworkPolicyDecision::Ask {
|
|
reason: "ask permission".to_string(),
|
|
},
|
|
NetworkDecision::ask("ask permission"),
|
|
),
|
|
(
|
|
ExecServerNetworkPolicyDecision::Ask {
|
|
reason: "ask\npermission".to_string(),
|
|
},
|
|
NetworkDecision::deny("not_allowed"),
|
|
),
|
|
];
|
|
|
|
for (response, expected) in cases {
|
|
let decision = harness.request("example.com");
|
|
let (request_id, _) = harness.next_request().await;
|
|
harness.requests.complete(
|
|
request_id,
|
|
Ok(
|
|
serde_json::to_value(NetworkPolicyRequestResponse { decision: response })
|
|
.expect("serialize policy response"),
|
|
),
|
|
);
|
|
assert_eq!(await_decision(decision).await, expected);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn boundary_host_is_relayed() {
|
|
let mut harness = DeciderHarness::new();
|
|
let host = "h".repeat(MAX_NETWORK_POLICY_HOST_BYTES);
|
|
let decision = harness.request(&host);
|
|
let (request_id, params) = harness.next_request().await;
|
|
assert_eq!(params.request.host, host);
|
|
harness.requests.complete(
|
|
request_id,
|
|
Ok(serde_json::to_value(NetworkPolicyRequestResponse {
|
|
decision: ExecServerNetworkPolicyDecision::Allow,
|
|
})
|
|
.expect("serialize policy response")),
|
|
);
|
|
|
|
assert_eq!(await_decision(decision).await, NetworkDecision::Allow);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_hosts_fail_closed_before_reverse_rpc() {
|
|
let mut harness = DeciderHarness::new();
|
|
let invalid_hosts = [
|
|
String::new(),
|
|
"host name".to_string(),
|
|
"host\u{0000}name".to_string(),
|
|
"h".repeat(MAX_NETWORK_POLICY_HOST_BYTES + 1),
|
|
];
|
|
|
|
for host in invalid_hosts {
|
|
assert_eq!(
|
|
await_decision(harness.request(&host)).await,
|
|
NetworkDecision::deny("not_allowed")
|
|
);
|
|
assert!(harness.outgoing.try_recv().is_err());
|
|
assert_eq!(harness.requests.pending_request_count(), 0);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn process_exit_and_disconnect_fail_closed() {
|
|
let mut process_exit = DeciderHarness::new();
|
|
let process_decision = process_exit.request("process-exit.example.com");
|
|
process_exit.next_request().await;
|
|
process_exit.process_shutdown.cancel();
|
|
assert_eq!(
|
|
await_decision(process_decision).await,
|
|
NetworkDecision::deny("not_allowed")
|
|
);
|
|
assert_eq!(process_exit.requests.pending_request_count(), 0);
|
|
|
|
let mut disconnect = DeciderHarness::new();
|
|
let disconnect_decision = disconnect.request("disconnect.example.com");
|
|
disconnect.next_request().await;
|
|
disconnect.requests.close();
|
|
assert_eq!(
|
|
await_decision(disconnect_decision).await,
|
|
NetworkDecision::deny("not_allowed")
|
|
);
|
|
assert_eq!(disconnect.requests.pending_request_count(), 0);
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn configured_decision_timeout_fails_closed() {
|
|
let mut harness = DeciderHarness::new();
|
|
harness.controller_timeout = Duration::from_secs(17);
|
|
let decision = harness.request("timeout.example.com");
|
|
harness.next_request().await;
|
|
|
|
tokio::time::advance(Duration::from_secs(21)).await;
|
|
assert!(!decision.is_finished());
|
|
|
|
tokio::time::advance(Duration::from_secs(1)).await;
|
|
assert_eq!(
|
|
await_decision(decision).await,
|
|
NetworkDecision::deny("not_allowed")
|
|
);
|
|
assert_eq!(harness.requests.pending_request_count(), 0);
|
|
}
|