mirror of
https://github.com/openai/codex.git
synced 2026-03-05 21:45:28 +03:00
Compare commits
6 Commits
a5420779c4
...
dev/mcgrew
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7ce246480 | ||
|
|
661c78e937 | ||
|
|
131b8624b5 | ||
|
|
114b01c01c | ||
|
|
f801b231cc | ||
|
|
f8092de827 |
5
codex-rs/Cargo.lock
generated
5
codex-rs/Cargo.lock
generated
@@ -2034,8 +2034,11 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"clap",
|
||||
"codex-otel",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-home-dir",
|
||||
"codex-utils-rustls-provider",
|
||||
"globset",
|
||||
"pretty_assertions",
|
||||
@@ -2053,7 +2056,9 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
]
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ use codex_hooks::HookEventAfterAgent;
|
||||
use codex_hooks::HookPayload;
|
||||
use codex_hooks::Hooks;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_network_proxy::NetworkProxyAuditMetadata;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::approvals::ExecPolicyAmendment;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
@@ -232,6 +233,7 @@ use crate::windows_sandbox::WindowsSandboxLevelExt;
|
||||
use codex_async_utils::OrCancelExt;
|
||||
use codex_otel::OtelManager;
|
||||
use codex_otel::TelemetryAuthMode;
|
||||
use codex_otel::sanitize_metric_tag_value;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
@@ -1011,16 +1013,33 @@ impl Session {
|
||||
|
||||
let auth = auth.as_ref();
|
||||
let auth_mode = auth.map(CodexAuth::auth_mode).map(TelemetryAuthMode::from);
|
||||
let account_id = auth.and_then(CodexAuth::get_account_id);
|
||||
let account_email = auth.and_then(CodexAuth::get_account_email);
|
||||
let originator = crate::default_client::originator().value;
|
||||
let terminal_type = terminal::user_agent();
|
||||
let model = session_configuration.collaboration_mode.model().to_string();
|
||||
let slug = model.clone();
|
||||
let network_proxy_audit_metadata = NetworkProxyAuditMetadata {
|
||||
conversation_id: Some(conversation_id.to_string()),
|
||||
app_version: Some(env!("CARGO_PKG_VERSION").to_string()),
|
||||
auth_mode: auth_mode.map(|mode| mode.to_string()),
|
||||
originator: Some(sanitize_metric_tag_value(originator.as_str())),
|
||||
account_id: account_id.clone(),
|
||||
account_email: account_email.clone(),
|
||||
terminal_type: Some(terminal_type.clone()),
|
||||
model: Some(model.clone()),
|
||||
slug: Some(slug.clone()),
|
||||
};
|
||||
let otel_manager = OtelManager::new(
|
||||
conversation_id,
|
||||
session_configuration.collaboration_mode.model(),
|
||||
session_configuration.collaboration_mode.model(),
|
||||
auth.and_then(CodexAuth::get_account_id),
|
||||
auth.and_then(CodexAuth::get_account_email),
|
||||
model.as_str(),
|
||||
slug.as_str(),
|
||||
account_id,
|
||||
account_email,
|
||||
auth_mode,
|
||||
crate::default_client::originator().value,
|
||||
originator,
|
||||
config.otel.log_user_prompt,
|
||||
terminal::user_agent(),
|
||||
terminal_type,
|
||||
session_configuration.session_source.clone(),
|
||||
);
|
||||
config.features.emit_metrics(&otel_manager);
|
||||
@@ -1075,13 +1094,16 @@ impl Session {
|
||||
};
|
||||
session_configuration.thread_name = thread_name.clone();
|
||||
let mut state = SessionState::new(session_configuration.clone());
|
||||
let network_proxy =
|
||||
match config.network.as_ref() {
|
||||
Some(spec) => Some(spec.start_proxy().await.map_err(|err| {
|
||||
anyhow::anyhow!("failed to start managed network proxy: {err}")
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
let network_proxy = match config.network.as_ref() {
|
||||
Some(spec) => Some(
|
||||
spec.start_proxy_with_audit_metadata(network_proxy_audit_metadata)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
anyhow::anyhow!("failed to start managed network proxy: {err}")
|
||||
})?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let session_network_proxy = network_proxy.as_ref().map(|started| {
|
||||
let proxy = started.proxy();
|
||||
SessionNetworkProxyRuntime {
|
||||
|
||||
@@ -4,6 +4,7 @@ use async_trait::async_trait;
|
||||
use codex_network_proxy::ConfigReloader;
|
||||
use codex_network_proxy::ConfigState;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_network_proxy::NetworkProxyAuditMetadata;
|
||||
use codex_network_proxy::NetworkProxyConfig;
|
||||
use codex_network_proxy::NetworkProxyConstraints;
|
||||
use codex_network_proxy::NetworkProxyHandle;
|
||||
@@ -93,12 +94,21 @@ impl NetworkProxySpec {
|
||||
}
|
||||
|
||||
pub async fn start_proxy(&self) -> std::io::Result<StartedNetworkProxy> {
|
||||
self.start_proxy_with_audit_metadata(NetworkProxyAuditMetadata::default())
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn start_proxy_with_audit_metadata(
|
||||
&self,
|
||||
audit_metadata: NetworkProxyAuditMetadata,
|
||||
) -> std::io::Result<StartedNetworkProxy> {
|
||||
let state =
|
||||
build_config_state(self.config.clone(), self.constraints.clone()).map_err(|err| {
|
||||
std::io::Error::other(format!("failed to build network proxy state: {err}"))
|
||||
})?;
|
||||
let reloader = Arc::new(StaticNetworkProxyReloader::new(state.clone()));
|
||||
let state = NetworkProxyState::with_reloader(state, reloader);
|
||||
let state =
|
||||
NetworkProxyState::with_reloader_and_audit_metadata(state, reloader, audit_metadata);
|
||||
let proxy = NetworkProxy::builder()
|
||||
.state(Arc::new(state))
|
||||
.build()
|
||||
|
||||
@@ -15,6 +15,9 @@ workspace = true
|
||||
anyhow = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
chrono = { workspace = true }
|
||||
codex-utils-home-dir = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-rustls-provider = { workspace = true }
|
||||
globset = { workspace = true }
|
||||
@@ -24,6 +27,8 @@ thiserror = { workspace = true }
|
||||
time = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
toml = { workspace = true }
|
||||
url = { workspace = true }
|
||||
rama-core = { version = "=0.3.0-alpha.4" }
|
||||
rama-http = { version = "=0.3.0-alpha.4" }
|
||||
|
||||
@@ -12,7 +12,12 @@ It enforces an allow/deny policy and a "limited" mode intended for read-only net
|
||||
|
||||
### 1) Configure
|
||||
|
||||
`codex-network-proxy` reads from Codex's merged `config.toml` (via `codex-core` config loading).
|
||||
`codex-network-proxy` has two config-loading modes:
|
||||
|
||||
- Standalone binary (`cargo run -p codex-network-proxy --`): reads `network` and `otel`
|
||||
directly from `$CODEX_HOME/config.toml`.
|
||||
- Embedded via Codex CLI/core: the proxy is created from Codex-managed network config
|
||||
(`NetworkProxySpec` / managed constraints), rather than using the standalone binary loader.
|
||||
|
||||
Example config:
|
||||
|
||||
@@ -55,6 +60,11 @@ allow_unix_sockets = ["/tmp/example.sock"]
|
||||
cargo run -p codex-network-proxy --
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- If `network.enabled = false` (default), the process exits without binding listeners.
|
||||
- In standalone mode, `POST /reload` is not supported.
|
||||
|
||||
### 3) Point a client at it
|
||||
|
||||
For HTTP(S) traffic:
|
||||
@@ -83,6 +93,91 @@ When a request is blocked, the proxy responds with `403` and includes:
|
||||
In "limited" mode, only `GET`, `HEAD`, and `OPTIONS` are allowed. HTTPS `CONNECT` and SOCKS5 are
|
||||
blocked because they would bypass method enforcement.
|
||||
|
||||
### 5) OpenTelemetry logs and audit events
|
||||
|
||||
`codex-network-proxy` logs use normal `tracing` targets (for example
|
||||
`codex_network_proxy::http_proxy`).
|
||||
|
||||
In standalone mode, `codex-network-proxy` reads the top-level `[otel]` section from
|
||||
`$CODEX_HOME/config.toml` and initializes OTEL export directly in the binary. If OTEL
|
||||
initialization fails, the proxy still starts and keeps stderr logging enabled.
|
||||
In embedded (non-standalone) mode, Codex core initializes OTEL, and the proxy emits audit events
|
||||
through that shared tracing pipeline.
|
||||
OTEL resolution follows the same defaults as Codex core (`environment = "dev"`,
|
||||
`exporter = "none"`, `trace_exporter = exporter`, `metrics_exporter = "statsig"`), and
|
||||
`log_user_prompt` is accepted for compatibility but ignored by the proxy.
|
||||
Standalone mode also honors top-level `[analytics].enabled`; when it is `false`, metrics export is
|
||||
disabled (`metrics_exporter = "none"`), even if a metrics exporter is configured under `[otel]`.
|
||||
|
||||
Example:
|
||||
|
||||
```toml
|
||||
[analytics]
|
||||
enabled = false
|
||||
|
||||
[otel]
|
||||
metrics_exporter = "statsig" # ignored while analytics is disabled
|
||||
```
|
||||
|
||||
To filter proxy logs locally, use:
|
||||
|
||||
```bash
|
||||
RUST_LOG=codex_network_proxy=info
|
||||
```
|
||||
|
||||
The proxy emits structured policy audit events at target `codex_otel.network_proxy` (current
|
||||
`OTEL_NETWORK_PROXY_TARGET` constant in code):
|
||||
|
||||
Domain-policy event (one per domain policy evaluation):
|
||||
|
||||
- `event.name = "codex.network_proxy.domain_policy_decision"`
|
||||
- `event.timestamp = <RFC3339 UTC timestamp with milliseconds>`
|
||||
- `conversation.id = <thread id>` (optional)
|
||||
- `app.version = <codex version>` (optional)
|
||||
- `auth_mode = <auth mode>` (optional)
|
||||
- `originator = <client originator>` (optional)
|
||||
- `user.account_id = <account id>` (optional)
|
||||
- `user.email = <account email>` (optional)
|
||||
- `terminal.type = <terminal identifier>` (optional)
|
||||
- `model = <model>` (optional)
|
||||
- `slug = <model slug>` (optional)
|
||||
- `network.policy.scope = "domain_rule"`
|
||||
- `network.policy.decision = "allow" | "deny" | "ask"`
|
||||
- `network.policy.source = "baseline_policy" | "decider"`
|
||||
- `network.policy.reason = <policy reason>`
|
||||
- `network.transport.protocol = "http" | "https_connect" | "socks5_tcp" | "socks5_udp"`
|
||||
- `server.address = <normalized host>`
|
||||
- `server.port = <port>`
|
||||
- `http.request.method = <method or "none">`
|
||||
- `client.address = <client address or "unknown">`
|
||||
- `network.policy.override = true|false` (`true` only when decider overrides baseline `not_allowed`)
|
||||
|
||||
Supplemental non-domain block event (only when blocked by mode guard or proxy state):
|
||||
|
||||
- `event.name = "codex.network_proxy.block_decision"`
|
||||
- `event.timestamp = <RFC3339 UTC timestamp with milliseconds>`
|
||||
- `conversation.id = <thread id>` (optional)
|
||||
- `app.version = <codex version>` (optional)
|
||||
- `auth_mode = <auth mode>` (optional)
|
||||
- `originator = <client originator>` (optional)
|
||||
- `user.account_id = <account id>` (optional)
|
||||
- `user.email = <account email>` (optional)
|
||||
- `terminal.type = <terminal identifier>` (optional)
|
||||
- `model = <model>` (optional)
|
||||
- `slug = <model slug>` (optional)
|
||||
- `network.policy.scope = "mode_guard" | "proxy_state"`
|
||||
- `network.policy.decision = "deny"`
|
||||
- `network.policy.source = "mode_guard" | "proxy_state"`
|
||||
- `network.policy.reason = "method_not_allowed" | "proxy_disabled" | "not_allowed" | "unix_socket_unsupported"`
|
||||
- `network.transport.protocol = "http" | "https_connect" | "socks5_tcp" | "socks5_udp"`
|
||||
- `server.address = <host>` (`"unix-socket"` sentinel for unix-socket block paths)
|
||||
- `server.port = <port>` (`0` for unix-socket sentinel events)
|
||||
- `http.request.method = <method or "none">`
|
||||
- `client.address = <client address or "unknown">`
|
||||
- `network.policy.override = false`
|
||||
|
||||
These audit events are intentionally domain/policy focused and do not include full URLs.
|
||||
|
||||
## Library API
|
||||
|
||||
`codex-network-proxy` can be embedded as a library with a thin API:
|
||||
|
||||
@@ -6,11 +6,14 @@ use crate::network_policy::NetworkPolicyDecision;
|
||||
use crate::network_policy::NetworkPolicyRequest;
|
||||
use crate::network_policy::NetworkPolicyRequestArgs;
|
||||
use crate::network_policy::NetworkProtocol;
|
||||
use crate::network_policy::NonDomainDenyAuditEventArgs;
|
||||
use crate::network_policy::emit_non_domain_deny_audit_event;
|
||||
use crate::network_policy::evaluate_host_policy;
|
||||
use crate::policy::normalize_host;
|
||||
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
|
||||
use crate::reasons::REASON_NOT_ALLOWED;
|
||||
use crate::reasons::REASON_PROXY_DISABLED;
|
||||
use crate::reasons::REASON_UNIX_SOCKET_UNSUPPORTED;
|
||||
use crate::responses::PolicyDecisionDetails;
|
||||
use crate::responses::blocked_header_value;
|
||||
use crate::responses::blocked_message_with_policy;
|
||||
@@ -174,6 +177,7 @@ async fn http_connect_accept(
|
||||
client_addr(&req),
|
||||
Some("CONNECT".to_string()),
|
||||
NetworkProtocol::HttpsConnect,
|
||||
None,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
@@ -232,6 +236,16 @@ async fn http_connect_accept(
|
||||
.map_err(|err| internal_error("failed to read network mode", err))?;
|
||||
|
||||
if mode == NetworkMode::Limited {
|
||||
emit_non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
|
||||
source: NetworkDecisionSource::ModeGuard,
|
||||
reason: REASON_METHOD_NOT_ALLOWED,
|
||||
protocol: NetworkProtocol::HttpsConnect,
|
||||
host: &host,
|
||||
port: authority.port,
|
||||
method: Some("CONNECT"),
|
||||
client_addr: client.as_deref(),
|
||||
metadata: app_state.audit_metadata(),
|
||||
});
|
||||
let details = PolicyDecisionDetails {
|
||||
decision: NetworkPolicyDecision::Deny,
|
||||
reason: REASON_METHOD_NOT_ALLOWED,
|
||||
@@ -405,10 +419,21 @@ async fn http_plain_proxy(
|
||||
client_addr(&req),
|
||||
Some(req.method().as_str().to_string()),
|
||||
NetworkProtocol::Http,
|
||||
Some("unix-socket"),
|
||||
)
|
||||
.await);
|
||||
}
|
||||
if !method_allowed {
|
||||
emit_non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
|
||||
source: NetworkDecisionSource::ModeGuard,
|
||||
reason: REASON_METHOD_NOT_ALLOWED,
|
||||
protocol: NetworkProtocol::Http,
|
||||
host: "unix-socket",
|
||||
port: 0,
|
||||
method: Some(req.method().as_str()),
|
||||
client_addr: client.as_deref(),
|
||||
metadata: app_state.audit_metadata(),
|
||||
});
|
||||
let client = client.as_deref().unwrap_or_default();
|
||||
let method = req.method();
|
||||
warn!(
|
||||
@@ -418,6 +443,16 @@ async fn http_plain_proxy(
|
||||
}
|
||||
|
||||
if !unix_socket_permissions_supported() {
|
||||
emit_non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
|
||||
source: NetworkDecisionSource::ProxyState,
|
||||
reason: REASON_UNIX_SOCKET_UNSUPPORTED,
|
||||
protocol: NetworkProtocol::Http,
|
||||
host: "unix-socket",
|
||||
port: 0,
|
||||
method: Some(req.method().as_str()),
|
||||
client_addr: client.as_deref(),
|
||||
metadata: app_state.audit_metadata(),
|
||||
});
|
||||
warn!("unix socket proxy unsupported on this platform (path={socket_path})");
|
||||
return Ok(text_response(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
@@ -441,6 +476,16 @@ async fn http_plain_proxy(
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
emit_non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
|
||||
source: NetworkDecisionSource::ProxyState,
|
||||
reason: REASON_NOT_ALLOWED,
|
||||
protocol: NetworkProtocol::Http,
|
||||
host: "unix-socket",
|
||||
port: 0,
|
||||
method: Some(req.method().as_str()),
|
||||
client_addr: client.as_deref(),
|
||||
metadata: app_state.audit_metadata(),
|
||||
});
|
||||
let client = client.as_deref().unwrap_or_default();
|
||||
warn!("unix socket blocked (client={client}, path={socket_path})");
|
||||
Ok(json_blocked("unix-socket", REASON_NOT_ALLOWED, None))
|
||||
@@ -480,6 +525,7 @@ async fn http_plain_proxy(
|
||||
client_addr(&req),
|
||||
Some(req.method().as_str().to_string()),
|
||||
NetworkProtocol::Http,
|
||||
None,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
@@ -530,6 +576,16 @@ async fn http_plain_proxy(
|
||||
}
|
||||
|
||||
if !method_allowed {
|
||||
emit_non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
|
||||
source: NetworkDecisionSource::ModeGuard,
|
||||
reason: REASON_METHOD_NOT_ALLOWED,
|
||||
protocol: NetworkProtocol::Http,
|
||||
host: &host,
|
||||
port,
|
||||
method: Some(req.method().as_str()),
|
||||
client_addr: client.as_deref(),
|
||||
metadata: app_state.audit_metadata(),
|
||||
});
|
||||
let details = PolicyDecisionDetails {
|
||||
decision: NetworkPolicyDecision::Deny,
|
||||
reason: REASON_METHOD_NOT_ALLOWED,
|
||||
@@ -657,7 +713,19 @@ async fn proxy_disabled_response(
|
||||
client: Option<String>,
|
||||
method: Option<String>,
|
||||
protocol: NetworkProtocol,
|
||||
audit_host: Option<&str>,
|
||||
) -> Response {
|
||||
emit_non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
|
||||
source: NetworkDecisionSource::ProxyState,
|
||||
reason: REASON_PROXY_DISABLED,
|
||||
protocol,
|
||||
host: audit_host.unwrap_or(host.as_str()),
|
||||
port,
|
||||
method: method.as_deref(),
|
||||
client_addr: client.as_deref(),
|
||||
metadata: app_state.audit_metadata(),
|
||||
});
|
||||
|
||||
let blocked_host = host.clone();
|
||||
let _ = app_state
|
||||
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
|
||||
@@ -718,7 +786,49 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
use rama_http::Method;
|
||||
use rama_http::Request;
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct CapturingMakeWriter {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl CapturingMakeWriter {
|
||||
fn from_buffer(buffer: Arc<Mutex<Vec<u8>>>) -> Self {
|
||||
Self { buffer }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for CapturingMakeWriter {
|
||||
type Writer = CapturingWriter;
|
||||
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
CapturingWriter {
|
||||
buffer: self.buffer.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CapturingWriter {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl io::Write for CapturingWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.buffer
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_connect_accept_blocks_in_limited_mode() {
|
||||
@@ -744,4 +854,91 @@ mod tests {
|
||||
"blocked-by-method-policy"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn http_plain_proxy_emits_unix_socket_unsupported_audit_event() {
|
||||
let policy = NetworkProxySettings::default();
|
||||
let state = Arc::new(network_proxy_state_for_policy(policy));
|
||||
let mut req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("http://example.com/")
|
||||
.header("x-unix-socket", "/tmp/example.sock")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut().insert(state);
|
||||
|
||||
let buffer = Arc::new(Mutex::new(Vec::new()));
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_ansi(false)
|
||||
.with_level(false)
|
||||
.with_target(true)
|
||||
.without_time()
|
||||
.with_writer(CapturingMakeWriter::from_buffer(buffer.clone()))
|
||||
.finish();
|
||||
let _guard = tracing::subscriber::set_default(subscriber);
|
||||
|
||||
let response = http_plain_proxy(None, req).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
|
||||
|
||||
let logs = String::from_utf8(
|
||||
buffer
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(logs.contains("event.name=\"codex.network_proxy.block_decision\""));
|
||||
assert!(logs.contains("network.policy.source=\"proxy_state\""));
|
||||
assert!(logs.contains("network.policy.reason=\"unix_socket_unsupported\""));
|
||||
assert!(logs.contains("server.address=\"unix-socket\""));
|
||||
assert!(logs.contains("server.port=0"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn http_plain_proxy_emits_unix_socket_not_allowed_audit_event() {
|
||||
let policy = NetworkProxySettings {
|
||||
allow_unix_sockets: vec!["/tmp/allowed.sock".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let state = Arc::new(network_proxy_state_for_policy(policy));
|
||||
let mut req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("http://example.com/")
|
||||
.header("x-unix-socket", "/tmp/not-allowed.sock")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut().insert(state);
|
||||
|
||||
let buffer = Arc::new(Mutex::new(Vec::new()));
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_ansi(false)
|
||||
.with_level(false)
|
||||
.with_target(true)
|
||||
.without_time()
|
||||
.with_writer(CapturingMakeWriter::from_buffer(buffer.clone()))
|
||||
.finish();
|
||||
let _guard = tracing::subscriber::set_default(subscriber);
|
||||
|
||||
let response = http_plain_proxy(None, req).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
response.headers().get("x-proxy-error").unwrap(),
|
||||
"blocked-by-allowlist"
|
||||
);
|
||||
|
||||
let logs = String::from_utf8(
|
||||
buffer
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(logs.contains("event.name=\"codex.network_proxy.block_decision\""));
|
||||
assert!(logs.contains("network.policy.source=\"proxy_state\""));
|
||||
assert!(logs.contains("network.policy.reason=\"not_allowed\""));
|
||||
assert!(logs.contains("server.address=\"unix-socket\""));
|
||||
assert!(logs.contains("server.port=0"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ pub use proxy::has_proxy_url_env_vars;
|
||||
pub use proxy::proxy_url_env_value;
|
||||
pub use runtime::ConfigReloader;
|
||||
pub use runtime::ConfigState;
|
||||
pub use runtime::NetworkProxyAuditMetadata;
|
||||
pub use runtime::NetworkProxyState;
|
||||
pub use state::NetworkProxyConstraintError;
|
||||
pub use state::NetworkProxyConstraints;
|
||||
|
||||
221
codex-rs/network-proxy/src/main.rs
Normal file
221
codex-rs/network-proxy/src/main.rs
Normal file
@@ -0,0 +1,221 @@
|
||||
mod standalone_otel;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use clap::Parser;
|
||||
use codex_network_proxy::Args;
|
||||
use codex_network_proxy::ConfigReloader;
|
||||
use codex_network_proxy::ConfigState;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_network_proxy::NetworkProxyConfig;
|
||||
use codex_network_proxy::NetworkProxyConstraints;
|
||||
use codex_network_proxy::NetworkProxyState;
|
||||
use codex_network_proxy::build_config_state;
|
||||
use codex_otel::otel_provider::OtelProvider;
|
||||
use codex_utils_home_dir::find_codex_home;
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
const CONFIG_TOML_FILE: &str = "config.toml";
|
||||
|
||||
struct StandaloneConfig {
|
||||
codex_home: PathBuf,
|
||||
config_path: PathBuf,
|
||||
config_missing: bool,
|
||||
network: NetworkProxyConfig,
|
||||
otel: standalone_otel::StandaloneOtelConfigToml,
|
||||
analytics_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct StandaloneAnalyticsConfigToml {
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct StandaloneConfigToml {
|
||||
#[serde(flatten)]
|
||||
network: NetworkProxyConfig,
|
||||
#[serde(default)]
|
||||
otel: standalone_otel::StandaloneOtelConfigToml,
|
||||
#[serde(default)]
|
||||
analytics: Option<StandaloneAnalyticsConfigToml>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let _ = Args::parse();
|
||||
let config = load_standalone_config().await?;
|
||||
let StandaloneConfig {
|
||||
codex_home,
|
||||
config_path,
|
||||
config_missing,
|
||||
network,
|
||||
otel: otel_config,
|
||||
analytics_enabled,
|
||||
} = config;
|
||||
|
||||
let otel_provider = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
standalone_otel::build_provider(
|
||||
otel_config,
|
||||
analytics_enabled,
|
||||
codex_home,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
)
|
||||
})) {
|
||||
Ok(Ok(otel)) => otel,
|
||||
Ok(Err(err)) => {
|
||||
eprintln!("Could not create otel exporter: {err}");
|
||||
None
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!("Could not create otel exporter: panicked during initialization");
|
||||
None
|
||||
}
|
||||
};
|
||||
init_tracing(otel_provider.as_ref());
|
||||
|
||||
if config_missing {
|
||||
warn!(
|
||||
"config file not found at {}; using defaults",
|
||||
config_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let state = build_config_state(network, NetworkProxyConstraints::default())?;
|
||||
let state = NetworkProxyState::with_reloader(state, Arc::new(StandaloneConfigReloader));
|
||||
let proxy = NetworkProxy::builder()
|
||||
.state(Arc::new(state))
|
||||
.managed_by_codex(false)
|
||||
.build()
|
||||
.await?;
|
||||
let handle = proxy.run().await?;
|
||||
|
||||
info!(
|
||||
http = %proxy.http_addr(),
|
||||
socks = %proxy.socks_addr(),
|
||||
admin = %proxy.admin_addr(),
|
||||
"network proxy started"
|
||||
);
|
||||
|
||||
tokio::select! {
|
||||
result = handle.wait() => result,
|
||||
_ = tokio::signal::ctrl_c() => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn init_tracing(otel: Option<&OtelProvider>) {
|
||||
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
let stderr_fmt = tracing_subscriber::fmt::layer()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_filter(env_filter);
|
||||
let otel_logger_layer = otel.and_then(|provider| provider.logger_layer());
|
||||
let otel_tracing_layer = otel.and_then(|provider| provider.tracing_layer());
|
||||
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(stderr_fmt)
|
||||
.with(otel_logger_layer)
|
||||
.with(otel_tracing_layer)
|
||||
.try_init();
|
||||
}
|
||||
|
||||
async fn load_standalone_config() -> Result<StandaloneConfig> {
|
||||
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let raw = match tokio::fs::read_to_string(&config_path).await {
|
||||
Ok(raw) => Some(raw),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(err) => {
|
||||
return Err(err).with_context(|| format!("failed to read {}", config_path.display()));
|
||||
}
|
||||
};
|
||||
|
||||
let (network, otel, analytics_enabled, config_missing) = match raw {
|
||||
Some(raw) => {
|
||||
let parsed: StandaloneConfigToml = toml::from_str(&raw)
|
||||
.with_context(|| format!("failed to parse {}", config_path.display()))?;
|
||||
let StandaloneConfigToml {
|
||||
network,
|
||||
otel,
|
||||
analytics,
|
||||
} = parsed;
|
||||
(network, otel, resolve_analytics_enabled(analytics), false)
|
||||
}
|
||||
None => (
|
||||
NetworkProxyConfig::default(),
|
||||
standalone_otel::StandaloneOtelConfigToml::default(),
|
||||
true,
|
||||
true,
|
||||
),
|
||||
};
|
||||
|
||||
Ok(StandaloneConfig {
|
||||
codex_home,
|
||||
config_path,
|
||||
config_missing,
|
||||
network,
|
||||
otel,
|
||||
analytics_enabled,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_analytics_enabled(analytics: Option<StandaloneAnalyticsConfigToml>) -> bool {
|
||||
analytics.and_then(|a| a.enabled).unwrap_or(true)
|
||||
}
|
||||
|
||||
struct StandaloneConfigReloader;
|
||||
|
||||
#[async_trait]
|
||||
impl ConfigReloader for StandaloneConfigReloader {
|
||||
fn source_label(&self) -> String {
|
||||
"standalone config state".to_string()
|
||||
}
|
||||
|
||||
async fn maybe_reload(&self) -> Result<Option<ConfigState>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn reload_now(&self) -> Result<ConfigState> {
|
||||
Err(anyhow::anyhow!(
|
||||
"config reload is not supported in standalone mode"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn standalone_config_analytics_defaults_enabled_when_omitted() {
|
||||
let parsed: StandaloneConfigToml = toml::from_str(
|
||||
r#"
|
||||
[network]
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resolve_analytics_enabled(parsed.analytics), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_config_analytics_respects_disabled_flag() {
|
||||
let parsed: StandaloneConfigToml = toml::from_str(
|
||||
r#"
|
||||
[analytics]
|
||||
enabled = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resolve_analytics_enabled(parsed.analytics), false);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,25 @@
|
||||
use crate::reasons::REASON_POLICY_DENIED;
|
||||
use crate::runtime::HostBlockDecision;
|
||||
use crate::runtime::HostBlockReason;
|
||||
use crate::state::NetworkProxyAuditMetadata;
|
||||
use crate::state::NetworkProxyState;
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::SecondsFormat;
|
||||
use chrono::Utc;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
|
||||
const OTEL_NETWORK_PROXY_TARGET: &str = "codex_otel.network_proxy";
|
||||
const OTEL_DOMAIN_POLICY_EVENT_NAME: &str = "codex.network_proxy.domain_policy_decision";
|
||||
const OTEL_BLOCK_POLICY_EVENT_NAME: &str = "codex.network_proxy.block_decision";
|
||||
const DOMAIN_POLICY_SCOPE: &str = "domain_rule";
|
||||
const POLICY_DECISION_DENY: &str = "deny";
|
||||
const DOMAIN_POLICY_DECISION_ALLOW: &str = "allow";
|
||||
const DOMAIN_POLICY_REASON_ALLOWED: &str = "allowed";
|
||||
const DOMAIN_POLICY_METHOD_NONE: &str = "none";
|
||||
const DOMAIN_POLICY_CLIENT_UNKNOWN: &str = "unknown";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum NetworkProtocol {
|
||||
Http,
|
||||
@@ -114,6 +127,49 @@ pub enum NetworkDecision {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum BaselinePolicyOutcome {
|
||||
Allowed,
|
||||
Blocked(HostBlockReason),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct DomainPolicyAuditEvent {
|
||||
decision: &'static str,
|
||||
source: &'static str,
|
||||
reason: String,
|
||||
protocol: &'static str,
|
||||
domain: String,
|
||||
port: u16,
|
||||
method: String,
|
||||
client_addr: String,
|
||||
policy_override: bool,
|
||||
metadata: NetworkProxyAuditMetadata,
|
||||
}
|
||||
|
||||
pub(crate) struct NonDomainDenyAuditEventArgs<'a> {
|
||||
pub source: NetworkDecisionSource,
|
||||
pub reason: &'a str,
|
||||
pub protocol: NetworkProtocol,
|
||||
pub host: &'a str,
|
||||
pub port: u16,
|
||||
pub method: Option<&'a str>,
|
||||
pub client_addr: Option<&'a str>,
|
||||
pub metadata: &'a NetworkProxyAuditMetadata,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct NonDomainDenyAuditEvent {
|
||||
source: &'static str,
|
||||
reason: String,
|
||||
protocol: &'static str,
|
||||
domain: String,
|
||||
port: u16,
|
||||
method: String,
|
||||
client_addr: String,
|
||||
metadata: NetworkProxyAuditMetadata,
|
||||
}
|
||||
|
||||
impl NetworkDecision {
|
||||
pub fn deny(reason: impl Into<String>) -> Self {
|
||||
Self::deny_with_source(reason, NetworkDecisionSource::Decider)
|
||||
@@ -181,23 +237,34 @@ pub(crate) async fn evaluate_host_policy(
|
||||
decider: Option<&Arc<dyn NetworkPolicyDecider>>,
|
||||
request: &NetworkPolicyRequest,
|
||||
) -> Result<NetworkDecision> {
|
||||
match state.host_blocked(&request.host, request.port).await? {
|
||||
HostBlockDecision::Allowed => Ok(NetworkDecision::Allow),
|
||||
HostBlockDecision::Blocked(HostBlockReason::NotAllowed) => {
|
||||
let baseline_outcome = match state.host_blocked(&request.host, request.port).await? {
|
||||
HostBlockDecision::Allowed => BaselinePolicyOutcome::Allowed,
|
||||
HostBlockDecision::Blocked(reason) => BaselinePolicyOutcome::Blocked(reason),
|
||||
};
|
||||
|
||||
let decision = match baseline_outcome {
|
||||
BaselinePolicyOutcome::Allowed => NetworkDecision::Allow,
|
||||
BaselinePolicyOutcome::Blocked(HostBlockReason::NotAllowed) => {
|
||||
if let Some(decider) = decider {
|
||||
Ok(map_decider_decision(decider.decide(request.clone()).await))
|
||||
map_decider_decision(decider.decide(request.clone()).await)
|
||||
} else {
|
||||
Ok(NetworkDecision::deny_with_source(
|
||||
NetworkDecision::deny_with_source(
|
||||
HostBlockReason::NotAllowed.as_str(),
|
||||
NetworkDecisionSource::BaselinePolicy,
|
||||
))
|
||||
)
|
||||
}
|
||||
}
|
||||
HostBlockDecision::Blocked(reason) => Ok(NetworkDecision::deny_with_source(
|
||||
BaselinePolicyOutcome::Blocked(reason) => NetworkDecision::deny_with_source(
|
||||
reason.as_str(),
|
||||
NetworkDecisionSource::BaselinePolicy,
|
||||
)),
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
let audit_event =
|
||||
domain_policy_audit_event(request, baseline_outcome, &decision, state.audit_metadata());
|
||||
emit_domain_policy_audit_event(&audit_event);
|
||||
|
||||
Ok(decision)
|
||||
}
|
||||
|
||||
fn map_decider_decision(decision: NetworkDecision) -> NetworkDecision {
|
||||
@@ -213,19 +280,488 @@ fn map_decider_decision(decision: NetworkDecision) -> NetworkDecision {
|
||||
}
|
||||
}
|
||||
|
||||
fn domain_policy_audit_event(
|
||||
request: &NetworkPolicyRequest,
|
||||
baseline_outcome: BaselinePolicyOutcome,
|
||||
decision: &NetworkDecision,
|
||||
metadata: &NetworkProxyAuditMetadata,
|
||||
) -> DomainPolicyAuditEvent {
|
||||
let method = request
|
||||
.method
|
||||
.clone()
|
||||
.unwrap_or_else(|| DOMAIN_POLICY_METHOD_NONE.to_string());
|
||||
let client_addr = request
|
||||
.client_addr
|
||||
.clone()
|
||||
.unwrap_or_else(|| DOMAIN_POLICY_CLIENT_UNKNOWN.to_string());
|
||||
|
||||
match decision {
|
||||
NetworkDecision::Allow => {
|
||||
let (source, reason, policy_override) = match baseline_outcome {
|
||||
BaselinePolicyOutcome::Allowed => (
|
||||
NetworkDecisionSource::BaselinePolicy.as_str(),
|
||||
DOMAIN_POLICY_REASON_ALLOWED.to_string(),
|
||||
false,
|
||||
),
|
||||
BaselinePolicyOutcome::Blocked(HostBlockReason::NotAllowed) => (
|
||||
NetworkDecisionSource::Decider.as_str(),
|
||||
HostBlockReason::NotAllowed.as_str().to_string(),
|
||||
true,
|
||||
),
|
||||
BaselinePolicyOutcome::Blocked(reason) => (
|
||||
NetworkDecisionSource::Decider.as_str(),
|
||||
reason.as_str().to_string(),
|
||||
false,
|
||||
),
|
||||
};
|
||||
|
||||
DomainPolicyAuditEvent {
|
||||
decision: DOMAIN_POLICY_DECISION_ALLOW,
|
||||
source,
|
||||
reason,
|
||||
protocol: request.protocol.as_policy_protocol(),
|
||||
domain: request.host.clone(),
|
||||
port: request.port,
|
||||
method,
|
||||
client_addr,
|
||||
policy_override,
|
||||
metadata: metadata.clone(),
|
||||
}
|
||||
}
|
||||
NetworkDecision::Deny {
|
||||
reason,
|
||||
source,
|
||||
decision,
|
||||
} => DomainPolicyAuditEvent {
|
||||
decision: decision.as_str(),
|
||||
source: source.as_str(),
|
||||
reason: reason.clone(),
|
||||
protocol: request.protocol.as_policy_protocol(),
|
||||
domain: request.host.clone(),
|
||||
port: request.port,
|
||||
method,
|
||||
client_addr,
|
||||
policy_override: false,
|
||||
metadata: metadata.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_domain_policy_audit_event(event: &DomainPolicyAuditEvent) {
|
||||
tracing::event!(
|
||||
target: OTEL_NETWORK_PROXY_TARGET,
|
||||
tracing::Level::INFO,
|
||||
event.name = OTEL_DOMAIN_POLICY_EVENT_NAME,
|
||||
event.timestamp = %audit_event_timestamp(),
|
||||
conversation.id = event.metadata.conversation_id.as_deref(),
|
||||
app.version = event.metadata.app_version.as_deref(),
|
||||
auth_mode = event.metadata.auth_mode.as_deref(),
|
||||
originator = event.metadata.originator.as_deref(),
|
||||
user.account_id = event.metadata.account_id.as_deref(),
|
||||
user.email = event.metadata.account_email.as_deref(),
|
||||
terminal.type = event.metadata.terminal_type.as_deref(),
|
||||
model = event.metadata.model.as_deref(),
|
||||
slug = event.metadata.slug.as_deref(),
|
||||
network.policy.scope = DOMAIN_POLICY_SCOPE,
|
||||
network.policy.decision = event.decision,
|
||||
network.policy.source = event.source,
|
||||
network.policy.reason = event.reason.as_str(),
|
||||
network.transport.protocol = event.protocol,
|
||||
server.address = event.domain.as_str(),
|
||||
server.port = event.port,
|
||||
http.request.method = event.method.as_str(),
|
||||
client.address = event.client_addr.as_str(),
|
||||
network.policy.override = event.policy_override,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn emit_non_domain_deny_audit_event(args: NonDomainDenyAuditEventArgs<'_>) {
|
||||
let event = non_domain_deny_audit_event(args);
|
||||
|
||||
tracing::event!(
|
||||
target: OTEL_NETWORK_PROXY_TARGET,
|
||||
tracing::Level::INFO,
|
||||
event.name = OTEL_BLOCK_POLICY_EVENT_NAME,
|
||||
event.timestamp = %audit_event_timestamp(),
|
||||
conversation.id = event.metadata.conversation_id.as_deref(),
|
||||
app.version = event.metadata.app_version.as_deref(),
|
||||
auth_mode = event.metadata.auth_mode.as_deref(),
|
||||
originator = event.metadata.originator.as_deref(),
|
||||
user.account_id = event.metadata.account_id.as_deref(),
|
||||
user.email = event.metadata.account_email.as_deref(),
|
||||
terminal.type = event.metadata.terminal_type.as_deref(),
|
||||
model = event.metadata.model.as_deref(),
|
||||
slug = event.metadata.slug.as_deref(),
|
||||
network.policy.scope = event.source,
|
||||
network.policy.decision = POLICY_DECISION_DENY,
|
||||
network.policy.source = event.source,
|
||||
network.policy.reason = event.reason.as_str(),
|
||||
network.transport.protocol = event.protocol,
|
||||
server.address = event.domain.as_str(),
|
||||
server.port = event.port,
|
||||
http.request.method = event.method.as_str(),
|
||||
client.address = event.client_addr.as_str(),
|
||||
network.policy.override = false,
|
||||
);
|
||||
}
|
||||
|
||||
fn non_domain_deny_audit_event(args: NonDomainDenyAuditEventArgs<'_>) -> NonDomainDenyAuditEvent {
|
||||
debug_assert!(matches!(
|
||||
args.source,
|
||||
NetworkDecisionSource::ModeGuard | NetworkDecisionSource::ProxyState
|
||||
));
|
||||
|
||||
NonDomainDenyAuditEvent {
|
||||
source: args.source.as_str(),
|
||||
reason: args.reason.to_string(),
|
||||
protocol: args.protocol.as_policy_protocol(),
|
||||
domain: args.host.to_string(),
|
||||
port: args.port,
|
||||
method: args.method.unwrap_or(DOMAIN_POLICY_METHOD_NONE).to_string(),
|
||||
client_addr: args
|
||||
.client_addr
|
||||
.unwrap_or(DOMAIN_POLICY_CLIENT_UNKNOWN)
|
||||
.to_string(),
|
||||
metadata: args.metadata.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn audit_event_timestamp() -> String {
|
||||
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use crate::config::NetworkProxySettings;
|
||||
use crate::reasons::REASON_DENIED;
|
||||
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
|
||||
use crate::reasons::REASON_NOT_ALLOWED;
|
||||
use crate::reasons::REASON_NOT_ALLOWED_LOCAL;
|
||||
use crate::reasons::REASON_PROXY_DISABLED;
|
||||
use crate::state::network_proxy_state_for_policy;
|
||||
use chrono::DateTime;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
fn sample_request() -> NetworkPolicyRequest {
|
||||
NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
|
||||
protocol: NetworkProtocol::Http,
|
||||
host: "api.example.com".to_string(),
|
||||
port: 443,
|
||||
client_addr: Some("127.0.0.1:9999".to_string()),
|
||||
method: Some("GET".to_string()),
|
||||
command: None,
|
||||
exec_policy_hint: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn sample_metadata() -> NetworkProxyAuditMetadata {
|
||||
NetworkProxyAuditMetadata {
|
||||
conversation_id: Some("019c4a22-679d-7eb2-aa9c-b95114e5ee8d".to_string()),
|
||||
app_version: Some("0.0.0".to_string()),
|
||||
auth_mode: Some("Chatgpt".to_string()),
|
||||
originator: Some("codex_cli_rs".to_string()),
|
||||
account_id: Some("f7f33107-5fb9-4ee1-8922-3eae76b5b5a0".to_string()),
|
||||
account_email: Some("test@example.com".to_string()),
|
||||
terminal_type: Some("iTerm.app/3.6.5".to_string()),
|
||||
model: Some("gpt-5.3-codex".to_string()),
|
||||
slug: Some("gpt-5.3-codex".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_policy_audit_event_reports_baseline_allow() {
|
||||
let request = sample_request();
|
||||
|
||||
let event = domain_policy_audit_event(
|
||||
&request,
|
||||
BaselinePolicyOutcome::Allowed,
|
||||
&NetworkDecision::Allow,
|
||||
&NetworkProxyAuditMetadata::default(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
event,
|
||||
DomainPolicyAuditEvent {
|
||||
decision: "allow",
|
||||
source: "baseline_policy",
|
||||
reason: "allowed".to_string(),
|
||||
protocol: "http",
|
||||
domain: "api.example.com".to_string(),
|
||||
port: 443,
|
||||
method: "GET".to_string(),
|
||||
client_addr: "127.0.0.1:9999".to_string(),
|
||||
policy_override: false,
|
||||
metadata: NetworkProxyAuditMetadata::default(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_policy_audit_event_reports_baseline_deny_denied() {
|
||||
let request = sample_request();
|
||||
let decision =
|
||||
NetworkDecision::deny_with_source(REASON_DENIED, NetworkDecisionSource::BaselinePolicy);
|
||||
|
||||
let event = domain_policy_audit_event(
|
||||
&request,
|
||||
BaselinePolicyOutcome::Blocked(HostBlockReason::Denied),
|
||||
&decision,
|
||||
&NetworkProxyAuditMetadata::default(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
event,
|
||||
DomainPolicyAuditEvent {
|
||||
decision: "deny",
|
||||
source: "baseline_policy",
|
||||
reason: REASON_DENIED.to_string(),
|
||||
protocol: "http",
|
||||
domain: "api.example.com".to_string(),
|
||||
port: 443,
|
||||
method: "GET".to_string(),
|
||||
client_addr: "127.0.0.1:9999".to_string(),
|
||||
policy_override: false,
|
||||
metadata: NetworkProxyAuditMetadata::default(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_policy_audit_event_reports_baseline_deny_not_allowed_local() {
|
||||
let request = sample_request();
|
||||
let decision = NetworkDecision::deny_with_source(
|
||||
REASON_NOT_ALLOWED_LOCAL,
|
||||
NetworkDecisionSource::BaselinePolicy,
|
||||
);
|
||||
|
||||
let event = domain_policy_audit_event(
|
||||
&request,
|
||||
BaselinePolicyOutcome::Blocked(HostBlockReason::NotAllowedLocal),
|
||||
&decision,
|
||||
&NetworkProxyAuditMetadata::default(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
event,
|
||||
DomainPolicyAuditEvent {
|
||||
decision: "deny",
|
||||
source: "baseline_policy",
|
||||
reason: REASON_NOT_ALLOWED_LOCAL.to_string(),
|
||||
protocol: "http",
|
||||
domain: "api.example.com".to_string(),
|
||||
port: 443,
|
||||
method: "GET".to_string(),
|
||||
client_addr: "127.0.0.1:9999".to_string(),
|
||||
policy_override: false,
|
||||
metadata: NetworkProxyAuditMetadata::default(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_policy_audit_event_reports_decider_override_allow() {
|
||||
let request = sample_request();
|
||||
|
||||
let event = domain_policy_audit_event(
|
||||
&request,
|
||||
BaselinePolicyOutcome::Blocked(HostBlockReason::NotAllowed),
|
||||
&NetworkDecision::Allow,
|
||||
&NetworkProxyAuditMetadata::default(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
event,
|
||||
DomainPolicyAuditEvent {
|
||||
decision: "allow",
|
||||
source: "decider",
|
||||
reason: REASON_NOT_ALLOWED.to_string(),
|
||||
protocol: "http",
|
||||
domain: "api.example.com".to_string(),
|
||||
port: 443,
|
||||
method: "GET".to_string(),
|
||||
client_addr: "127.0.0.1:9999".to_string(),
|
||||
policy_override: true,
|
||||
metadata: NetworkProxyAuditMetadata::default(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_policy_audit_event_reports_decider_ask() {
|
||||
let request = sample_request();
|
||||
let decision = NetworkDecision::ask_with_source(
|
||||
"requires_user_approval",
|
||||
NetworkDecisionSource::Decider,
|
||||
);
|
||||
|
||||
let event = domain_policy_audit_event(
|
||||
&request,
|
||||
BaselinePolicyOutcome::Blocked(HostBlockReason::NotAllowed),
|
||||
&decision,
|
||||
&NetworkProxyAuditMetadata::default(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
event,
|
||||
DomainPolicyAuditEvent {
|
||||
decision: "ask",
|
||||
source: "decider",
|
||||
reason: "requires_user_approval".to_string(),
|
||||
protocol: "http",
|
||||
domain: "api.example.com".to_string(),
|
||||
port: 443,
|
||||
method: "GET".to_string(),
|
||||
client_addr: "127.0.0.1:9999".to_string(),
|
||||
policy_override: false,
|
||||
metadata: NetworkProxyAuditMetadata::default(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_policy_audit_event_includes_metadata() {
|
||||
let request = sample_request();
|
||||
let metadata = sample_metadata();
|
||||
|
||||
let event = domain_policy_audit_event(
|
||||
&request,
|
||||
BaselinePolicyOutcome::Allowed,
|
||||
&NetworkDecision::Allow,
|
||||
&metadata,
|
||||
);
|
||||
|
||||
assert_eq!(event.metadata, metadata);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_domain_deny_audit_event_reports_mode_guard_method_block() {
|
||||
let event = non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
|
||||
source: NetworkDecisionSource::ModeGuard,
|
||||
reason: REASON_METHOD_NOT_ALLOWED,
|
||||
protocol: NetworkProtocol::Http,
|
||||
host: "api.example.com",
|
||||
port: 443,
|
||||
method: Some("POST"),
|
||||
client_addr: Some("127.0.0.1:9999"),
|
||||
metadata: &NetworkProxyAuditMetadata::default(),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
event,
|
||||
NonDomainDenyAuditEvent {
|
||||
source: "mode_guard",
|
||||
reason: REASON_METHOD_NOT_ALLOWED.to_string(),
|
||||
protocol: "http",
|
||||
domain: "api.example.com".to_string(),
|
||||
port: 443,
|
||||
method: "POST".to_string(),
|
||||
client_addr: "127.0.0.1:9999".to_string(),
|
||||
metadata: NetworkProxyAuditMetadata::default(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_domain_deny_audit_event_reports_proxy_state_proxy_disabled() {
|
||||
let event = non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
|
||||
source: NetworkDecisionSource::ProxyState,
|
||||
reason: REASON_PROXY_DISABLED,
|
||||
protocol: NetworkProtocol::Socks5Tcp,
|
||||
host: "api.example.com",
|
||||
port: 443,
|
||||
method: None,
|
||||
client_addr: None,
|
||||
metadata: &NetworkProxyAuditMetadata::default(),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
event,
|
||||
NonDomainDenyAuditEvent {
|
||||
source: "proxy_state",
|
||||
reason: REASON_PROXY_DISABLED.to_string(),
|
||||
protocol: "socks5_tcp",
|
||||
domain: "api.example.com".to_string(),
|
||||
port: 443,
|
||||
method: "none".to_string(),
|
||||
client_addr: "unknown".to_string(),
|
||||
metadata: NetworkProxyAuditMetadata::default(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_domain_deny_audit_event_uses_none_and_unknown_fallbacks() {
|
||||
let event = non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
|
||||
source: NetworkDecisionSource::ModeGuard,
|
||||
reason: REASON_METHOD_NOT_ALLOWED,
|
||||
protocol: NetworkProtocol::Http,
|
||||
host: "api.example.com",
|
||||
port: 80,
|
||||
method: None,
|
||||
client_addr: None,
|
||||
metadata: &NetworkProxyAuditMetadata::default(),
|
||||
});
|
||||
|
||||
assert_eq!(event.method, "none");
|
||||
assert_eq!(event.client_addr, "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_domain_deny_audit_event_supports_unix_socket_sentinel() {
|
||||
let event = non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
|
||||
source: NetworkDecisionSource::ModeGuard,
|
||||
reason: REASON_METHOD_NOT_ALLOWED,
|
||||
protocol: NetworkProtocol::Http,
|
||||
host: "unix-socket",
|
||||
port: 0,
|
||||
method: Some("POST"),
|
||||
client_addr: Some("127.0.0.1:9999"),
|
||||
metadata: &NetworkProxyAuditMetadata::default(),
|
||||
});
|
||||
|
||||
assert_eq!(event.domain, "unix-socket");
|
||||
assert_eq!(event.port, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_domain_deny_audit_event_includes_metadata() {
|
||||
let metadata = sample_metadata();
|
||||
|
||||
let event = non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
|
||||
source: NetworkDecisionSource::ModeGuard,
|
||||
reason: REASON_METHOD_NOT_ALLOWED,
|
||||
protocol: NetworkProtocol::Http,
|
||||
host: "api.example.com",
|
||||
port: 80,
|
||||
method: Some("GET"),
|
||||
client_addr: Some("127.0.0.1:9999"),
|
||||
metadata: &metadata,
|
||||
});
|
||||
|
||||
assert_eq!(event.metadata, metadata);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_policy_audit_target_is_exportable_by_otel_filter() {
|
||||
assert!(OTEL_NETWORK_PROXY_TARGET.starts_with("codex_otel"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_event_timestamp_is_rfc3339_utc_with_millisecond_precision() {
|
||||
let timestamp = audit_event_timestamp();
|
||||
let parsed = DateTime::parse_from_rfc3339(×tamp).unwrap();
|
||||
|
||||
assert_eq!(parsed.offset().local_minus_utc(), 0);
|
||||
assert!(timestamp.ends_with('Z'));
|
||||
assert_eq!(timestamp.matches('.').count(), 1);
|
||||
assert_eq!(timestamp.split('.').nth(1).unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn evaluate_host_policy_invokes_decider_for_not_allowed() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings::default());
|
||||
|
||||
@@ -4,3 +4,4 @@ pub(crate) const REASON_NOT_ALLOWED: &str = "not_allowed";
|
||||
pub(crate) const REASON_NOT_ALLOWED_LOCAL: &str = "not_allowed_local";
|
||||
pub(crate) const REASON_POLICY_DENIED: &str = "policy_denied";
|
||||
pub(crate) const REASON_PROXY_DISABLED: &str = "proxy_disabled";
|
||||
pub(crate) const REASON_UNIX_SOCKET_UNSUPPORTED: &str = "unix_socket_unsupported";
|
||||
|
||||
@@ -114,6 +114,19 @@ pub struct ConfigState {
|
||||
pub blocked: VecDeque<BlockedRequest>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct NetworkProxyAuditMetadata {
|
||||
pub conversation_id: Option<String>,
|
||||
pub app_version: Option<String>,
|
||||
pub auth_mode: Option<String>,
|
||||
pub originator: Option<String>,
|
||||
pub account_id: Option<String>,
|
||||
pub account_email: Option<String>,
|
||||
pub terminal_type: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub slug: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ConfigReloader: Send + Sync {
|
||||
/// Human-readable description of where config is loaded from, for logs.
|
||||
@@ -129,6 +142,7 @@ pub trait ConfigReloader: Send + Sync {
|
||||
pub struct NetworkProxyState {
|
||||
state: Arc<RwLock<ConfigState>>,
|
||||
reloader: Arc<dyn ConfigReloader>,
|
||||
audit_metadata: NetworkProxyAuditMetadata,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for NetworkProxyState {
|
||||
@@ -144,18 +158,36 @@ impl Clone for NetworkProxyState {
|
||||
Self {
|
||||
state: self.state.clone(),
|
||||
reloader: self.reloader.clone(),
|
||||
audit_metadata: self.audit_metadata.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkProxyState {
|
||||
pub fn with_reloader(state: ConfigState, reloader: Arc<dyn ConfigReloader>) -> Self {
|
||||
Self::with_reloader_and_audit_metadata(
|
||||
state,
|
||||
reloader,
|
||||
NetworkProxyAuditMetadata::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn with_reloader_and_audit_metadata(
|
||||
state: ConfigState,
|
||||
reloader: Arc<dyn ConfigReloader>,
|
||||
audit_metadata: NetworkProxyAuditMetadata,
|
||||
) -> Self {
|
||||
Self {
|
||||
state: Arc::new(RwLock::new(state)),
|
||||
reloader,
|
||||
audit_metadata,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn audit_metadata(&self) -> &NetworkProxyAuditMetadata {
|
||||
&self.audit_metadata
|
||||
}
|
||||
|
||||
pub async fn current_cfg(&self) -> Result<NetworkProxyConfig> {
|
||||
// Callers treat `NetworkProxyState` as a live view of policy. We reload-on-demand so edits to
|
||||
// `config.toml` (including Codex-managed writes) take effect without a restart.
|
||||
|
||||
@@ -6,6 +6,8 @@ use crate::network_policy::NetworkPolicyDecision;
|
||||
use crate::network_policy::NetworkPolicyRequest;
|
||||
use crate::network_policy::NetworkPolicyRequestArgs;
|
||||
use crate::network_policy::NetworkProtocol;
|
||||
use crate::network_policy::NonDomainDenyAuditEventArgs;
|
||||
use crate::network_policy::emit_non_domain_deny_audit_event;
|
||||
use crate::network_policy::evaluate_host_policy;
|
||||
use crate::policy::normalize_host;
|
||||
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
|
||||
@@ -152,6 +154,16 @@ async fn handle_socks5_tcp(
|
||||
match app_state.enabled().await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
emit_non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
|
||||
source: NetworkDecisionSource::ProxyState,
|
||||
reason: REASON_PROXY_DISABLED,
|
||||
protocol: NetworkProtocol::Socks5Tcp,
|
||||
host: &host,
|
||||
port,
|
||||
method: None,
|
||||
client_addr: client.as_deref(),
|
||||
metadata: app_state.audit_metadata(),
|
||||
});
|
||||
let details = PolicyDecisionDetails {
|
||||
decision: NetworkPolicyDecision::Deny,
|
||||
reason: REASON_PROXY_DISABLED,
|
||||
@@ -182,6 +194,16 @@ async fn handle_socks5_tcp(
|
||||
|
||||
match app_state.network_mode().await {
|
||||
Ok(NetworkMode::Limited) => {
|
||||
emit_non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
|
||||
source: NetworkDecisionSource::ModeGuard,
|
||||
reason: REASON_METHOD_NOT_ALLOWED,
|
||||
protocol: NetworkProtocol::Socks5Tcp,
|
||||
host: &host,
|
||||
port,
|
||||
method: None,
|
||||
client_addr: client.as_deref(),
|
||||
metadata: app_state.audit_metadata(),
|
||||
});
|
||||
let details = PolicyDecisionDetails {
|
||||
decision: NetworkPolicyDecision::Deny,
|
||||
reason: REASON_METHOD_NOT_ALLOWED,
|
||||
@@ -289,6 +311,16 @@ async fn inspect_socks5_udp(
|
||||
match state.enabled().await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
emit_non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
|
||||
source: NetworkDecisionSource::ProxyState,
|
||||
reason: REASON_PROXY_DISABLED,
|
||||
protocol: NetworkProtocol::Socks5Udp,
|
||||
host: &host,
|
||||
port,
|
||||
method: None,
|
||||
client_addr: client.as_deref(),
|
||||
metadata: state.audit_metadata(),
|
||||
});
|
||||
let details = PolicyDecisionDetails {
|
||||
decision: NetworkPolicyDecision::Deny,
|
||||
reason: REASON_PROXY_DISABLED,
|
||||
@@ -319,6 +351,16 @@ async fn inspect_socks5_udp(
|
||||
|
||||
match state.network_mode().await {
|
||||
Ok(NetworkMode::Limited) => {
|
||||
emit_non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
|
||||
source: NetworkDecisionSource::ModeGuard,
|
||||
reason: REASON_METHOD_NOT_ALLOWED,
|
||||
protocol: NetworkProtocol::Socks5Udp,
|
||||
host: &host,
|
||||
port,
|
||||
method: None,
|
||||
client_addr: client.as_deref(),
|
||||
metadata: state.audit_metadata(),
|
||||
});
|
||||
let details = PolicyDecisionDetails {
|
||||
decision: NetworkPolicyDecision::Deny,
|
||||
reason: REASON_METHOD_NOT_ALLOWED,
|
||||
|
||||
343
codex-rs/network-proxy/src/standalone_otel.rs
Normal file
343
codex-rs/network-proxy/src/standalone_otel.rs
Normal file
@@ -0,0 +1,343 @@
|
||||
use codex_otel::config::OtelExporter;
|
||||
use codex_otel::config::OtelHttpProtocol;
|
||||
use codex_otel::config::OtelSettings;
|
||||
use codex_otel::config::OtelTlsConfig;
|
||||
use codex_otel::otel_provider::OtelProvider;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const DEFAULT_OTEL_ENVIRONMENT: &str = "dev";
|
||||
const STANDALONE_SERVICE_NAME: &str = "codex_network_proxy";
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub(crate) struct StandaloneOtelConfigToml {
|
||||
#[serde(rename = "log_user_prompt")]
|
||||
pub(crate) _log_user_prompt: Option<bool>,
|
||||
pub(crate) environment: Option<String>,
|
||||
pub(crate) exporter: Option<StandaloneOtelExporterKind>,
|
||||
pub(crate) trace_exporter: Option<StandaloneOtelExporterKind>,
|
||||
pub(crate) metrics_exporter: Option<StandaloneOtelExporterKind>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub(crate) enum StandaloneOtelHttpProtocol {
|
||||
Binary,
|
||||
Json,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub(crate) struct StandaloneOtelTlsConfigToml {
|
||||
pub(crate) ca_certificate: Option<AbsolutePathBuf>,
|
||||
pub(crate) client_certificate: Option<AbsolutePathBuf>,
|
||||
pub(crate) client_private_key: Option<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub(crate) enum StandaloneOtelExporterKind {
|
||||
None,
|
||||
Statsig,
|
||||
OtlpHttp {
|
||||
endpoint: String,
|
||||
#[serde(default)]
|
||||
headers: HashMap<String, String>,
|
||||
protocol: StandaloneOtelHttpProtocol,
|
||||
#[serde(default)]
|
||||
tls: Option<StandaloneOtelTlsConfigToml>,
|
||||
},
|
||||
OtlpGrpc {
|
||||
endpoint: String,
|
||||
#[serde(default)]
|
||||
headers: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
tls: Option<StandaloneOtelTlsConfigToml>,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct StandaloneConfigToml {
|
||||
#[serde(default)]
|
||||
otel: Option<StandaloneOtelConfigToml>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct ResolvedStandaloneOtelConfig {
|
||||
pub(crate) environment: String,
|
||||
pub(crate) exporter: StandaloneOtelExporterKind,
|
||||
pub(crate) trace_exporter: StandaloneOtelExporterKind,
|
||||
pub(crate) metrics_exporter: StandaloneOtelExporterKind,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn parse_otel_config(raw: &str) -> Result<StandaloneOtelConfigToml, toml::de::Error> {
|
||||
let config: StandaloneConfigToml = toml::from_str(raw)?;
|
||||
Ok(config.otel.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_otel_config(
|
||||
config: StandaloneOtelConfigToml,
|
||||
analytics_enabled: bool,
|
||||
) -> ResolvedStandaloneOtelConfig {
|
||||
let exporter = config.exporter.unwrap_or(StandaloneOtelExporterKind::None);
|
||||
let trace_exporter = config.trace_exporter.unwrap_or_else(|| exporter.clone());
|
||||
let metrics_exporter = if analytics_enabled {
|
||||
config
|
||||
.metrics_exporter
|
||||
.unwrap_or(StandaloneOtelExporterKind::Statsig)
|
||||
} else {
|
||||
StandaloneOtelExporterKind::None
|
||||
};
|
||||
|
||||
ResolvedStandaloneOtelConfig {
|
||||
environment: config
|
||||
.environment
|
||||
.unwrap_or_else(|| DEFAULT_OTEL_ENVIRONMENT.to_string()),
|
||||
exporter,
|
||||
trace_exporter,
|
||||
metrics_exporter,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_provider(
|
||||
config: StandaloneOtelConfigToml,
|
||||
analytics_enabled: bool,
|
||||
codex_home: PathBuf,
|
||||
service_version: &str,
|
||||
) -> Result<Option<OtelProvider>, Box<dyn Error>> {
|
||||
let resolved = resolve_otel_config(config, analytics_enabled);
|
||||
let settings = OtelSettings {
|
||||
environment: resolved.environment,
|
||||
service_name: STANDALONE_SERVICE_NAME.to_string(),
|
||||
service_version: service_version.to_string(),
|
||||
codex_home,
|
||||
exporter: to_otel_exporter(resolved.exporter),
|
||||
trace_exporter: to_otel_exporter(resolved.trace_exporter),
|
||||
metrics_exporter: to_otel_exporter(resolved.metrics_exporter),
|
||||
runtime_metrics: false,
|
||||
};
|
||||
OtelProvider::from(&settings)
|
||||
}
|
||||
|
||||
fn to_otel_exporter(exporter: StandaloneOtelExporterKind) -> OtelExporter {
|
||||
match exporter {
|
||||
StandaloneOtelExporterKind::None => OtelExporter::None,
|
||||
StandaloneOtelExporterKind::Statsig => OtelExporter::Statsig,
|
||||
StandaloneOtelExporterKind::OtlpHttp {
|
||||
endpoint,
|
||||
headers,
|
||||
protocol,
|
||||
tls,
|
||||
} => OtelExporter::OtlpHttp {
|
||||
endpoint,
|
||||
headers,
|
||||
protocol: to_otel_http_protocol(protocol),
|
||||
tls: to_otel_tls_config(tls),
|
||||
},
|
||||
StandaloneOtelExporterKind::OtlpGrpc {
|
||||
endpoint,
|
||||
headers,
|
||||
tls,
|
||||
} => OtelExporter::OtlpGrpc {
|
||||
endpoint,
|
||||
headers,
|
||||
tls: to_otel_tls_config(tls),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn to_otel_http_protocol(protocol: StandaloneOtelHttpProtocol) -> OtelHttpProtocol {
|
||||
match protocol {
|
||||
StandaloneOtelHttpProtocol::Binary => OtelHttpProtocol::Binary,
|
||||
StandaloneOtelHttpProtocol::Json => OtelHttpProtocol::Json,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_otel_tls_config(config: Option<StandaloneOtelTlsConfigToml>) -> Option<OtelTlsConfig> {
|
||||
config.map(|config| OtelTlsConfig {
|
||||
ca_certificate: config.ca_certificate,
|
||||
client_certificate: config.client_certificate,
|
||||
client_private_key: config.client_private_key,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn parse_minimal_config_uses_core_defaults() {
|
||||
let parsed = parse_otel_config(
|
||||
r#"
|
||||
[network]
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let resolved = resolve_otel_config(parsed, true);
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ResolvedStandaloneOtelConfig {
|
||||
environment: "dev".to_string(),
|
||||
exporter: StandaloneOtelExporterKind::None,
|
||||
trace_exporter: StandaloneOtelExporterKind::None,
|
||||
metrics_exporter: StandaloneOtelExporterKind::Statsig,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_otlp_http_exporter_defaults_trace_to_exporter() {
|
||||
let parsed = parse_otel_config(
|
||||
r#"
|
||||
[otel]
|
||||
environment = "staging"
|
||||
exporter = { otlp-http = { endpoint = "https://collector.example/v1/logs", protocol = "json", headers = { "x-api-key" = "abc" } } }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let resolved = resolve_otel_config(parsed, true);
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ResolvedStandaloneOtelConfig {
|
||||
environment: "staging".to_string(),
|
||||
exporter: StandaloneOtelExporterKind::OtlpHttp {
|
||||
endpoint: "https://collector.example/v1/logs".to_string(),
|
||||
headers: HashMap::from([("x-api-key".to_string(), "abc".to_string())]),
|
||||
protocol: StandaloneOtelHttpProtocol::Json,
|
||||
tls: None,
|
||||
},
|
||||
trace_exporter: StandaloneOtelExporterKind::OtlpHttp {
|
||||
endpoint: "https://collector.example/v1/logs".to_string(),
|
||||
headers: HashMap::from([("x-api-key".to_string(), "abc".to_string())]),
|
||||
protocol: StandaloneOtelHttpProtocol::Json,
|
||||
tls: None,
|
||||
},
|
||||
metrics_exporter: StandaloneOtelExporterKind::Statsig,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_trace_exporter_independently_of_log_exporter() {
|
||||
let parsed = parse_otel_config(
|
||||
r#"
|
||||
[otel]
|
||||
trace_exporter = { otlp-grpc = { endpoint = "https://collector.example:4317" } }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let resolved = resolve_otel_config(parsed, true);
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ResolvedStandaloneOtelConfig {
|
||||
environment: "dev".to_string(),
|
||||
exporter: StandaloneOtelExporterKind::None,
|
||||
trace_exporter: StandaloneOtelExporterKind::OtlpGrpc {
|
||||
endpoint: "https://collector.example:4317".to_string(),
|
||||
headers: HashMap::new(),
|
||||
tls: None,
|
||||
},
|
||||
metrics_exporter: StandaloneOtelExporterKind::Statsig,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_log_user_prompt_field_without_error() {
|
||||
let parsed = parse_otel_config(
|
||||
r#"
|
||||
[otel]
|
||||
log_user_prompt = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let resolved = resolve_otel_config(parsed, true);
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ResolvedStandaloneOtelConfig {
|
||||
environment: "dev".to_string(),
|
||||
exporter: StandaloneOtelExporterKind::None,
|
||||
trace_exporter: StandaloneOtelExporterKind::None,
|
||||
metrics_exporter: StandaloneOtelExporterKind::Statsig,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_unknown_otel_field_forwards_compatibly() {
|
||||
let parsed = parse_otel_config(
|
||||
r#"
|
||||
[otel]
|
||||
future_field = "ignored"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let resolved = resolve_otel_config(parsed, true);
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ResolvedStandaloneOtelConfig {
|
||||
environment: "dev".to_string(),
|
||||
exporter: StandaloneOtelExporterKind::None,
|
||||
trace_exporter: StandaloneOtelExporterKind::None,
|
||||
metrics_exporter: StandaloneOtelExporterKind::Statsig,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_metrics_exporter_defaults_to_none_when_analytics_disabled() {
|
||||
let parsed = parse_otel_config(
|
||||
r#"
|
||||
[otel]
|
||||
environment = "staging"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let resolved = resolve_otel_config(parsed, false);
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ResolvedStandaloneOtelConfig {
|
||||
environment: "staging".to_string(),
|
||||
exporter: StandaloneOtelExporterKind::None,
|
||||
trace_exporter: StandaloneOtelExporterKind::None,
|
||||
metrics_exporter: StandaloneOtelExporterKind::None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_metrics_exporter_is_none_when_analytics_disabled_even_if_explicitly_set() {
|
||||
let parsed = parse_otel_config(
|
||||
r#"
|
||||
[otel]
|
||||
metrics_exporter = { otlp-grpc = { endpoint = "https://collector.example:4317" } }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let resolved = resolve_otel_config(parsed, false);
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ResolvedStandaloneOtelConfig {
|
||||
environment: "dev".to_string(),
|
||||
exporter: StandaloneOtelExporterKind::None,
|
||||
trace_exporter: StandaloneOtelExporterKind::None,
|
||||
metrics_exporter: StandaloneOtelExporterKind::None,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use std::collections::HashSet;
|
||||
|
||||
pub use crate::runtime::BlockedRequest;
|
||||
pub use crate::runtime::BlockedRequestArgs;
|
||||
pub use crate::runtime::NetworkProxyAuditMetadata;
|
||||
pub use crate::runtime::NetworkProxyState;
|
||||
#[cfg(test)]
|
||||
pub(crate) use crate::runtime::network_proxy_state_for_policy;
|
||||
|
||||
Reference in New Issue
Block a user