refactor: add wasm-safe shims for host-only runtime crates

This commit is contained in:
Jeremy lewi
2026-04-14 14:42:28 -07:00
parent dfbd21e294
commit a050c5ee91
17 changed files with 2051 additions and 193 deletions

View File

@@ -15,16 +15,18 @@ workspace = true
[dependencies]
codex-app-server-protocol = { workspace = true }
codex-git-utils = { workspace = true }
codex-login = { workspace = true }
codex-plugin = { workspace = true }
codex-protocol = { workspace = true }
serde = { workspace = true, features = ["derive"] }
sha1 = { workspace = true }
tracing = { workspace = true, features = ["log"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
codex-login = { workspace = true }
tokio = { workspace = true, features = [
"macros",
"rt-multi-thread",
] }
tracing = { workspace = true, features = ["log"] }
[dev-dependencies]
pretty_assertions = { workspace = true }

View File

@@ -1,17 +1,7 @@
mod analytics_client;
#[cfg(not(target_arch = "wasm32"))]
include!("native.rs");
pub use analytics_client::AnalyticsEventsClient;
pub use analytics_client::AnalyticsFact;
pub use analytics_client::AnalyticsReducer;
pub use analytics_client::AppInvocation;
pub use analytics_client::AppMentionedInput;
pub use analytics_client::AppUsedInput;
pub use analytics_client::CustomAnalyticsFact;
pub use analytics_client::InvocationType;
pub use analytics_client::PluginState;
pub use analytics_client::PluginStateChangedInput;
pub use analytics_client::PluginUsedInput;
pub use analytics_client::SkillInvocation;
pub use analytics_client::SkillInvokedInput;
pub use analytics_client::TrackEventsContext;
pub use analytics_client::build_track_events_context;
#[cfg(target_arch = "wasm32")]
mod wasm;
#[cfg(target_arch = "wasm32")]
pub use wasm::*;

View File

@@ -0,0 +1,17 @@
mod analytics_client;
pub use analytics_client::AnalyticsEventsClient;
pub use analytics_client::AnalyticsFact;
pub use analytics_client::AnalyticsReducer;
pub use analytics_client::AppInvocation;
pub use analytics_client::AppMentionedInput;
pub use analytics_client::AppUsedInput;
pub use analytics_client::CustomAnalyticsFact;
pub use analytics_client::InvocationType;
pub use analytics_client::PluginState;
pub use analytics_client::PluginStateChangedInput;
pub use analytics_client::PluginUsedInput;
pub use analytics_client::SkillInvocation;
pub use analytics_client::SkillInvokedInput;
pub use analytics_client::TrackEventsContext;
pub use analytics_client::build_track_events_context;

View File

@@ -0,0 +1,170 @@
use codex_plugin::PluginTelemetryMetadata;
use codex_protocol::protocol::SkillScope;
use serde::Serialize;
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Clone)]
pub struct TrackEventsContext {
pub model_slug: String,
pub thread_id: String,
pub turn_id: String,
}
pub fn build_track_events_context(
model_slug: String,
thread_id: String,
turn_id: String,
) -> TrackEventsContext {
TrackEventsContext {
model_slug,
thread_id,
turn_id,
}
}
#[derive(Clone, Debug)]
pub struct SkillInvocation {
pub skill_name: String,
pub skill_scope: SkillScope,
pub skill_path: PathBuf,
pub invocation_type: InvocationType,
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum InvocationType {
Explicit,
Implicit,
}
pub struct AppInvocation {
pub connector_id: Option<String>,
pub app_name: Option<String>,
pub invocation_type: Option<InvocationType>,
}
pub enum AnalyticsFact {
Initialize {
connection_id: u64,
params: codex_app_server_protocol::InitializeParams,
},
Request {
connection_id: u64,
request_id: codex_app_server_protocol::RequestId,
request: Box<codex_app_server_protocol::ClientRequest>,
},
Response {
connection_id: u64,
response: Box<codex_app_server_protocol::ClientResponse>,
},
Notification(Box<codex_app_server_protocol::ServerNotification>),
Custom(CustomAnalyticsFact),
}
pub enum CustomAnalyticsFact {
SkillInvoked(SkillInvokedInput),
AppMentioned(AppMentionedInput),
AppUsed(AppUsedInput),
PluginUsed(PluginUsedInput),
PluginStateChanged(PluginStateChangedInput),
}
pub struct SkillInvokedInput {
pub tracking: TrackEventsContext,
pub invocations: Vec<SkillInvocation>,
}
pub struct AppMentionedInput {
pub tracking: TrackEventsContext,
pub mentions: Vec<AppInvocation>,
}
pub struct AppUsedInput {
pub tracking: TrackEventsContext,
pub app: AppInvocation,
}
pub struct PluginUsedInput {
pub tracking: TrackEventsContext,
pub plugin: PluginTelemetryMetadata,
}
pub struct PluginStateChangedInput {
pub plugin: PluginTelemetryMetadata,
pub state: PluginState,
}
#[derive(Clone, Copy)]
pub enum PluginState {
Installed,
Uninstalled,
Enabled,
Disabled,
}
#[derive(Default)]
pub struct AnalyticsReducer;
#[derive(Clone, Default)]
pub struct AnalyticsEventsClient {
analytics_enabled: Option<bool>,
_marker: Arc<()>,
}
impl AnalyticsEventsClient {
pub fn new<T>(
_auth_manager: Arc<T>,
_base_url: String,
analytics_enabled: Option<bool>,
) -> Self {
Self {
analytics_enabled,
_marker: Arc::new(()),
}
}
pub fn track_skill_invocations(
&self,
_tracking: TrackEventsContext,
_invocations: Vec<SkillInvocation>,
) {
let _ = self.analytics_enabled;
}
pub fn track_app_mentioned(
&self,
_tracking: TrackEventsContext,
_mentions: Vec<AppInvocation>,
) {
let _ = self.analytics_enabled;
}
pub fn track_app_used(&self, _tracking: TrackEventsContext, _app: AppInvocation) {
let _ = self.analytics_enabled;
}
pub fn track_plugin_used(
&self,
_tracking: TrackEventsContext,
_plugin: PluginTelemetryMetadata,
) {
let _ = self.analytics_enabled;
}
pub fn track_plugin_installed(&self, _plugin: PluginTelemetryMetadata) {
let _ = self.analytics_enabled;
}
pub fn track_plugin_uninstalled(&self, _plugin: PluginTelemetryMetadata) {
let _ = self.analytics_enabled;
}
pub fn track_plugin_enabled(&self, _plugin: PluginTelemetryMetadata) {
let _ = self.analytics_enabled;
}
pub fn track_plugin_disabled(&self, _plugin: PluginTelemetryMetadata) {
let _ = self.analytics_enabled;
}
}

View File

@@ -15,17 +15,20 @@ path = "src/bin/codex-exec-server.rs"
workspace = true
[dependencies]
arc-swap = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-app-server-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-pty = { workspace = true }
futures = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
arc-swap = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-utils-pty = { workspace = true }
futures = { workspace = true }
tokio = { workspace = true, features = [
"fs",
"io-std",
@@ -38,7 +41,9 @@ tokio = { workspace = true, features = [
"time",
] }
tokio-tungstenite = { workspace = true }
tracing = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
tokio = { workspace = true, features = ["io-util", "macros", "rt", "sync"] }
[dev-dependencies]
anyhow = { workspace = true }

View File

@@ -1,5 +1,7 @@
#[cfg(not(target_arch = "wasm32"))]
use clap::Parser;
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Parser)]
struct ExecServerArgs {
/// Transport endpoint URL. Supported values: `ws://IP:PORT` (default).
@@ -11,8 +13,14 @@ struct ExecServerArgs {
listen: String,
}
#[cfg(not(target_arch = "wasm32"))]
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let args = ExecServerArgs::parse();
codex_exec_server::run_main_with_listen_url(&args.listen).await
}
#[cfg(target_arch = "wasm32")]
fn main() {
panic!("codex-exec-server binary is unavailable on wasm32");
}

View File

@@ -1,67 +1,7 @@
mod client;
mod client_api;
mod connection;
mod environment;
mod file_system;
mod local_file_system;
mod local_process;
mod process;
mod process_id;
mod protocol;
mod remote_file_system;
mod remote_process;
mod rpc;
mod server;
#[cfg(not(target_arch = "wasm32"))]
include!("native.rs");
pub use client::ExecServerClient;
pub use client::ExecServerError;
pub use client_api::ExecServerClientConnectOptions;
pub use client_api::RemoteExecServerConnectArgs;
pub use codex_app_server_protocol::FsCopyParams;
pub use codex_app_server_protocol::FsCopyResponse;
pub use codex_app_server_protocol::FsCreateDirectoryParams;
pub use codex_app_server_protocol::FsCreateDirectoryResponse;
pub use codex_app_server_protocol::FsGetMetadataParams;
pub use codex_app_server_protocol::FsGetMetadataResponse;
pub use codex_app_server_protocol::FsReadDirectoryParams;
pub use codex_app_server_protocol::FsReadDirectoryResponse;
pub use codex_app_server_protocol::FsReadFileParams;
pub use codex_app_server_protocol::FsReadFileResponse;
pub use codex_app_server_protocol::FsRemoveParams;
pub use codex_app_server_protocol::FsRemoveResponse;
pub use codex_app_server_protocol::FsWriteFileParams;
pub use codex_app_server_protocol::FsWriteFileResponse;
pub use environment::CODEX_EXEC_SERVER_URL_ENV_VAR;
pub use environment::Environment;
pub use environment::EnvironmentManager;
pub use environment::ExecutorEnvironment;
pub use file_system::CopyOptions;
pub use file_system::CreateDirectoryOptions;
pub use file_system::ExecutorFileSystem;
pub use file_system::FileMetadata;
pub use file_system::FileSystemResult;
pub use file_system::ReadDirectoryEntry;
pub use file_system::RemoveOptions;
pub use process::ExecBackend;
pub use process::ExecProcess;
pub use process::StartedExecProcess;
pub use process_id::ProcessId;
pub use protocol::ExecClosedNotification;
pub use protocol::ExecExitedNotification;
pub use protocol::ExecOutputDeltaNotification;
pub use protocol::ExecOutputStream;
pub use protocol::ExecParams;
pub use protocol::ExecResponse;
pub use protocol::InitializeParams;
pub use protocol::InitializeResponse;
pub use protocol::ReadParams;
pub use protocol::ReadResponse;
pub use protocol::TerminateParams;
pub use protocol::TerminateResponse;
pub use protocol::WriteParams;
pub use protocol::WriteResponse;
pub use protocol::WriteStatus;
pub use server::DEFAULT_LISTEN_URL;
pub use server::ExecServerListenUrlParseError;
pub use server::run_main;
pub use server::run_main_with_listen_url;
#[cfg(target_arch = "wasm32")]
mod wasm;
#[cfg(target_arch = "wasm32")]
pub use wasm::*;

View File

@@ -0,0 +1,67 @@
mod client;
mod client_api;
mod connection;
mod environment;
mod file_system;
mod local_file_system;
mod local_process;
mod process;
mod process_id;
mod protocol;
mod remote_file_system;
mod remote_process;
mod rpc;
mod server;
pub use client::ExecServerClient;
pub use client::ExecServerError;
pub use client_api::ExecServerClientConnectOptions;
pub use client_api::RemoteExecServerConnectArgs;
pub use codex_app_server_protocol::FsCopyParams;
pub use codex_app_server_protocol::FsCopyResponse;
pub use codex_app_server_protocol::FsCreateDirectoryParams;
pub use codex_app_server_protocol::FsCreateDirectoryResponse;
pub use codex_app_server_protocol::FsGetMetadataParams;
pub use codex_app_server_protocol::FsGetMetadataResponse;
pub use codex_app_server_protocol::FsReadDirectoryParams;
pub use codex_app_server_protocol::FsReadDirectoryResponse;
pub use codex_app_server_protocol::FsReadFileParams;
pub use codex_app_server_protocol::FsReadFileResponse;
pub use codex_app_server_protocol::FsRemoveParams;
pub use codex_app_server_protocol::FsRemoveResponse;
pub use codex_app_server_protocol::FsWriteFileParams;
pub use codex_app_server_protocol::FsWriteFileResponse;
pub use environment::CODEX_EXEC_SERVER_URL_ENV_VAR;
pub use environment::Environment;
pub use environment::EnvironmentManager;
pub use environment::ExecutorEnvironment;
pub use file_system::CopyOptions;
pub use file_system::CreateDirectoryOptions;
pub use file_system::ExecutorFileSystem;
pub use file_system::FileMetadata;
pub use file_system::FileSystemResult;
pub use file_system::ReadDirectoryEntry;
pub use file_system::RemoveOptions;
pub use process::ExecBackend;
pub use process::ExecProcess;
pub use process::StartedExecProcess;
pub use process_id::ProcessId;
pub use protocol::ExecClosedNotification;
pub use protocol::ExecExitedNotification;
pub use protocol::ExecOutputDeltaNotification;
pub use protocol::ExecOutputStream;
pub use protocol::ExecParams;
pub use protocol::ExecResponse;
pub use protocol::InitializeParams;
pub use protocol::InitializeResponse;
pub use protocol::ReadParams;
pub use protocol::ReadResponse;
pub use protocol::TerminateParams;
pub use protocol::TerminateResponse;
pub use protocol::WriteParams;
pub use protocol::WriteResponse;
pub use protocol::WriteStatus;
pub use server::DEFAULT_LISTEN_URL;
pub use server::ExecServerListenUrlParseError;
pub use server::run_main;
pub use server::run_main_with_listen_url;

View File

@@ -0,0 +1,415 @@
#[path = "file_system.rs"]
mod file_system;
#[path = "process_id.rs"]
mod process_id;
#[path = "protocol.rs"]
mod protocol;
use std::io;
use std::sync::Arc;
use async_trait::async_trait;
use tokio::sync::OnceCell;
use tokio::sync::watch;
pub use codex_app_server_protocol::FsCopyParams;
pub use codex_app_server_protocol::FsCopyResponse;
pub use codex_app_server_protocol::FsCreateDirectoryParams;
pub use codex_app_server_protocol::FsCreateDirectoryResponse;
pub use codex_app_server_protocol::FsGetMetadataParams;
pub use codex_app_server_protocol::FsGetMetadataResponse;
pub use codex_app_server_protocol::FsReadDirectoryParams;
pub use codex_app_server_protocol::FsReadDirectoryResponse;
pub use codex_app_server_protocol::FsReadFileParams;
pub use codex_app_server_protocol::FsReadFileResponse;
pub use codex_app_server_protocol::FsRemoveParams;
pub use codex_app_server_protocol::FsRemoveResponse;
pub use codex_app_server_protocol::FsWriteFileParams;
pub use codex_app_server_protocol::FsWriteFileResponse;
pub use file_system::CopyOptions;
pub use file_system::CreateDirectoryOptions;
pub use file_system::ExecutorFileSystem;
pub use file_system::FileMetadata;
pub use file_system::FileSystemResult;
pub use file_system::ReadDirectoryEntry;
pub use file_system::RemoveOptions;
pub use process_id::ProcessId;
pub use protocol::ExecClosedNotification;
pub use protocol::ExecExitedNotification;
pub use protocol::ExecOutputDeltaNotification;
pub use protocol::ExecOutputStream;
pub use protocol::ExecParams;
pub use protocol::ExecResponse;
pub use protocol::InitializeParams;
pub use protocol::InitializeResponse;
pub use protocol::ReadParams;
pub use protocol::ReadResponse;
pub use protocol::TerminateParams;
pub use protocol::TerminateResponse;
pub use protocol::WriteParams;
pub use protocol::WriteResponse;
pub use protocol::WriteStatus;
pub struct StartedExecProcess {
pub process: Arc<dyn ExecProcess>,
}
#[async_trait]
pub trait ExecProcess: Send + Sync {
fn process_id(&self) -> &ProcessId;
fn subscribe_wake(&self) -> watch::Receiver<u64>;
async fn read(
&self,
after_seq: Option<u64>,
max_bytes: Option<usize>,
wait_ms: Option<u64>,
) -> Result<ReadResponse, ExecServerError>;
async fn write(&self, chunk: Vec<u8>) -> Result<WriteResponse, ExecServerError>;
async fn terminate(&self) -> Result<(), ExecServerError>;
}
#[async_trait]
pub trait ExecBackend: Send + Sync {
async fn start(&self, params: ExecParams) -> Result<StartedExecProcess, ExecServerError>;
}
pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL";
pub const DEFAULT_LISTEN_URL: &str = "ws://127.0.0.1:0";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExecServerClientConnectOptions {
pub client_name: String,
pub initialize_timeout: std::time::Duration,
}
impl Default for ExecServerClientConnectOptions {
fn default() -> Self {
Self {
client_name: "codex-core".to_string(),
initialize_timeout: std::time::Duration::from_secs(10),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteExecServerConnectArgs {
pub websocket_url: String,
pub client_name: String,
pub connect_timeout: std::time::Duration,
pub initialize_timeout: std::time::Duration,
}
impl RemoteExecServerConnectArgs {
pub fn new(websocket_url: String, client_name: String) -> Self {
Self {
websocket_url,
client_name,
connect_timeout: std::time::Duration::from_secs(10),
initialize_timeout: std::time::Duration::from_secs(10),
}
}
}
impl From<RemoteExecServerConnectArgs> for ExecServerClientConnectOptions {
fn from(value: RemoteExecServerConnectArgs) -> Self {
Self {
client_name: value.client_name,
initialize_timeout: value.initialize_timeout,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ExecServerError {
#[error("exec-server is unavailable on wasm32")]
Unsupported,
#[error("exec-server transport closed")]
Closed,
#[error("failed to serialize or deserialize exec-server JSON: {0}")]
Json(#[from] serde_json::Error),
#[error("exec-server protocol error: {0}")]
Protocol(String),
#[error("exec-server rejected request ({code}): {message}")]
Server { code: i64, message: String },
}
#[derive(Debug, thiserror::Error)]
pub enum ExecServerListenUrlParseError {
#[error("exec-server listen URLs are unavailable on wasm32: {0}")]
UnsupportedListenUrl(String),
}
#[derive(Clone, Default)]
pub struct ExecServerClient;
impl ExecServerClient {
pub async fn connect_websocket(
_args: RemoteExecServerConnectArgs,
) -> Result<Self, ExecServerError> {
Err(ExecServerError::Unsupported)
}
pub async fn initialize(
&self,
_options: ExecServerClientConnectOptions,
) -> Result<InitializeResponse, ExecServerError> {
Err(ExecServerError::Unsupported)
}
pub async fn exec(&self, _params: ExecParams) -> Result<ExecResponse, ExecServerError> {
Err(ExecServerError::Unsupported)
}
pub async fn read(&self, _params: ReadParams) -> Result<ReadResponse, ExecServerError> {
Err(ExecServerError::Unsupported)
}
pub async fn write(
&self,
_process_id: &ProcessId,
_chunk: Vec<u8>,
) -> Result<WriteResponse, ExecServerError> {
Err(ExecServerError::Unsupported)
}
pub async fn terminate(
&self,
_process_id: &ProcessId,
) -> Result<TerminateResponse, ExecServerError> {
Err(ExecServerError::Unsupported)
}
}
pub trait ExecutorEnvironment: Send + Sync {
fn get_exec_backend(&self) -> Arc<dyn ExecBackend>;
}
#[derive(Default)]
pub struct EnvironmentManager {
exec_server_url: Option<String>,
current_environment: OnceCell<Arc<Environment>>,
}
impl EnvironmentManager {
pub fn new(exec_server_url: Option<String>) -> Self {
Self {
exec_server_url: normalize_exec_server_url(exec_server_url),
current_environment: OnceCell::new(),
}
}
pub fn from_env() -> Self {
Self::new(std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok())
}
pub fn exec_server_url(&self) -> Option<&str> {
self.exec_server_url.as_deref()
}
pub async fn current(&self) -> Result<Arc<Environment>, ExecServerError> {
self.current_environment
.get_or_init(|| async { Arc::new(Environment::default()) })
.await;
self.current_environment
.get()
.cloned()
.ok_or(ExecServerError::Unsupported)
}
}
#[derive(Clone)]
pub struct Environment {
exec_server_url: Option<String>,
exec_backend: Arc<dyn ExecBackend>,
filesystem: Arc<dyn ExecutorFileSystem>,
}
impl Default for Environment {
fn default() -> Self {
Self {
exec_server_url: None,
exec_backend: Arc::new(NoopExecBackend),
filesystem: Arc::new(UnsupportedFileSystem),
}
}
}
impl Environment {
pub async fn create(exec_server_url: Option<String>) -> Result<Self, ExecServerError> {
Ok(Self {
exec_server_url: normalize_exec_server_url(exec_server_url),
..Self::default()
})
}
pub fn exec_server_url(&self) -> Option<&str> {
self.exec_server_url.as_deref()
}
pub fn get_exec_backend(&self) -> Arc<dyn ExecBackend> {
Arc::clone(&self.exec_backend)
}
pub fn get_filesystem(&self) -> Arc<dyn ExecutorFileSystem> {
Arc::clone(&self.filesystem)
}
}
impl ExecutorEnvironment for Environment {
fn get_exec_backend(&self) -> Arc<dyn ExecBackend> {
Arc::clone(&self.exec_backend)
}
}
#[derive(Debug, Default)]
struct NoopExecBackend;
#[async_trait]
impl ExecBackend for NoopExecBackend {
async fn start(&self, params: ExecParams) -> Result<StartedExecProcess, ExecServerError> {
Ok(StartedExecProcess {
process: Arc::new(NoopExecProcess::new(params.process_id)),
})
}
}
#[derive(Debug)]
struct NoopExecProcess {
process_id: ProcessId,
wake_tx: watch::Sender<u64>,
}
impl NoopExecProcess {
fn new(process_id: ProcessId) -> Self {
let (wake_tx, _wake_rx) = watch::channel(0);
Self {
process_id,
wake_tx,
}
}
}
#[async_trait]
impl ExecProcess for NoopExecProcess {
fn process_id(&self) -> &ProcessId {
&self.process_id
}
fn subscribe_wake(&self) -> watch::Receiver<u64> {
self.wake_tx.subscribe()
}
async fn read(
&self,
_after_seq: Option<u64>,
_max_bytes: Option<usize>,
_wait_ms: Option<u64>,
) -> Result<ReadResponse, ExecServerError> {
Ok(ReadResponse {
chunks: Vec::new(),
next_seq: 0,
exited: true,
exit_code: Some(1),
closed: true,
failure: Some("exec-server is unavailable on wasm32".to_string()),
})
}
async fn write(&self, _chunk: Vec<u8>) -> Result<WriteResponse, ExecServerError> {
Ok(WriteResponse {
status: WriteStatus::UnknownProcess,
})
}
async fn terminate(&self) -> Result<(), ExecServerError> {
Ok(())
}
}
#[derive(Debug, Default)]
struct UnsupportedFileSystem;
#[async_trait]
impl ExecutorFileSystem for UnsupportedFileSystem {
async fn read_file(
&self,
_path: &codex_utils_absolute_path::AbsolutePathBuf,
) -> FileSystemResult<Vec<u8>> {
Err(unsupported_io_error())
}
async fn write_file(
&self,
_path: &codex_utils_absolute_path::AbsolutePathBuf,
_contents: Vec<u8>,
) -> FileSystemResult<()> {
Err(unsupported_io_error())
}
async fn create_directory(
&self,
_path: &codex_utils_absolute_path::AbsolutePathBuf,
_options: CreateDirectoryOptions,
) -> FileSystemResult<()> {
Err(unsupported_io_error())
}
async fn get_metadata(
&self,
_path: &codex_utils_absolute_path::AbsolutePathBuf,
) -> FileSystemResult<FileMetadata> {
Err(unsupported_io_error())
}
async fn read_directory(
&self,
_path: &codex_utils_absolute_path::AbsolutePathBuf,
) -> FileSystemResult<Vec<ReadDirectoryEntry>> {
Err(unsupported_io_error())
}
async fn remove(
&self,
_path: &codex_utils_absolute_path::AbsolutePathBuf,
_options: RemoveOptions,
) -> FileSystemResult<()> {
Err(unsupported_io_error())
}
async fn copy(
&self,
_source_path: &codex_utils_absolute_path::AbsolutePathBuf,
_destination_path: &codex_utils_absolute_path::AbsolutePathBuf,
_options: CopyOptions,
) -> FileSystemResult<()> {
Err(unsupported_io_error())
}
}
pub async fn run_main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
Err(Box::new(ExecServerError::Unsupported))
}
pub async fn run_main_with_listen_url(
_listen_url: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
Err(Box::new(ExecServerError::Unsupported))
}
fn normalize_exec_server_url(exec_server_url: Option<String>) -> Option<String> {
exec_server_url.and_then(|url| {
let url = url.trim();
(!url.is_empty()).then(|| url.to_string())
})
}
fn unsupported_io_error() -> io::Error {
io::Error::new(
io::ErrorKind::Unsupported,
"exec-server filesystem is unavailable on wasm32",
)
}

View File

@@ -18,15 +18,17 @@ clap = { workspace = true, features = ["derive"] }
chrono = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-home-dir = { workspace = true }
codex-utils-rustls-provider = { workspace = true }
globset = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
time = { workspace = true }
tokio = { workspace = true, features = ["full"] }
tracing = { workspace = true }
url = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
codex-utils-rustls-provider = { workspace = true }
time = { workspace = true }
tokio = { workspace = true, features = ["full"] }
rama-core = { version = "=0.3.0-alpha.4" }
rama-http = { version = "=0.3.0-alpha.4" }
rama-http-backend = { version = "=0.3.0-alpha.4", features = ["tls"] }
@@ -35,9 +37,12 @@ rama-socks5 = { version = "=0.3.0-alpha.4" }
rama-tcp = { version = "=0.3.0-alpha.4", features = ["http"] }
rama-tls-rustls = { version = "=0.3.0-alpha.4", features = ["http"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
tokio = { workspace = true, features = ["macros", "rt", "sync"] }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
[target.'cfg(target_family = "unix")'.dependencies]
[target.'cfg(all(target_family = "unix", not(target_arch = "wasm32")))'.dependencies]
rama-unix = { version = "=0.3.0-alpha.4" }

View File

@@ -1,56 +1,9 @@
#![deny(clippy::print_stdout, clippy::print_stderr)]
mod certs;
mod config;
mod http_proxy;
mod mitm;
mod network_policy;
mod policy;
mod proxy;
mod reasons;
mod responses;
mod runtime;
mod socks5;
mod state;
mod upstream;
#[cfg(not(target_arch = "wasm32"))]
include!("native.rs");
pub use config::NetworkDomainPermission;
pub use config::NetworkDomainPermissionEntry;
pub use config::NetworkDomainPermissions;
pub use config::NetworkMode;
pub use config::NetworkProxyConfig;
pub use config::NetworkUnixSocketPermission;
pub use config::NetworkUnixSocketPermissions;
pub use config::host_and_port_from_network_addr;
pub use network_policy::NetworkDecision;
pub use network_policy::NetworkDecisionSource;
pub use network_policy::NetworkPolicyDecider;
pub use network_policy::NetworkPolicyDecision;
pub use network_policy::NetworkPolicyRequest;
pub use network_policy::NetworkPolicyRequestArgs;
pub use network_policy::NetworkProtocol;
pub use policy::normalize_host;
pub use proxy::ALL_PROXY_ENV_KEYS;
pub use proxy::ALLOW_LOCAL_BINDING_ENV_KEY;
pub use proxy::Args;
pub use proxy::DEFAULT_NO_PROXY_VALUE;
pub use proxy::NO_PROXY_ENV_KEYS;
pub use proxy::NetworkProxy;
pub use proxy::NetworkProxyBuilder;
pub use proxy::NetworkProxyHandle;
pub use proxy::PROXY_URL_ENV_KEYS;
pub use proxy::has_proxy_url_env_vars;
pub use proxy::proxy_url_env_value;
pub use runtime::BlockedRequest;
pub use runtime::BlockedRequestArgs;
pub use runtime::BlockedRequestObserver;
pub use runtime::ConfigReloader;
pub use runtime::ConfigState;
pub use runtime::NetworkProxyState;
pub use state::NetworkProxyAuditMetadata;
pub use state::NetworkProxyConstraintError;
pub use state::NetworkProxyConstraints;
pub use state::PartialNetworkConfig;
pub use state::PartialNetworkProxyConfig;
pub use state::build_config_state;
pub use state::validate_policy_against_constraints;
#[cfg(target_arch = "wasm32")]
mod wasm;
#[cfg(target_arch = "wasm32")]
pub use wasm::*;

View File

@@ -0,0 +1,54 @@
mod certs;
mod config;
mod http_proxy;
mod mitm;
mod network_policy;
mod policy;
mod proxy;
mod reasons;
mod responses;
mod runtime;
mod socks5;
mod state;
mod upstream;
pub use config::NetworkDomainPermission;
pub use config::NetworkDomainPermissionEntry;
pub use config::NetworkDomainPermissions;
pub use config::NetworkMode;
pub use config::NetworkProxyConfig;
pub use config::NetworkUnixSocketPermission;
pub use config::NetworkUnixSocketPermissions;
pub use config::host_and_port_from_network_addr;
pub use network_policy::NetworkDecision;
pub use network_policy::NetworkDecisionSource;
pub use network_policy::NetworkPolicyDecider;
pub use network_policy::NetworkPolicyDecision;
pub use network_policy::NetworkPolicyRequest;
pub use network_policy::NetworkPolicyRequestArgs;
pub use network_policy::NetworkProtocol;
pub use policy::normalize_host;
pub use proxy::ALL_PROXY_ENV_KEYS;
pub use proxy::ALLOW_LOCAL_BINDING_ENV_KEY;
pub use proxy::Args;
pub use proxy::DEFAULT_NO_PROXY_VALUE;
pub use proxy::NO_PROXY_ENV_KEYS;
pub use proxy::NetworkProxy;
pub use proxy::NetworkProxyBuilder;
pub use proxy::NetworkProxyHandle;
pub use proxy::PROXY_URL_ENV_KEYS;
pub use proxy::has_proxy_url_env_vars;
pub use proxy::proxy_url_env_value;
pub use runtime::BlockedRequest;
pub use runtime::BlockedRequestArgs;
pub use runtime::BlockedRequestObserver;
pub use runtime::ConfigReloader;
pub use runtime::ConfigState;
pub use runtime::NetworkProxyState;
pub use state::NetworkProxyAuditMetadata;
pub use state::NetworkProxyConstraintError;
pub use state::NetworkProxyConstraints;
pub use state::PartialNetworkConfig;
pub use state::PartialNetworkProxyConfig;
pub use state::build_config_state;
pub use state::validate_policy_against_constraints;

View File

@@ -0,0 +1,863 @@
#[path = "config.rs"]
mod config;
use anyhow::Context;
use anyhow::Result;
use async_trait::async_trait;
use serde::Deserialize;
use serde::Serialize;
use std::collections::HashMap;
use std::collections::VecDeque;
use std::future::Future;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::RwLock;
pub use config::NetworkDomainPermission;
pub use config::NetworkDomainPermissionEntry;
pub use config::NetworkDomainPermissions;
pub use config::NetworkMode;
pub use config::NetworkProxyConfig;
pub use config::NetworkUnixSocketPermission;
pub use config::NetworkUnixSocketPermissions;
pub use config::host_and_port_from_network_addr;
pub const PROXY_URL_ENV_KEYS: &[&str] = &[
"HTTP_PROXY",
"HTTPS_PROXY",
"WS_PROXY",
"WSS_PROXY",
"ALL_PROXY",
"FTP_PROXY",
"YARN_HTTP_PROXY",
"YARN_HTTPS_PROXY",
"NPM_CONFIG_HTTP_PROXY",
"NPM_CONFIG_HTTPS_PROXY",
"NPM_CONFIG_PROXY",
"BUNDLE_HTTP_PROXY",
"BUNDLE_HTTPS_PROXY",
"PIP_PROXY",
"DOCKER_HTTP_PROXY",
"DOCKER_HTTPS_PROXY",
];
pub const ALL_PROXY_ENV_KEYS: &[&str] = &["ALL_PROXY", "all_proxy"];
pub const ALLOW_LOCAL_BINDING_ENV_KEY: &str = "CODEX_NETWORK_ALLOW_LOCAL_BINDING";
pub const NO_PROXY_ENV_KEYS: &[&str] = &[
"NO_PROXY",
"no_proxy",
"npm_config_noproxy",
"NPM_CONFIG_NOPROXY",
"YARN_NO_PROXY",
"BUNDLE_NO_PROXY",
];
pub const DEFAULT_NO_PROXY_VALUE: &str = concat!(
"localhost,127.0.0.1,::1,",
"*.local,.local,",
"169.254.0.0/16,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
);
const FTP_PROXY_ENV_KEYS: &[&str] = &["FTP_PROXY", "ftp_proxy"];
const WEBSOCKET_PROXY_ENV_KEYS: &[&str] = &["WS_PROXY", "WSS_PROXY", "ws_proxy", "wss_proxy"];
#[derive(Debug, Clone, clap::Parser)]
#[command(name = "codex-network-proxy", about = "Codex network sandbox proxy")]
pub struct Args {}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NetworkProtocol {
Http,
HttpsConnect,
Socks5Tcp,
Socks5Udp,
}
impl NetworkProtocol {
pub const fn as_policy_protocol(self) -> &'static str {
match self {
Self::Http => "http",
Self::HttpsConnect => "https_connect",
Self::Socks5Tcp => "socks5_tcp",
Self::Socks5Udp => "socks5_udp",
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum NetworkPolicyDecision {
Deny,
Ask,
}
impl NetworkPolicyDecision {
pub const fn as_str(self) -> &'static str {
match self {
Self::Deny => "deny",
Self::Ask => "ask",
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum NetworkDecisionSource {
BaselinePolicy,
ModeGuard,
ProxyState,
Decider,
}
impl NetworkDecisionSource {
pub const fn as_str(self) -> &'static str {
match self {
Self::BaselinePolicy => "baseline_policy",
Self::ModeGuard => "mode_guard",
Self::ProxyState => "proxy_state",
Self::Decider => "decider",
}
}
}
#[derive(Clone, Debug)]
pub struct NetworkPolicyRequest {
pub protocol: NetworkProtocol,
pub host: String,
pub port: u16,
pub client_addr: Option<String>,
pub method: Option<String>,
pub command: Option<String>,
pub exec_policy_hint: Option<String>,
}
pub struct NetworkPolicyRequestArgs {
pub protocol: NetworkProtocol,
pub host: String,
pub port: u16,
pub client_addr: Option<String>,
pub method: Option<String>,
pub command: Option<String>,
pub exec_policy_hint: Option<String>,
}
impl NetworkPolicyRequest {
pub fn new(args: NetworkPolicyRequestArgs) -> Self {
let NetworkPolicyRequestArgs {
protocol,
host,
port,
client_addr,
method,
command,
exec_policy_hint,
} = args;
Self {
protocol,
host,
port,
client_addr,
method,
command,
exec_policy_hint,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum NetworkDecision {
Allow,
Deny {
reason: String,
source: NetworkDecisionSource,
decision: NetworkPolicyDecision,
},
}
impl NetworkDecision {
pub fn deny(reason: impl Into<String>) -> Self {
Self::deny_with_source(reason, NetworkDecisionSource::Decider)
}
pub fn ask(reason: impl Into<String>) -> Self {
Self::ask_with_source(reason, NetworkDecisionSource::Decider)
}
pub fn deny_with_source(reason: impl Into<String>, source: NetworkDecisionSource) -> Self {
Self::Deny {
reason: reason.into(),
source,
decision: NetworkPolicyDecision::Deny,
}
}
pub fn ask_with_source(reason: impl Into<String>, source: NetworkDecisionSource) -> Self {
Self::Deny {
reason: reason.into(),
source,
decision: NetworkPolicyDecision::Ask,
}
}
}
#[async_trait]
pub trait NetworkPolicyDecider: Send + Sync + 'static {
async fn decide(&self, request: NetworkPolicyRequest) -> Result<NetworkDecision>;
}
#[async_trait]
impl<D: NetworkPolicyDecider + ?Sized> NetworkPolicyDecider for Arc<D> {
async fn decide(&self, request: NetworkPolicyRequest) -> Result<NetworkDecision> {
(**self).decide(request).await
}
}
#[async_trait]
impl<F, Fut> NetworkPolicyDecider for F
where
F: Fn(NetworkPolicyRequest) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<NetworkDecision>> + Send,
{
async fn decide(&self, request: NetworkPolicyRequest) -> Result<NetworkDecision> {
(self)(request).await
}
}
pub fn normalize_host(host: &str) -> String {
let host = host.trim();
if host.starts_with('[')
&& let Some(end) = host.find(']')
{
return host[1..end]
.to_ascii_lowercase()
.trim_end_matches('.')
.to_string();
}
if host.bytes().filter(|b| *b == b':').count() == 1 {
let trimmed = host.split(':').next().unwrap_or_default();
return trimmed
.to_ascii_lowercase()
.trim_end_matches('.')
.to_string();
}
host.to_ascii_lowercase().trim_end_matches('.').to_string()
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NetworkProxyConstraints {
pub enabled: Option<bool>,
pub mode: Option<NetworkMode>,
pub allow_upstream_proxy: Option<bool>,
pub dangerously_allow_non_loopback_proxy: Option<bool>,
pub dangerously_allow_all_unix_sockets: Option<bool>,
pub allowed_domains: Option<Vec<String>>,
pub allowlist_expansion_enabled: Option<bool>,
pub denied_domains: Option<Vec<String>>,
pub denylist_expansion_enabled: Option<bool>,
pub allow_unix_sockets: Option<Vec<String>>,
pub allow_local_binding: Option<bool>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PartialNetworkProxyConfig {
#[serde(default)]
pub network: PartialNetworkConfig,
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct PartialNetworkConfig {
pub enabled: Option<bool>,
pub mode: Option<NetworkMode>,
pub allow_upstream_proxy: Option<bool>,
pub dangerously_allow_non_loopback_proxy: Option<bool>,
pub dangerously_allow_all_unix_sockets: Option<bool>,
#[serde(default)]
pub domains: Option<NetworkDomainPermissions>,
#[serde(default)]
pub unix_sockets: Option<NetworkUnixSocketPermissions>,
pub allow_local_binding: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum NetworkProxyConstraintError {
#[error("invalid value for {field_name}: {candidate} (allowed {allowed})")]
InvalidValue {
field_name: &'static str,
candidate: String,
allowed: String,
},
}
impl NetworkProxyConstraintError {
pub fn into_anyhow(self) -> anyhow::Error {
anyhow::anyhow!(self)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct NetworkProxyAuditMetadata {
pub conversation_id: Option<String>,
pub app_version: Option<String>,
pub user_account_id: Option<String>,
pub auth_mode: Option<String>,
pub originator: Option<String>,
pub user_email: Option<String>,
pub terminal_type: Option<String>,
pub model: Option<String>,
pub slug: Option<String>,
}
#[derive(Clone, Debug, serde::Serialize)]
pub struct BlockedRequest {
pub host: String,
pub reason: String,
pub client: Option<String>,
pub method: Option<String>,
pub mode: Option<NetworkMode>,
pub protocol: String,
pub decision: Option<String>,
pub source: Option<String>,
pub port: Option<u16>,
pub timestamp: i64,
}
pub struct BlockedRequestArgs {
pub host: String,
pub reason: String,
pub client: Option<String>,
pub method: Option<String>,
pub mode: Option<NetworkMode>,
pub protocol: String,
pub decision: Option<String>,
pub source: Option<String>,
pub port: Option<u16>,
}
impl BlockedRequest {
pub fn new(args: BlockedRequestArgs) -> Self {
let BlockedRequestArgs {
host,
reason,
client,
method,
mode,
protocol,
decision,
source,
port,
} = args;
Self {
host,
reason,
client,
method,
mode,
protocol,
decision,
source,
port,
timestamp: chrono::Utc::now().timestamp(),
}
}
}
#[derive(Clone)]
pub struct ConfigState {
pub config: NetworkProxyConfig,
pub constraints: NetworkProxyConstraints,
pub blocked: VecDeque<BlockedRequest>,
pub blocked_total: u64,
}
#[async_trait]
pub trait ConfigReloader: Send + Sync {
fn source_label(&self) -> String;
async fn maybe_reload(&self) -> Result<Option<ConfigState>>;
async fn reload_now(&self) -> Result<ConfigState>;
}
#[async_trait]
pub trait BlockedRequestObserver: Send + Sync + 'static {
async fn on_blocked_request(&self, request: BlockedRequest);
}
#[async_trait]
impl<O: BlockedRequestObserver + ?Sized> BlockedRequestObserver for Arc<O> {
async fn on_blocked_request(&self, request: BlockedRequest) {
(**self).on_blocked_request(request).await
}
}
#[async_trait]
impl<F, Fut> BlockedRequestObserver for F
where
F: Fn(BlockedRequest) -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send,
{
async fn on_blocked_request(&self, request: BlockedRequest) {
(self)(request).await;
}
}
pub fn build_config_state(
config: NetworkProxyConfig,
constraints: NetworkProxyConstraints,
) -> anyhow::Result<ConfigState> {
validate_policy_against_constraints(&config, &constraints)?;
Ok(ConfigState {
config,
constraints,
blocked: VecDeque::new(),
blocked_total: 0,
})
}
pub fn validate_policy_against_constraints(
config: &NetworkProxyConfig,
constraints: &NetworkProxyConstraints,
) -> Result<(), NetworkProxyConstraintError> {
if let Some(false) = constraints.enabled
&& config.network.enabled
{
return Err(NetworkProxyConstraintError::InvalidValue {
field_name: "network.enabled",
candidate: "true".to_string(),
allowed: "false (disabled by managed config)".to_string(),
});
}
if let Some(false) = constraints.allow_local_binding
&& config.network.allow_local_binding
{
return Err(NetworkProxyConstraintError::InvalidValue {
field_name: "network.allow_local_binding",
candidate: "true".to_string(),
allowed: "false (disabled by managed config)".to_string(),
});
}
Ok(())
}
pub struct NetworkProxyState {
state: Arc<RwLock<ConfigState>>,
reloader: Arc<dyn ConfigReloader>,
blocked_request_observer: Arc<RwLock<Option<Arc<dyn BlockedRequestObserver>>>>,
audit_metadata: NetworkProxyAuditMetadata,
}
impl std::fmt::Debug for NetworkProxyState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NetworkProxyState").finish_non_exhaustive()
}
}
impl Clone for NetworkProxyState {
fn clone(&self) -> Self {
Self {
state: self.state.clone(),
reloader: self.reloader.clone(),
blocked_request_observer: self.blocked_request_observer.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_blocked_observer(
state: ConfigState,
reloader: Arc<dyn ConfigReloader>,
blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
) -> Self {
Self::with_reloader_and_audit_metadata_and_blocked_observer(
state,
reloader,
NetworkProxyAuditMetadata::default(),
blocked_request_observer,
)
}
pub fn with_reloader_and_audit_metadata(
state: ConfigState,
reloader: Arc<dyn ConfigReloader>,
audit_metadata: NetworkProxyAuditMetadata,
) -> Self {
Self::with_reloader_and_audit_metadata_and_blocked_observer(
state,
reloader,
audit_metadata,
None,
)
}
pub fn with_reloader_and_audit_metadata_and_blocked_observer(
state: ConfigState,
reloader: Arc<dyn ConfigReloader>,
audit_metadata: NetworkProxyAuditMetadata,
blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
) -> Self {
Self {
state: Arc::new(RwLock::new(state)),
reloader,
blocked_request_observer: Arc::new(RwLock::new(blocked_request_observer)),
audit_metadata,
}
}
pub async fn set_blocked_request_observer(
&self,
blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
) {
let mut observer = self.blocked_request_observer.write().await;
*observer = blocked_request_observer;
}
pub fn audit_metadata(&self) -> &NetworkProxyAuditMetadata {
&self.audit_metadata
}
pub async fn current_cfg(&self) -> Result<NetworkProxyConfig> {
self.reload_if_needed().await?;
let guard = self.state.read().await;
Ok(guard.config.clone())
}
pub async fn add_allowed_domain(&self, host: &str) -> Result<()> {
let mut guard = self.state.write().await;
guard.config.network.upsert_domain_permission(
host.to_string(),
NetworkDomainPermission::Allow,
normalize_host,
);
Ok(())
}
pub async fn add_denied_domain(&self, host: &str) -> Result<()> {
let mut guard = self.state.write().await;
guard.config.network.upsert_domain_permission(
host.to_string(),
NetworkDomainPermission::Deny,
normalize_host,
);
Ok(())
}
pub async fn record_blocked(&self, entry: BlockedRequest) -> Result<()> {
let blocked_for_observer = entry.clone();
let blocked_request_observer = self.blocked_request_observer.read().await.clone();
let mut guard = self.state.write().await;
guard.blocked.push_back(entry);
guard.blocked_total = guard.blocked_total.saturating_add(1);
while guard.blocked.len() > 200 {
guard.blocked.pop_front();
}
drop(guard);
if let Some(observer) = blocked_request_observer {
observer.on_blocked_request(blocked_for_observer).await;
}
Ok(())
}
async fn reload_if_needed(&self) -> Result<()> {
if let Some(mut new_state) = self.reloader.maybe_reload().await? {
let blocked = {
let guard = self.state.read().await;
(guard.blocked.clone(), guard.blocked_total)
};
new_state.blocked = blocked.0;
new_state.blocked_total = blocked.1;
let mut guard = self.state.write().await;
*guard = new_state;
}
Ok(())
}
}
#[derive(Clone)]
pub struct NetworkProxyBuilder {
state: Option<Arc<NetworkProxyState>>,
http_addr: Option<SocketAddr>,
socks_addr: Option<SocketAddr>,
_managed_by_codex: bool,
policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
}
impl Default for NetworkProxyBuilder {
fn default() -> Self {
Self {
state: None,
http_addr: None,
socks_addr: None,
_managed_by_codex: true,
policy_decider: None,
blocked_request_observer: None,
}
}
}
impl NetworkProxyBuilder {
pub fn state(mut self, state: Arc<NetworkProxyState>) -> Self {
self.state = Some(state);
self
}
pub fn http_addr(mut self, addr: SocketAddr) -> Self {
self.http_addr = Some(addr);
self
}
pub fn socks_addr(mut self, addr: SocketAddr) -> Self {
self.socks_addr = Some(addr);
self
}
pub fn managed_by_codex(mut self, managed_by_codex: bool) -> Self {
self._managed_by_codex = managed_by_codex;
self
}
pub fn policy_decider<D>(mut self, decider: D) -> Self
where
D: NetworkPolicyDecider,
{
self.policy_decider = Some(Arc::new(decider));
self
}
pub fn policy_decider_arc(mut self, decider: Arc<dyn NetworkPolicyDecider>) -> Self {
self.policy_decider = Some(decider);
self
}
pub fn blocked_request_observer<O>(mut self, observer: O) -> Self
where
O: BlockedRequestObserver,
{
self.blocked_request_observer = Some(Arc::new(observer));
self
}
pub fn blocked_request_observer_arc(
mut self,
observer: Arc<dyn BlockedRequestObserver>,
) -> Self {
self.blocked_request_observer = Some(observer);
self
}
pub async fn build(self) -> Result<NetworkProxy> {
let state = self.state.context("NetworkProxyBuilder requires a state")?;
state
.set_blocked_request_observer(self.blocked_request_observer.clone())
.await;
let current_cfg = state.current_cfg().await?;
let http_addr = self
.http_addr
.unwrap_or(SocketAddr::from(([127, 0, 0, 1], 0)));
let socks_addr = self
.socks_addr
.unwrap_or(SocketAddr::from(([127, 0, 0, 1], 0)));
Ok(NetworkProxy {
state,
http_addr,
socks_addr,
socks_enabled: current_cfg.network.enable_socks5,
allow_local_binding: current_cfg.network.allow_local_binding,
allow_unix_sockets: current_cfg.network.allow_unix_sockets(),
dangerously_allow_all_unix_sockets: current_cfg
.network
.dangerously_allow_all_unix_sockets,
_policy_decider: self.policy_decider,
})
}
}
#[derive(Clone)]
pub struct NetworkProxy {
state: Arc<NetworkProxyState>,
http_addr: SocketAddr,
socks_addr: SocketAddr,
socks_enabled: bool,
allow_local_binding: bool,
allow_unix_sockets: Vec<String>,
dangerously_allow_all_unix_sockets: bool,
_policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
}
impl std::fmt::Debug for NetworkProxy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NetworkProxy")
.field("http_addr", &self.http_addr)
.field("socks_addr", &self.socks_addr)
.finish_non_exhaustive()
}
}
impl PartialEq for NetworkProxy {
fn eq(&self, other: &Self) -> bool {
self.http_addr == other.http_addr
&& self.socks_addr == other.socks_addr
&& self.allow_local_binding == other.allow_local_binding
}
}
impl Eq for NetworkProxy {}
pub fn proxy_url_env_value<'a>(
env: &'a HashMap<String, String>,
canonical_key: &str,
) -> Option<&'a str> {
if let Some(value) = env.get(canonical_key) {
return Some(value.as_str());
}
let lower_key = canonical_key.to_ascii_lowercase();
env.get(lower_key.as_str()).map(String::as_str)
}
pub fn has_proxy_url_env_vars(env: &HashMap<String, String>) -> bool {
PROXY_URL_ENV_KEYS
.iter()
.any(|key| proxy_url_env_value(env, key).is_some_and(|value| !value.trim().is_empty()))
}
fn set_env_keys(env: &mut HashMap<String, String>, keys: &[&str], value: &str) {
for key in keys {
env.insert((*key).to_string(), value.to_string());
}
}
fn apply_proxy_env_overrides(
env: &mut HashMap<String, String>,
http_addr: SocketAddr,
socks_addr: SocketAddr,
socks_enabled: bool,
allow_local_binding: bool,
) {
let http_proxy_url = format!("http://{http_addr}");
let socks_proxy_url = format!("socks5h://{socks_addr}");
env.insert(
ALLOW_LOCAL_BINDING_ENV_KEY.to_string(),
if allow_local_binding { "1" } else { "0" }.to_string(),
);
set_env_keys(
env,
&[
"HTTP_PROXY",
"HTTPS_PROXY",
"http_proxy",
"https_proxy",
"YARN_HTTP_PROXY",
"YARN_HTTPS_PROXY",
"npm_config_http_proxy",
"npm_config_https_proxy",
"npm_config_proxy",
"NPM_CONFIG_HTTP_PROXY",
"NPM_CONFIG_HTTPS_PROXY",
"NPM_CONFIG_PROXY",
"BUNDLE_HTTP_PROXY",
"BUNDLE_HTTPS_PROXY",
"PIP_PROXY",
"DOCKER_HTTP_PROXY",
"DOCKER_HTTPS_PROXY",
],
&http_proxy_url,
);
set_env_keys(env, WEBSOCKET_PROXY_ENV_KEYS, &http_proxy_url);
set_env_keys(env, NO_PROXY_ENV_KEYS, DEFAULT_NO_PROXY_VALUE);
env.insert("ELECTRON_GET_USE_PROXY".to_string(), "true".to_string());
if socks_enabled {
set_env_keys(env, ALL_PROXY_ENV_KEYS, &socks_proxy_url);
set_env_keys(env, FTP_PROXY_ENV_KEYS, &socks_proxy_url);
} else {
set_env_keys(env, ALL_PROXY_ENV_KEYS, &http_proxy_url);
set_env_keys(env, FTP_PROXY_ENV_KEYS, &http_proxy_url);
}
}
impl NetworkProxy {
pub fn builder() -> NetworkProxyBuilder {
NetworkProxyBuilder::default()
}
pub fn http_addr(&self) -> SocketAddr {
self.http_addr
}
pub fn socks_addr(&self) -> SocketAddr {
self.socks_addr
}
pub async fn current_cfg(&self) -> Result<NetworkProxyConfig> {
self.state.current_cfg().await
}
pub async fn add_allowed_domain(&self, host: &str) -> Result<()> {
self.state.add_allowed_domain(host).await
}
pub async fn add_denied_domain(&self, host: &str) -> Result<()> {
self.state.add_denied_domain(host).await
}
pub fn allow_local_binding(&self) -> bool {
self.allow_local_binding
}
pub fn allow_unix_sockets(&self) -> &[String] {
&self.allow_unix_sockets
}
pub fn dangerously_allow_all_unix_sockets(&self) -> bool {
self.dangerously_allow_all_unix_sockets
}
pub fn apply_to_env(&self, env: &mut HashMap<String, String>) {
apply_proxy_env_overrides(
env,
self.http_addr,
self.socks_addr,
self.socks_enabled,
self.allow_local_binding,
);
}
pub async fn run(&self) -> Result<NetworkProxyHandle> {
Ok(NetworkProxyHandle::noop())
}
}
pub struct NetworkProxyHandle {
completed: bool,
}
impl NetworkProxyHandle {
fn noop() -> Self {
Self { completed: true }
}
pub async fn wait(mut self) -> Result<()> {
self.completed = true;
Ok(())
}
pub async fn shutdown(mut self) -> Result<()> {
self.completed = true;
Ok(())
}
}
impl Drop for NetworkProxyHandle {
fn drop(&mut self) {
let _ = self.completed;
}
}

View File

@@ -3,22 +3,54 @@ name = "codex-rmcp-client"
version.workspace = true
edition.workspace = true
license.workspace = true
autobins = false
[lints]
workspace = true
[features]
native-bin = []
[[bin]]
name = "rmcp_test_server"
path = "src/bin/rmcp_test_server.rs"
required-features = ["native-bin"]
[[bin]]
name = "test_stdio_server"
path = "src/bin/test_stdio_server.rs"
required-features = ["native-bin"]
[[bin]]
name = "test_streamable_http_server"
path = "src/bin/test_streamable_http_server.rs"
required-features = ["native-bin"]
[dependencies]
anyhow = "1"
codex-protocol = { workspace = true }
futures = { workspace = true, default-features = false, features = ["std"] }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true, features = ["log"] }
[dev-dependencies]
codex-utils-cargo-bin = { workspace = true }
pretty_assertions = { workspace = true }
serial_test = { workspace = true }
tempfile = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
axum = { workspace = true, default-features = false, features = [
"http1",
"tokio",
] }
codex-client = { workspace = true }
codex-keyring-store = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-pty = { workspace = true }
codex-utils-home-dir = { workspace = true }
futures = { workspace = true, default-features = false, features = ["std"] }
codex-utils-pty = { workspace = true }
keyring = { workspace = true, features = ["crypto-rust"] }
oauth2 = "5"
reqwest = { version = "0.12", default-features = false, features = [
@@ -37,12 +69,8 @@ rmcp = { workspace = true, default-features = false, features = [
"transport-streamable-http-client-reqwest",
"transport-streamable-http-server",
] }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
sse-stream = "0.2.1"
thiserror = { workspace = true }
tiny_http = { workspace = true }
tokio = { workspace = true, features = [
"io-util",
@@ -53,24 +81,18 @@ tokio = { workspace = true, features = [
"io-std",
"time",
] }
tracing = { workspace = true, features = ["log"] }
urlencoding = { workspace = true }
webbrowser = { workspace = true }
which = { workspace = true }
[dev-dependencies]
codex-utils-cargo-bin = { workspace = true }
pretty_assertions = { workspace = true }
serial_test = { workspace = true }
tempfile = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
[target.'cfg(all(not(target_arch = "wasm32"), target_os = "linux"))'.dependencies]
keyring = { workspace = true, features = ["linux-native-async-persistent"] }
[target.'cfg(target_os = "macos")'.dependencies]
[target.'cfg(all(not(target_arch = "wasm32"), target_os = "macos"))'.dependencies]
keyring = { workspace = true, features = ["apple-native"] }
[target.'cfg(target_os = "windows")'.dependencies]
[target.'cfg(all(not(target_arch = "wasm32"), target_os = "windows"))'.dependencies]
keyring = { workspace = true, features = ["windows-native"] }
[target.'cfg(any(target_os = "freebsd", target_os = "openbsd"))'.dependencies]
[target.'cfg(all(not(target_arch = "wasm32"), any(target_os = "freebsd", target_os = "openbsd")))'.dependencies]
keyring = { workspace = true, features = ["sync-secret-service"] }

View File

@@ -1,31 +1,7 @@
mod auth_status;
mod logging_client_handler;
mod oauth;
mod perform_oauth_login;
mod program_resolver;
mod rmcp_client;
mod utils;
#[cfg(not(target_arch = "wasm32"))]
include!("native.rs");
pub use auth_status::StreamableHttpOAuthDiscovery;
pub use auth_status::determine_streamable_http_auth_status;
pub use auth_status::discover_streamable_http_oauth;
pub use auth_status::supports_oauth_login;
pub use codex_protocol::protocol::McpAuthStatus;
pub use oauth::OAuthCredentialsStoreMode;
pub use oauth::StoredOAuthTokens;
pub use oauth::WrappedOAuthTokenResponse;
pub use oauth::delete_oauth_tokens;
pub(crate) use oauth::load_oauth_tokens;
pub use oauth::save_oauth_tokens;
pub use perform_oauth_login::OAuthProviderError;
pub use perform_oauth_login::OauthLoginHandle;
pub use perform_oauth_login::perform_oauth_login;
pub use perform_oauth_login::perform_oauth_login_return_url;
pub use perform_oauth_login::perform_oauth_login_silent;
pub use rmcp::model::ElicitationAction;
pub use rmcp_client::Elicitation;
pub use rmcp_client::ElicitationResponse;
pub use rmcp_client::ListToolsWithConnectorIdResult;
pub use rmcp_client::RmcpClient;
pub use rmcp_client::SendElicitation;
pub use rmcp_client::ToolWithConnectorId;
#[cfg(target_arch = "wasm32")]
mod wasm;
#[cfg(target_arch = "wasm32")]
pub use wasm::*;

View File

@@ -0,0 +1,31 @@
mod auth_status;
mod logging_client_handler;
mod oauth;
mod perform_oauth_login;
mod program_resolver;
mod rmcp_client;
mod utils;
pub use auth_status::StreamableHttpOAuthDiscovery;
pub use auth_status::determine_streamable_http_auth_status;
pub use auth_status::discover_streamable_http_oauth;
pub use auth_status::supports_oauth_login;
pub use codex_protocol::protocol::McpAuthStatus;
pub use oauth::OAuthCredentialsStoreMode;
pub use oauth::StoredOAuthTokens;
pub use oauth::WrappedOAuthTokenResponse;
pub use oauth::delete_oauth_tokens;
pub(crate) use oauth::load_oauth_tokens;
pub use oauth::save_oauth_tokens;
pub use perform_oauth_login::OAuthProviderError;
pub use perform_oauth_login::OauthLoginHandle;
pub use perform_oauth_login::perform_oauth_login;
pub use perform_oauth_login::perform_oauth_login_return_url;
pub use perform_oauth_login::perform_oauth_login_silent;
pub use rmcp::model::ElicitationAction;
pub use rmcp_client::Elicitation;
pub use rmcp_client::ElicitationResponse;
pub use rmcp_client::ListToolsWithConnectorIdResult;
pub use rmcp_client::RmcpClient;
pub use rmcp_client::SendElicitation;
pub use rmcp_client::ToolWithConnectorId;

View File

@@ -0,0 +1,340 @@
use std::collections::HashMap;
use std::ffi::OsString;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Result;
use anyhow::anyhow;
use futures::future::BoxFuture;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
pub use codex_protocol::protocol::McpAuthStatus;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ElicitationAction {
Accept,
Decline,
Cancel,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StreamableHttpOAuthDiscovery {
pub scopes_supported: Option<Vec<String>>,
}
pub async fn determine_streamable_http_auth_status(
_server_name: &str,
_url: &str,
bearer_token_env_var: Option<&str>,
http_headers: Option<HashMap<String, String>>,
env_http_headers: Option<HashMap<String, String>>,
_store_mode: OAuthCredentialsStoreMode,
) -> Result<McpAuthStatus> {
if bearer_token_env_var.is_some() {
return Ok(McpAuthStatus::BearerToken);
}
let has_auth_header = http_headers
.into_iter()
.flatten()
.chain(env_http_headers.into_iter().flatten())
.any(|(key, _)| key.eq_ignore_ascii_case("authorization"));
if has_auth_header {
return Ok(McpAuthStatus::BearerToken);
}
Ok(McpAuthStatus::Unsupported)
}
pub async fn supports_oauth_login(_url: &str) -> Result<bool> {
Ok(false)
}
pub async fn discover_streamable_http_oauth(
_url: &str,
_http_headers: Option<HashMap<String, String>>,
_env_http_headers: Option<HashMap<String, String>>,
) -> Result<Option<StreamableHttpOAuthDiscovery>> {
Ok(None)
}
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum OAuthCredentialsStoreMode {
#[default]
Auto,
File,
Keyring,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StoredOAuthTokens {
pub server_name: String,
pub url: String,
pub client_id: String,
pub token_response: WrappedOAuthTokenResponse,
#[serde(default)]
pub expires_at: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct WrappedOAuthTokenResponse(pub serde_json::Value);
pub fn delete_oauth_tokens(
_server_name: &str,
_url: &str,
_store_mode: OAuthCredentialsStoreMode,
) -> Result<bool> {
Ok(false)
}
pub fn save_oauth_tokens(
_server_name: &str,
_tokens: &StoredOAuthTokens,
_store_mode: OAuthCredentialsStoreMode,
) -> Result<()> {
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OAuthProviderError {
error: Option<String>,
error_description: Option<String>,
}
impl OAuthProviderError {
pub fn new(error: Option<String>, error_description: Option<String>) -> Self {
Self {
error,
error_description,
}
}
}
impl std::fmt::Display for OAuthProviderError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match (self.error.as_deref(), self.error_description.as_deref()) {
(Some(error), Some(error_description)) => {
write!(f, "OAuth provider returned `{error}`: {error_description}")
}
(Some(error), None) => write!(f, "OAuth provider returned `{error}`"),
(None, Some(error_description)) => write!(f, "OAuth error: {error_description}"),
(None, None) => write!(f, "OAuth provider returned an error"),
}
}
}
impl std::error::Error for OAuthProviderError {}
#[allow(clippy::too_many_arguments)]
pub async fn perform_oauth_login(
_server_name: &str,
_server_url: &str,
_store_mode: OAuthCredentialsStoreMode,
_http_headers: Option<HashMap<String, String>>,
_env_http_headers: Option<HashMap<String, String>>,
_scopes: &[String],
_oauth_resource: Option<&str>,
_callback_port: Option<u16>,
_callback_url: Option<&str>,
) -> Result<()> {
Err(anyhow!("MCP OAuth login is unavailable on wasm32"))
}
#[allow(clippy::too_many_arguments)]
pub async fn perform_oauth_login_silent(
_server_name: &str,
_server_url: &str,
_store_mode: OAuthCredentialsStoreMode,
_http_headers: Option<HashMap<String, String>>,
_env_http_headers: Option<HashMap<String, String>>,
_scopes: &[String],
_oauth_resource: Option<&str>,
_callback_port: Option<u16>,
_callback_url: Option<&str>,
) -> Result<()> {
Err(anyhow!("MCP OAuth login is unavailable on wasm32"))
}
#[allow(clippy::too_many_arguments)]
pub async fn perform_oauth_login_return_url(
_server_name: &str,
_server_url: &str,
_store_mode: OAuthCredentialsStoreMode,
_http_headers: Option<HashMap<String, String>>,
_env_http_headers: Option<HashMap<String, String>>,
_scopes: &[String],
_oauth_resource: Option<&str>,
_callback_port: Option<u16>,
_callback_url: Option<&str>,
) -> Result<OauthLoginHandle> {
Err(anyhow!("MCP OAuth login is unavailable on wasm32"))
}
pub struct OauthLoginHandle {
authorization_url: String,
}
impl OauthLoginHandle {
pub fn authorization_url(&self) -> &str {
&self.authorization_url
}
pub fn into_parts(self) -> (String, futures::channel::oneshot::Receiver<Result<()>>) {
let (_tx, rx) = futures::channel::oneshot::channel();
(self.authorization_url, rx)
}
pub async fn wait(self) -> Result<()> {
Err(anyhow!("MCP OAuth login is unavailable on wasm32"))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RequestId(pub String);
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Elicitation {
#[serde(default)]
pub message: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ElicitationResponse {
pub action: ElicitationAction,
pub content: Option<serde_json::Value>,
#[serde(rename = "_meta")]
pub meta: Option<serde_json::Value>,
}
pub type SendElicitation = Box<
dyn Fn(RequestId, Elicitation) -> BoxFuture<'static, Result<ElicitationResponse>> + Send + Sync,
>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ToolDefinition {
#[serde(default)]
pub name: String,
}
pub struct ToolWithConnectorId {
pub tool: ToolDefinition,
pub connector_id: Option<String>,
pub connector_name: Option<String>,
pub connector_description: Option<String>,
}
pub struct ListToolsWithConnectorIdResult {
pub next_cursor: Option<String>,
pub tools: Vec<ToolWithConnectorId>,
}
pub struct RmcpClient;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct InitializeRequestParams;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct InitializeResult;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct PaginatedRequestParams {
#[serde(default)]
pub cursor: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ListResourcesResult;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ListResourceTemplatesResult;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ReadResourceRequestParams;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ReadResourceResult;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct CallToolResult;
impl RmcpClient {
pub async fn new_stdio_client(
_program: OsString,
_args: Vec<OsString>,
_env: Option<HashMap<OsString, OsString>>,
_env_vars: &[String],
_cwd: Option<PathBuf>,
) -> std::io::Result<Self> {
Err(std::io::Error::other(
"MCP stdio transport is unavailable on wasm32",
))
}
#[allow(clippy::too_many_arguments)]
pub async fn new_streamable_http_client(
_server_name: &str,
_url: &str,
_bearer_token: Option<String>,
_http_headers: Option<HashMap<String, String>>,
_env_http_headers: Option<HashMap<String, String>>,
_store_mode: OAuthCredentialsStoreMode,
) -> Result<Self> {
Err(anyhow!("MCP HTTP transport is unavailable on wasm32"))
}
pub async fn initialize(
&self,
_params: InitializeRequestParams,
_timeout: Option<Duration>,
_send_elicitation: SendElicitation,
) -> Result<InitializeResult> {
Err(anyhow!("MCP initialize is unavailable on wasm32"))
}
pub async fn list_tools_with_connector_ids(
&self,
_params: Option<PaginatedRequestParams>,
_timeout: Option<Duration>,
) -> Result<ListToolsWithConnectorIdResult> {
Err(anyhow!("MCP tools are unavailable on wasm32"))
}
pub async fn list_resources(
&self,
_params: Option<PaginatedRequestParams>,
_timeout: Option<Duration>,
) -> Result<ListResourcesResult> {
Err(anyhow!("MCP resources are unavailable on wasm32"))
}
pub async fn list_resource_templates(
&self,
_params: Option<PaginatedRequestParams>,
_timeout: Option<Duration>,
) -> Result<ListResourceTemplatesResult> {
Err(anyhow!("MCP resource templates are unavailable on wasm32"))
}
pub async fn read_resource(
&self,
_params: ReadResourceRequestParams,
_timeout: Option<Duration>,
) -> Result<ReadResourceResult> {
Err(anyhow!("MCP resources are unavailable on wasm32"))
}
pub async fn call_tool(
&self,
_name: String,
_arguments: Option<serde_json::Value>,
_meta: Option<serde_json::Value>,
_timeout: Option<Duration>,
) -> Result<CallToolResult> {
Err(anyhow!("MCP tools are unavailable on wasm32"))
}
}