diff --git a/README.ja.md b/README.ja.md index ebf9ffd8..17763b87 100644 --- a/README.ja.md +++ b/README.ja.md @@ -105,6 +105,14 @@ OpenAI、Claude、Gemini、およびその他の互換形式の間で変換で 対応クライアントでは、利用可能なモデルカタログの同期、デフォルトモデルの選択、管理設定を適用する前の 元設定のバックアップ、以前の設定への復元、利用可能なデスクトップまたは CLI エントリーポイントの起動ができます。 +Claude Desktop では、Cowork タスクと Claude Code セッションの両方が使用するツールネットワークの +許可リスト `coworkEgressAllowedHosts` もエージェント画面で管理できます。推奨初期値は +`localhost`、`127.0.0.1`、`api.anthropic.com`、`github.com`、`*.github.com`、 +`*.githubusercontent.com`、`gitlab.com`、`*.gitlab.com` で、連携を再適用しても既存のカスタム項目は +保持されます。1 行に 1 件、`localhost`、`host[:port]`、または `*.host[:port]` を入力してください。 +互換性のため単独の `*` も使用できますが、すべてのホストを許可してネットワークサンドボックスの制限を +無効にするため、既定値には使用しません。 + ## その他の機能 - コア設定、API Key、リモート管理用認証情報、ルーティング戦略の管理。 diff --git a/README.md b/README.md index fe350c39..47ea5bb9 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,14 @@ For supported clients, the application can synchronize the available model catal default model, back up the original configuration before applying managed settings, and restore the previous configuration. +For Claude Desktop, the Agents page also manages `coworkEgressAllowedHosts`, the tool-network +allowlist used by both Cowork tasks and Claude Code sessions. The recommended defaults are +`localhost`, `127.0.0.1`, `api.anthropic.com`, `github.com`, `*.github.com`, +`*.githubusercontent.com`, `gitlab.com`, and `*.gitlab.com`; existing custom entries are preserved +when the integration is reapplied. Add one entry per line using `localhost`, `host[:port]`, or +`*.host[:port]`. A bare `*` is supported for compatibility, but it allows every host and disables the +network sandbox restriction, so it is never used as the default. + ## Additional Capabilities - Manage core settings, API keys, remote management credentials, and routing strategy. diff --git a/README.zh-CN.md b/README.zh-CN.md index 34745c51..813d231b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -102,6 +102,13 @@ API 接入页面按照协议或 Provider 管理上游 API 凭证和服务地址 对于受支持的客户端,软件可以同步可用模型目录、选择默认模型、在应用托管配置前备份原始配置, 以及恢复之前的配置。 +对于 Claude Desktop,智能体页面还会管理 `coworkEgressAllowedHosts`,它是 Cowork 任务和 +Claude Code 会话共同使用的工具网络白名单。推荐默认值为 `localhost`、`127.0.0.1`、 +`api.anthropic.com`、`github.com`、`*.github.com`、`*.githubusercontent.com`、 +`gitlab.com` 和 `*.gitlab.com`;重新应用集成时会保留现有的自定义条目。每行填写一个 +`localhost`、`host[:port]` 或 `*.host[:port]`。为兼容 Claude Desktop 也支持单独的 `*`, +但它会允许访问所有主机并关闭网络沙箱限制,因此不会作为默认值。 + ## 其他功能 - 管理内核配置、API Key、远程管理凭证和路由策略。 diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index 29e0054b..f12b53b5 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -746,6 +746,18 @@ pub(crate) fn resolve_claude_desktop_model_mappings( })) } +pub(crate) fn resolve_claude_desktop_egress_allowed_hosts( + client: AgentClient, + requested: Option>, +) -> Result>, String> { + if client != AgentClient::ClaudeDesktop { + return Ok(None); + } + requested + .map(|hosts| normalize_claude_desktop_egress_allowed_hosts(&hosts)) + .transpose() +} + pub(crate) fn resolve_claude_code_model_mappings( client: AgentClient, models: &[AgentModelOption], @@ -801,6 +813,7 @@ pub(crate) fn prepare_codex_agent_models( } #[tauri::command] +#[allow(clippy::too_many_arguments)] pub(crate) async fn apply_agent_config( app: tauri::AppHandle, gui_config_state: tauri::State<'_, GuiConfigState>, @@ -809,6 +822,7 @@ pub(crate) async fn apply_agent_config( oauth_configuration: bool, claude_code_model_mappings: Option, claude_desktop_model_mappings: Option, + claude_desktop_egress_allowed_hosts: Option>, ) -> Result { let client = AgentClient::parse(&client)?; let home = app @@ -835,6 +849,8 @@ pub(crate) async fn apply_agent_config( &model, claude_desktop_model_mappings, )?; + let claude_desktop_egress_allowed_hosts = + resolve_claude_desktop_egress_allowed_hosts(client, claude_desktop_egress_allowed_hosts)?; if let Some(mappings) = claude_desktop_model_mappings.as_ref() { ensure_claude_desktop_model_aliases(&config, mappings, &prepared.models).await?; } @@ -853,6 +869,7 @@ pub(crate) async fn apply_agent_config( oauth_configuration, claude_code_model_mappings: claude_code_model_mappings.as_ref(), claude_desktop_model_mappings: claude_desktop_model_mappings.as_ref(), + claude_desktop_egress_allowed_hosts: claude_desktop_egress_allowed_hosts.as_deref(), }, ) } @@ -875,6 +892,7 @@ pub(crate) fn close_agent_config_modification( } #[tauri::command] +#[allow(clippy::too_many_arguments)] pub(crate) async fn reset_agent_config_to_default( app: tauri::AppHandle, gui_config_state: tauri::State<'_, GuiConfigState>, @@ -883,6 +901,7 @@ pub(crate) async fn reset_agent_config_to_default( oauth_configuration: bool, claude_code_model_mappings: Option, claude_desktop_model_mappings: Option, + claude_desktop_egress_allowed_hosts: Option>, ) -> Result { let client = AgentClient::parse(&client)?; let home = app @@ -909,6 +928,8 @@ pub(crate) async fn reset_agent_config_to_default( &model, claude_desktop_model_mappings, )?; + let claude_desktop_egress_allowed_hosts = + resolve_claude_desktop_egress_allowed_hosts(client, claude_desktop_egress_allowed_hosts)?; if let Some(mappings) = claude_desktop_model_mappings.as_ref() { ensure_claude_desktop_model_aliases(&config, mappings, &prepared.models).await?; } @@ -926,6 +947,7 @@ pub(crate) async fn reset_agent_config_to_default( oauth_configuration, claude_code_model_mappings: claude_code_model_mappings.as_ref(), claude_desktop_model_mappings: claude_desktop_model_mappings.as_ref(), + claude_desktop_egress_allowed_hosts: claude_desktop_egress_allowed_hosts.as_deref(), }) } @@ -952,6 +974,7 @@ pub(crate) async fn set_agent_config_enabled( force_restore: bool, claude_code_model_mappings: Option, claude_desktop_model_mappings: Option, + claude_desktop_egress_allowed_hosts: Option>, ) -> Result { let client = AgentClient::parse(&client)?; let home = app @@ -979,6 +1002,10 @@ pub(crate) async fn set_agent_config_enabled( &model, claude_desktop_model_mappings, )?; + let claude_desktop_egress_allowed_hosts = resolve_claude_desktop_egress_allowed_hosts( + client, + claude_desktop_egress_allowed_hosts, + )?; if let Some(mappings) = claude_desktop_model_mappings.as_ref() { ensure_claude_desktop_model_aliases(&config, mappings, &prepared.models).await?; } @@ -997,6 +1024,7 @@ pub(crate) async fn set_agent_config_enabled( oauth_configuration: false, claude_code_model_mappings: claude_code_model_mappings.as_ref(), claude_desktop_model_mappings: claude_desktop_model_mappings.as_ref(), + claude_desktop_egress_allowed_hosts: claude_desktop_egress_allowed_hosts.as_deref(), }, ) } else { @@ -1016,6 +1044,7 @@ pub(crate) async fn update_agent_config( model: String, claude_code_model_mappings: Option, claude_desktop_model_mappings: Option, + claude_desktop_egress_allowed_hosts: Option>, ) -> Result { let client = AgentClient::parse(&client)?; let home = app @@ -1039,6 +1068,8 @@ pub(crate) async fn update_agent_config( &model, claude_desktop_model_mappings, )?; + let claude_desktop_egress_allowed_hosts = + resolve_claude_desktop_egress_allowed_hosts(client, claude_desktop_egress_allowed_hosts)?; if let Some(mappings) = claude_desktop_model_mappings.as_ref() { ensure_claude_desktop_model_aliases(&config, mappings, &prepared.models).await?; } @@ -1057,6 +1088,7 @@ pub(crate) async fn update_agent_config( oauth_configuration: false, claude_code_model_mappings: claude_code_model_mappings.as_ref(), claude_desktop_model_mappings: claude_desktop_model_mappings.as_ref(), + claude_desktop_egress_allowed_hosts: claude_desktop_egress_allowed_hosts.as_deref(), }, ) } diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index c38d99d2..a49ab5d4 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -22,6 +22,7 @@ pub(crate) fn build_agent_updates( oauth_configuration: false, claude_code_model_mappings: None, claude_desktop_model_mappings: None, + claude_desktop_egress_allowed_hosts: None, }, ) } @@ -40,6 +41,7 @@ pub(crate) fn build_agent_updates_with_oauth( oauth_configuration, claude_code_model_mappings, claude_desktop_model_mappings, + claude_desktop_egress_allowed_hosts, } = options; let paths = agent_config_paths(client, home); let root_base = format!("http://127.0.0.1:{port}"); @@ -98,6 +100,7 @@ pub(crate) fn build_agent_updates_with_oauth( model, models, claude_desktop_model_mappings, + claude_desktop_egress_allowed_hosts, ) .or_else(|_| { build_claude_desktop_profile( @@ -107,6 +110,7 @@ pub(crate) fn build_agent_updates_with_oauth( model, models, claude_desktop_model_mappings, + claude_desktop_egress_allowed_hosts, ) })?, }, @@ -637,6 +641,113 @@ pub(crate) fn ordered_agent_models( ordered } +pub(crate) fn default_claude_desktop_egress_allowed_hosts() -> Vec { + DEFAULT_CLAUDE_DESKTOP_EGRESS_ALLOWED_HOSTS + .iter() + .map(|host| (*host).to_string()) + .collect() +} + +fn claude_desktop_egress_host_is_valid(value: &str) -> bool { + if value == "*" { + return true; + } + if value.is_empty() || value.chars().any(char::is_whitespace) { + return false; + } + + let mut parts = value.split(':'); + let host = parts.next().unwrap_or_default(); + if let Some(port) = parts.next() { + if parts.next().is_some() + || port.is_empty() + || !port.bytes().all(|byte| byte.is_ascii_digit()) + || port.parse::().ok().filter(|port| *port > 0).is_none() + { + return false; + } + } + + let (wildcard, host) = match host.strip_prefix("*.") { + Some(host) => (true, host), + None => (false, host), + }; + if host.is_empty() || host.contains('*') || host.len() > 253 { + return false; + } + if host.eq_ignore_ascii_case("localhost") { + return !wildcard; + } + if host.parse::().is_ok() { + return !wildcard; + } + if host.contains('.') + && host + .bytes() + .all(|byte| byte.is_ascii_digit() || byte == b'.') + { + return false; + } + + host.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + && label + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + && label + .as_bytes() + .last() + .is_some_and(u8::is_ascii_alphanumeric) + }) +} + +pub(crate) fn normalize_claude_desktop_egress_allowed_hosts( + hosts: &[String], +) -> Result, String> { + if hosts.is_empty() { + return Err("Claude Desktop 出站主机列表不能为空".to_string()); + } + let mut normalized = Vec::with_capacity(hosts.len()); + for host in hosts { + let value = host.trim().to_ascii_lowercase(); + if !claude_desktop_egress_host_is_valid(&value) { + return Err(format!( + "Claude Desktop 出站主机格式无效: {host};仅支持 *、localhost、host[:port] 或 *.host[:port]" + )); + } + if !normalized.iter().any(|existing| existing == &value) { + normalized.push(value); + } + } + Ok(normalized) +} + +pub(crate) fn claude_desktop_egress_allowed_hosts_from_root( + root: &serde_json::Map, +) -> Result>, String> { + let Some(value) = root.get(CLAUDE_DESKTOP_EGRESS_ALLOWED_HOSTS_KEY) else { + return Ok(None); + }; + let entries = value + .as_array() + .ok_or_else(|| "Claude Desktop 出站主机必须是数组".to_string())?; + let hosts = entries + .iter() + .map(|entry| { + entry + .as_str() + .map(str::to_string) + .ok_or_else(|| "Claude Desktop 出站主机必须是字符串".to_string()) + }) + .collect::, _>>()?; + normalize_claude_desktop_egress_allowed_hosts(&hosts).map(Some) +} + pub(crate) fn build_claude_desktop_deployment_config( existing: Option<&str>, ) -> Result { @@ -652,9 +763,20 @@ pub(crate) fn build_claude_desktop_profile( model: &str, models: &[AgentModelOption], mappings: Option<&ClaudeDesktopModelMappings>, + egress_allowed_hosts: Option<&[String]>, ) -> Result { let mut root = parse_agent_json_object(existing, "Claude Desktop 网关配置")?; - root.remove("coworkEgressAllowedHosts"); + let egress_allowed_hosts = match egress_allowed_hosts { + Some(hosts) => normalize_claude_desktop_egress_allowed_hosts(hosts)?, + None => claude_desktop_egress_allowed_hosts_from_root(&root) + .ok() + .flatten() + .unwrap_or_else(default_claude_desktop_egress_allowed_hosts), + }; + root.insert( + CLAUDE_DESKTOP_EGRESS_ALLOWED_HOSTS_KEY.to_string(), + serde_json::json!(egress_allowed_hosts), + ); root.insert( "disableDeploymentModeChooser".to_string(), serde_json::json!(true), @@ -841,7 +963,7 @@ pub(crate) fn remove_claude_desktop_managed_configuration( if update_agent_json_file(&paths[2], "Claude Desktop 网关配置", |root| { let mut updated = false; for key in [ - "coworkEgressAllowedHosts", + CLAUDE_DESKTOP_EGRESS_ALLOWED_HOSTS_KEY, "disableDeploymentModeChooser", "inferenceGatewayApiKey", "inferenceGatewayAuthScheme", @@ -1614,7 +1736,7 @@ pub(crate) fn build_restored_claude_desktop_config( 0 | 1 => restore_json_key(&mut root, original_root.as_ref(), "deploymentMode"), 2 => { for key in [ - "coworkEgressAllowedHosts", + CLAUDE_DESKTOP_EGRESS_ALLOWED_HOSTS_KEY, "disableDeploymentModeChooser", "inferenceGatewayApiKey", "inferenceGatewayAuthScheme", diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index 0d75290e..a992842f 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -387,6 +387,7 @@ pub(crate) fn inspect_pi_provider_status( applied_model: current_model, claude_code_model_mappings: None, claude_desktop_model_mappings: None, + claude_desktop_egress_allowed_hosts: None, warnings, error, } @@ -872,6 +873,21 @@ pub(crate) fn inspect_agent_config( if let Some(message) = error.as_ref() { warnings.push(message.clone()); } + let claude_desktop_egress_allowed_hosts = if client == AgentClient::ClaudeDesktop { + match paths + .get(2) + .ok_or_else(|| "Claude Desktop 网关配置路径不可用".to_string()) + .and_then(|path| inspect_claude_desktop_egress_allowed_hosts(path)) + { + Ok(hosts) => hosts, + Err(error) => { + warnings.push(error); + None + } + } + } else { + None + }; let modification = inspect_agent_application(client, home); let configuration_synchronized = agent_configuration_is_synchronized(client, home, configured); warnings.extend(modification.warnings.iter().cloned()); @@ -902,6 +918,7 @@ pub(crate) fn inspect_agent_config( .then(|| inspect_claude_code_model_mappings(&paths[0]).ok().flatten()) .flatten(), claude_desktop_model_mappings: modification.claude_desktop_model_mappings, + claude_desktop_egress_allowed_hosts, warnings, error, } @@ -2526,6 +2543,16 @@ pub(crate) fn inspect_claude_desktop_agent_config( Ok((configured, model)) } +pub(crate) fn inspect_claude_desktop_egress_allowed_hosts( + profile_path: &Path, +) -> Result>, String> { + let profile = read_agent_json_or_empty(profile_path, "Claude Desktop 网关配置")?; + let root = profile + .as_object() + .ok_or_else(|| "Claude Desktop 网关配置根节点必须是对象".to_string())?; + claude_desktop_egress_allowed_hosts_from_root(root) +} + pub(crate) fn read_agent_json_or_empty( path: &Path, label: &str, diff --git a/src-tauri/src/agents/state.rs b/src-tauri/src/agents/state.rs index d1eed974..c51ef1a4 100644 --- a/src-tauri/src/agents/state.rs +++ b/src-tauri/src/agents/state.rs @@ -664,6 +664,7 @@ pub(crate) fn fresh_agent_contents( oauth_configuration: false, claude_code_model_mappings: None, claude_desktop_model_mappings: None, + claude_desktop_egress_allowed_hosts: None, }, ) } @@ -680,6 +681,7 @@ pub(crate) fn fresh_agent_contents_with_oauth( oauth_configuration, claude_code_model_mappings, claude_desktop_model_mappings, + claude_desktop_egress_allowed_hosts, .. } = options; let root_base = format!("http://127.0.0.1:{port}"); @@ -703,6 +705,7 @@ pub(crate) fn fresh_agent_contents_with_oauth( model, models, claude_desktop_model_mappings, + claude_desktop_egress_allowed_hosts, )?, build_claude_desktop_meta(None)?, ]), @@ -1553,6 +1556,7 @@ pub(crate) fn apply_agent_configuration( oauth_configuration: false, claude_code_model_mappings: None, claude_desktop_model_mappings: None, + claude_desktop_egress_allowed_hosts: None, }, ) } @@ -1602,6 +1606,7 @@ pub(crate) fn reset_agent_configuration_to_default( oauth_configuration: false, claude_code_model_mappings: None, claude_desktop_model_mappings: None, + claude_desktop_egress_allowed_hosts: None, }) } @@ -1616,6 +1621,7 @@ pub(crate) struct AgentDefaultConfiguration<'a> { pub(crate) oauth_configuration: bool, pub(crate) claude_code_model_mappings: Option<&'a ClaudeDesktopModelMappings>, pub(crate) claude_desktop_model_mappings: Option<&'a ClaudeDesktopModelMappings>, + pub(crate) claude_desktop_egress_allowed_hosts: Option<&'a [String]>, } pub(crate) fn reset_agent_configuration_to_default_with_oauth( @@ -1632,6 +1638,7 @@ pub(crate) fn reset_agent_configuration_to_default_with_oauth( oauth_configuration, claude_code_model_mappings, claude_desktop_model_mappings, + claude_desktop_egress_allowed_hosts, } = request; if client == AgentClient::DeepSeekHarness { let updates = build_agent_updates_with_oauth( @@ -1646,6 +1653,7 @@ pub(crate) fn reset_agent_configuration_to_default_with_oauth( oauth_configuration, claude_code_model_mappings, claude_desktop_model_mappings, + claude_desktop_egress_allowed_hosts, }, )?; return commit_agent_configuration( @@ -1669,6 +1677,7 @@ pub(crate) fn reset_agent_configuration_to_default_with_oauth( oauth_configuration, claude_code_model_mappings, claude_desktop_model_mappings, + claude_desktop_egress_allowed_hosts, }, )?; if paths.len() != contents.len() { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 59366016..1508c198 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -151,6 +151,17 @@ const CLAUDE_DESKTOP_PROFILE_ID: &str = "00000000-0000-4000-8000-000000831700"; const CLAUDE_DESKTOP_OPUS_MODEL_ID: &str = "claude-opus-5"; const CLAUDE_DESKTOP_SONNET_MODEL_ID: &str = "claude-sonnet-4-6"; const CLAUDE_DESKTOP_HAIKU_MODEL_ID: &str = "claude-haiku-4-5"; +const CLAUDE_DESKTOP_EGRESS_ALLOWED_HOSTS_KEY: &str = "coworkEgressAllowedHosts"; +const DEFAULT_CLAUDE_DESKTOP_EGRESS_ALLOWED_HOSTS: &[&str] = &[ + "localhost", + "127.0.0.1", + "api.anthropic.com", + "github.com", + "*.github.com", + "*.githubusercontent.com", + "gitlab.com", + "*.gitlab.com", +]; const MANAGED_CLAUDE_OPUS_ALIAS_DISPLAY_NAME: &str = "EasyCLIProxyAPI managed Claude Opus mapping"; const MANAGED_CLAUDE_SONNET_ALIAS_DISPLAY_NAME: &str = "EasyCLIProxyAPI managed Claude Sonnet mapping"; @@ -781,6 +792,7 @@ struct AgentConfigStatus { applied_model: Option, claude_code_model_mappings: Option, claude_desktop_model_mappings: Option, + claude_desktop_egress_allowed_hosts: Option>, warnings: Vec, error: Option, } @@ -1086,6 +1098,7 @@ struct AgentConfigurationOptions<'a> { oauth_configuration: bool, claude_code_model_mappings: Option<&'a ClaudeDesktopModelMappings>, claude_desktop_model_mappings: Option<&'a ClaudeDesktopModelMappings>, + claude_desktop_egress_allowed_hosts: Option<&'a [String]>, } struct PreparedAgentModels { diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 85cfe580..5992e9d0 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -358,6 +358,7 @@ fn codex_api_and_oauth_modes_write_the_same_catalog() { oauth_configuration, claude_code_model_mappings: None, claude_desktop_model_mappings: None, + claude_desktop_egress_allowed_hosts: None, }, ) .unwrap() @@ -452,6 +453,7 @@ fn claude_desktop_config_builds_gateway_profile_and_index() { "claude-sonnet-test", &[], None, + None, ) .unwrap(); let meta = build_claude_desktop_meta(Some( @@ -464,7 +466,10 @@ fn claude_desktop_config_builds_gateway_profile_and_index() { let meta: serde_json::Value = serde_json::from_str(&meta).unwrap(); assert_eq!(profile["keep"], true); - assert!(profile.get("coworkEgressAllowedHosts").is_none()); + assert_eq!( + profile[CLAUDE_DESKTOP_EGRESS_ALLOWED_HOSTS_KEY], + serde_json::json!(["*"]) + ); assert_eq!(profile["inferenceGatewayApiKey"], DEFAULT_API_KEY); assert_eq!(profile["inferenceGatewayBaseUrl"], "http://127.0.0.1:8317"); assert_eq!( @@ -486,6 +491,107 @@ fn claude_desktop_config_builds_gateway_profile_and_index() { assert_eq!(managed_entry["custom"], true); } +#[test] +fn claude_desktop_profile_uses_safe_defaults_and_normalizes_explicit_hosts() { + let profile = build_claude_desktop_profile( + None, + "http://127.0.0.1:8317", + DEFAULT_API_KEY, + "claude-sonnet-test", + &[], + None, + None, + ) + .unwrap(); + let profile: serde_json::Value = serde_json::from_str(&profile).unwrap(); + assert_eq!( + profile[CLAUDE_DESKTOP_EGRESS_ALLOWED_HOSTS_KEY], + serde_json::json!(DEFAULT_CLAUDE_DESKTOP_EGRESS_ALLOWED_HOSTS) + ); + assert!(!profile[CLAUDE_DESKTOP_EGRESS_ALLOWED_HOSTS_KEY] + .as_array() + .unwrap() + .contains(&serde_json::json!("*"))); + + let explicit = vec![ + " GitHub.com ".to_string(), + "*.GitHub.com:443".to_string(), + "github.com".to_string(), + "127.0.0.1:8317".to_string(), + ]; + let profile = build_claude_desktop_profile( + Some(r#"{"coworkEgressAllowedHosts":["*"]}"#), + "http://127.0.0.1:8317", + DEFAULT_API_KEY, + "claude-sonnet-test", + &[], + None, + Some(&explicit), + ) + .unwrap(); + let profile: serde_json::Value = serde_json::from_str(&profile).unwrap(); + assert_eq!( + profile[CLAUDE_DESKTOP_EGRESS_ALLOWED_HOSTS_KEY], + serde_json::json!(["github.com", "*.github.com:443", "127.0.0.1:8317"]) + ); +} + +#[test] +fn claude_desktop_egress_hosts_reject_invalid_entries() { + for invalid in [ + "", + "https://github.com", + "github.com/path", + "github.com:0", + "github.com:65536", + "github.com:abc", + "*:443", + "*.127.0.0.1", + "*.localhost", + "2001:db8::1", + "bad_host.example.com", + ] { + let hosts = vec![invalid.to_string()]; + assert!( + normalize_claude_desktop_egress_allowed_hosts(&hosts).is_err(), + "accepted invalid egress host: {invalid}" + ); + } + for valid in [ + "*", + "localhost", + "localhost:8317", + "api.github.com", + "*.corp.example.com", + "*.corp.example.com:8443", + "127.0.0.1", + ] { + let hosts = vec![valid.to_string()]; + assert_eq!( + normalize_claude_desktop_egress_allowed_hosts(&hosts).unwrap(), + hosts, + "rejected valid egress host: {valid}" + ); + } +} + +#[test] +fn claude_desktop_profile_inspection_reads_current_egress_hosts() { + let home = agent_test_home("claude-desktop-egress-inspection"); + let path = home.join("profile.json"); + fs::write( + &path, + r#"{"coworkEgressAllowedHosts":["GitHub.com","*.github.com"]}"#, + ) + .unwrap(); + + assert_eq!( + inspect_claude_desktop_egress_allowed_hosts(&path).unwrap(), + Some(vec!["github.com".to_string(), "*.github.com".to_string()]) + ); + fs::remove_dir_all(home).unwrap(); +} + #[test] fn claude_desktop_profile_keeps_non_claude_models_internal() { let mappings = ClaudeDesktopModelMappings { @@ -524,6 +630,7 @@ fn claude_desktop_profile_keeps_non_claude_models_internal() { "gpt-5.6-sol", &models, Some(&mappings), + None, ) .unwrap(); let profile: serde_json::Value = serde_json::from_str(&profile).unwrap(); @@ -1632,6 +1739,7 @@ fn claude_desktop_uses_selected_alias_directly_with_original_context() { "gpt-high", &models, Some(&mappings), + None, ) .unwrap(); let profile: serde_json::Value = serde_json::from_str(&profile).unwrap(); diff --git a/src-tauri/src/tests/agent_state.rs b/src-tauri/src/tests/agent_state.rs index aec01db8..2cbb5041 100644 --- a/src-tauri/src/tests/agent_state.rs +++ b/src-tauri/src/tests/agent_state.rs @@ -518,6 +518,47 @@ fn kimi_and_grok_legacy_states_require_context_catalog_resynchronization() { } } +#[test] +fn claude_desktop_reapply_preserves_current_egress_hosts() { + let home = agent_test_home("claude-desktop-egress-reapply"); + let models = test_agent_models(&["gpt-test"]); + apply_agent_configuration( + AgentClient::ClaudeDesktop, + &home, + 8317, + DEFAULT_API_KEY, + "gpt-test", + &models, + None, + ) + .unwrap(); + + let paths = agent_config_paths(AgentClient::ClaudeDesktop, &home); + let mut profile: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&paths[2]).unwrap()).unwrap(); + profile[CLAUDE_DESKTOP_EGRESS_ALLOWED_HOSTS_KEY] = + serde_json::json!(["localhost", "github.com", "registry-1.docker.io"]); + fs::write(&paths[2], serde_json::to_string_pretty(&profile).unwrap()).unwrap(); + + apply_agent_configuration( + AgentClient::ClaudeDesktop, + &home, + 8317, + DEFAULT_API_KEY, + "gpt-test", + &models, + None, + ) + .unwrap(); + let profile: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&paths[2]).unwrap()).unwrap(); + assert_eq!( + profile[CLAUDE_DESKTOP_EGRESS_ALLOWED_HOSTS_KEY], + serde_json::json!(["localhost", "github.com", "registry-1.docker.io"]) + ); + fs::remove_dir_all(home).unwrap(); +} + #[test] fn session_merge_preserves_runtime_fields_for_other_agent_formats() { let claude_original = r#"{"env":{"KEEP_ENV":"original","CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY":"0"},"keep":"claude"}"#; @@ -560,6 +601,7 @@ fn session_merge_preserves_runtime_fields_for_other_agent_formats() { "gpt-test", &[], None, + None, ) .unwrap(); let mut desktop_current: serde_json::Value = serde_json::from_str(&desktop_managed).unwrap(); diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 7ceee643..87625e25 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -498,6 +498,8 @@ export const jaOverrides = { 'agents.error.noModels': '選択可能なモデルがないため、設定変更を適用できません', 'agents.error.selectionGone': '選択中のモデルは利用可能一覧にありません。更新後に選び直してください', 'agents.error.mappingSelectionGone': 'Claude Desktop のモデルマッピングに利用できないモデルがあります。モデルを更新して選び直してください', + 'agents.error.egressHostsEmpty': '許可する送信先ホストを 1 件以上追加してください', + 'agents.error.egressHostInvalid': '「{host}」は無効です。*、localhost、host[:port]、または *.host[:port] を使用してください', 'agents.error.claudeCodeRuntimeSettingsInvalid': '最大コンテキストは 100000~1000000、自動圧縮率は 1~100 にしてください', 'agents.error.applyFirst': '選択したモデルはまだ適用されていません。先に「設定変更を適用」をクリックしてください', 'agents.launch.enableFirst': '先に設定変更を適用してください', @@ -616,6 +618,11 @@ export const jaOverrides = { 'agents.modify.oauthConfiguration': 'OAuth ログイン設定方式', 'agents.claudeDesktopMapping.title': 'Claude Desktop モデル → 上流モデル', 'agents.claudeDesktopMapping.description': '役割ごとに上流モデルとコンテキスト機能を選択します。対応モデルでのみ 1M を有効にしてください。', + 'agents.claudeDesktopEgress.title': 'ツールの送信先ホスト', + 'agents.claudeDesktopEgress.description': 'Cowork タスクと Claude Code セッションがツール経由で接続できるホストです。設定を再適用しても既存の項目を保持します。', + 'agents.claudeDesktopEgress.useRecommended': '推奨設定を使用', + 'agents.claudeDesktopEgress.hint': '1 行に 1 件:*、localhost、host[:port]、または *.host[:port]。ワイルドカードにはルートドメインが含まれません。', + 'agents.claudeDesktopEgress.wildcardWarning': '* はすべてのホストを許可し、ネットワークサンドボックスの制限を無効にします。可能な限り明示的なホストを使用してください。', 'agents.claudeCodeMapping.title': 'Claude Code モデル → 上流モデル', 'agents.claudeCodeMapping.description': 'タスク役割ごとに上流モデルを選び、共通のコンテキストと自動圧縮ポリシーを設定します。', 'agents.claudeCodeRuntime.maxContextTokens': '最大コンテキスト', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 2fdb9b6d..bf4edccd 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -498,6 +498,8 @@ export const en: Record = { 'agents.error.noModels': 'No models are available, so configuration changes cannot be applied', 'agents.error.selectionGone': 'The selected model is no longer available. Refresh and select it again.', 'agents.error.mappingSelectionGone': 'A Claude Desktop mapping is no longer available. Refresh the models and select it again.', + 'agents.error.egressHostsEmpty': 'Add at least one allowed egress host.', + 'agents.error.egressHostInvalid': '“{host}” is invalid. Use *, localhost, host[:port], or *.host[:port].', 'agents.error.claudeCodeRuntimeSettingsInvalid': 'Maximum context must be 100000–1000000 and the auto-compact percentage must be 1–100.', 'agents.error.applyFirst': 'The selected model has not been applied. Click “Apply Configuration Changes” first.', 'agents.launch.enableFirst': 'Apply the configuration changes first', @@ -611,6 +613,11 @@ export const en: Record = { 'agents.pending': 'Not Applied', 'agents.claudeDesktopMapping.title': 'Claude Desktop Model → Upstream Model', 'agents.claudeDesktopMapping.description': 'Choose the upstream model and context capability for each role. Turn on 1M only for supported models.', + 'agents.claudeDesktopEgress.title': 'Allowed tool egress', + 'agents.claudeDesktopEgress.description': 'Hosts that Cowork tasks and Claude Code sessions may reach through tools. Existing entries are preserved when configuration is reapplied.', + 'agents.claudeDesktopEgress.useRecommended': 'Use recommended defaults', + 'agents.claudeDesktopEgress.hint': 'One entry per line: *, localhost, host[:port], or *.host[:port]. A wildcard does not include the apex domain.', + 'agents.claudeDesktopEgress.wildcardWarning': '* allows every host and disables the network sandbox restriction. Use explicit hosts whenever possible.', 'agents.claudeCodeMapping.title': 'Claude Code Model → Upstream Model', 'agents.claudeCodeMapping.description': 'Choose the upstream model for each task role and configure one shared context and auto-compact policy.', 'agents.claudeCodeRuntime.maxContextTokens': 'Maximum context', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 159ee74c..b54f90b5 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -496,6 +496,8 @@ export const zhCN = { 'agents.error.noModels': '当前没有可选模型,无法应用配置修改', 'agents.error.selectionGone': '当前选择已不在可用模型列表中,请刷新模型后重新选择', 'agents.error.mappingSelectionGone': 'Claude Desktop 模型映射包含不可用模型,请刷新模型后重新选择', + 'agents.error.egressHostsEmpty': '请至少添加一个允许出站访问的主机', + 'agents.error.egressHostInvalid': '“{host}”格式无效;仅支持 *、localhost、host[:port] 或 *.host[:port]', 'agents.error.claudeCodeRuntimeSettingsInvalid': '最大窗口必须为 100000–1000000,触发压缩百分比必须为 1–100', 'agents.error.applyFirst': '模型选择尚未应用,请先点击“应用配置修改”', 'agents.launch.enableFirst': '请先应用配置修改', @@ -609,6 +611,11 @@ export const zhCN = { 'agents.pending': '尚未应用', 'agents.claudeDesktopMapping.title': 'Claude Desktop 模型 → 上游模型', 'agents.claudeDesktopMapping.description': '分别选择上游模型和上下文能力;仅为确认支持的模型打开 1M。', + 'agents.claudeDesktopEgress.title': '工具出站访问主机', + 'agents.claudeDesktopEgress.description': 'Cowork 任务和 Claude Code 会话可通过工具访问的主机;重新应用配置时会保留现有条目。', + 'agents.claudeDesktopEgress.useRecommended': '使用推荐默认值', + 'agents.claudeDesktopEgress.hint': '每行一个条目:*、localhost、host[:port] 或 *.host[:port]。通配符不包含根域名。', + 'agents.claudeDesktopEgress.wildcardWarning': '* 会允许访问所有主机并关闭网络沙箱限制。请尽量使用明确的主机列表。', 'agents.claudeCodeMapping.title': 'Claude Code 模型 → 上游模型', 'agents.claudeCodeMapping.description': '分别为不同任务角色选择上游模型,并统一配置上下文窗口与自动压缩。', 'agents.claudeCodeRuntime.maxContextTokens': '最大窗口', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 20190d9c..2ee3316f 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -51,6 +51,13 @@ import { sameAgentModel, sameAgentModelMappings, } from '../services/agentConfigurationDraft'; +import { + claudeDesktopEgressContainsWildcard, + DEFAULT_CLAUDE_DESKTOP_EGRESS_ALLOWED_HOSTS, + formatClaudeDesktopEgressAllowedHosts, + parseClaudeDesktopEgressAllowedHosts, + sameClaudeDesktopEgressAllowedHosts, +} from '../services/claudeDesktopEgress'; import { parseAgentLaunchDirectoryHistory, rememberAgentLaunchDirectory, @@ -99,6 +106,7 @@ type AgentConfigStatus = { appliedModel: string | null; claudeCodeModelMappings: ClaudeModelMappings | null; claudeDesktopModelMappings: ClaudeModelMappings | null; + claudeDesktopEgressAllowedHosts: string[] | null; warnings: string[]; error: string | null; }; @@ -169,6 +177,11 @@ const createClaudeBooleanByClient = (): Record ({ ...claudeCustomMappingCache }), ); + const [claudeDesktopEgressDraft, setClaudeDesktopEgressDraftState] = useState( + () => claudeDesktopEgressDraftCache, + ); const [loading, setLoading] = useState(true); const [modelLoading, setModelLoading] = useState(false); const [busyAction, setBusyAction] = useState< @@ -676,6 +692,7 @@ export function AgentsPage() { const modelRequestRef = useRef(0); const piUpdateRequestRef = useRef(0); const claudeModelMappingsDirtyRef = useRef(claudeModelMappingsDirtyCache); + const claudeDesktopEgressDirtyRef = useRef(claudeDesktopEgressDirtyCache); const setClaudeModelMappingsDraftByClient = useCallback(( update: ( @@ -701,6 +718,11 @@ export function AgentsPage() { }); }, []); + const setClaudeDesktopEgressDraft = useCallback((value: string) => { + claudeDesktopEgressDraftCache = value; + setClaudeDesktopEgressDraftState(value); + }, []); + const loadStatuses = useCallback(async (forceRefresh = false) => { const command = forceRefresh ? 'refresh_agent_config_statuses' @@ -905,6 +927,19 @@ export function AgentsPage() { selectedModel, ]); + useEffect(() => { + if (selected !== 'claude-desktop' || claudeDesktopEgressDirtyRef.current) return; + const next = formatClaudeDesktopEgressAllowedHosts( + activeStatus?.claudeDesktopEgressAllowedHosts + ?? DEFAULT_CLAUDE_DESKTOP_EGRESS_ALLOWED_HOSTS, + ); + if (next !== claudeDesktopEgressDraftCache) setClaudeDesktopEgressDraft(next); + }, [ + activeStatus?.claudeDesktopEgressAllowedHosts, + selected, + setClaudeDesktopEgressDraft, + ]); + const appliedModel = activeStatus?.appliedModel ?? activeStatus?.currentModel ?? ''; const modelDraftChanged = !isClaudeModelMappingClient && Boolean( selectedModel.trim() @@ -915,6 +950,23 @@ export function AgentsPage() { ? activeStatus?.claudeCodeModelMappings : activeStatus?.claudeDesktopModelMappings) ?? createClaudeModelMappings(appliedModel); + const claudeDesktopEgressResult = useMemo( + () => parseClaudeDesktopEgressAllowedHosts(claudeDesktopEgressDraft), + [claudeDesktopEgressDraft], + ); + const claudeDesktopEgressAllowedHosts = claudeDesktopEgressResult.valid + ? claudeDesktopEgressResult.hosts + : []; + const appliedClaudeDesktopEgressAllowedHosts = + activeStatus?.claudeDesktopEgressAllowedHosts ?? []; + const claudeDesktopEgressReady = selected !== 'claude-desktop' + || claudeDesktopEgressResult.valid; + const claudeDesktopEgressDraftChanged = selected === 'claude-desktop' + && activeStatus?.modificationState === 'applied' + && (!claudeDesktopEgressResult.valid || !sameClaudeDesktopEgressAllowedHosts( + claudeDesktopEgressAllowedHosts, + appliedClaudeDesktopEgressAllowedHosts, + )); const claudeMappingsReady = !isClaudeModelMappingClient || claudeMappingRoles.every((role) => Boolean(findAgentModel(models, claudeModelMappingsDraft[role.key])), @@ -933,7 +985,10 @@ export function AgentsPage() { ); const oauthConfigurationChanged = selected === 'codex' && oauthConfiguration !== Boolean(activeStatus?.oauthConfiguration); - const draftChanged = modelDraftChanged || claudeMappingDraftChanged || oauthConfigurationChanged; + const draftChanged = modelDraftChanged + || claudeMappingDraftChanged + || claudeDesktopEgressDraftChanged + || oauthConfigurationChanged; const configurationAction = resolveAgentConfigurationAction({ client: selected, modificationState: activeStatus?.modificationState ?? 'unconfigured', @@ -944,13 +999,15 @@ export function AgentsPage() { appliedOauthConfiguration: Boolean(activeStatus?.oauthConfiguration), modelMappings: claudeModelMappingsDraft, appliedModelMappings: appliedClaudeModelMappings, + egressAllowedHosts: claudeDesktopEgressAllowedHosts, + appliedEgressAllowedHosts: appliedClaudeDesktopEgressAllowedHosts, }); const canEnable = Boolean( activeStatus?.supportedPlatform && activeStatus.installed && !modelLoading && (isClaudeModelMappingClient - ? claudeMappingsReady && claudeCodeRuntimeSettingsReady + ? claudeMappingsReady && claudeCodeRuntimeSettingsReady && claudeDesktopEgressReady : selectedModelOption), ); const activeLaunchTargets = activeStatus?.launchTargets ?? []; @@ -981,6 +1038,8 @@ export function AgentsPage() { : selected === 'zcode' ? t('agents.modify.zcodeRestart') : ''; + const claudeDesktopEgressWildcard = claudeDesktopEgressResult.valid + && claudeDesktopEgressContainsWildcard(claudeDesktopEgressResult.hosts); const refreshModels = () => { void loadModels(selected); }; @@ -1085,6 +1144,16 @@ export function AgentsPage() { }); }; + const changeClaudeDesktopEgressDraft = (value: string) => { + claudeDesktopEgressDirtyRef.current = true; + claudeDesktopEgressDirtyCache = true; + setClaudeDesktopEgressDraft(value); + }; + + const resetClaudeDesktopEgressDraft = () => { + changeClaudeDesktopEgressDraft(defaultClaudeDesktopEgressDraft); + }; + const requireSelectedModel = () => { if (modelLoading) { setModelSelectionError(t('agents.error.modelsLoading')); @@ -1159,6 +1228,7 @@ export function AgentsPage() { setConfigurationError(''); const claudeModelMappings = requireClaudeModelMappings(); if (isClaudeModelMappingClient && !claudeModelMappings) return; + if (selected === 'claude-desktop' && !claudeDesktopEgressResult.valid) return; const model = isClaudeModelMappingClient ? claudeModelMappings?.sonnet ?? null : requireSelectedModel(); @@ -1171,10 +1241,17 @@ export function AgentsPage() { oauthConfiguration, claudeCodeModelMappings: selected === 'claude-code' ? claudeModelMappings : null, claudeDesktopModelMappings: selected === 'claude-desktop' ? claudeModelMappings : null, + claudeDesktopEgressAllowedHosts: selected === 'claude-desktop' + ? claudeDesktopEgressAllowedHosts + : null, }); if (isClaudeModelMappingClient) { claudeModelMappingsDirtyRef.current[selected] = false; } + if (selected === 'claude-desktop') { + claudeDesktopEgressDirtyRef.current = false; + claudeDesktopEgressDirtyCache = false; + } await reloadStatusesAfterAction(); setOauthConfigurationDraft(null); } catch (requestError) { @@ -1255,6 +1332,10 @@ export function AgentsPage() { if (isClaudeModelMappingClient) { claudeModelMappingsDirtyRef.current[selected] = false; } + if (selected === 'claude-desktop') { + claudeDesktopEgressDirtyRef.current = false; + claudeDesktopEgressDirtyCache = false; + } await reloadStatusesAfterAction(); setOauthConfigurationDraft(null); } catch (requestError) { @@ -1267,6 +1348,7 @@ export function AgentsPage() { const resetConfigurationToDefault = async () => { const claudeModelMappings = requireClaudeModelMappings(); if (isClaudeModelMappingClient && !claudeModelMappings) return; + if (selected === 'claude-desktop' && !claudeDesktopEgressResult.valid) return; const model = isClaudeModelMappingClient ? claudeModelMappings?.sonnet ?? null : requireSelectedModel(); @@ -1280,11 +1362,18 @@ export function AgentsPage() { oauthConfiguration, claudeCodeModelMappings: selected === 'claude-code' ? claudeModelMappings : null, claudeDesktopModelMappings: selected === 'claude-desktop' ? claudeModelMappings : null, + claudeDesktopEgressAllowedHosts: selected === 'claude-desktop' + ? claudeDesktopEgressAllowedHosts + : null, }); setDefaultConfirmOpen(false); if (isClaudeModelMappingClient) { claudeModelMappingsDirtyRef.current[selected] = false; } + if (selected === 'claude-desktop') { + claudeDesktopEgressDirtyRef.current = false; + claudeDesktopEgressDirtyCache = false; + } await reloadStatusesAfterAction(); setOauthConfigurationDraft(null); } catch (requestError) { @@ -1741,6 +1830,56 @@ export function AgentsPage() { ) : null} + {selected === 'claude-desktop' ? ( +
+
+
+ {t('agents.claudeDesktopEgress.title')} + {t('agents.claudeDesktopEgress.description')} +
+
+ +
+
+