diff --git a/crates/freshell-runtime-protocol/src/lib.rs b/crates/freshell-runtime-protocol/src/lib.rs index 6fe83a3d3..9cf498b06 100644 --- a/crates/freshell-runtime-protocol/src/lib.rs +++ b/crates/freshell-runtime-protocol/src/lib.rs @@ -918,6 +918,7 @@ pub enum ProviderSecretProfile { CodexOnecliAuthFile, OpencodeOnecliEnvironment, OpencodeOnecliAuthFile, + OpencodeOnecliCaFile, AmplifierOnecliEnvironment, AmplifierOnecliKeysFile, /// The approved OneCLI deployment: Amplifier's VLLM module talks to the @@ -936,7 +937,9 @@ impl ProviderSecretProfile { match self { Self::ClaudeOnecliEnvironment | Self::ClaudeOnecliAuthFile => "claude", Self::CodexOnecliEnvironment | Self::CodexOnecliAuthFile => "codex", - Self::OpencodeOnecliEnvironment | Self::OpencodeOnecliAuthFile => "opencode", + Self::OpencodeOnecliEnvironment + | Self::OpencodeOnecliAuthFile + | Self::OpencodeOnecliCaFile => "opencode", Self::AmplifierOnecliEnvironment | Self::AmplifierOnecliKeysFile | Self::AmplifierOnecliLunarouteGlm53 @@ -953,6 +956,15 @@ impl ProviderSecretProfile { _ => None, } } + + /// Provider-home path for a OneCLI file grant, including non-auth files + /// such as the gateway CA certificate. + pub fn provider_file_relative_path(self) -> Option<&'static str> { + self.auth_relative_path().or(match self { + Self::OpencodeOnecliCaFile => Some(".config/onecli/gateway-ca.pem"), + _ => None, + }) + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -1473,28 +1485,29 @@ mod provider_launch_context_tests { for (provider, profiles) in [ ( "claude", - [ + vec![ ProviderSecretProfile::ClaudeOnecliEnvironment, ProviderSecretProfile::ClaudeOnecliAuthFile, ], ), ( "codex", - [ + vec![ ProviderSecretProfile::CodexOnecliEnvironment, ProviderSecretProfile::CodexOnecliAuthFile, ], ), ( "opencode", - [ + vec![ ProviderSecretProfile::OpencodeOnecliEnvironment, ProviderSecretProfile::OpencodeOnecliAuthFile, + ProviderSecretProfile::OpencodeOnecliCaFile, ], ), ( "amplifier", - [ + vec![ ProviderSecretProfile::AmplifierOnecliEnvironment, ProviderSecretProfile::AmplifierOnecliKeysFile, ], diff --git a/crates/freshell-server/src/managed_provider_bootstrap.rs b/crates/freshell-server/src/managed_provider_bootstrap.rs index bf25862eb..b709b373e 100644 --- a/crates/freshell-server/src/managed_provider_bootstrap.rs +++ b/crates/freshell-server/src/managed_provider_bootstrap.rs @@ -301,31 +301,39 @@ pub fn named_provider_onecli_references( provider: &str, ) -> Result, String> { use freshell_runtime_protocol::ProviderSecretProfile; - let (prefix, environment, auth_file) = match provider { + let (prefix, environment, auth_file, ca_file) = match provider { "claude" => ( "CLAUDE", ProviderSecretProfile::ClaudeOnecliEnvironment, ProviderSecretProfile::ClaudeOnecliAuthFile, + None, ), "codex" => ( "CODEX", ProviderSecretProfile::CodexOnecliEnvironment, ProviderSecretProfile::CodexOnecliAuthFile, + None, ), "opencode" => ( "OPENCODE", ProviderSecretProfile::OpencodeOnecliEnvironment, ProviderSecretProfile::OpencodeOnecliAuthFile, + Some(ProviderSecretProfile::OpencodeOnecliCaFile), ), "amplifier" => ( "AMPLIFIER", ProviderSecretProfile::AmplifierOnecliEnvironment, ProviderSecretProfile::AmplifierOnecliKeysFile, + None, ), _ => return Ok(Vec::new()), }; let mut references = Vec::new(); - for (suffix, profile) in [("ENV_FILE", environment), ("AUTH_FILE", auth_file)] { + let mut profiles = vec![("ENV_FILE", environment), ("AUTH_FILE", auth_file)]; + if let Some(profile) = ca_file { + profiles.push(("CA_FILE", profile)); + } + for (suffix, profile) in profiles { let key = format!("FRESHELL_MANAGED_{prefix}_ONECLI_{suffix}"); if let Some(path) = std::env::var_os(&key).filter(|value| !value.is_empty()) { let path = std::path::PathBuf::from(path); @@ -356,6 +364,67 @@ mod tests { use super::*; use std::fs; + struct RestoreOnecliEnv(Vec<(&'static str, Option)>); + + impl Drop for RestoreOnecliEnv { + fn drop(&mut self) { + for (name, value) in self.0.drain(..) { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + } + } + + #[test] + fn named_opencode_onecli_references_include_private_environment_auth_and_ca_files() { + let _env_lock = crate::test_env_lock::CLAUDE_ENV_TEST_LOCK.blocking_lock(); + let workspace = tempfile::tempdir().unwrap(); + let environment = workspace.path().join("opencode.env"); + let auth = workspace.path().join("opencode-auth.json"); + let ca = workspace.path().join("gateway-ca.pem"); + for file in [&environment, &auth, &ca] { + fs::write(file, "fixture").unwrap(); + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(file, fs::Permissions::from_mode(0o600)).unwrap(); + } + let keys = [ + "FRESHELL_MANAGED_OPENCODE_ONECLI_ENV_FILE", + "FRESHELL_MANAGED_OPENCODE_ONECLI_AUTH_FILE", + "FRESHELL_MANAGED_OPENCODE_ONECLI_CA_FILE", + ]; + let _restore = RestoreOnecliEnv(keys.map(|key| (key, std::env::var_os(key))).into()); + for (key, file) in keys.into_iter().zip([&environment, &auth, &ca]) { + std::env::set_var(key, file); + } + + let references = named_provider_onecli_references("opencode").unwrap(); + + assert_eq!( + references + .iter() + .map(|reference| reference.profile) + .collect::>(), + vec![ + freshell_runtime_protocol::ProviderSecretProfile::OpencodeOnecliEnvironment, + freshell_runtime_protocol::ProviderSecretProfile::OpencodeOnecliAuthFile, + freshell_runtime_protocol::ProviderSecretProfile::OpencodeOnecliCaFile, + ], + ); + let actual_paths = references + .iter() + .map(|reference| reference.source_path.clone()) + .collect::>(); + let expected_paths = [environment, auth, ca].map(|file| { + fs::canonicalize(file) + .unwrap() + .to_string_lossy() + .into_owned() + }); + assert_eq!(actual_paths, expected_paths.to_vec()); + } + #[test] fn managed_claude_provider_context_renders_scoped_mcp_recipe() { let workspace = tempfile::tempdir().unwrap(); diff --git a/crates/freshell-server/src/settings_store.rs b/crates/freshell-server/src/settings_store.rs index 0840c7007..86aab3dfd 100644 --- a/crates/freshell-server/src/settings_store.rs +++ b/crates/freshell-server/src/settings_store.rs @@ -2887,8 +2887,14 @@ mod tests { } fn uuid_like() -> String { - format!("{}-{:?}", std::process::id(), std::time::SystemTime::now()) - .replace([':', '.', ' '], "-") + static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let sequence = NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + format!( + "{}-{:?}-{sequence}", + std::process::id(), + std::time::SystemTime::now() + ) + .replace([':', '.', ' '], "-") } // ── CFG-04: legacyLocalSettingsSeed ───────────────────────────────────── diff --git a/crates/freshell-session-host/src/main.rs b/crates/freshell-session-host/src/main.rs index ee3705665..473f34339 100644 --- a/crates/freshell-session-host/src/main.rs +++ b/crates/freshell-session-host/src/main.rs @@ -857,16 +857,16 @@ async fn grant_execution( format!("resolve managed provider credentials: {error}"), ) })?; - prepare_provider_auth_files( + prepare_provider_files( &terminal.mode, - &resolved_secrets.auth_files, + &resolved_secrets.provider_files, terminal.run_as_uid, terminal.run_as_gid, ) .map_err(|error| { RuntimeError::new( RuntimeErrorCode::HostUnreachable, - format!("prepare OneCLI auth files: {error}"), + format!("prepare OneCLI provider files: {error}"), ) })?; let prepared = providers::prepare_terminal(terminal, &resolved_secrets.environment) @@ -974,16 +974,16 @@ async fn grant_execution( format!("resolve hosted OneCLI grant: {error}"), ) })?; - prepare_provider_auth_files( + prepare_provider_files( launch.provider.as_str(), - &resolved_secrets.auth_files, + &resolved_secrets.provider_files, launch.run_as_uid, launch.run_as_gid, ) .map_err(|error| { RuntimeError::new( RuntimeErrorCode::HostUnreachable, - format!("prepare hosted OneCLI auth files: {error}"), + format!("prepare hosted OneCLI provider files: {error}"), ) })?; providers::prepare_fresh_agent_child_environment( @@ -1671,9 +1671,9 @@ fn prepare_provider_context_at( Ok(()) } -fn prepare_provider_auth_files( +fn prepare_provider_files( provider: &str, - auth_files: &[(&str, Vec)], + provider_files: &[(&str, Vec)], run_as_uid: u32, run_as_gid: u32, ) -> Result<(), String> { @@ -1681,7 +1681,7 @@ fn prepare_provider_auth_files( std::fs::create_dir_all(auth_dir).map_err(|error| error.to_string())?; set_mode(auth_dir, 0o711)?; let home = Path::new("/home/freshell/provider"); - if !home.exists() && auth_files.is_empty() { + if !home.exists() && provider_files.is_empty() { return Ok(()); } let legacy_relative = match provider { @@ -1705,7 +1705,7 @@ fn prepare_provider_auth_files( ], )?; } - for (index, (relative, contents)) in auth_files.iter().enumerate() { + for (index, (relative, contents)) in provider_files.iter().enumerate() { let destination = home.join(relative); let target = auth_dir.join(format!("provider-{index}")); let temporary = auth_dir.join(format!("provider-{index}-tmp-{}", std::process::id())); diff --git a/crates/freshell-session-host/src/provider_secret_resolution.rs b/crates/freshell-session-host/src/provider_secret_resolution.rs index acaf0fed9..5d356424b 100644 --- a/crates/freshell-session-host/src/provider_secret_resolution.rs +++ b/crates/freshell-session-host/src/provider_secret_resolution.rs @@ -15,10 +15,12 @@ const ALLOWED_NAMES: &[&str] = &[ "GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY", + "HTTP_PROXY", "HTTPS_PROXY", "LUNAROUTE_API_KEY", "LUNAROUTE_BASE_URL", "NODE_EXTRA_CA_CERTS", + "NODE_USE_ENV_PROXY", "NO_PROXY", "ONECLI_GATEWAY", "ONECLI_URL", @@ -28,6 +30,7 @@ const ALLOWED_NAMES: &[&str] = &[ "OPENROUTER_API_KEY", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", + "http_proxy", "https_proxy", "no_proxy", ]; @@ -41,7 +44,7 @@ const CERTIFICATE_CHILD_NAMES: &[&str] = &[ #[derive(Default)] pub struct ResolvedProviderSecrets { pub environment: BTreeMap, - pub auth_files: Vec<(&'static str, Vec)>, + pub provider_files: Vec<(&'static str, Vec)>, } pub fn resolve_child_secrets( @@ -64,11 +67,11 @@ pub fn resolve_child_secrets( return Err("OneCLI grant must be a bounded regular file".into()); } let raw = fs::read(&mount).map_err(|error| format!("read OneCLI grant: {error}"))?; - if let Some(relative) = reference.profile.auth_relative_path() { + if let Some(relative) = reference.profile.provider_file_relative_path() { if raw.is_empty() { - return Err("OneCLI auth-file grant is empty".into()); + return Err("OneCLI provider-file grant is empty".into()); } - resolved.auth_files.push((relative, raw)); + resolved.provider_files.push((relative, raw)); } else { let text = std::str::from_utf8(&raw).map_err(|_| "OneCLI environment grant is not UTF-8")?; @@ -89,20 +92,55 @@ fn resolve_profile( let parsed = parse_keys_env(raw)?; match profile { ProviderSecretProfile::ClaudeOnecliEnvironment => { - resolve_provider_environment(&parsed, &["ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "CLAUDE_CODE_OAUTH_TOKEN"]) + resolve_provider_environment( + &parsed, + &["ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "CLAUDE_CODE_OAUTH_TOKEN"], + false, + ) } ProviderSecretProfile::CodexOnecliEnvironment => { - resolve_provider_environment(&parsed, &["OPENAI_API_KEY", "OPENAI_BASE_URL"]) + resolve_provider_environment(&parsed, &["OPENAI_API_KEY", "OPENAI_BASE_URL"], false) } ProviderSecretProfile::OpencodeOnecliEnvironment => { - resolve_provider_environment(&parsed, &["ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "OPENAI_API_KEY", "OPENAI_BASE_URL", "GOOGLE_GENERATIVE_AI_API_KEY", "GOOGLE_API_KEY", "GEMINI_API_KEY", "OPENROUTER_API_KEY", "OPENCODE_API_KEY"]) + resolve_provider_environment( + &parsed, + &[ + "ANTHROPIC_API_KEY", + "ANTHROPIC_BASE_URL", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "GOOGLE_GENERATIVE_AI_API_KEY", + "GOOGLE_API_KEY", + "GEMINI_API_KEY", + "OPENROUTER_API_KEY", + "OPENCODE_API_KEY", + ], + true, + ) } ProviderSecretProfile::AmplifierOnecliEnvironment => { - resolve_provider_environment(&parsed, &["ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "OPENAI_API_KEY", "OPENAI_BASE_URL", "LUNAROUTE_API_KEY", "LUNAROUTE_BASE_URL", "GLM_RUNPOD_API_KEY", "GLM_RUNPOD_BASE_URL", "GOOGLE_API_KEY", "GEMINI_API_KEY", "OPENROUTER_API_KEY"]) + resolve_provider_environment( + &parsed, + &[ + "ANTHROPIC_API_KEY", + "ANTHROPIC_BASE_URL", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "LUNAROUTE_API_KEY", + "LUNAROUTE_BASE_URL", + "GLM_RUNPOD_API_KEY", + "GLM_RUNPOD_BASE_URL", + "GOOGLE_API_KEY", + "GEMINI_API_KEY", + "OPENROUTER_API_KEY", + ], + false, + ) } ProviderSecretProfile::ClaudeOnecliAuthFile | ProviderSecretProfile::CodexOnecliAuthFile | ProviderSecretProfile::OpencodeOnecliAuthFile + | ProviderSecretProfile::OpencodeOnecliCaFile | ProviderSecretProfile::AmplifierOnecliKeysFile => Err("OneCLI auth-file grant cannot be parsed as an environment profile".into()), ProviderSecretProfile::AmplifierOnecliLunarouteGlm53 => { // The approved proxy may replace a provider placeholder key, so @@ -155,6 +193,7 @@ fn resolve_profile( fn resolve_provider_environment( parsed: &BTreeMap, provider_names: &[&str], + allow_opencode_proxy_settings: bool, ) -> Result, String> { let mut child = BTreeMap::new(); for (name, value) in parsed { @@ -166,10 +205,25 @@ fn resolve_provider_environment( reject_proxy_placeholder(value)?; validate_container_proxy(value)?; child.insert(name.clone(), value.clone()); - } else if !matches!( - name.as_str(), - "ONECLI_URL" | "ONECLI_GATEWAY" | "NO_PROXY" | "no_proxy" - ) { + } else if matches!(name.as_str(), "HTTP_PROXY" | "http_proxy") { + if allow_opencode_proxy_settings { + reject_proxy_placeholder(value)?; + validate_container_proxy(value)?; + child.insert(name.clone(), value.clone()); + } + } else if name == "NODE_USE_ENV_PROXY" { + if allow_opencode_proxy_settings { + if value != "1" { + return Err("NODE_USE_ENV_PROXY must be set to 1".into()); + } + child.insert(name.clone(), value.clone()); + } + } else if matches!(name.as_str(), "NO_PROXY" | "no_proxy") { + if allow_opencode_proxy_settings { + validate_loopback_no_proxy(value)?; + child.insert(name.clone(), value.clone()); + } + } else if !matches!(name.as_str(), "ONECLI_URL" | "ONECLI_GATEWAY") { return Err(format!("OneCLI environment profile does not allow {name}")); } } @@ -180,14 +234,35 @@ fn resolve_provider_environment( { return Err("OneCLI environment profile has no provider values".into()); } - if let (Some(upper), Some(lower)) = (child.get("HTTPS_PROXY"), child.get("https_proxy")) { - if upper != lower { - return Err("OneCLI HTTPS proxy values conflict".into()); + for (upper_name, lower_name, display_name) in [ + ("HTTPS_PROXY", "https_proxy", "HTTPS_PROXY"), + ("HTTP_PROXY", "http_proxy", "HTTP_PROXY"), + ("NO_PROXY", "no_proxy", "NO_PROXY"), + ] { + if let (Some(upper), Some(lower)) = (child.get(upper_name), child.get(lower_name)) { + if upper != lower { + return Err(format!("OneCLI {display_name} values conflict")); + } } } Ok(child) } +fn validate_loopback_no_proxy(value: &str) -> Result<(), String> { + let entries = value + .split(',') + .map(|entry| entry.trim().to_ascii_lowercase()) + .collect::>(); + if entries.is_empty() + || entries + .iter() + .any(|entry| !matches!(entry.as_str(), "localhost" | "127.0.0.1" | "::1" | "[::1]")) + { + return Err("OneCLI NO_PROXY may bypass only loopback hosts".into()); + } + Ok(()) +} + fn validate_https_upstream(value: &str) -> Result<(), String> { let url = url::Url::parse(value) .map_err(|_| "LUNAROUTE_BASE_URL must be a valid credential-free HTTPS URL".to_string())?; @@ -205,19 +280,19 @@ fn validate_https_upstream(value: &str) -> Result<(), String> { fn validate_container_proxy(value: &str) -> Result<(), String> { let url = url::Url::parse(value) - .map_err(|_| "HTTPS_PROXY must be a valid HTTP(S) URL".to_string())?; + .map_err(|_| "OneCLI proxy value must be a valid HTTP(S) URL".to_string())?; if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { return Err("HTTPS_PROXY must be a valid HTTP(S) URL".into()); } let host = url.host_str().unwrap_or_default().to_ascii_lowercase(); if host == "localhost" || host == "::1" || host.starts_with("127.") { return Err( - "HTTPS_PROXY cannot use container loopback; use the approved OneCLI proxy address reachable from the managed bridge" + "OneCLI proxy cannot use container loopback; use the approved proxy address reachable from the managed bridge" .into(), ); } if url.query().is_some() || url.fragment().is_some() { - return Err("HTTPS_PROXY cannot contain query or fragment data".into()); + return Err("OneCLI proxy cannot contain query or fragment data".into()); } Ok(()) } @@ -402,19 +477,143 @@ mod tests { profile, }]; let resolved = resolve_child_secrets(provider, &references, root.path()).unwrap(); - assert_eq!(resolved.environment.len(), 1); + let allows_loopback_bypass = provider == "opencode"; + assert_eq!( + resolved.environment.len(), + if allows_loopback_bypass { 2 } else { 1 } + ); assert!(resolved .environment .values() .any(|value| value.ends_with("-fixture"))); assert!(!resolved.environment.contains_key("ONECLI_URL")); - assert!(!resolved.environment.contains_key("NO_PROXY")); + assert_eq!( + resolved.environment.get("NO_PROXY").map(String::as_str), + allows_loopback_bypass.then_some("localhost") + ); assert!(resolve_child_secrets("wrong-provider", &references, root.path()).is_err()); } } #[test] - fn provider_secret_file_grants_return_only_provider_relative_auth_files() { + fn opencode_onecli_environment_forwards_the_authenticated_proxy_and_node_ca_settings() { + let root = tempfile::tempdir().unwrap(); + let mount = root.path().join("provider-0"); + fs::write( + &mount, + concat!( + "OPENAI_BASE_URL=https://api.openai.com/v1\n", + "HTTPS_PROXY=http://agent:fixture@192.168.3.150:10255\n", + "https_proxy=http://agent:fixture@192.168.3.150:10255\n", + "HTTP_PROXY=http://agent:fixture@192.168.3.150:10255\n", + "http_proxy=http://agent:fixture@192.168.3.150:10255\n", + "NODE_EXTRA_CA_CERTS=/home/freshell/provider/.config/onecli/gateway-ca.pem\n", + "NODE_USE_ENV_PROXY=1\n", + "ONECLI_URL=http://127.0.0.1:10254\n", + "NO_PROXY=localhost,127.0.0.1\n", + ), + ) + .unwrap(); + let reference = ProviderSecretReference { + source_path: mount.to_string_lossy().into_owned(), + profile: ProviderSecretProfile::OpencodeOnecliEnvironment, + }; + + let resolved = resolve_child_secrets("opencode", &[reference], root.path()).unwrap(); + + assert_eq!( + resolved.environment.get("OPENAI_BASE_URL").unwrap(), + "https://api.openai.com/v1" + ); + assert_eq!( + resolved.environment.get("HTTPS_PROXY").unwrap(), + "http://agent:fixture@192.168.3.150:10255" + ); + assert_eq!( + resolved.environment.get("https_proxy").unwrap(), + "http://agent:fixture@192.168.3.150:10255" + ); + assert_eq!( + resolved.environment.get("HTTP_PROXY").unwrap(), + "http://agent:fixture@192.168.3.150:10255" + ); + assert_eq!( + resolved.environment.get("http_proxy").unwrap(), + "http://agent:fixture@192.168.3.150:10255" + ); + assert_eq!( + resolved.environment.get("NODE_EXTRA_CA_CERTS").unwrap(), + "/home/freshell/provider/.config/onecli/gateway-ca.pem" + ); + assert_eq!(resolved.environment.get("NODE_USE_ENV_PROXY").unwrap(), "1"); + assert!(!resolved.environment.contains_key("ONECLI_URL")); + assert_eq!( + resolved.environment.get("NO_PROXY").unwrap(), + "localhost,127.0.0.1" + ); + } + + #[test] + fn non_opencode_onecli_profiles_do_not_enable_http_or_node_environment_proxies() { + let cases = [ + ( + "claude", + ProviderSecretProfile::ClaudeOnecliEnvironment, + "ANTHROPIC_API_KEY=claude-fixture", + ), + ( + "codex", + ProviderSecretProfile::CodexOnecliEnvironment, + "OPENAI_API_KEY=codex-fixture", + ), + ( + "amplifier", + ProviderSecretProfile::AmplifierOnecliEnvironment, + "OPENAI_API_KEY=amplifier-fixture", + ), + ]; + + for (provider, profile, provider_value) in cases { + let root = tempfile::tempdir().unwrap(); + let mount = root.path().join("provider-0"); + fs::write( + &mount, + format!( + concat!( + "{provider_value}\n", + "HTTPS_PROXY=http://proxy.example:10255\n", + "https_proxy=http://proxy.example:10255\n", + "HTTP_PROXY=http://proxy.example:10255\n", + "http_proxy=http://proxy.example:10255\n", + "NODE_USE_ENV_PROXY=1\n" + ), + provider_value = provider_value + ), + ) + .unwrap(); + let reference = ProviderSecretReference { + source_path: mount.to_string_lossy().into_owned(), + profile, + }; + + let resolved = resolve_child_secrets(provider, &[reference], root.path()).unwrap(); + + assert_eq!( + resolved.environment.get("HTTPS_PROXY").map(String::as_str), + Some("http://proxy.example:10255") + ); + assert_eq!( + resolved.environment.get("https_proxy").map(String::as_str), + Some("http://proxy.example:10255") + ); + assert!(!resolved.environment.contains_key("HTTP_PROXY")); + assert!(!resolved.environment.contains_key("http_proxy")); + assert!(!resolved.environment.contains_key("NODE_USE_ENV_PROXY")); + } + } + + #[test] + fn provider_secret_file_grants_return_only_provider_relative_files() { let cases = [ ( "claude", @@ -431,6 +630,11 @@ mod tests { ProviderSecretProfile::OpencodeOnecliAuthFile, ".local/share/opencode/auth.json", ), + ( + "opencode", + ProviderSecretProfile::OpencodeOnecliCaFile, + ".config/onecli/gateway-ca.pem", + ), ( "amplifier", ProviderSecretProfile::AmplifierOnecliKeysFile, @@ -448,13 +652,41 @@ mod tests { let resolved = resolve_child_secrets(provider, &references, root.path()).unwrap(); assert!(resolved.environment.is_empty()); assert_eq!( - resolved.auth_files, + resolved.provider_files, vec![(expected, b"fake-OneCLI-auth-file".to_vec())] ); assert!(resolve_child_secrets("wrong-provider", &references, root.path()).is_err()); } } + #[test] + fn opencode_onecli_environment_rejects_proxy_aliases_invalid_node_switch_and_non_loopback_bypass( + ) { + for invalid in [ + "HTTP_PROXY=http://onecli.example:10255\nhttp_proxy=http://different.example:10255\n", + "NODE_USE_ENV_PROXY=true\n", + "NO_PROXY=*\n", + "NO_PROXY=api.openai.com\n", + "NO_PROXY=localhost,127.0.0.1,api.openai.com\n", + "NO_PROXY=localhost,127.0.0.1\nno_proxy=localhost\n", + ] { + let root = tempfile::tempdir().unwrap(); + let mount = root.path().join("provider-0"); + fs::write( + &mount, + format!( + "OPENAI_BASE_URL=https://api.openai.com/v1\nHTTPS_PROXY=http://192.168.3.150:10255\nhttps_proxy=http://192.168.3.150:10255\n{invalid}" + ), + ) + .unwrap(); + let reference = ProviderSecretReference { + source_path: mount.to_string_lossy().into_owned(), + profile: ProviderSecretProfile::OpencodeOnecliEnvironment, + }; + assert!(resolve_child_secrets("opencode", &[reference], root.path()).is_err()); + } + } + #[test] fn resolves_actual_onecli_lunaroute_profile_to_vllm_child_environment() { let raw = "ONECLI_GATEWAY=1\nONECLI_URL=http://127.0.0.1:10254\nLUNAROUTE_API_KEY='proxy-managed-placeholder'\nLUNAROUTE_BASE_URL=https://lunaroute.example/v1\nHTTPS_PROXY=http://user:credential@192.0.2.10:10255\nNO_PROXY=localhost,127.0.0.1\nSSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt\nREQUESTS_CA_BUNDLE=$SSL_CERT_FILE\n"; diff --git a/docker/runtime/Dockerfile b/docker/runtime/Dockerfile index 8117d0b36..5d7360639 100644 --- a/docker/runtime/Dockerfile +++ b/docker/runtime/Dockerfile @@ -24,11 +24,14 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY packages/freshell-mcp-runtime/package.json packages/freshell-mcp-runtime/ COPY tools/freshell-mcp/*.ts tools/freshell-mcp/ COPY tools/node-client-runtime/*.ts tools/node-client-runtime/ +# pnpm deploy only carries the package's generated/ allowlist, so copy this +# image build's root entry point into the deployed runtime explicitly. RUN npm install --global pnpm@10.34.5 \ && pnpm --filter freshell-mcp-runtime install --frozen-lockfile --ignore-scripts \ && pnpm --filter freshell-mcp-runtime exec esbuild ../../tools/freshell-mcp/server.ts \ --bundle --platform=node --format=esm --packages=external --outfile=server.js \ - && pnpm --filter freshell-mcp-runtime --prod --frozen-lockfile --config.node-linker=hoisted deploy /opt/freshell-mcp + && pnpm --filter freshell-mcp-runtime --prod --frozen-lockfile --config.node-linker=hoisted deploy /opt/freshell-mcp \ + && cp packages/freshell-mcp-runtime/server.js /opt/freshell-mcp/server.js FROM ubuntu@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 ARG CLAUDE_CODE_VERSION=2.1.263 diff --git a/docker/runtime/README.md b/docker/runtime/README.md index dd1a0b3d3..91eb92b22 100644 --- a/docker/runtime/README.md +++ b/docker/runtime/README.md @@ -44,7 +44,9 @@ The image includes the same Freshell MCP tool as an ordinary terminal at the frozen workspace lock, so a managed provider can launch it without a web server worktree mount. Check the image with `pnpm exec tsx scripts/testing/probe-managed-mcp-image.ts --image `; -the probe calls `tools/list` and `tools/call` through a fake local endpoint. +the probe calls `tools/list` and `tools/call` through a fake local endpoint +while Node's environment proxy is enabled, then verifies Freshell's API request +did not reach the proxy. The controller supplies each incarnation's scoped MCP grant in the provider environment. Provider MCP configuration, including user entries, keeps its ordinary semantics. @@ -57,12 +59,26 @@ separate legacy credential bootstrap. ## Real-provider acceptance order -OpenCode is the first real-provider Phase 2 acceptance lane. The live gate pins OpenCode 1.18.21 and uses `openai/gpt-5.6-luna` with the configured OpenAI OAuth credential. The receipt reads the actual provider and model from OpenCode's native session database and fails if they differ; it does not silently fall back. Managed OpenCode is one provider runtime per soul rather than the legacy shared serve process. Its ordinary JSON, JSONC, plugin, and TUI rebind configuration is retained. +OpenCode is the first real-provider Phase 2 acceptance lane. The live gate pins OpenCode 1.18.21 and uses `openai/gpt-5.6-luna` through the dedicated OneCLI OpenAI OAuth grant. The receipt reads the actual provider and model from OpenCode's native session database and fails if they differ; it does not silently fall back. Managed OpenCode is one provider runtime per soul rather than the legacy shared serve process. Its ordinary JSON, JSONC, plugin, and TUI rebind configuration is retained. -P2-G04 runs its web server with an isolated home directory. Configure a private -OneCLI grant through `FRESHELL_MANAGED_OPENCODE_ONECLI_ENV_FILE` or -`FRESHELL_MANAGED_OPENCODE_ONECLI_AUTH_FILE` before the browser gate. The -session host resolves the grant only for the provider child. +P2-G04 runs its web server with an isolated home directory. Before the browser +gate, set all three private grant references: + +```bash +export FRESHELL_MANAGED_OPENCODE_ONECLI_AUTH_FILE=/path/to/private/opencode-auth.json +export FRESHELL_MANAGED_OPENCODE_ONECLI_ENV_FILE=/path/to/private/opencode-onecli.env +export FRESHELL_MANAGED_OPENCODE_ONECLI_CA_FILE=/path/to/private/gateway-ca.pem +``` + +The auth file is an OpenCode-native OAuth placeholder with `onecli-managed` +access and refresh values and a future expiry; it must not contain a provider +token. The environment grant carries the dedicated authenticated proxy aliases, +`OPENAI_BASE_URL`, `NODE_USE_ENV_PROXY=1`, `NODE_EXTRA_CA_CERTS` pointing to +`/home/freshell/provider/.config/onecli/gateway-ca.pem`, and +`NO_PROXY=localhost,127.0.0.1`. Only those loopback hosts may bypass the proxy, +because OpenCode's TUI must reach its own local server directly. The CA grant +is staged at that provider-home path before OpenCode starts. Freshell resolves +the grants only for the provider child. Amplifier launches the ordinary configured CLI and bundle. The image carries the generic Amplifier app and a pinned vLLM module for existing profiles; it diff --git a/package.json b/package.json index c13c79c9a..59e26ec96 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,7 @@ "react-syntax-highlighter": "^16.1.1", "react-window": "^2.2.6", "remark-gfm": "^4.0.0", + "undici": "7.30.0", "ws": "^8.18.0", "zod": "^4.0.0" }, diff --git a/packages/freshell-mcp-runtime/package.json b/packages/freshell-mcp-runtime/package.json index 90ccd723f..7d1d9de83 100644 --- a/packages/freshell-mcp-runtime/package.json +++ b/packages/freshell-mcp-runtime/package.json @@ -5,6 +5,7 @@ "type": "module", "dependencies": { "@modelcontextprotocol/sdk": "1.30.0", + "undici": "7.30.0", "zod": "4.3.6" }, "files": ["generated"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95d9cc33d..9c834c71b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,6 +96,9 @@ importers: remark-gfm: specifier: ^4.0.0 version: 4.0.1 + undici: + specifier: 7.30.0 + version: 7.30.0 ws: specifier: ^8.18.0 version: 8.19.0 @@ -250,6 +253,9 @@ importers: '@modelcontextprotocol/sdk': specifier: 1.30.0 version: 1.30.0(zod@4.3.6) + undici: + specifier: 7.30.0 + version: 7.30.0 zod: specifier: 4.3.6 version: 4.3.6 @@ -8542,8 +8548,7 @@ snapshots: undici@6.29.0: {} - undici@7.30.0: - optional: true + undici@7.30.0: {} unified@11.0.5: dependencies: diff --git a/scripts/prepare-electron-runtime.ts b/scripts/prepare-electron-runtime.ts index 715557e45..69ddc1e27 100644 --- a/scripts/prepare-electron-runtime.ts +++ b/scripts/prepare-electron-runtime.ts @@ -55,6 +55,11 @@ export type ElectronRuntimePlatform = 'darwin' | 'linux' | 'win32' export type ElectronRuntimeArch = 'x64' | 'arm64' export type DeployableRuntimePackage = 'freshell-claude-sidecar' | 'freshell-mcp-runtime' +// electron-builder always omits these dependency-tree placeholders, even when +// extraResources filters include them. They carry no runtime code and should +// not be recorded as required staged files. +const ELECTRON_BUILDER_OMITTED_PACKAGE_FILES = new Set(['.gitkeep']) + export interface RuntimePaths { root: string serverBinary: string @@ -466,10 +471,18 @@ function materializeTree( destination: string, deployRoot: string, packageName: DeployableRuntimePackage, + omittedNames?: ReadonlySet, ): void { mkdirSync(destination, { recursive: true }) for (const entry of readdirSync(source, { withFileTypes: true })) { - materializeEntry(path.join(source, entry.name), path.join(destination, entry.name), deployRoot, packageName) + if (omittedNames?.has(entry.name)) continue + materializeEntry( + path.join(source, entry.name), + path.join(destination, entry.name), + deployRoot, + packageName, + omittedNames, + ) } } @@ -478,7 +491,9 @@ function materializeEntry( destination: string, deployRoot: string, packageName: DeployableRuntimePackage, + omittedNames?: ReadonlySet, ): void { + if (omittedNames?.has(path.basename(source))) return const stats = lstatSync(source) if (stats.isSymbolicLink()) { let resolved: string @@ -494,7 +509,7 @@ function materializeEntry( if (target.isFile()) { copyRequiredFile(resolved, destination) } else if (target.isDirectory()) { - materializeTree(resolved, destination, deployRoot, packageName) + materializeTree(resolved, destination, deployRoot, packageName, omittedNames) } else { throw structuredLinkError(packageName, `the link target has an unsupported type (${resolved})`, source) } @@ -505,7 +520,7 @@ function materializeEntry( return } if (stats.isDirectory()) { - materializeTree(source, destination, deployRoot, packageName) + materializeTree(source, destination, deployRoot, packageName, omittedNames) return } throw new Error(`Electron runtime staging cannot copy the unsupported filesystem entry: ${source}`) @@ -558,7 +573,13 @@ function stageDeployNodeModules( ): void { for (const entry of readdirSync(source, { withFileTypes: true })) { if (entry.name === '.pnpm') continue - materializeEntry(path.join(source, entry.name), path.join(destination, entry.name), deployRoot, packageName) + materializeEntry( + path.join(source, entry.name), + path.join(destination, entry.name), + deployRoot, + packageName, + ELECTRON_BUILDER_OMITTED_PACKAGE_FILES, + ) } } diff --git a/scripts/retirement/runtime-surfaces.json b/scripts/retirement/runtime-surfaces.json index b984d1204..23a200ded 100644 --- a/scripts/retirement/runtime-surfaces.json +++ b/scripts/retirement/runtime-surfaces.json @@ -561,6 +561,12 @@ "role": "test-listener-assertion", "listener": "assertion-only" }, + { + "id": "assertion-test-mcp-http-client", + "path": "test/unit/mcp/http-client.test.ts", + "role": "test-listener-assertion", + "listener": "assertion-only" + }, { "id": "assertion-test-cli-action-capabilities", "path": "test/unit/cli/action-capabilities.test.ts", diff --git a/scripts/testing/probe-managed-mcp-image.ts b/scripts/testing/probe-managed-mcp-image.ts index 912499da8..21937cce9 100644 --- a/scripts/testing/probe-managed-mcp-image.ts +++ b/scripts/testing/probe-managed-mcp-image.ts @@ -12,6 +12,7 @@ if (!image || image.startsWith('--')) { const token = 'freshell-mcp-image-probe-token' let acceptedRequests = 0 +let proxyRequests = 0 const endpoint = createServer((request, response) => { if (request.method !== 'GET' || request.url !== '/api/health' || request.headers['x-auth-token'] !== token) { response.writeHead(403).end() @@ -21,11 +22,25 @@ const endpoint = createServer((request, response) => { response.writeHead(200, { 'Content-Type': 'application/json' }) response.end(JSON.stringify({ probe: 'freshell-mcp-image' })) }) +const proxy = createServer((_request, response) => { + proxyRequests++ + const body = 'the model proxy must not receive Freshell API traffic' + response.writeHead(502, { connection: 'close', 'content-length': String(body.length) }) + response.end(body) +}) +proxy.on('connect', (_request, socket) => { + proxyRequests++ + socket.end('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nContent-Length: 0\r\n\r\n') +}) endpoint.listen(0, '127.0.0.1') await once(endpoint, 'listening') +proxy.listen(0, '127.0.0.1') +await once(proxy, 'listening') const address = endpoint.address() +const proxyAddress = proxy.address() if (!address || typeof address === 'string') throw new Error('Probe endpoint has no TCP port') +if (!proxyAddress || typeof proxyAddress === 'string') throw new Error('Probe proxy has no TCP port') const containerName = `freshell-mcp-probe-${process.pid}` try { @@ -34,16 +49,27 @@ try { '--cap-drop', 'ALL', '--env', `FRESHELL_URL=http://127.0.0.1:${address.port}`, '--env', `FRESHELL_TOKEN=${token}`, + '--env', 'NODE_USE_ENV_PROXY=1', + '--env', `HTTP_PROXY=http://127.0.0.1:${proxyAddress.port}`, + '--env', `http_proxy=http://127.0.0.1:${proxyAddress.port}`, + '--env', `HTTPS_PROXY=http://127.0.0.1:${proxyAddress.port}`, + '--env', `https_proxy=http://127.0.0.1:${proxyAddress.port}`, + '--env', 'NO_PROXY=', + '--env', 'no_proxy=', image, 'node', '/opt/freshell-mcp/server.js', '--self-test', ], { timeout: 60_000 }) const result = JSON.parse(stdout.trim()) - if (result.tool !== 'freshell' || result.action !== 'health' || result.ok !== true || acceptedRequests !== 1) { - throw new Error(`MCP image probe returned an unexpected result: ${stdout.trim()}; requests=${acceptedRequests}`) + if (result.tool !== 'freshell' || result.action !== 'health' || result.ok !== true || acceptedRequests !== 1 || proxyRequests !== 0) { + throw new Error(`MCP image probe returned an unexpected result: ${stdout.trim()}; endpointRequests=${acceptedRequests}; proxyRequests=${proxyRequests}`) } - process.stdout.write(JSON.stringify({ image, ...result, requests: acceptedRequests }) + '\n') + process.stdout.write(JSON.stringify({ image, ...result, endpointRequests: acceptedRequests, proxyRequests }) + '\n') } finally { - endpoint.close() + const closeServer = (server: typeof endpoint) => new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()) + server.closeAllConnections() + }) // execFile timeout kills the Docker client, so remove only this named probe // container if it outlived the client. A successful --rm run is already gone. await execFileAsync('docker', ['rm', '-f', containerName]).catch(() => undefined) + await Promise.all([closeServer(endpoint), closeServer(proxy)]) } diff --git a/scripts/testing/runtime-sandbox.ts b/scripts/testing/runtime-sandbox.ts index 300351edb..36157448e 100644 --- a/scripts/testing/runtime-sandbox.ts +++ b/scripts/testing/runtime-sandbox.ts @@ -90,6 +90,7 @@ export function phase2BootstrapFiles( addRegularFile(env[`FRESHELL_MANAGED_${provider}_ONECLI_${suffix}`]?.trim()) } } + addRegularFile(env.FRESHELL_MANAGED_OPENCODE_ONECLI_CA_FILE?.trim()) for (const grant of configuredAmplifierOnecliGrantFiles(env)) files.add(grant) return [...files] } diff --git a/scripts/verify-workspace-peers.mjs b/scripts/verify-workspace-peers.mjs index 60d6bba76..104d15bb2 100644 --- a/scripts/verify-workspace-peers.mjs +++ b/scripts/verify-workspace-peers.mjs @@ -83,11 +83,13 @@ const assertImporterDep = (path, name, expected) => { }; assertImporterDep('.', 'zod', '4.3.6'); +assertImporterDep('.', 'undici', '7.30.0'); assertImporterDep('crates/freshell-claude-sidecar', '@anthropic-ai/claude-agent-sdk', '0.3.237'); assertImporterDep('crates/freshell-claude-sidecar', '@anthropic-ai/sdk', '0.120.0'); assertImporterDep('crates/freshell-claude-sidecar', '@modelcontextprotocol/sdk', '1.30.0'); assertImporterDep('crates/freshell-claude-sidecar', 'zod', '4.4.3'); assertImporterDep('packages/freshell-mcp-runtime', '@modelcontextprotocol/sdk', '1.30.0'); +assertImporterDep('packages/freshell-mcp-runtime', 'undici', '7.30.0'); assertImporterDep('packages/freshell-mcp-runtime', 'zod', '4.3.6'); const packageKeys = new Set(); diff --git a/src/lib/recovery/managed-runtime-recovery.ts b/src/lib/recovery/managed-runtime-recovery.ts index 61595248e..eec2950e7 100644 --- a/src/lib/recovery/managed-runtime-recovery.ts +++ b/src/lib/recovery/managed-runtime-recovery.ts @@ -148,9 +148,20 @@ function updateExistingContent( ): PaneContent { const fields = managedProjectionFields(soul, view) if (existing.kind === 'terminal') { + const managedContent = managedTerminalContent(soul, view) + // Inventory retains the stable launch stream ID. A live terminal can + // advance to a host-specific stream epoch after recovery, so only the + // terminal transport (terminal.stream.changed / attach.ready) may replace + // an existing pane's stream ID while it still represents the same PTY. + const preserveLiveStreamId = Boolean( + existing.terminalId + && existing.terminalId === soul.terminalId + && existing.streamId, + ) return { ...existing, - ...definedOnly(managedTerminalContent(soul, view)), + ...definedOnly(managedContent), + ...(preserveLiveStreamId ? { streamId: existing.streamId } : {}), // Preserve a user pane's stable create key when the supervisor record // predates that field. Otherwise use the authoritative managed key. createRequestId: soul.terminalCreateRequestId || existing.createRequestId, diff --git a/test/e2e-browser/helpers/opencode-auth-file.ts b/test/e2e-browser/helpers/opencode-auth-file.ts index f257ba841..8076bcd41 100644 --- a/test/e2e-browser/helpers/opencode-auth-file.ts +++ b/test/e2e-browser/helpers/opencode-auth-file.ts @@ -1,39 +1,146 @@ import fs from 'node:fs' import path from 'node:path' -/** Require the explicit typed OneCLI auth-file grant used by isolated OpenCode gates. */ -export function requireOpenCodeAuthFile( - env: NodeJS.ProcessEnv = process.env, - qualification = 'P2-G04', +const ONECLI_PROVIDER_CA_PATH = '/home/freshell/provider/.config/onecli/gateway-ca.pem' + +type OpenCodeOnecliBootstrap = { + authFile: string + environmentFile: string + caFile: string +} + +function requirePrivateGrantFile( + env: NodeJS.ProcessEnv, + key: string, + qualification: string, + description: string, ): string { - const configured = env.FRESHELL_MANAGED_OPENCODE_ONECLI_AUTH_FILE?.trim() - if (!configured) { - throw new Error( - `${qualification} requires FRESHELL_MANAGED_OPENCODE_ONECLI_AUTH_FILE to point at a private OpenCode auth.json OneCLI grant`, - ) - } + const configured = env[key]?.trim() + if (!configured) throw new Error(`${qualification} requires ${key} to point at a private ${description}`) + if (!path.isAbsolute(configured)) throw new Error(`${qualification} ${key} must be an absolute path`) - const authFile = path.resolve(configured) let stat: fs.Stats try { - stat = fs.lstatSync(authFile) + stat = fs.lstatSync(configured) } catch { - throw new Error(`${qualification} OpenCode OneCLI auth grant is not an existing file: ${authFile}`) + throw new Error(`${qualification} ${description} is not an existing file: ${configured}`) } if (!stat.isFile()) { - throw new Error(`${qualification} OpenCode OneCLI auth grant must be a regular file: ${authFile}`) + throw new Error(`${qualification} ${description} must be a regular file: ${configured}`) } if ((stat.mode & 0o077) !== 0) { - throw new Error(`${qualification} OpenCode OneCLI auth grant must be private (mode 0600 or stricter): ${authFile}`) + throw new Error(`${qualification} ${description} must be private (mode 0600 or stricter): ${configured}`) } if ((stat.mode & 0o400) === 0) { - throw new Error(`${qualification} OpenCode OneCLI auth grant must be owner-readable: ${authFile}`) + throw new Error(`${qualification} ${description} must be owner-readable: ${configured}`) } try { - const descriptor = fs.openSync(authFile, 'r') + const descriptor = fs.openSync(configured, 'r') fs.closeSync(descriptor) - return fs.realpathSync(authFile) + return fs.realpathSync(configured) + } catch { + throw new Error(`${qualification} ${description} is unreadable: ${configured}`) + } +} + +/** Require the explicit typed OneCLI auth-file grant used by isolated OpenCode gates. */ +export function requireOpenCodeAuthFile( + env: NodeJS.ProcessEnv = process.env, + qualification = 'P2-G04', +): string { + return requirePrivateGrantFile( + env, + 'FRESHELL_MANAGED_OPENCODE_ONECLI_AUTH_FILE', + qualification, + 'OpenCode auth.json OneCLI grant', + ) +} + +function readEnvironmentGrant(file: string, qualification: string): Map { + const values = new Map() + let text: string + try { + text = fs.readFileSync(file, 'utf8') } catch { - throw new Error(`${qualification} OpenCode OneCLI auth grant is unreadable: ${authFile}`) + throw new Error(`${qualification} OpenCode OneCLI environment grant is unreadable`) + } + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const separator = trimmed.indexOf('=') + if (separator <= 0) throw new Error(`${qualification} OpenCode OneCLI environment grant is invalid`) + const key = trimmed.slice(0, separator).trim() + const value = trimmed.slice(separator + 1).trim() + if (!key || values.has(key)) throw new Error(`${qualification} OpenCode OneCLI environment grant is invalid`) + values.set(key, value) + } + return values +} + +/** Require a safe OpenCode OAuth stub plus the full OneCLI proxy and CA transport. */ +export function requireOpenCodeOnecliBootstrap( + env: NodeJS.ProcessEnv = process.env, + qualification = 'P2-G04', +): OpenCodeOnecliBootstrap { + const authFile = requireOpenCodeAuthFile(env, qualification) + const environmentFile = requirePrivateGrantFile( + env, + 'FRESHELL_MANAGED_OPENCODE_ONECLI_ENV_FILE', + qualification, + 'OpenCode OneCLI environment grant', + ) + const caFile = requirePrivateGrantFile( + env, + 'FRESHELL_MANAGED_OPENCODE_ONECLI_CA_FILE', + qualification, + 'OpenCode OneCLI CA certificate grant', + ) + if (new Set([authFile, environmentFile, caFile]).size !== 3) { + throw new Error(`${qualification} OpenCode OneCLI grants must be separate files`) } + + let auth: unknown + try { + auth = JSON.parse(fs.readFileSync(authFile, 'utf8')) + } catch { + throw new Error(`${qualification} OpenCode OneCLI auth grant must be valid JSON`) + } + const openai = (auth as { openai?: Record } | null)?.openai + const expectedAuthKeys = ['access', 'expires', 'refresh', 'type'] + if ( + !openai + || Object.keys(auth as Record).length !== 1 + || Object.keys(openai).sort().join(',') !== expectedAuthKeys.join(',') + || openai.type !== 'oauth' + || openai.access !== 'onecli-managed' + || openai.refresh !== 'onecli-managed' + || typeof openai.expires !== 'number' + || !Number.isFinite(openai.expires) + || openai.expires < Date.now() + 60 * 60 * 1000 + ) { + throw new Error(`${qualification} requires a future OneCLI placeholder OAuth credential for OpenCode`) + } + + const environment = readEnvironmentGrant(environmentFile, qualification) + const proxy = environment.get('HTTPS_PROXY') + const noProxy = environment.get('NO_PROXY') + const noProxyHosts = noProxy?.split(',').map((host) => host.trim().toLowerCase()).sort() + const lowerNoProxy = environment.get('no_proxy') + if ( + !proxy + || environment.get('https_proxy') !== proxy + || environment.get('HTTP_PROXY') !== proxy + || environment.get('http_proxy') !== proxy + || environment.get('OPENAI_BASE_URL') !== 'https://api.openai.com/v1' + || environment.get('NODE_EXTRA_CA_CERTS') !== ONECLI_PROVIDER_CA_PATH + || environment.get('NODE_USE_ENV_PROXY') !== '1' + || noProxyHosts?.join(',') !== '127.0.0.1,localhost' + || (lowerNoProxy !== undefined && lowerNoProxy !== noProxy) + ) { + throw new Error( + `${qualification} OpenCode OneCLI environment grant is missing required proxy, CA, or loopback-only NO_PROXY settings`, + ) + } + + return { authFile, environmentFile, caFile } } diff --git a/test/e2e-browser/helpers/opencode-native-history.ts b/test/e2e-browser/helpers/opencode-native-history.ts index ebd20ebfe..392d92e9d 100644 --- a/test/e2e-browser/helpers/opencode-native-history.ts +++ b/test/e2e-browser/helpers/opencode-native-history.ts @@ -5,12 +5,37 @@ export { nativeTurnProof } from './provider-native-history/proof.js' import type { NativeAssistantTurn } from './provider-native-history/types.js' export function openCodeCredentialFailureMessage(output: string): string | null { - if (/\bToken refresh failed:\s*401\b/i.test(stripVTControlCharacters(output))) { + const terminalText = stripVTControlCharacters(output) + if (/\bToken refresh failed:\s*401\b/i.test(terminalText)) { return 'OpenCode credential refresh was rejected with HTTP 401; provide a currently valid OpenAI auth grant' } + if ( + /could not parse your authentication token|\bhttp(?:\/\d+(?:\.\d+)?)?\s+401(?:\s+unauthorized)?\b|\b401\s+unauthorized\b|\bstatus code\s*:?\s*401\b|\binvalid (?:openai )?(?:api )?key\b/i + .test(terminalText) + ) { + return 'OpenCode credential was rejected by OpenAI; provide a currently valid OneCLI OAuth grant' + } return null } +export async function waitForOpenCodeTerminalCondition(params: { + readTerminalText: () => Promise + wait: (milliseconds: number) => Promise + description: string + succeeds: (terminalText: string) => boolean | Promise + timeoutMs: number +}): Promise { + const deadline = Date.now() + params.timeoutMs + while (Date.now() < deadline) { + const terminalText = await params.readTerminalText() + const failure = openCodeCredentialFailureMessage(terminalText) + if (failure) throw new Error(failure) + if (await params.succeeds(terminalText)) return + await params.wait(250) + } + throw new Error(`timed out waiting for ${params.description}`) +} + /** * Self-contained query-only form of the current OpenCode native-history reader. * Runtime chaos tests execute it only through an ownership-checked provider diff --git a/test/e2e-browser/specs/runtime-opencode-provider-qualification-rust.spec.ts b/test/e2e-browser/specs/runtime-opencode-provider-qualification-rust.spec.ts index a2813d5d0..4a5597bb4 100644 --- a/test/e2e-browser/specs/runtime-opencode-provider-qualification-rust.spec.ts +++ b/test/e2e-browser/specs/runtime-opencode-provider-qualification-rust.spec.ts @@ -22,7 +22,7 @@ import { P2_OPENCODE_VERSION, type ManagedRuntimeView, } from '../helpers/managed-runtime.js' -import { requireOpenCodeAuthFile } from '../helpers/opencode-auth-file.js' +import { requireOpenCodeOnecliBootstrap } from '../helpers/opencode-auth-file.js' import { openPanePicker } from '../helpers/pane-picker.js' import { TerminalHelper } from '../helpers/terminal-helpers.js' import { TestHarness } from '../helpers/test-harness.js' @@ -137,6 +137,27 @@ async function waitForPaneOutput( timeoutMs: number, excludedTerminalIds: ReadonlySet = new Set(), ): Promise { + const failOnProviderCrash = async (): Promise => { + const leaf = leavesByMode(await harness.getPaneLayout(tabId), 'opencode') + .find((candidate) => candidate.id === paneId) + const currentId = typeof leaf?.content?.terminalId === 'string' + ? leaf.content.terminalId + : undefined + const registered = await harness.getRegisteredTerminalIds() + const candidates = [currentId, ...registered] + .filter((id): id is string => Boolean(id) && !excludedTerminalIds.has(id as string)) + .filter((id, index, all) => all.indexOf(id) === index) + for (const terminalId of candidates) { + const buffer = await page.evaluate((id) => ( + window.__FRESHELL_TEST_HARNESS__?.getTerminalBuffer?.(id) + ), terminalId) + if (typeof buffer === 'string' && /Bun has crashed|Segmentation fault at address/i.test(buffer)) { + return new Error(`OpenCode provider process crashed during startup in terminal ${terminalId}`) + } + } + return null + } + return waitForValue(`pane ${paneId} output ${text}`, async () => { const leaf = leavesByMode(await harness.getPaneLayout(tabId), 'opencode') .find((candidate) => candidate.id === paneId) @@ -155,7 +176,7 @@ async function waitForPaneOutput( if (contains) return terminalId } return null - }, timeoutMs) + }, timeoutMs, failOnProviderCrash) } async function createOpencodePane( @@ -212,6 +233,16 @@ async function waitForPaneIncarnation( }, timeoutMs) } +async function paneStreamId(harness: TestHarness, tabId: string, paneId: string): Promise { + return waitForValue(`pane ${paneId} stream ID`, async () => { + const leaf = leavesByMode(await harness.getPaneLayout(tabId), 'opencode') + .find((candidate) => candidate.id === paneId) + return typeof leaf?.content?.streamId === 'string' && leaf.content.streamId.length > 0 + ? leaf.content.streamId + : null + }, 30_000) +} + async function waitForReplacementPrompt( page: Page, harness: TestHarness, @@ -219,58 +250,103 @@ async function waitForReplacementPrompt( tabId: string, paneId: string, view: ManagedRuntimeView, + previousStreamId: string, + observedStreamChanges: Array<{ terminalId: string; streamId: string; reason: string; attachRequestId: string | null }>, + observedWebSocketFrameTypes: Set, + observedWebSocketCount: { value: number }, ): Promise { const terminalId = view.terminalId if (!terminalId) throw new Error('replacement provider has no terminal identity') + if (!view.terminalStreamId) throw new Error('replacement provider has no stable launch stream identity') const modelTexts = ['GPT-5.6 Luna', P2_OPENCODE_MODEL] - let diagnostic: Record = { soulId: view.soulId, incarnationId: view.incarnationId, terminalId } - let sourceEpoch = '' - let cursor = 0 - let sourceText = '' + let diagnostic: Record = { + soulId: view.soulId, + incarnationId: view.incarnationId, + terminalId, + stableLaunchStreamId: view.terminalStreamId, + } try { - await waitForValue('replacement provider input readiness in its actual source epoch and browser', async () => { - // Inventory RUNNING means the process exists, not that its TUI has begun - // reading input. Observe the NEW host's own output before typing: otherwise - // a pre-raw-mode PTY echo can swallow the recall request at startup. + // Wait on the real browser attachment first. Polling the supervisor's + // output endpoint every 200 ms races the server's own output reader and + // can starve the stream-change notification this check needs to observe. + const browserReady = await waitForValue('replacement provider input readiness in its current browser stream', async () => { + const leaf = leavesByMode(await harness.getPaneLayout(tabId), 'opencode') + .find((row) => row.id === paneId) + const paneStreamId = leaf?.content?.streamId + diagnostic = { + ...diagnostic, + paneStreamId, + paneIncarnationId: leaf?.content?.incarnationId, + } + if ( + leaf?.content?.incarnationId !== view.incarnationId + || typeof paneStreamId !== 'string' + || paneStreamId.length === 0 + || paneStreamId === view.terminalStreamId + || paneStreamId === previousStreamId + ) return null + + const rendered = await page.evaluate((id) => { + const h = window.__FRESHELL_TEST_HARNESS__ + return { text: h?.getTerminalBuffer(id), modes: h?.getTerminalModes?.(id) } + }, terminalId) + const browserModelBanner = hasOpenCodePromptModelText(rendered.text ?? '', modelTexts) + diagnostic = { + ...diagnostic, + browserInputReady: rendered.modes?.bracketedPasteMode, + browserModelBanner, + } + if (!browserModelBanner || !rendered.modes?.bracketedPasteMode) return null + return { streamId: paneStreamId } + }, 120_000) + + // The host epoch is distinct from the supervisor's stable launch ID. Read + // it once after the browser reports the changed stream, so the QA probe + // does not compete with the server's live output poll. const output = dataOf(await rig.runtime.adminOk( rig.supervisor, - rig.runtime.terminalReadOutputBody(view.soulId, cursor, 256 * 1024, await rig.controlEpoch()), + rig.runtime.terminalReadOutputBody(view.soulId, 0, 256 * 1024, await rig.controlEpoch()), ), 'terminal_output') - diagnostic = { ...diagnostic, sourceIncarnationId: output.incarnationId, sourceStreamEpoch: output.streamEpoch, sourceTerminalId: output.terminalId, sourceExited: output.exited } - if (output.incarnationId !== view.incarnationId || output.terminalId !== terminalId || !output.streamEpoch || output.exited) return null - if (output.streamEpoch !== sourceEpoch || output.resetRequired) { - sourceEpoch = output.streamEpoch - sourceText = '' - cursor = 0 - } - for (const frame of output.frames ?? []) { - if (frame.streamEpoch !== sourceEpoch || frame.terminalId !== terminalId) { + const sourceText = (output.frames ?? []).map((frame) => { + if (frame.streamEpoch !== output.streamEpoch || frame.terminalId !== terminalId) { throw new Error('replacement prompt frame has mismatched ownership') } - sourceText = (sourceText + frame.data).slice(-512 * 1024) - cursor = Math.max(cursor, frame.seqEnd) - } + return frame.data + }).join('').slice(-512 * 1024) const sourceReady = openCodeTerminalReady(sourceText, modelTexts) - diagnostic = { ...diagnostic, sourceReady, cursor } - if (!sourceReady) return null - const leaf = leavesByMode(await harness.getPaneLayout(tabId), 'opencode').find((row) => row.id === paneId) - diagnostic = { ...diagnostic, paneStreamId: leaf?.content?.streamId, paneIncarnationId: leaf?.content?.incarnationId } - if (leaf?.content?.streamId !== sourceEpoch || leaf?.content?.incarnationId !== view.incarnationId) return null - const rendered = await page.evaluate((terminalId) => { - const h = window.__FRESHELL_TEST_HARNESS__ - return { text: h?.getTerminalBuffer(terminalId), modes: h?.getTerminalModes?.(terminalId) } - }, terminalId) - const browserModelBanner = hasOpenCodePromptModelText(rendered.text ?? '', modelTexts) - diagnostic = { ...diagnostic, browserInputReady: rendered.modes?.bracketedPasteMode, browserModelBanner } - if (!browserModelBanner || !rendered.modes?.bracketedPasteMode) return null + diagnostic = { + ...diagnostic, + sourceIncarnationId: output.incarnationId, + sourceStreamEpoch: output.streamEpoch, + sourceTerminalId: output.terminalId, + sourceExited: output.exited, + sourceReady, + sourceCursor: output.headSeq, + } + if ( + output.incarnationId !== view.incarnationId + || output.terminalId !== terminalId + || output.streamEpoch !== browserReady.streamId + || output.exited + || !sourceReady + ) { + throw new Error('replacement provider source and browser stream identities did not agree') + } rig.runtime.assert('PC-OPENCODE', true, 'replacement TUI prompt is source-observed and rendered before input', { - soulId: view.soulId, incarnationId: view.incarnationId, terminalId: view.terminalId, streamEpoch: sourceEpoch, cursor, + soulId: view.soulId, + incarnationId: view.incarnationId, + terminalId, + streamEpoch: output.streamEpoch, + cursor: output.headSeq, }) - return true - }, 120_000) } catch (error) { // Keep identity/readiness facts, never conversation text, in failure receipts. - rig.runtime.writeBrowserArtifact('opencode-replacement-readiness', diagnostic) + rig.runtime.writeBrowserArtifact('opencode-replacement-readiness', { + ...diagnostic, + observedStreamChanges: observedStreamChanges.slice(-10), + observedWebSocketFrameTypes: [...observedWebSocketFrameTypes].sort(), + observedWebSocketCount: observedWebSocketCount.value, + }) throw error } } @@ -363,14 +439,16 @@ test.describe.serial('OpenCode provider qualification', () => { ) test.setTimeout(1_800_000) - const authFile = requireOpenCodeAuthFile(process.env, 'OpenCode provider qualification') + const onecli = requireOpenCodeOnecliBootstrap(process.env, 'OpenCode provider qualification') const blockerEvidence = runBlockerMatrixTests(process.cwd()) const rig = new ManagedRuntimeBrowserRig( process.cwd(), 5, { FRESHELL_BIND_HOST: '0.0.0.0', - FRESHELL_MANAGED_OPENCODE_ONECLI_AUTH_FILE: authFile, + FRESHELL_MANAGED_OPENCODE_ONECLI_AUTH_FILE: onecli.authFile, + FRESHELL_MANAGED_OPENCODE_ONECLI_ENV_FILE: onecli.environmentFile, + FRESHELL_MANAGED_OPENCODE_ONECLI_CA_FILE: onecli.caFile, }, { FRESHELL_RUNTIME_OBSERVER_INTERVAL_MS: '750' }, 'release', @@ -388,6 +466,40 @@ test.describe.serial('OpenCode provider qualification', () => { ), 'migration_plan') expect(rollout.currentMode).toBe('managed-opt-in') + let qualifiedTerminalId: string | undefined + const observedStreamChanges: Array<{ + terminalId: string + streamId: string + reason: string + attachRequestId: string | null + }> = [] + const observedWebSocketFrameTypes = new Set() + const observedWebSocketCount = { value: 0 } + page.on('websocket', (socket) => { + if (new URL(socket.url()).pathname !== '/ws') return + observedWebSocketCount.value += 1 + socket.on('framereceived', ({ payload }) => { + try { + const message = JSON.parse(String(payload)) as Record + if (typeof message.type === 'string') observedWebSocketFrameTypes.add(message.type) + if ( + message.type === 'terminal.stream.changed' + && message.terminalId === qualifiedTerminalId + && typeof message.streamId === 'string' + && typeof message.reason === 'string' + ) { + observedStreamChanges.push({ + terminalId: message.terminalId, + streamId: message.streamId, + reason: message.reason, + attachRequestId: typeof message.attachRequestId === 'string' ? message.attachRequestId : null, + }) + } + } catch { + // Ignore unrelated or non-JSON WebSocket frames. + } + }) + }) await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) const harness = new TestHarness(page) const terminal = new TerminalHelper(page) @@ -406,6 +518,7 @@ test.describe.serial('OpenCode provider qualification', () => { tabId, new Set(leavesByMode(await harness.getPaneLayout(tabId), 'opencode').map((leaf) => leaf.id)), ) + qualifiedTerminalId = first.terminalId if (!first.view.containerId || !first.view.hostBootId) { throw new Error('first OpenCode view lacks exact runtime identity') } @@ -415,9 +528,9 @@ test.describe.serial('OpenCode provider qualification', () => { expect(processArgs).toContain(P2_OPENCODE_MODEL) const authProbe = rig.ownedProviderExec(first.view.containerId, [ 'node', '--no-warnings', '-e', - "const fs=require('node:fs');const auth=JSON.parse(fs.readFileSync('/home/freshell/provider/.local/share/opencode/auth.json','utf8'));const openai=auth.openai;if(!openai||typeof openai.access!=='string'||typeof openai.refresh!=='string')process.exit(2);process.stdout.write('OpenAI credential present')", + "const fs=require('node:fs');const auth=JSON.parse(fs.readFileSync('/home/freshell/provider/.local/share/opencode/auth.json','utf8'));const openai=auth.openai;if(!openai||openai.type!=='oauth'||openai.access!=='onecli-managed'||openai.refresh!=='onecli-managed'||openai.expires { // Proof comes from NEW provider-native assistant rows, never echoed input // or a redraw of old terminal history. The project name has 128 bits. const nonce = `p-${randomBytes(16).toString('base64url')}` + // Keep this unambiguously conversational: asking for a project name can + // make a coding model search the workspace instead of answering from its + // current turn, leaving native history open until the qualification times out. await executeInPane( page, first.paneId, - `For the project we are discussing, the name is ${nonce}. What is the project name?`, + `Remember this exact string for our conversation: ${nonce}. Reply with only the string. Do not search files or call tools.`, ) const nativeSessionId = await waitForValue('first exact OpenCode session id', async () => ( await paneSessionId(harness, tabId, first.paneId) @@ -441,6 +557,7 @@ test.describe.serial('OpenCode provider qualification', () => { // Session-host/container loss: exact old enclosure must be empty before // the new incarnation becomes the sole writer. + const beforeHostCrashStreamId = await paneStreamId(harness, tabId, first.paneId) rig.runtime.killOwnedRuntimeExact(first.view.containerId) const afterHostCrash = await waitForRunningView( rig, @@ -461,9 +578,13 @@ test.describe.serial('OpenCode provider qualification', () => { await waitForValue('pane retains the exact native identity after host loss', async () => ( (await paneSessionId(harness, tabId, first.paneId)) === nativeSessionId ? true : null ), 120_000) - await waitForReplacementPrompt(page, harness, rig, tabId, first.paneId, afterHostCrash) + await waitForReplacementPrompt( + page, harness, rig, tabId, first.paneId, afterHostCrash, beforeHostCrashStreamId, + observedStreamChanges, observedWebSocketFrameTypes, observedWebSocketCount, + ) const beforeRecall = new Set(nativeAssistantTurns(rig, afterHostCrash, nativeSessionId).map((turn) => turn.messageId)) - await executeInPane(page, first.paneId, 'What is the name of the project we chose earlier?') + await executeInPane(page, first.paneId, + 'What exact string did I ask you to remember? Reply with only the string. Do not search files or call tools.') const recalledAnswer = await nextNativeAssistantTurn(page, first.terminalId, rig, afterHostCrash, nativeSessionId, beforeRecall, nonce) verifyMemoryAnswer(recalledAnswer, nonce) expect(recalledAnswer.messageId).not.toBe(firstAnswer.messageId) @@ -472,6 +593,7 @@ test.describe.serial('OpenCode provider qualification', () => { // Provider-process loss: kill only the exact host-recorded worker PID, // then require another exact native resume and usable follow-up. + const beforeProviderCrashStreamId = await paneStreamId(harness, tabId, first.paneId) const hostStatePath = path.join( rig.runtime.runtimeDir(rig.supervisor, afterHostCrash.incarnationId), 'host-state.json', @@ -495,9 +617,13 @@ test.describe.serial('OpenCode provider qualification', () => { first.paneId, afterProviderCrash.incarnationId, ) - await waitForReplacementPrompt(page, harness, rig, tabId, first.paneId, afterProviderCrash) + await waitForReplacementPrompt( + page, harness, rig, tabId, first.paneId, afterProviderCrash, beforeProviderCrashStreamId, + observedStreamChanges, observedWebSocketFrameTypes, observedWebSocketCount, + ) const beforeProviderFollowup = new Set(nativeAssistantTurns(rig, afterProviderCrash, nativeSessionId).map((turn) => turn.messageId)) - await executeInPane(page, first.paneId, 'Please remind me of the project name we selected.') + await executeInPane(page, first.paneId, + 'What exact string did I ask you to remember? Reply with only the string. Do not search files or call tools.') const providerAnswer = await nextNativeAssistantTurn(page, first.terminalId, rig, afterProviderCrash, nativeSessionId, beforeProviderFollowup, nonce) verifyMemoryAnswer(providerAnswer, nonce) expect(providerAnswer.messageId).not.toBe(recalledAnswer.messageId) @@ -511,7 +637,7 @@ test.describe.serial('OpenCode provider qualification', () => { if (!second.view.containerId) throw new Error('second OpenCode view lacks container') const secondNonce = `p-${randomBytes(16).toString('base64url')}` await executeInPane(page, second.paneId, - `For the project we are discussing, the name is ${secondNonce}. What is the project name?`) + `Remember this exact string for our conversation: ${secondNonce}. Reply with only the string. Do not search files or call tools.`) const secondSessionId = await waitForValue('second exact OpenCode session id', async () => ( await paneSessionId(harness, tabId, second.paneId) ), 120_000) diff --git a/test/e2e-browser/specs/runtime-terminal-continuity-rust.spec.ts b/test/e2e-browser/specs/runtime-terminal-continuity-rust.spec.ts index b709ec4ba..620fccf2f 100644 --- a/test/e2e-browser/specs/runtime-terminal-continuity-rust.spec.ts +++ b/test/e2e-browser/specs/runtime-terminal-continuity-rust.spec.ts @@ -13,8 +13,8 @@ import fs from 'node:fs' import path from 'node:path' import { ManagedRuntimeBrowserRig, P2_OPENCODE_MODEL, P2_OPENCODE_VERSION, type ManagedRuntimeView } from '../helpers/managed-runtime.js' -import { requireOpenCodeAuthFile } from '../helpers/opencode-auth-file.js' -import { OPENCODE_NATIVE_HISTORY_SCRIPT, type NativeHistory } from '../helpers/opencode-native-history.js' +import { requireOpenCodeOnecliBootstrap } from '../helpers/opencode-auth-file.js' +import { OPENCODE_NATIVE_HISTORY_SCRIPT, type NativeHistory, waitForOpenCodeTerminalCondition } from '../helpers/opencode-native-history.js' import { TestHarness } from '../helpers/test-harness.js' import { TerminalHelper } from '../helpers/terminal-helpers.js' import { openPanePicker } from '../helpers/pane-picker.js' @@ -323,13 +323,15 @@ test.describe.serial('Phase 2 managed runtime continuity', () => { test('P2-G04: OpenAI-authenticated OpenCode tool turn survives web replacement in one native session', async ({ page }) => { test.setTimeout(900_000) - // The rig runs the web server with an isolated HOME. Pass the existing - // private auth file as a typed OneCLI grant; otherwise OpenCode silently - // uses its default model in the managed provider volume. - const authFile = requireOpenCodeAuthFile() + // The rig runs the web server with an isolated HOME. Pass the native + // OpenCode OAuth stub, OneCLI proxy settings, and gateway CA as typed + // grants so the provider uses the dedicated OneCLI gateway. + const onecli = requireOpenCodeOnecliBootstrap() const rig = new ManagedRuntimeBrowserRig(process.cwd(), 2, { FRESHELL_BIND_HOST: '0.0.0.0', - FRESHELL_MANAGED_OPENCODE_ONECLI_AUTH_FILE: authFile, + FRESHELL_MANAGED_OPENCODE_ONECLI_AUTH_FILE: onecli.authFile, + FRESHELL_MANAGED_OPENCODE_ONECLI_ENV_FILE: onecli.environmentFile, + FRESHELL_MANAGED_OPENCODE_ONECLI_CA_FILE: onecli.caFile, }) try { const info = await rig.start() @@ -369,9 +371,9 @@ test.describe.serial('Phase 2 managed runtime continuity', () => { expect(rig.ownedContainerExec(view.containerId, ['opencode', '--version']).trim()).toBe(P2_OPENCODE_VERSION) const authProbe = rig.ownedProviderExec(view.containerId, [ 'node', '--no-warnings', '-e', - "const fs=require('node:fs');const auth=JSON.parse(fs.readFileSync('/home/freshell/provider/.local/share/opencode/auth.json','utf8'));const openai=auth.openai;if(!openai||typeof openai.access!=='string'||typeof openai.refresh!=='string')process.exit(2);process.stdout.write('openai credential present')", + "const fs=require('node:fs');const auth=JSON.parse(fs.readFileSync('/home/freshell/provider/.local/share/opencode/auth.json','utf8'));const openai=auth.openai;if(!openai||openai.type!=='oauth'||openai.access!=='onecli-managed'||openai.refresh!=='onecli-managed'||openai.expires { expect(processArgs).toContain('--port 4096') const opencodeIndex = Math.max(0, (await page.locator('.xterm').count()) - 1) - await terminal.waitForOutput('Ask anything...', { timeout: 90_000, terminalId }) + await waitForOpenCodeTerminalCondition({ + readTerminalText: () => terminal.getVisibleText(terminalId), + wait: (milliseconds) => page.waitForTimeout(milliseconds), + description: 'the OpenCode prompt', + succeeds: (text) => text.includes('Ask anything...'), + timeoutMs: 90_000, + }) + const firstTurnMarker = 'P2_OPENCODE_FIRST_TURN_DONE' await terminal.executeCommandInserted( 'Use the bash tool to run exactly: echo P2_OPENCODE_FIRST_TURN_DONE > "$HOME/p2-opencode-first-turn". Reply with exactly READY when the command completes.', opencodeIndex, ) - await expect.poll(() => { - return rig.ownedProviderExec(view.containerId!, ['sh', '-lc', 'cat /home/freshell/provider/p2-opencode-first-turn 2>/dev/null || true']).trim() - }, { timeout: 90_000 }).toBe('P2_OPENCODE_FIRST_TURN_DONE') + await waitForOpenCodeTerminalCondition({ + readTerminalText: () => terminal.getVisibleText(terminalId), + wait: (milliseconds) => page.waitForTimeout(milliseconds), + description: `OpenCode tool marker ${firstTurnMarker}`, + succeeds: () => rig.ownedProviderExec(view.containerId!, [ + 'sh', '-lc', 'cat /home/freshell/provider/p2-opencode-first-turn 2>/dev/null || true', + ]).trim() === firstTurnMarker, + timeoutMs: 90_000, + }) const sessionId = await waitForSessionId(content, 90_000) expect(sessionId).toMatch(/^ses_/) diff --git a/test/runtime/README.md b/test/runtime/README.md index 97b015412..28d3276c9 100644 --- a/test/runtime/README.md +++ b/test/runtime/README.md @@ -97,6 +97,19 @@ counts/duration, provider/mode coverage, cleanup, and zero unsafe broker attempts, then copies the receipt into its own evidence directory. Never reuse a receipt from another commit or image. +The live OpenCode qualification uses three private OneCLI grants: set +`FRESHELL_MANAGED_OPENCODE_ONECLI_AUTH_FILE` to an OpenCode-native OAuth stub, +`FRESHELL_MANAGED_OPENCODE_ONECLI_ENV_FILE` to the dedicated agent's proxy +settings, and `FRESHELL_MANAGED_OPENCODE_ONECLI_CA_FILE` to its gateway CA +certificate. The stub must contain only the `onecli-managed` access/refresh +placeholders and a future expiry; it must not contain a provider token. The +proxy environment supplies `OPENAI_BASE_URL=https://api.openai.com/v1`, both +HTTP and HTTPS proxy aliases, `NODE_USE_ENV_PROXY=1`, +`NO_PROXY=localhost,127.0.0.1` for OpenCode's local server, and +`NODE_EXTRA_CA_CERTS` pointing to +`/home/freshell/provider/.config/onecli/gateway-ca.pem`. The session host stages +the CA at that provider-home path before OpenCode starts. + Provider qualification receipts use schema v2. A v2 receipt names its exact `receiptRunId`, candidate-bound `evidenceRun`, pinned `runtimeImage`, and the provider version/model/reasoning-effort/native-session identity observed by the diff --git a/test/runtime/gate-manifest.json b/test/runtime/gate-manifest.json index f72ca3264..959afa895 100644 --- a/test/runtime/gate-manifest.json +++ b/test/runtime/gate-manifest.json @@ -283,7 +283,7 @@ { "id": "P2-G04", "required": true, - "procedure": "Start OpenCode 1.18.21 with openai/gpt-5.6-luna using the explicit FRESHELL_MANAGED_OPENCODE_ONECLI_AUTH_FILE reference to the configured private OpenAI OAuth credential; verify the managed provider volume contains the OpenAI credential and the requested model is available; complete a first Bash-tool turn, begin a controlled long Bash tool, SIGKILL/restart web while the tool is running, then run a provider-side tool in a follow-up.", + "procedure": "Start OpenCode 1.18.21 with openai/gpt-5.6-luna using private FRESHELL_MANAGED_OPENCODE_ONECLI_AUTH_FILE, ENV_FILE, and CA_FILE references: a native OpenCode OAuth stub with onecli-managed placeholders, the dedicated agent proxy settings, its trusted gateway CA, and loopback-only NO_PROXY entries for localhost and 127.0.0.1; verify the provider home contains only the placeholder OAuth stub and staged CA and that the requested model is available; complete a first Bash-tool turn, begin a controlled long Bash tool, SIGKILL/restart web while the tool is running, then run a provider-side tool in a follow-up.", "pass_assertion": "Same native ses_* session, soul, OS incarnation, container and host boot; tool effects occur exactly once; provider launch count stays one; no paid-model fallback or CLI auto-update.", "status": "NOT_RUN" }, diff --git a/test/unit/electron/port-check.test.ts b/test/unit/electron/port-check.test.ts index 6b5e40f67..38d92a363 100644 --- a/test/unit/electron/port-check.test.ts +++ b/test/unit/electron/port-check.test.ts @@ -16,7 +16,7 @@ function listenOnEphemeral(): Promise<{ port: number; close: () => Promise } describe('createPortAvailabilityCheck', () => { - it('reports a port held by another listener as unavailable, and free once released', async () => { + it('reports a port held by another listener as unavailable', async () => { const isPortAvailable = createPortAvailabilityCheck() const { port, close } = await listenOnEphemeral() try { @@ -24,6 +24,11 @@ describe('createPortAvailabilityCheck', () => { } finally { await close() } - expect(await isPortAvailable(port)).toBe(true) + }) + + it('reports an OS-selected port as available', async () => { + // Avoid racing another local listener to claim the released ephemeral port. + const isPortAvailable = createPortAvailabilityCheck() + expect(await isPortAvailable(0)).toBe(true) }) }) diff --git a/test/unit/electron/prepare-electron-runtime.test.ts b/test/unit/electron/prepare-electron-runtime.test.ts index 83176f834..ea5c3edca 100644 --- a/test/unit/electron/prepare-electron-runtime.test.ts +++ b/test/unit/electron/prepare-electron-runtime.test.ts @@ -114,6 +114,7 @@ function createSourceFixture(root: string): { type: 'module', dependencies: { '@modelcontextprotocol/sdk': '1.30.0', + undici: '7.30.0', zod: '4.3.6', }, files: ['generated'], @@ -195,6 +196,7 @@ function mcpDeployFixture(destination: string, generatedSource: string): void { dependencies: { // pnpm's deploy annotates peer resolutions in the exported manifest. '@modelcontextprotocol/sdk': '1.30.0(zod@4.3.6)', + undici: '7.30.0', zod: '4.3.6', }, files: ['generated'], @@ -217,6 +219,10 @@ function mcpDeployFixture(destination: string, generatedSource: string): void { const zodDir = path.join(destination, 'node_modules', 'zod') mkdirSync(zodDir, { recursive: true }) writeFileSync(path.join(zodDir, 'package.json'), JSON.stringify({ name: 'zod', version: '4.3.6' })) + const undiciLlhttpDir = path.join(destination, 'node_modules', 'undici', 'lib', 'llhttp') + mkdirSync(undiciLlhttpDir, { recursive: true }) + writeFileSync(path.join(undiciLlhttpDir, '.gitkeep'), '') + writeFileSync(path.join(undiciLlhttpDir, 'llhttp-wasm.js'), 'export const llhttp = true\n') writeBinShim(destination, 'which', 'node-which', 'bin/node-which') mkdirSync(path.join(destination, 'node_modules', '.pnpm'), { recursive: true }) writeFileSync(path.join(destination, 'node_modules', '.pnpm', 'lock.yaml'), 'inert pnpm install state\n') @@ -430,9 +436,12 @@ describe('prepare-electron-runtime staging', () => { type: 'module', dependencies: { '@modelcontextprotocol/sdk': '1.30.0', + undici: '7.30.0', zod: '4.3.6', }, }) + expect(receipt.files).not.toContain('mcp/node_modules/undici/lib/llhttp/.gitkeep') + expect(receipt.files).toContain('mcp/node_modules/undici/lib/llhttp/llhttp-wasm.js') const stagedFiles = collectFiles(outputRoot) expect(stagedFiles.filter((file) => diff --git a/test/unit/lib/managed-runtime-recovery.test.ts b/test/unit/lib/managed-runtime-recovery.test.ts index c47f851a7..f22e8030c 100644 --- a/test/unit/lib/managed-runtime-recovery.test.ts +++ b/test/unit/lib/managed-runtime-recovery.test.ts @@ -245,6 +245,49 @@ describe('managed runtime recovery merge', () => { expect(content.initialCwd).toBe('/workspace/real') }) + it('preserves the live host stream epoch when a same-terminal inventory refresh arrives', () => { + const state = baseState() + state.panes.layouts['user-tab'].content = { + kind: 'terminal', + createRequestId: 'create-one', + terminalId: 'terminal-one', + streamId: 'stream-one-hostboot-replacement', + status: 'running', + mode: 'opencode', + shell: 'system', + soulId: 'soul-one', + viewIntentId: 'view-one', + incarnationId: 'incarnation-replacement', + } + const staleLaunchIdentity = soul({ incarnationId: 'incarnation-replacement' }) + + const plan = buildManagedRuntimeMergePlan(snapshot([staleLaunchIdentity]), state) + + expect(plan.updates).toHaveLength(1) + expect((plan.updates[0].content as any).streamId).toBe('stream-one-hostboot-replacement') + }) + + it('takes the projected stream ID when the pane is rebound to a different terminal', () => { + const state = baseState() + state.panes.layouts['user-tab'].content = { + kind: 'terminal', + createRequestId: 'create-one', + terminalId: 'terminal-old', + streamId: 'stream-old-hostboot', + status: 'running', + mode: 'opencode', + shell: 'system', + soulId: 'soul-one', + viewIntentId: 'view-one', + } + + const plan = buildManagedRuntimeMergePlan(snapshot(), state) + + expect(plan.updates).toHaveLength(1) + expect((plan.updates[0].content as any).terminalId).toBe('terminal-one') + expect((plan.updates[0].content as any).streamId).toBe('stream-one') + }) + it('rekeys an offline fresh-agent view to the supervisor current native branch after provider fork', () => { const state = baseState() state.tabs.tabs[0].mode = 'freshcodex' diff --git a/test/unit/mcp/http-client.test.ts b/test/unit/mcp/http-client.test.ts index 3b8a4ff8e..462485df5 100644 --- a/test/unit/mcp/http-client.test.ts +++ b/test/unit/mcp/http-client.test.ts @@ -1,4 +1,6 @@ // @vitest-environment node +import { spawn } from 'node:child_process' +import { createServer, type AddressInfo, type Server } from 'node:http' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' describe('resolveConfig', () => { @@ -237,4 +239,88 @@ describe('createApiClient', () => { expect(result).not.toBeNull() expect(result).not.toBeUndefined() }) + + it('sends owned Freshell API traffic directly when Node environment proxies are enabled', async () => { + let targetRequests = 0 + let proxyRequests = 0 + const targetServer = createServer((_request, response) => { + targetRequests += 1 + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ status: 'ok' })) + }) + const proxyServer = createServer((_request, response) => { + proxyRequests += 1 + const body = 'the model proxy must not receive Freshell API traffic' + response.writeHead(502, { connection: 'close', 'content-length': String(body.length) }) + response.end(body) + }) + proxyServer.on('connect', (_request, socket) => { + proxyRequests += 1 + socket.end('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nContent-Length: 0\r\n\r\n') + }) + const listen = (server: Server) => new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const close = (server: Server) => new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()) + server.closeAllConnections() + }) + await Promise.all([listen(targetServer), listen(proxyServer)]) + + try { + const targetPort = (targetServer.address() as AddressInfo).port + const proxyPort = (proxyServer.address() as AddressInfo).port + const targetUrl = `http://127.0.0.1:${targetPort}` + const proxyUrl = `http://127.0.0.1:${proxyPort}` + const moduleUrl = new URL('../../../tools/freshell-mcp/http-client.ts', import.meta.url).href + const source = [ + 'import { createApiClient } from ' + JSON.stringify(moduleUrl), + '(async () => {', + `const baseUrl = ${JSON.stringify(targetUrl)}`, + 'await createApiClient({ url: baseUrl, token: "" }).get("/api/health")', + '})().then(() => process.exit(0), () => process.exit(1))', + ].join('\n') + const child = spawn(process.execPath, ['--import', 'tsx/esm', '--input-type=module', '--eval', source], { + cwd: process.cwd(), + env: { + ...process.env, + NODE_USE_ENV_PROXY: '1', + NODE_OPTIONS: '', + HTTP_PROXY: proxyUrl, + http_proxy: proxyUrl, + HTTPS_PROXY: proxyUrl, + https_proxy: proxyUrl, + NO_PROXY: '', + no_proxy: '', + ALL_PROXY: '', + all_proxy: '', + }, + stdio: ['ignore', 'ignore', 'pipe'], + }) + let stderr = '' + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => { stderr += chunk }) + const result = await new Promise<{ code: number | null; stderr: string }>((resolve, reject) => { + const timeout = setTimeout(() => child.kill('SIGKILL'), 5_000) + child.once('error', (error) => { + clearTimeout(timeout) + reject(error) + }) + child.once('exit', (code) => { + clearTimeout(timeout) + child.stderr.destroy() + resolve({ code, stderr }) + }) + }) + + expect({ targetRequests, proxyRequests, exitCode: result.code }, result.stderr).toEqual({ + targetRequests: 1, + proxyRequests: 0, + exitCode: 0, + }) + } finally { + await Promise.all([close(targetServer), close(proxyServer)]) + } + }) }) diff --git a/test/unit/tooling/testing/opencode-auth-file.test.ts b/test/unit/tooling/testing/opencode-auth-file.test.ts index 85dd6d4a2..111a5eaf9 100644 --- a/test/unit/tooling/testing/opencode-auth-file.test.ts +++ b/test/unit/tooling/testing/opencode-auth-file.test.ts @@ -4,7 +4,10 @@ import os from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { requireOpenCodeAuthFile } from '../../../e2e-browser/helpers/opencode-auth-file.js' +import { + requireOpenCodeAuthFile, + requireOpenCodeOnecliBootstrap, +} from '../../../e2e-browser/helpers/opencode-auth-file.js' let root: string @@ -61,3 +64,92 @@ describe('OpenCode qualification auth reference', () => { })).toThrow(/owner-readable/) }) }) + +describe('OpenCode OneCLI qualification bootstrap', () => { + function writeGrant(name: string, value: string): string { + const file = path.join(root, name) + fs.writeFileSync(file, value, { mode: 0o600 }) + fs.chmodSync(file, 0o600) + return file + } + + function makeBootstrap() { + const authFile = writeGrant('onecli-auth.json', JSON.stringify({ + openai: { + type: 'oauth', + access: 'onecli-managed', + refresh: 'onecli-managed', + expires: Date.now() + 24 * 60 * 60 * 1000, + }, + })) + const environmentFile = writeGrant('onecli.env', [ + 'OPENAI_BASE_URL=https://api.openai.com/v1', + 'HTTPS_PROXY=http://agent:secret@192.168.3.150:10255', + 'https_proxy=http://agent:secret@192.168.3.150:10255', + 'HTTP_PROXY=http://agent:secret@192.168.3.150:10255', + 'http_proxy=http://agent:secret@192.168.3.150:10255', + 'NODE_EXTRA_CA_CERTS=/home/freshell/provider/.config/onecli/gateway-ca.pem', + 'NODE_USE_ENV_PROXY=1', + 'NO_PROXY=localhost,127.0.0.1', + '', + ].join('\n')) + const caFile = writeGrant('gateway-ca.pem', 'OneCLI gateway CA fixture') + const env = { + FRESHELL_MANAGED_OPENCODE_ONECLI_AUTH_FILE: authFile, + FRESHELL_MANAGED_OPENCODE_ONECLI_ENV_FILE: environmentFile, + FRESHELL_MANAGED_OPENCODE_ONECLI_CA_FILE: caFile, + } + return { authFile, environmentFile, caFile, env } + } + + it('fails fast unless all three private OneCLI grants are configured', () => { + const { authFile, environmentFile } = makeBootstrap() + expect(() => requireOpenCodeOnecliBootstrap({ + FRESHELL_MANAGED_OPENCODE_ONECLI_AUTH_FILE: authFile, + })).toThrow(/FRESHELL_MANAGED_OPENCODE_ONECLI_ENV_FILE/) + expect(() => requireOpenCodeOnecliBootstrap({ + FRESHELL_MANAGED_OPENCODE_ONECLI_AUTH_FILE: authFile, + FRESHELL_MANAGED_OPENCODE_ONECLI_ENV_FILE: environmentFile, + })).toThrow(/FRESHELL_MANAGED_OPENCODE_ONECLI_CA_FILE/) + }) + + it('accepts the private native OpenCode stub, proxy environment, and CA grants', () => { + const { authFile, environmentFile, caFile, env } = makeBootstrap() + expect(requireOpenCodeOnecliBootstrap(env)).toEqual({ + authFile: fs.realpathSync(authFile), + environmentFile: fs.realpathSync(environmentFile), + caFile: fs.realpathSync(caFile), + }) + }) + + it('requires a loopback-only proxy bypass for the OpenCode local server', () => { + const { env, environmentFile } = makeBootstrap() + const original = fs.readFileSync(environmentFile, 'utf8') + for (const noProxy of [ + undefined, + 'localhost', + 'localhost,127.0.0.1,api.openai.com', + '*', + ]) { + const environment = original.replace('NO_PROXY=localhost,127.0.0.1\n', '') + fs.writeFileSync(environmentFile, `${environment}${noProxy ? `NO_PROXY=${noProxy}\n` : ''}`, { mode: 0o600 }) + expect(() => requireOpenCodeOnecliBootstrap(env)).toThrow(/NO_PROXY/) + } + + fs.writeFileSync(environmentFile, `${original}no_proxy=localhost\n`, { mode: 0o600 }) + expect(() => requireOpenCodeOnecliBootstrap(env)).toThrow(/NO_PROXY/) + }) + + it('rejects expired, real-token, and Codex-shaped OpenCode auth files', () => { + const { env, authFile } = makeBootstrap() + const original = JSON.parse(fs.readFileSync(authFile, 'utf8')) + for (const openai of [ + { ...original.openai, expires: Date.now() - 1 }, + { ...original.openai, access: 'real-access-token' }, + { ...original.openai, last_refresh: Date.now() }, + ]) { + fs.writeFileSync(authFile, JSON.stringify({ openai }), { mode: 0o600 }) + expect(() => requireOpenCodeOnecliBootstrap(env)).toThrow(/OneCLI placeholder OAuth credential/) + } + }) +}) diff --git a/test/unit/tooling/testing/opencode-native-history.test.ts b/test/unit/tooling/testing/opencode-native-history.test.ts index b7781704d..ddaf0a889 100644 --- a/test/unit/tooling/testing/opencode-native-history.test.ts +++ b/test/unit/tooling/testing/opencode-native-history.test.ts @@ -5,7 +5,7 @@ import os from 'node:os' import path from 'node:path' import { DatabaseSync } from 'node:sqlite' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { hasOpenCodePromptModelText, nativeTurnProof, openCodeCredentialFailureMessage, openCodeTerminalReady, selectNativeAssistantTurn } from '../../../e2e-browser/helpers/opencode-native-history.js' +import { hasOpenCodePromptModelText, nativeTurnProof, openCodeCredentialFailureMessage, openCodeTerminalReady, selectNativeAssistantTurn, waitForOpenCodeTerminalCondition } from '../../../e2e-browser/helpers/opencode-native-history.js' import { readOpenCodeNativeHistory } from '../../../e2e-browser/helpers/provider-native-history/opencode.js' let root: string @@ -39,10 +39,31 @@ describe('live recovery proves new native assistant responses, never TUI echo or it('surfaces an OpenAI token refresh rejection instead of waiting for the native response timeout', () => { expect(openCodeCredentialFailureMessage('\u001b[31mToken refresh failed: 401\u001b[0m')) .toMatch(/credential refresh was rejected.*401/i) + expect(openCodeCredentialFailureMessage( + 'Could not parse your authentication token. Please try signing in again.', + )).toMatch(/credential was rejected/i) + expect(openCodeCredentialFailureMessage('HTTP 401 Unauthorized')).toMatch(/credential was rejected/i) + expect(openCodeCredentialFailureMessage('401 tokens remain in this request')).toBeNull() expect(openCodeCredentialFailureMessage('Token refresh failed: 403')).toBeNull() expect(openCodeCredentialFailureMessage('GPT-5.6 Luna')).toBeNull() }) + it('detects authentication errors when OpenCode redraws a shorter terminal screen', async () => { + const snapshots = [ + 'OpenCode prompt and status text '.repeat(20), + 'Could not parse your authentication token. Please try signing in again.', + ] + let reads = 0 + await expect(waitForOpenCodeTerminalCondition({ + readTerminalText: async () => snapshots[Math.min(reads++, snapshots.length - 1)], + wait: async () => {}, + description: 'an OpenCode tool result', + succeeds: (text) => text.includes('P2_OPENCODE_FIRST_TURN_DONE'), + timeoutMs: 1_000, + })).rejects.toThrow(/credential was rejected/i) + expect(reads).toBe(2) + }) + it('reads only completed assistant messages for the exact native session', () => { message('echo', 'ses_owned', 'user', 'nonce-in-echo') message('unfinished', 'ses_owned', 'assistant', 'nonce-unfinished', null) diff --git a/test/unit/tooling/testing/runtime-amplifier-onecli.test.ts b/test/unit/tooling/testing/runtime-amplifier-onecli.test.ts index 6bae5b813..b4624d64d 100644 --- a/test/unit/tooling/testing/runtime-amplifier-onecli.test.ts +++ b/test/unit/tooling/testing/runtime-amplifier-onecli.test.ts @@ -59,6 +59,13 @@ describe('live Amplifier OneCLI qualification grants', () => { })).toEqual([onecliGrant]) }) + it('admits the OpenCode OneCLI gateway CA grant to the sandbox broker', () => { + const caGrant = privateGrant('gateway-ca.pem') + expect(phase2BootstrapFiles({ + FRESHELL_MANAGED_OPENCODE_ONECLI_CA_FILE: caGrant, + })).toEqual([caGrant]) + }) + it('rejects a missing grant even when the old keys-file input is set', () => { const legacyKeys = privateGrant('keys.env') const env = { FRESHELL_MANAGED_AMPLIFIER_ONECLI_KEYS_FILE: legacyKeys } diff --git a/tools/freshell-mcp/http-client.ts b/tools/freshell-mcp/http-client.ts index 973cdbb86..ad32f264d 100644 --- a/tools/freshell-mcp/http-client.ts +++ b/tools/freshell-mcp/http-client.ts @@ -5,6 +5,7 @@ * Freshell into every spawned terminal). Does NOT read config files -- the * MCP server always runs in a terminal that already has env vars set. */ +import { Agent } from 'undici' export type ApiClientConfig = { url: string @@ -25,6 +26,10 @@ export type ApiClient = { delete: (path: string) => Promise } +// Node's environment proxy can route global fetch through a model gateway. +// Freshell's own control API must keep using the direct transport. +const directFreshellApiDispatcher = new Agent() + function joinUrl(base: string, path: string): string { const trimmed = base.endsWith('/') ? base.slice(0, -1) : base const suffix = path.startsWith('/') ? path : `/${path}` @@ -66,11 +71,13 @@ export function createApiClient(config?: ApiClientConfig): ApiClient { if (body !== undefined) headers['Content-Type'] = 'application/json' if (token) headers['x-auth-token'] = token - const res = await fetch(joinUrl(baseUrl, path), { + const init = { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, - }) + dispatcher: directFreshellApiDispatcher, + } + const res = await fetch(joinUrl(baseUrl, path), init) const data = await parseResponse(res) if (!res.ok) {