diff --git a/codex-rs/core/src/config/network_proxy_spec_tests.rs b/codex-rs/core/src/config/network_proxy_spec_tests.rs index 20e8c02282..d44b39f75c 100644 --- a/codex-rs/core/src/config/network_proxy_spec_tests.rs +++ b/codex-rs/core/src/config/network_proxy_spec_tests.rs @@ -10,6 +10,7 @@ use codex_network_proxy::LocalBindingPolicy::RequireTrue; use codex_network_proxy::NetworkDomainPermission; use codex_network_proxy::NetworkUnixSocketPermission; use codex_network_proxy::NetworkUnixSocketPermissions; +use codex_network_proxy::RemoteCredentialMasking; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::NetworkSandboxPolicy; @@ -105,7 +106,7 @@ async fn attachment_socket_grants_respect_configured_restrictions_at_remote_laun /*fallback_policy_decider*/ None, )?; let launch = scoped - .remote_launch_config(DefaultFalse, /*credential_masking*/ false) + .remote_launch_config(DefaultFalse, RemoteCredentialMasking::Disabled) .await?; assert_eq!( launch.proxy.dangerously_allow_all_unix_sockets, expected, @@ -122,7 +123,7 @@ async fn attachment_socket_grants_respect_configured_restrictions_at_remote_laun // Inheritance must not grant sockets to ordinary controller-only commands. assert!( !proxy - .remote_launch_config(DefaultFalse, /*credential_masking*/ false) + .remote_launch_config(DefaultFalse, RemoteCredentialMasking::Disabled) .await? .proxy .dangerously_allow_all_unix_sockets @@ -130,7 +131,7 @@ async fn attachment_socket_grants_respect_configured_restrictions_at_remote_laun state.add_allowed_domain("granted.example").await?; assert!( scoped - .remote_launch_config(DefaultFalse, /*credential_masking*/ false) + .remote_launch_config(DefaultFalse, RemoteCredentialMasking::Disabled) .await? .proxy .dangerously_allow_all_unix_sockets @@ -151,7 +152,7 @@ async fn attachment_socket_grants_respect_configured_restrictions_at_remote_laun .await?; assert!( !scoped - .remote_launch_config(DefaultFalse, /*credential_masking*/ false) + .remote_launch_config(DefaultFalse, RemoteCredentialMasking::Disabled) .await? .proxy .dangerously_allow_all_unix_sockets @@ -893,7 +894,7 @@ async fn environment_local_binding_preserves_explicit_denials_and_inherits_omitt Some(Arc::new(|_request| async { NetworkDecision::Allow })), )?; let launch = scoped - .remote_launch_config(local_binding_policy, /*credential_masking*/ false) + .remote_launch_config(local_binding_policy, RemoteCredentialMasking::Disabled) .await; assert_eq!(proxy.current_cfg().await?, carrier.config, "{name}"); if local_binding_policy == RequireTrue && !expected { diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index c6e8f26ef5..35c70df79d 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -40,11 +40,11 @@ use crate::unified_exec::TerminalSandboxSource; use crate::unified_exec::UnifiedExecProcess; use crate::unified_exec::UnifiedExecProcessManager; use codex_core_plugins::PluginMetricsSidecar; -use codex_exec_server::EnvironmentCapabilities; use codex_features::Feature; use codex_network_proxy::CREDENTIAL_BROKER_ACTIVE_ENV_KEY; use codex_network_proxy::ManagedNetworkSandboxContext; use codex_network_proxy::NetworkProxy; +use codex_network_proxy::RemoteCredentialMasking; use codex_network_proxy::RemoteNetworkProxyConfig; use codex_protocol::error::CodexErr; use codex_protocol::models::AdditionalPermissionProfile; @@ -352,13 +352,18 @@ impl<'a> ToolRuntime for UnifiedExecRunt "failed to query exec-server capabilities: {err}" )))) })?; - let credential_masking = ctx + let network_proxy = environment_info.capabilities.network_proxy(); + let credential_masking = if ctx .step_context .turn .config .features .enabled(Feature::CredentialMasking) - && environment_info.capabilities.credential_masking; + { + network_proxy.credential_masking + } else { + RemoteCredentialMasking::Disabled + }; let mut launch = network .remote_launch_config( crate::windows_sandbox::local_binding_policy_for_sandbox( @@ -374,7 +379,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt warn_if_remote_credential_masking_unavailable( ctx, req, - &environment_info.capabilities, + network_proxy.credential_masking, &launch.proxy, ) .await; @@ -402,7 +407,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt if !launch.proxy.enabled { (env, None, None) } else { - if !environment_info.capabilities.network_proxy_launch { + if !network_proxy.launch { return Err(ToolError::Rejected( "selected exec-server does not support executor-local network proxy launches" .to_string(), @@ -701,7 +706,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt async fn warn_if_remote_credential_masking_unavailable( ctx: &ToolCtx, req: &UnifiedExecRequest, - capabilities: &EnvironmentCapabilities, + credential_masking: RemoteCredentialMasking, proxy_config: &RemoteNetworkProxyConfig, ) { if !ctx @@ -715,11 +720,11 @@ async fn warn_if_remote_credential_masking_unavailable( } let environment_id = &req.turn_environment.selection.environment_id; - let message = if !capabilities.credential_masking { + let message = if credential_masking == RemoteCredentialMasking::Disabled { format!( "Credential masking is unavailable on environment `{environment_id}` because its exec-server does not support it. This command will run with unmasked credentials." ) - } else if !proxy_config.credential_masking { + } else if !proxy_config.credential_masking_enabled() { format!( "Credential masking is unavailable for this network proxy configuration on environment `{environment_id}`. This command will run with unmasked credentials." ) diff --git a/codex-rs/core/tests/suite/scenarios_credential_masking.rs b/codex-rs/core/tests/suite/scenarios_credential_masking.rs index 3bd9396de4..64e12e329d 100644 --- a/codex-rs/core/tests/suite/scenarios_credential_masking.rs +++ b/codex-rs/core/tests/suite/scenarios_credential_masking.rs @@ -11,7 +11,7 @@ use pretty_assertions::assert_eq; use pretty_assertions::assert_ne; #[test_case::test_case(false, false, None; "builtin_providers")] -#[test_case::test_case(true, false, None; "configured_provider_fallback")] +#[test_case::test_case(true, false, None; "configured_provider")] #[test_case::test_case(true, true, None; "configured_provider_login_fallback")] #[test_case::test_case(true, false, Some("BASH_ENV"); "configured_provider_executor_startup_fallback")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -27,7 +27,8 @@ async fn remote_credential_masking_request_history( eprintln!("skipping credential masking: this scenario requires a Bash or Zsh executor"); return Ok(()); } - let use_zsh_snapshot = shell.name() == "zsh" && !configured_provider; + let expect_masking = !use_login_shell && executor_startup_env.is_none(); + let use_zsh_snapshot = shell.name() == "zsh" && expect_masking; let home = Arc::new(TempDir::new()?); if let Some(warning) = codex_sandboxing::system_bwrap_warning(&PermissionProfile::workspace_write(), home.path()) @@ -35,11 +36,19 @@ async fn remote_credential_masking_request_history( eprintln!("skipping sandbox test: {warning}"); return Ok(()); } - const API_KEY: &str = "sk-proj-executor-only-0000000000000000000000000000000000"; + let (credential_env, credential, prefix) = if configured_provider { + ("VENDOR_TOKEN", "vendor_abcdefgh", "vendor_") + } else { + ( + "OPENAI_API_KEY", + "sk-proj-executor-only-0000000000000000000000000000000000", + "sk-proj-", + ) + }; const CALL_ID: &str = "print-credential"; // Use our own native executor so only its environment contains the credential. let mut executor_env = vec![ - ("OPENAI_API_KEY", API_KEY), + (credential_env, credential), ("OPENAI_BASE_URL", "https://api.openai.com/v1"), ]; executor_env.extend(executor_startup_env.map(|key| (key, "/dev/null"))); @@ -78,7 +87,7 @@ allow_local_binding = true [shell_environment_policy] ignore_default_excludes = true -include_only = ["HOME", "PATH", "OPENAI_API_KEY", "OPENAI_BASE_URL"{startup_env}] +include_only = ["HOME", "PATH", "{credential_env}", "OPENAI_BASE_URL"{startup_env}] [features] unified_exec = true @@ -104,13 +113,14 @@ credential_masking = true }) .build(&server) .await?; - let mut command = if configured_provider { + let mut command = if !expect_masking { // Keep the real credential out of the model-visible command text. - fs::write(test.workspace_path("expected-api-key"), API_KEY)?; - "test \"$OPENAI_API_KEY\" = \"$("); + .replace(placeholder, "") + .replace(credential_env, ""); for (pattern, replacement) in [ (r"Chunk ID: [a-z0-9]+", "Chunk ID: "), ( diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__credential_masking__remote_credential_masking.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__credential_masking__remote_credential_masking.snap index bca45a7e74..c2b061d2aa 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__credential_masking__remote_credential_masking.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__credential_masking__remote_credential_masking.snap @@ -13,9 +13,9 @@ Scenario: A remote executor masks its credential before the command output reach 02:message/user: 03:message/user: - Print OPENAI_API_KEY from the executor environment. + Print from the executor environment. -- request 2 (turn) -- -04:function_call/exec_command:{"cmd":"printf '%s' \\"$OPENAI_API_KEY\\"","environment_id":"remote","login":false,"yield_time_ms":1000} +04:function_call/exec_command:{"cmd":"printf '%s' \\"$\\"","environment_id":"remote","login":false,"yield_time_ms":1000} 05:function_call_output:Chunk ID: Wall time: seconds Process exited with code 0 diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__credential_masking__remote_credential_masking_zsh.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__credential_masking__remote_credential_masking_zsh.snap index 443d337a08..5af06702d2 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__credential_masking__remote_credential_masking_zsh.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__credential_masking__remote_credential_masking_zsh.snap @@ -13,9 +13,9 @@ Scenario: A remote executor masks its credential before the command output reach 02:message/user: 03:message/user: - Print OPENAI_API_KEY from the executor environment. + Print from the executor environment. -- request 2 (turn) -- -04:function_call/exec_command:{"cmd":"printf '%s' \\"$OPENAI_API_KEY\\"","environment_id":"remote","login":true,"yield_time_ms":1000} +04:function_call/exec_command:{"cmd":"printf '%s' \\"$\\"","environment_id":"remote","login":true,"yield_time_ms":1000} 05:function_call_output:Chunk ID: Wall time: seconds Process exited with code 0 diff --git a/codex-rs/core/tests/suite/unified_exec_process_events.rs b/codex-rs/core/tests/suite/unified_exec_process_events.rs index 08e50dfcd3..1454676e0f 100644 --- a/codex-rs/core/tests/suite/unified_exec_process_events.rs +++ b/codex-rs/core/tests/suite/unified_exec_process_events.rs @@ -76,6 +76,7 @@ const REPLAY_RETAINED_OUTPUT_SEQ: u64 = 800; enum PushedExecScenario { Complete, CredentialMasking, + CredentialMaskingCustomProvidersUnsupported, CredentialMaskingUnsupportedShell, CredentialMaskingUnsupported, OutputAfterExit, @@ -201,7 +202,9 @@ async fn respond_environment_info( json!({ "name": "sh", "path": "/bin/sh" }) } else if matches!( scenario, - PushedExecScenario::CredentialMasking | PushedExecScenario::CredentialMaskingUnsupported + PushedExecScenario::CredentialMasking + | PushedExecScenario::CredentialMaskingCustomProvidersUnsupported + | PushedExecScenario::CredentialMaskingUnsupported ) { json!({ "name": "bash", "path": "/bin/bash" }) } else { @@ -215,6 +218,7 @@ async fn respond_environment_info( | PushedExecScenario::DenyOldLinuxWithRoot(_) | PushedExecScenario::DenyLinux | PushedExecScenario::CredentialMasking + | PushedExecScenario::CredentialMaskingCustomProvidersUnsupported | PushedExecScenario::CredentialMaskingUnsupportedShell | PushedExecScenario::CredentialMaskingUnsupported => Some("linux"), _ => None, @@ -223,6 +227,7 @@ async fn respond_environment_info( if matches!( scenario, PushedExecScenario::CredentialMasking + | PushedExecScenario::CredentialMaskingCustomProvidersUnsupported | PushedExecScenario::CredentialMaskingUnsupportedShell ) { capabilities["credentialMasking"] = json!(true); @@ -566,6 +571,7 @@ async fn serve_exec_with_pushed_events( } PushedExecScenario::Complete | PushedExecScenario::CredentialMasking + | PushedExecScenario::CredentialMaskingCustomProvidersUnsupported | PushedExecScenario::CredentialMaskingUnsupportedShell | PushedExecScenario::CredentialMaskingUnsupported | PushedExecScenario::DenyWin @@ -722,6 +728,7 @@ async fn serve_exec_with_pushed_events( } PushedExecScenario::Complete | PushedExecScenario::CredentialMasking + | PushedExecScenario::CredentialMaskingCustomProvidersUnsupported | PushedExecScenario::CredentialMaskingUnsupportedShell | PushedExecScenario::CredentialMaskingUnsupported | PushedExecScenario::CodexPath @@ -859,6 +866,7 @@ async fn serve_exec_with_pushed_events( #[test_case(PushedExecScenario::ReplayGap, ManagedNetworkScenario::None, false ; "truncated_event_replay")] #[test_case(PushedExecScenario::Complete, ManagedNetworkScenario::Enabled { policy_callbacks: true }, false ; "managed_network_uses_executor_proxy_launch")] #[test_case(PushedExecScenario::CredentialMasking, ManagedNetworkScenario::Enabled { policy_callbacks: true }, false ; "credential_masking_launches_on_supported_executor")] +#[test_case(PushedExecScenario::CredentialMaskingCustomProvidersUnsupported, ManagedNetworkScenario::Enabled { policy_callbacks: true }, false ; "configured_credential_provider_warns_and_runs_on_old_executor")] #[test_case(PushedExecScenario::CredentialMaskingUnsupported, ManagedNetworkScenario::Enabled { policy_callbacks: true }, false ; "credential_masking_warns_and_runs_unsupported_executor")] #[test_case(PushedExecScenario::CredentialMaskingUnsupportedShell, ManagedNetworkScenario::Enabled { policy_callbacks: true }, false ; "credential_masking_warns_and_runs_unsupported_shell")] #[test_case(PushedExecScenario::Complete, ManagedNetworkScenario::Enabled { policy_callbacks: false }, false ; "strict_managed_allowlist_omits_policy_callbacks")] @@ -904,6 +912,7 @@ async fn exec_command_consumes_pushed_remote_process_events( let credential_masking = matches!( scenario, PushedExecScenario::CredentialMasking + | PushedExecScenario::CredentialMaskingCustomProvidersUnsupported | PushedExecScenario::CredentialMaskingUnsupported | PushedExecScenario::CredentialMaskingUnsupportedShell ); @@ -1085,6 +1094,19 @@ enabled = false builder = builder .with_cloud_config_bundle(cloud_config_bundle) .with_pre_build_hook(move |home| { + let provider_config = if matches!( + scenario, + PushedExecScenario::CredentialMaskingCustomProvidersUnsupported + ) { + r#"[features.network_proxy.credentials.vendor] +env = ["VENDOR_TOKEN"] +patterns = ["vendor_[a-z]{8}"] +url_prefix_from_env = "VENDOR_BASE_URL" +auth = ["bearer"] +"# + } else { + "" + }; fs::write( home.join("config.toml"), format!( @@ -1101,6 +1123,7 @@ allow_local_binding = true [features] hooks = true credential_masking = {credential_masking} +{provider_config} [hooks] @@ -1757,6 +1780,10 @@ timeout = 900 } ); assert_eq!(params["networkProxy"]["proxy"]["mode"], "full"); + assert_eq!( + params["networkProxy"]["proxy"]["credentialProviders"], + Value::Null + ); assert_eq!( params["networkProxy"]["policyDecisionTimeoutMs"].as_u64(), policy_callbacks.then_some(1_000_000) @@ -1774,6 +1801,7 @@ timeout = 900 scenario, PushedExecScenario::CredentialMaskingUnsupportedShell | PushedExecScenario::CredentialMaskingUnsupported + | PushedExecScenario::CredentialMaskingCustomProvidersUnsupported ) { let shell = if matches!( scenario, @@ -1796,6 +1824,11 @@ timeout = 900 PushedExecScenario::CredentialMaskingUnsupportedShell ) { insta::assert_snapshot!(warning.message, @"Credential masking is unavailable for shell `sh` on environment `remote`. This command will run with unmasked credentials."); + } else if matches!( + scenario, + PushedExecScenario::CredentialMaskingCustomProvidersUnsupported + ) { + insta::assert_snapshot!(warning.message, @"Credential masking is unavailable for this network proxy configuration on environment `remote`. This command will run with unmasked credentials."); } else { insta::assert_snapshot!(warning.message, @"Credential masking is unavailable on environment `remote` because its exec-server does not support it. This command will run with unmasked credentials."); } @@ -1884,6 +1917,7 @@ timeout = 900 } PushedExecScenario::Complete | PushedExecScenario::CredentialMasking + | PushedExecScenario::CredentialMaskingCustomProvidersUnsupported | PushedExecScenario::CredentialMaskingUnsupportedShell | PushedExecScenario::CredentialMaskingUnsupported | PushedExecScenario::DenyWin diff --git a/codex-rs/exec-server-protocol/src/protocol.rs b/codex-rs/exec-server-protocol/src/protocol.rs index 7477f0a78b..fbb2496f26 100644 --- a/codex-rs/exec-server-protocol/src/protocol.rs +++ b/codex-rs/exec-server-protocol/src/protocol.rs @@ -8,6 +8,7 @@ pub use codex_file_system::WalkOutcome; use codex_file_system::WireFileSystemSandboxContext; use codex_install_context::InstallContext; use codex_network_proxy::ManagedNetworkSandboxContext; +use codex_network_proxy::RemoteCredentialMasking; use codex_network_proxy::RemoteNetworkProxyLaunchConfig; use codex_protocol::ThreadId; use codex_protocol::capabilities::SelectedCapabilityRoot; @@ -151,10 +152,13 @@ fn unknown_executor_version() -> String { pub struct EnvironmentCapabilities { /// Whether `exec` accepts instructions for launching an executor-local network proxy. #[serde(default)] - pub network_proxy_launch: bool, + network_proxy_launch: bool, /// Whether the selected shell can mask executor environment credentials. #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub credential_masking: bool, + credential_masking: bool, + /// Whether credential masking also supports configured providers. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + credential_masking_custom_providers: bool, /// Whether capability discovery applies the filesystem sandbox sent with each root. #[serde(default)] pub capability_discovery_sandbox: bool, @@ -187,6 +191,31 @@ pub struct EnvironmentCapabilities { pub linux_approved_root_write_preserves_restrictions: bool, } +impl EnvironmentCapabilities { + /// Network proxy features available through this executor. + pub fn network_proxy(&self) -> NetworkProxyCapabilities { + NetworkProxyCapabilities { + launch: self.network_proxy_launch, + credential_masking: if !self.credential_masking { + RemoteCredentialMasking::Disabled + } else if self.credential_masking_custom_providers { + RemoteCredentialMasking::WithConfiguredProviders + } else { + RemoteCredentialMasking::BuiltInOnly + }, + } + } +} + +/// Features supported by an executor-local network proxy. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct NetworkProxyCapabilities { + /// Whether `exec` accepts instructions for launching an executor-local network proxy. + pub launch: bool, + /// Credential masking support available through the selected shell. + pub credential_masking: RemoteCredentialMasking, +} + /// Status returned by an initialized exec-server connection. /// /// The response is intentionally small today. New status details can be added @@ -286,6 +315,7 @@ impl EnvironmentInfo { capabilities: EnvironmentCapabilities { network_proxy_launch: true, credential_masking, + credential_masking_custom_providers: credential_masking, capability_discovery_sandbox: true, capability_discovery_v2: true, environment_config_read: true, @@ -1260,6 +1290,7 @@ mod tests { use super::FsOpenParams; use super::FsReadFileParams; use super::HttpRequestParams; + use super::NetworkProxyCapabilities; use super::ProcessId; use super::ProcessSandboxType; use super::ShellInfo; @@ -1270,6 +1301,7 @@ mod tests { use codex_network_proxy::ManagedNetworkSandboxContext; use codex_network_proxy::NetworkProxyAuditMetadata; use codex_network_proxy::NetworkProxyConfig; + use codex_network_proxy::RemoteCredentialMasking; use codex_network_proxy::RemoteNetworkProxyConfig; use codex_network_proxy::RemoteNetworkProxyLaunchConfig; use codex_protocol::config_types::WindowsSandboxProxySettingsMode; @@ -1508,6 +1540,7 @@ mod tests { EnvironmentCapabilities { network_proxy_launch: true, credential_masking: false, + credential_masking_custom_providers: false, capability_discovery_sandbox: true, capability_discovery_v2: false, environment_config_read: false, @@ -1520,6 +1553,88 @@ mod tests { linux_approved_root_write_preserves_restrictions: false, } ); + + for (input_masking, expected_masking, output_masking) in [ + ( + serde_json::json!({}), + RemoteCredentialMasking::Disabled, + serde_json::json!({}), + ), + ( + serde_json::json!({ "credentialMasking": true }), + RemoteCredentialMasking::BuiltInOnly, + serde_json::json!({ "credentialMasking": true }), + ), + ( + serde_json::json!({ + "credentialMasking": true, + "credentialMaskingCustomProviders": false, + }), + RemoteCredentialMasking::BuiltInOnly, + serde_json::json!({ "credentialMasking": true }), + ), + ( + serde_json::json!({ + "credentialMasking": true, + "credentialMaskingCustomProviders": true, + }), + RemoteCredentialMasking::WithConfiguredProviders, + serde_json::json!({ + "credentialMasking": true, + "credentialMaskingCustomProviders": true, + }), + ), + ( + serde_json::json!({ "credentialMaskingCustomProviders": true }), + RemoteCredentialMasking::Disabled, + serde_json::json!({ "credentialMaskingCustomProviders": true }), + ), + ( + serde_json::json!({ + "credentialMasking": false, + "credentialMaskingCustomProviders": true, + }), + RemoteCredentialMasking::Disabled, + serde_json::json!({ "credentialMaskingCustomProviders": true }), + ), + ] { + for launch in [None, Some(false), Some(true)] { + let mut input = serde_json::json!({ + "capabilityDiscoverySandbox": true, + }); + if let Some(launch) = launch { + input["networkProxyLaunch"] = launch.into(); + } + input + .as_object_mut() + .unwrap() + .extend(input_masking.as_object().unwrap().clone()); + let decoded: EnvironmentCapabilities = serde_json::from_value(input).unwrap(); + assert_eq!( + decoded.network_proxy(), + NetworkProxyCapabilities { + launch: launch.unwrap_or_default(), + credential_masking: expected_masking, + } + ); + let mut expected_wire = serde_json::json!({ + "networkProxyLaunch": launch.unwrap_or_default(), + "capabilityDiscoverySandbox": true, + "capabilityDiscoveryV2": false, + "environmentConfigRead": false, + "httpHeaderEnvVars": false, + "sandboxedFileStreaming": false, + "fileWriteStreaming": false, + "shellSnapshotV2": false, + "windowsMxc": false, + }); + expected_wire + .as_object_mut() + .unwrap() + .extend(output_masking.as_object().unwrap().clone()); + assert_eq!(serde_json::to_value(decoded).unwrap(), expected_wire); + } + } } #[test] diff --git a/codex-rs/exec-server/src/process_sandbox.rs b/codex-rs/exec-server/src/process_sandbox.rs index b9e51913d3..488b7c97f2 100644 --- a/codex-rs/exec-server/src/process_sandbox.rs +++ b/codex-rs/exec-server/src/process_sandbox.rs @@ -72,9 +72,8 @@ struct PreparedWindowsSandboxRequest { impl PreparedExecRequest { #[cfg(unix)] pub(crate) fn credential_snapshot_proxy(&self, params: &ExecParams) -> Option<&NetworkProxy> { - if !params.network_proxy.as_ref()?.proxy.credential_masking - || !supports_credential_snapshot(params) - { + let launch = params.network_proxy.as_ref()?; + if !launch.proxy.credential_masking_enabled() || !supports_credential_snapshot(params) { return None; } self.network_proxy_handle.as_ref()?.proxy() diff --git a/codex-rs/exec-server/src/process_sandbox_tests.rs b/codex-rs/exec-server/src/process_sandbox_tests.rs index 1ec954ae32..d0cd73545e 100644 --- a/codex-rs/exec-server/src/process_sandbox_tests.rs +++ b/codex-rs/exec-server/src/process_sandbox_tests.rs @@ -523,7 +523,9 @@ async fn native_request_handles_remote_proxy_config_for_platform( config.set_allowed_domains(vec!["allowed.example".to_string()]); let mut proxy_config = RemoteNetworkProxyConfig::from_effective_config(&config) .expect("supported remote proxy config"); - proxy_config.credential_masking = credential_masking; + if credential_masking { + proxy_config.enable_credential_masking(Default::default()); + } let params = ExecParams { metadata: Default::default(), process_id: ProcessId::from("process-remote-proxy"), @@ -627,7 +629,7 @@ async fn disabled_remote_proxy_config_is_rejected_before_exporting_ports() { let mut proxy_config = RemoteNetworkProxyConfig::from_effective_config(&NetworkProxyConfig::default()) .expect("serializable disabled proxy config"); - proxy_config.credential_masking = true; + proxy_config.enable_credential_masking(Default::default()); let params = ExecParams { metadata: Default::default(), process_id: ProcessId::from("process-disabled-remote-proxy"), diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index 7e82dfc912..2fbd64dc20 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -8,6 +8,8 @@ mod tls_proxy; #[path = "exec_process/windows_sandbox.rs"] mod windows_sandbox; +#[cfg(unix)] +use std::collections::BTreeMap; use std::collections::HashMap; use std::sync::Arc; @@ -522,7 +524,9 @@ async fn shell_snapshot_v2_remote_managed_proxy_uses_prepared_execution_context( const API_KEY: &str = "sk-proj-executor-only-0000000000000000000000000000000000"; const PROFILE_GH_TOKEN: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const REQUEST_GH_TOKEN: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const VENDOR_TOKEN: &str = "vendor_abcdefgh"; const CUSTOM_HOST: &str = "chatgpt.com"; + let configured_provider = shell_name == "bash" && shell_args.first() == Some(&"-c"); let shell_path = format!("/bin/{shell_name}"); if shell_name == "zsh" && !std::path::Path::new(&shell_path).exists() { eprintln!("skipping zsh test: {shell_path} is unavailable"); @@ -553,6 +557,12 @@ async fn shell_snapshot_v2_remote_managed_proxy_uses_prepared_execution_context( }) .transpose()?; let mut executor_env = vec![("OPENAI_API_KEY", API_KEY.to_string())]; + if configured_provider { + executor_env.extend([ + ("VENDOR_TOKEN", VENDOR_TOKEN.to_string()), + ("VENDOR_BASE_URL", "https://api.openai.com/v1".to_string()), + ]); + } if let Some(upstream) = &upstream { let ca_path = home.path().join("upstream-ca.pem"); std::fs::write(&ca_path, &upstream.ca_cert_pem)?; @@ -681,6 +691,11 @@ async fn shell_snapshot_v2_remote_managed_proxy_uses_prepared_execution_context( .include_only .extend(["GH_TOKEN", "GH_ALIAS", "AUTH_SIZE_*"].map(str::to_string)); } + if configured_provider { + policy + .include_only + .extend(["VENDOR_TOKEN", "VENDOR_BASE_URL"].map(str::to_string)); + } let mut proxy_settings = NetworkProxyConfig { enabled: true, ..NetworkProxyConfig::default() @@ -691,7 +706,23 @@ async fn shell_snapshot_v2_remote_managed_proxy_uses_prepared_execution_context( } proxy_settings.set_allowed_domains(allowed_domains); let mut proxy_config = RemoteNetworkProxyConfig::from_effective_config(&proxy_settings)?; - proxy_config.credential_masking = credential_masking; + let mut providers = BTreeMap::new(); + if configured_provider { + providers.insert( + "vendor".to_string(), + codex_network_proxy::CredentialProviderConfig { + env: vec!["VENDOR_TOKEN".to_string()], + patterns: vec!["vendor_[a-z]{8}".to_string()], + url_prefix_from_env: Some("VENDOR_BASE_URL".to_string()), + auth: vec![codex_network_proxy::CredentialAuthMethod::Header], + header: Some("X-Vendor-Token".to_string()), + ..Default::default() + }, + ); + } + if credential_masking { + proxy_config.enable_credential_masking(providers); + } let mut saved_api_key = None; for (attempt, &shell_args) in shell_args.iter().enumerate() { @@ -741,8 +772,13 @@ async fn shell_snapshot_v2_remote_managed_proxy_uses_prepared_execution_context( command }; let command = if credential_masking { + let vendor_header = if configured_provider { + "--header \"X-Vendor-Token: $(<\"$HOME/vendor-token\")\"" + } else { + "" + }; format!( - "{command}; test \"$GH_ENTERPRISE_TOKEN\" = executor-unbound-enterprise-token || exit 45; test \"$AUTH_ALIAS\" = \"Bearer $OPENAI_API_KEY\" || exit 42; printf '|%s' \"$OPENAI_API_KEY\"; curl --silent --show-error --fail --max-time 5 --noproxy '' --config \"$HOME/curl.conf\" \"${{OPENAI_BASE_URL:-https://api.openai.com/v1}}/models\" >/dev/null" + "{command}; test \"$GH_ENTERPRISE_TOKEN\" = executor-unbound-enterprise-token || exit 45; test \"$AUTH_ALIAS\" = \"Bearer $OPENAI_API_KEY\" || exit 42; printf '|%s' \"$OPENAI_API_KEY\"; curl --silent --show-error --fail --max-time 5 --noproxy '' --config \"$HOME/curl.conf\" {vendor_header} \"${{OPENAI_BASE_URL:-https://api.openai.com/v1}}/models\" >/dev/null" ) } else { format!("{command}; test \"$OPENAI_API_KEY\" = '{API_KEY}' || exit 46") @@ -761,6 +797,11 @@ async fn shell_snapshot_v2_remote_managed_proxy_uses_prepared_execution_context( } else { command }; + let command = if configured_provider && attempt == 0 { + format!("printf '%s' \"$VENDOR_TOKEN\" > \"$HOME/vendor-token\"; {command}") + } else { + command + }; let started = timeout( Duration::from_secs(30), backend.start(ExecParams { @@ -833,6 +874,16 @@ async fn shell_snapshot_v2_remote_managed_proxy_uses_prepared_execution_context( format!("header = \"Authorization: Bearer {saved_api_key}\"\n") ); let request = upstream.next_request()?; + if configured_provider { + let placeholder = std::fs::read_to_string(home.path().join("vendor-token"))?; + assert!(placeholder.starts_with("vendor_")); + assert_eq!(placeholder.len(), VENDOR_TOKEN.len()); + assert_ne!(placeholder, VENDOR_TOKEN); + assert_eq!( + request.headers.get("x-vendor-token"), + Some(&vec![VENDOR_TOKEN.to_string()]) + ); + } assert_eq!( ( request.connect_authority.as_str(), diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index eeec200cf0..dfabfda63d 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -107,6 +107,7 @@ pub use proxy::is_managed_proxy_env_var; pub use proxy::proxy_url_env_value; pub use proxy::strip_managed_proxy_env; pub use remote_config::RemoteCredentialBrokerCache; +pub use remote_config::RemoteCredentialMasking; pub use remote_config::RemoteNetworkProxyConfig; pub use remote_config::RemoteNetworkProxyLaunchConfig; pub use request_cancellation::NetworkRequestCancellation; diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index 7b968a5ba4..9c412f31e0 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -12,6 +12,7 @@ use crate::http_proxy; use crate::network_policy::NetworkDecision; use crate::network_policy::NetworkDecisionSource; use crate::network_policy::NetworkPolicyDecider; +use crate::remote_config::RemoteCredentialMasking; use crate::runtime::BlockedRequestObserver; use crate::runtime::ConfigState; use crate::runtime::HostAuthorization; @@ -949,15 +950,17 @@ impl NetworkProxy { pub async fn remote_launch_config( &self, local_binding_policy: crate::LocalBindingPolicy, - credential_masking: bool, + credential_masking: RemoteCredentialMasking, ) -> Result { let (mut config, brokerage_created_default_allowlist, _) = self.state.current_cfg_with_brokerage_provenance().await?; - // Configured providers are not transported yet, so these commands run unmasked. - let credential_masking = credential_masking - && config.enabled + let credential_masking = config.enabled && config.credential_broker - && config.credential_providers.is_empty(); + && match credential_masking { + RemoteCredentialMasking::Disabled => false, + RemoteCredentialMasking::BuiltInOnly => config.credential_providers.is_empty(), + RemoteCredentialMasking::WithConfiguredProviders => true, + }; anyhow::ensure!( !credential_masking || (!config.dangerously_allow_plaintext_credential_injection @@ -1021,7 +1024,9 @@ impl NetworkProxy { "MXC cannot enforce allow_local_binding=false: native host-loopback access is bidirectional; use true or a different sandbox backend" ); let mut proxy = crate::RemoteNetworkProxyConfig::from_effective_config(&config)?; - proxy.credential_masking = credential_masking; + if credential_masking { + proxy.enable_credential_masking(config.credential_providers); + } tracing::debug!(network_config = ?proxy, "resolved remote execution network configuration"); let (environment_id, execution_id) = self .execution_scope @@ -1970,7 +1975,7 @@ mod tests { assert_eq!(proxy.current_cfg().await?, config); let remote = proxy - .remote_launch_config(DefaultFalse, /*credential_masking*/ false) + .remote_launch_config(DefaultFalse, RemoteCredentialMasking::Disabled) .await?; assert_eq!( ( @@ -2068,7 +2073,7 @@ mod tests { owner_config.set_denied_domains(vec!["denied.example".to_string()]); assert_eq!( scoped - .remote_launch_config(DefaultFalse, /*credential_masking*/ false) + .remote_launch_config(DefaultFalse, RemoteCredentialMasking::Disabled) .await? .proxy .domains, @@ -2507,7 +2512,7 @@ mod tests { /*fallback_policy_decider*/ None, )?; let launch = scoped - .remote_launch_config(RequireTrue, /*credential_masking*/ false) + .remote_launch_config(RequireTrue, RemoteCredentialMasking::Disabled) .await?; let prepared = scoped.prepare_for_optional_environment( HashMap::from([( @@ -2522,7 +2527,7 @@ mod tests { assert!(!launch.proxy.enabled); assert!( scoped - .remote_launch_config(DefaultFalse, /*credential_masking*/ true) + .remote_launch_config(DefaultFalse, RemoteCredentialMasking::BuiltInOnly) .await .is_err() ); @@ -2538,16 +2543,51 @@ mod tests { }, ); let custom_provider_proxy = NetworkProxy::builder() - .state(Arc::new(network_proxy_state_for_policy(config))) + .state(Arc::new(network_proxy_state_for_policy(config.clone()))) .build() .await?; assert_eq!( custom_provider_proxy - .remote_launch_config(DefaultFalse, /*credential_masking*/ true) + .remote_launch_config(DefaultFalse, RemoteCredentialMasking::BuiltInOnly) .await? .proxy, launch.proxy ); + assert!( + custom_provider_proxy + .remote_launch_config( + DefaultFalse, + RemoteCredentialMasking::WithConfiguredProviders + ) + .await + .is_err() + ); + config.dangerously_allow_plaintext_credential_injection = false; + let custom_provider_proxy = NetworkProxy::builder() + .state(Arc::new(network_proxy_state_for_policy(config.clone()))) + .build() + .await?; + assert_eq!( + custom_provider_proxy + .remote_launch_config(DefaultFalse, RemoteCredentialMasking::Disabled) + .await? + .proxy, + launch.proxy + ); + let custom_launch = custom_provider_proxy + .remote_launch_config( + DefaultFalse, + RemoteCredentialMasking::WithConfiguredProviders, + ) + .await?; + assert!(custom_launch.proxy.credential_masking_enabled()); + assert_eq!( + custom_launch + .proxy + .into_network_proxy_config() + .credential_providers, + config.credential_providers, + ); let mut owner_config = NetworkProxyConfig::default(); owner_config.set_allowed_domains(vec!["owner.example".to_string()]); @@ -2563,7 +2603,7 @@ mod tests { )?; assert!( owner_scoped - .remote_launch_config(DefaultFalse, /*credential_masking*/ false) + .remote_launch_config(DefaultFalse, RemoteCredentialMasking::Disabled) .await .is_err() ); @@ -2595,13 +2635,16 @@ mod tests { .state(Arc::new(network_proxy_state_for_policy(config))) .build() .await?; - for credential_masking in [false, true] { + for (credential_masking, expected_masking) in [ + (RemoteCredentialMasking::Disabled, false), + (RemoteCredentialMasking::BuiltInOnly, true), + ] { let launch = proxy .remote_launch_config(DefaultFalse, credential_masking) .await?; assert!(launch.proxy.enabled); assert_eq!(launch.proxy.mode, mode); - assert_eq!(launch.proxy.credential_masking, credential_masking); + assert_eq!(launch.proxy.credential_masking_enabled(), expected_masking); } } assert_eq!( diff --git a/codex-rs/network-proxy/src/remote_config.rs b/codex-rs/network-proxy/src/remote_config.rs index 5260ef6b00..4b00da931f 100644 --- a/codex-rs/network-proxy/src/remote_config.rs +++ b/codex-rs/network-proxy/src/remote_config.rs @@ -2,8 +2,11 @@ use anyhow::Result; use anyhow::ensure; use serde::Deserialize; use serde::Serialize; +use std::collections::BTreeMap; +use std::fmt; use std::sync::Mutex; +use crate::CredentialProviderConfig; use crate::NetworkDomainPermissions; use crate::NetworkMode; use crate::NetworkProxyAuditMetadata; @@ -69,6 +72,54 @@ impl RemoteCredentialBrokerCache { } } +/// Credential masking supported by an executor or requested for a remote launch. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum RemoteCredentialMasking { + #[default] + Disabled, + /// Mask only when the configuration contains no custom providers. + BuiltInOnly, + WithConfiguredProviders, +} + +/// Masking fields shared with independently deployed executors. +#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(default)] +struct CredentialMaskingConfig { + #[serde( + rename = "credentialMasking", + skip_serializing_if = "std::ops::Not::not" + )] + enabled: bool, + #[serde( + rename = "credentialProviders", + skip_serializing_if = "BTreeMap::is_empty" + )] + providers: BTreeMap, +} + +impl CredentialMaskingConfig { + fn deserialize_normalized<'de, D: serde::Deserializer<'de>>( + deserializer: D, + ) -> Result { + let mut config = Self::deserialize(deserializer)?; + // Discard provider rules sent with masking off, even if we forward this configuration. + if !config.enabled { + config.providers.clear(); + } + Ok(config) + } +} + +impl fmt::Debug for CredentialMaskingConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CredentialMaskingConfig") + .field("enabled", &self.enabled) + .field("credential_provider_count", &self.providers.len()) + .finish() + } +} + /// Executor-local proxy launch inputs transported with one process start. /// /// Unlike [`crate::ManagedNetworkSandboxContext`], this describes how the executor should create @@ -115,15 +166,18 @@ impl RemoteNetworkProxyLaunchConfig { /// Effective network proxy settings that are safe to send to a remote executor. /// /// Listener addresses are deliberately omitted because the executor chooses its own loopback -/// ports. Masking carries only built-in broker activation; credentials, destination context, custom -/// providers, CA configuration, and MITM hooks stay on their execution host. +/// ports. Masking carries provider rules; credential values and destination context are resolved +/// on the executor. CA configuration and MITM hooks stay on their execution host. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct RemoteNetworkProxyConfig { pub enabled: bool, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub credential_masking: bool, + #[serde( + flatten, + deserialize_with = "CredentialMaskingConfig::deserialize_normalized" + )] + credential_masking: CredentialMaskingConfig, pub enable_socks5: bool, pub enable_socks5_udp: bool, pub allow_upstream_proxy: bool, @@ -147,7 +201,7 @@ impl RemoteNetworkProxyConfig { ); Ok(Self { enabled: config.enabled, - credential_masking: false, + credential_masking: CredentialMaskingConfig::default(), enable_socks5: config.enable_socks5, enable_socks5_udp: config.enable_socks5_udp, allow_upstream_proxy: config.allow_upstream_proxy, @@ -161,6 +215,22 @@ impl RemoteNetworkProxyConfig { }) } + /// Enable masking with the given provider rules, or just the built-ins if empty. + pub fn enable_credential_masking( + &mut self, + providers: BTreeMap, + ) { + self.credential_masking = CredentialMaskingConfig { + enabled: true, + providers, + }; + } + + /// Whether the executor should mask credentials for this launch. + pub fn credential_masking_enabled(&self) -> bool { + self.credential_masking.enabled + } + pub(crate) fn into_network_proxy_config(self) -> NetworkProxyConfig { let mut config = NetworkProxyConfig { enabled: self.enabled, @@ -174,7 +244,10 @@ impl RemoteNetworkProxyConfig { allow_local_binding: Some(self.allow_local_binding), ..NetworkProxyConfig::default() }; - config.set_credential_broker_enabled(self.credential_masking); + if self.credential_masking.enabled { + config.set_credential_broker_enabled(/*enabled*/ true); + config.credential_providers = self.credential_masking.providers; + } config } } diff --git a/codex-rs/network-proxy/src/remote_config_tests.rs b/codex-rs/network-proxy/src/remote_config_tests.rs index 0379aff882..ec327bb902 100644 --- a/codex-rs/network-proxy/src/remote_config_tests.rs +++ b/codex-rs/network-proxy/src/remote_config_tests.rs @@ -12,6 +12,7 @@ use crate::NetworkProxyAuditMetadata; use crate::NetworkProxyConfig; use crate::NetworkProxyState; use crate::Platform; +use crate::RemoteCredentialMasking; use std::sync::Arc; #[test] @@ -47,13 +48,14 @@ fn optional_socket_policy_preserves_input_and_resolves_at_remote_boundary() { #[tokio::test] async fn round_trip_preserves_supported_effective_settings() { - for (executor_os, socket, credential_masking) in [ - (Platform::Linux, "/var/run/example.sock", false), - (Platform::Linux, "/var/run/example.sock", true), - (Platform::Macos, "/var/run/example.sock", false), - (Platform::Macos, "/var/run/example.sock", true), - (Platform::Windows, r"C:\example.sock", false), - (Platform::Unknown, r"C:\example.sock", false), + for (executor_os, socket, credential_masking, configured_provider) in [ + (Platform::Linux, "/var/run/example.sock", false, false), + (Platform::Linux, "/var/run/example.sock", true, false), + (Platform::Linux, "/var/run/example.sock", true, true), + (Platform::Macos, "/var/run/example.sock", false, false), + (Platform::Macos, "/var/run/example.sock", true, true), + (Platform::Windows, r"C:\example.sock", false, false), + (Platform::Unknown, r"C:\example.sock", false, false), ] { let mut config = NetworkProxyConfig { enabled: true, @@ -65,14 +67,67 @@ async fn round_trip_preserves_supported_effective_settings() { allow_local_binding: Some(true), ..NetworkProxyConfig::default() }; - config.set_allowed_domains(vec!["example.com".into()]); + // Domain permissions serialize as a sorted map. config.set_denied_domains(vec!["blocked.example.com".into()]); + config.set_allowed_domains(vec!["example.com".into()]); config.set_allow_unix_sockets(vec![socket.into()]); let mut remote = RemoteNetworkProxyConfig::from_effective_config(&config) .expect("supported remote config"); - remote.credential_masking = credential_masking; config.set_credential_broker_enabled(credential_masking); + if configured_provider { + config.credential_providers.insert( + "vendor".to_string(), + crate::CredentialProviderConfig { + env: vec!["VENDOR_TOKEN".to_string()], + patterns: vec!["vendor_[a-z]{8}".to_string()], + url_prefix_from_env: Some("VENDOR_BASE_URL".to_string()), + auth: vec![crate::CredentialAuthMethod::Bearer], + ..Default::default() + }, + ); + } + if credential_masking { + remote.enable_credential_masking(config.credential_providers.clone()); + } + let wire = serde_json::to_value(&remote).expect("serialize remote config"); + assert_eq!( + wire.get("credentialMasking"), + credential_masking.then_some(&serde_json::Value::Bool(true)) + ); + assert_eq!( + wire.get("credentialProviders"), + configured_provider + .then(|| serde_json::to_value(&config.credential_providers).unwrap()) + .as_ref() + ); + assert_eq!( + serde_json::from_value::(wire.clone()) + .expect("deserialize remote config"), + remote + ); + let mut disabled_wire = wire; + disabled_wire + .as_object_mut() + .unwrap() + .remove("credentialMasking"); + let mut expected_wire = disabled_wire.clone(); + expected_wire + .as_object_mut() + .unwrap() + .remove("credentialProviders"); + // Ignore provider rules when the peer omits or explicitly disables masking. + let mut explicitly_disabled_wire = disabled_wire.clone(); + explicitly_disabled_wire["credentialMasking"] = false.into(); + for wire in [disabled_wire, explicitly_disabled_wire] { + let disabled: RemoteNetworkProxyConfig = + serde_json::from_value(wire).expect("disabled masking ignores provider rules"); + assert!(!disabled.credential_masking_enabled()); + assert_eq!(serde_json::to_value(disabled).unwrap(), expected_wire); + } + let diagnostic = format!("{remote:?}"); + assert!(!diagnostic.contains("vendor_[a-z]{8}")); + assert!(!diagnostic.contains("VENDOR_BASE_URL")); let round_trip = remote.clone().into_network_proxy_config(); assert_eq!(round_trip, config); @@ -102,7 +157,14 @@ async fn round_trip_preserves_supported_effective_settings() { ); assert_eq!( proxy - .remote_launch_config(DefaultFalse, credential_masking) + .remote_launch_config( + DefaultFalse, + if credential_masking { + RemoteCredentialMasking::WithConfiguredProviders + } else { + RemoteCredentialMasking::Disabled + }, + ) .await .unwrap() .proxy @@ -242,7 +304,7 @@ async fn remote_credential_mappings_follow_the_environment_and_broker_config() { ..NetworkProxyConfig::default() }) .unwrap(); - proxy.credential_masking = true; + proxy.enable_credential_masking(Default::default()); NetworkProxyState::from_remote_launch_config( RemoteNetworkProxyLaunchConfig::new(proxy) .for_execution(environment_id.to_string(), "execution".to_string()) @@ -371,7 +433,7 @@ async fn local_binding_is_resolved_for_each_executor() -> anyhow::Result<()> { .await?; assert_eq!( proxy - .remote_launch_config(policy, /*credential_masking*/ false) + .remote_launch_config(policy, RemoteCredentialMasking::Disabled) .await .ok() .map(|launch| launch.proxy.allow_local_binding),