From 6b87e15a4e888703683d753efc15f6fdea9c7097 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:00:21 -0700 Subject: [PATCH 01/14] fix(testing): configure OpenCode OneCLI qualification --- crates/freshell-runtime-protocol/src/lib.rs | 23 ++- .../src/managed_provider_bootstrap.rs | 73 ++++++++- crates/freshell-session-host/src/main.rs | 20 +-- .../src/provider_secret_resolution.rs | 127 ++++++++++++++-- scripts/testing/runtime-sandbox.ts | 1 + .../e2e-browser/helpers/opencode-auth-file.ts | 138 +++++++++++++++--- ...encode-provider-qualification-rust.spec.ts | 12 +- .../runtime-terminal-continuity-rust.spec.ts | 18 ++- test/runtime/README.md | 11 ++ test/runtime/gate-manifest.json | 2 +- .../testing/opencode-auth-file.test.ts | 75 +++++++++- .../testing/runtime-amplifier-onecli.test.ts | 7 + 12 files changed, 443 insertions(+), 64 deletions(-) diff --git a/crates/freshell-runtime-protocol/src/lib.rs b/crates/freshell-runtime-protocol/src/lib.rs index 6fe83a3d3..402c40276 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_else(|| 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-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..58fd22143 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")?; @@ -103,6 +106,7 @@ fn resolve_profile( 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 @@ -162,10 +166,18 @@ fn resolve_provider_environment( || CERTIFICATE_CHILD_NAMES.contains(&name.as_str()) { child.insert(name.clone(), value.clone()); - } else if matches!(name.as_str(), "HTTPS_PROXY" | "https_proxy") { + } else if matches!( + name.as_str(), + "HTTPS_PROXY" | "https_proxy" | "HTTP_PROXY" | "http_proxy" + ) { reject_proxy_placeholder(value)?; validate_container_proxy(value)?; child.insert(name.clone(), value.clone()); + } else if name == "NODE_USE_ENV_PROXY" { + 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(), "ONECLI_URL" | "ONECLI_GATEWAY" | "NO_PROXY" | "no_proxy" @@ -180,9 +192,14 @@ 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"), + ] { + 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) @@ -205,19 +222,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(()) } @@ -414,7 +431,62 @@ mod tests { } #[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\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!(!resolved.environment.contains_key("NO_PROXY")); + } + + #[test] + fn provider_secret_file_grants_return_only_provider_relative_files() { let cases = [ ( "claude", @@ -431,6 +503,11 @@ mod tests { ProviderSecretProfile::OpencodeOnecliAuthFile, ".local/share/opencode/auth.json", ), + ( + "opencode", + ProviderSecretProfile::OpencodeOnecliCaFile, + ".config/onecli/gateway-ca.pem", + ), ( "amplifier", ProviderSecretProfile::AmplifierOnecliKeysFile, @@ -448,13 +525,37 @@ 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_conflicting_proxy_aliases_and_invalid_node_proxy_switch() + { + for invalid in [ + "HTTP_PROXY=http://onecli.example:10255\nhttp_proxy=http://different.example:10255\n", + "NODE_USE_ENV_PROXY=true\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/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/test/e2e-browser/helpers/opencode-auth-file.ts b/test/e2e-browser/helpers/opencode-auth-file.ts index f257ba841..d6fe4c556 100644 --- a/test/e2e-browser/helpers/opencode-auth-file.ts +++ b/test/e2e-browser/helpers/opencode-auth-file.ts @@ -1,39 +1,139 @@ 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') + 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' + ) { + throw new Error(`${qualification} OpenCode OneCLI environment grant is missing required proxy or CA settings`) + } + + return { authFile, environmentFile, caFile } } 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..2be0a4d10 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' @@ -363,14 +363,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', @@ -415,9 +417,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 { 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 { })).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', + '', + ].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('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/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 } From 8cfafd03fe94720d88683b065deee3b1319113d9 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:25:30 -0700 Subject: [PATCH 02/14] fix(runtime): keep OpenCode loopback traffic local --- .../src/provider_secret_resolution.rs | 95 ++++++++++++++++--- docker/runtime/README.md | 24 ++++- .../e2e-browser/helpers/opencode-auth-file.ts | 9 +- test/runtime/README.md | 8 +- test/runtime/gate-manifest.json | 2 +- .../testing/opencode-auth-file.test.ts | 19 ++++ 6 files changed, 133 insertions(+), 24 deletions(-) diff --git a/crates/freshell-session-host/src/provider_secret_resolution.rs b/crates/freshell-session-host/src/provider_secret_resolution.rs index 58fd22143..a9de1b74a 100644 --- a/crates/freshell-session-host/src/provider_secret_resolution.rs +++ b/crates/freshell-session-host/src/provider_secret_resolution.rs @@ -92,16 +92,50 @@ 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 @@ -159,6 +193,7 @@ fn resolve_profile( fn resolve_provider_environment( parsed: &BTreeMap, provider_names: &[&str], + allow_loopback_no_proxy: bool, ) -> Result, String> { let mut child = BTreeMap::new(); for (name, value) in parsed { @@ -178,10 +213,12 @@ fn resolve_provider_environment( return Err("NODE_USE_ENV_PROXY must be set to 1".into()); } 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(), "NO_PROXY" | "no_proxy") { + if allow_loopback_no_proxy { + 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}")); } } @@ -195,6 +232,7 @@ fn resolve_provider_environment( 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 { @@ -205,6 +243,21 @@ fn resolve_provider_environment( 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())?; @@ -419,13 +472,20 @@ 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()); } } @@ -445,7 +505,7 @@ mod tests { "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\n", + "NO_PROXY=localhost,127.0.0.1\n", ), ) .unwrap(); @@ -482,7 +542,10 @@ mod tests { ); assert_eq!(resolved.environment.get("NODE_USE_ENV_PROXY").unwrap(), "1"); assert!(!resolved.environment.contains_key("ONECLI_URL")); - assert!(!resolved.environment.contains_key("NO_PROXY")); + assert_eq!( + resolved.environment.get("NO_PROXY").unwrap(), + "localhost,127.0.0.1" + ); } #[test] @@ -533,11 +596,15 @@ mod tests { } #[test] - fn opencode_onecli_environment_rejects_conflicting_proxy_aliases_and_invalid_node_proxy_switch() - { + 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"); diff --git a/docker/runtime/README.md b/docker/runtime/README.md index dd1a0b3d3..d68739bb3 100644 --- a/docker/runtime/README.md +++ b/docker/runtime/README.md @@ -57,12 +57,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/test/e2e-browser/helpers/opencode-auth-file.ts b/test/e2e-browser/helpers/opencode-auth-file.ts index d6fe4c556..8076bcd41 100644 --- a/test/e2e-browser/helpers/opencode-auth-file.ts +++ b/test/e2e-browser/helpers/opencode-auth-file.ts @@ -123,6 +123,9 @@ export function requireOpenCodeOnecliBootstrap( 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 @@ -131,8 +134,12 @@ export function requireOpenCodeOnecliBootstrap( || 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 or CA settings`) + 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/runtime/README.md b/test/runtime/README.md index 85bc1ce18..28d3276c9 100644 --- a/test/runtime/README.md +++ b/test/runtime/README.md @@ -104,9 +104,11 @@ 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`, 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. +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 diff --git a/test/runtime/gate-manifest.json b/test/runtime/gate-manifest.json index 2f28074ce..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 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, and its trusted gateway CA; 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.", + "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/tooling/testing/opencode-auth-file.test.ts b/test/unit/tooling/testing/opencode-auth-file.test.ts index 381fd7985..111a5eaf9 100644 --- a/test/unit/tooling/testing/opencode-auth-file.test.ts +++ b/test/unit/tooling/testing/opencode-auth-file.test.ts @@ -90,6 +90,7 @@ describe('OpenCode OneCLI qualification bootstrap', () => { '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') @@ -121,6 +122,24 @@ describe('OpenCode OneCLI qualification bootstrap', () => { }) }) + 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')) From cbe23bdd756905c205e6f190088c73aa140e5a19 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:22:35 -0700 Subject: [PATCH 03/14] fix(testing): fail fast on OpenCode credential errors --- .../helpers/opencode-native-history.ts | 9 +++- .../runtime-terminal-continuity-rust.spec.ts | 49 +++++++++++++++++-- .../testing/opencode-native-history.test.ts | 4 ++ 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/test/e2e-browser/helpers/opencode-native-history.ts b/test/e2e-browser/helpers/opencode-native-history.ts index ebd20ebfe..fae5ccc86 100644 --- a/test/e2e-browser/helpers/opencode-native-history.ts +++ b/test/e2e-browser/helpers/opencode-native-history.ts @@ -5,9 +5,16 @@ 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|\b(?:http\s*)?401(?:\s+unauthorized)?\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 } 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 9d284af01..4920525f5 100644 --- a/test/e2e-browser/specs/runtime-terminal-continuity-rust.spec.ts +++ b/test/e2e-browser/specs/runtime-terminal-continuity-rust.spec.ts @@ -14,7 +14,7 @@ import path from 'node:path' import { ManagedRuntimeBrowserRig, P2_OPENCODE_MODEL, P2_OPENCODE_VERSION, type ManagedRuntimeView } from '../helpers/managed-runtime.js' import { requireOpenCodeOnecliBootstrap } from '../helpers/opencode-auth-file.js' -import { OPENCODE_NATIVE_HISTORY_SCRIPT, type NativeHistory } from '../helpers/opencode-native-history.js' +import { OPENCODE_NATIVE_HISTORY_SCRIPT, openCodeCredentialFailureMessage, type NativeHistory } 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' @@ -65,6 +65,28 @@ async function waitForValue( throw new Error(`timed out waiting for ${description}${suffix}`) } +async function waitForOpenCodeTerminalCondition(params: { + page: import('@playwright/test').Page + terminal: TerminalHelper + terminalId: string + outputStart?: number + description: string + succeeds: (terminalText: string) => boolean | Promise + timeoutMs: number +}): Promise { + const deadline = Date.now() + params.timeoutMs + while (Date.now() < deadline) { + const terminalText = await params.terminal.getVisibleText(params.terminalId) + if (await params.succeeds(terminalText)) return + const outputStart = Math.min(params.outputStart ?? 0, terminalText.length) + const failure = openCodeCredentialFailureMessage(terminalText.slice(outputStart)) + if (failure) throw new Error(failure) + + await params.page.waitForTimeout(250) + } + throw new Error(`timed out waiting for ${params.description}`) +} + async function readTerminalIdentity(harness: TestHarness): Promise { const tabId = await harness.getActiveTabId() if (!tabId) return null @@ -382,14 +404,31 @@ test.describe.serial('Phase 2 managed runtime continuity', () => { 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({ + page, + terminal, + terminalId, + description: 'the OpenCode prompt', + succeeds: (text) => text.includes('Ask anything...'), + timeoutMs: 90_000, + }) + const firstTurnMarker = 'P2_OPENCODE_FIRST_TURN_DONE' + const firstTurnOutputStart = (await terminal.getVisibleText(terminalId)).length 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({ + page, + terminal, + terminalId, + outputStart: firstTurnOutputStart, + 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/unit/tooling/testing/opencode-native-history.test.ts b/test/unit/tooling/testing/opencode-native-history.test.ts index b7781704d..450b22e74 100644 --- a/test/unit/tooling/testing/opencode-native-history.test.ts +++ b/test/unit/tooling/testing/opencode-native-history.test.ts @@ -39,6 +39,10 @@ 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('Token refresh failed: 403')).toBeNull() expect(openCodeCredentialFailureMessage('GPT-5.6 Luna')).toBeNull() }) From ec0e7d8739c111a146299e8142d10a6215eb591b Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:35:17 -0700 Subject: [PATCH 04/14] fix(testing): detect OpenCode auth failures after redraws --- .../helpers/opencode-native-history.ts | 20 ++++++++++- .../runtime-terminal-continuity-rust.spec.ts | 36 +++---------------- .../testing/opencode-native-history.test.ts | 19 +++++++++- 3 files changed, 42 insertions(+), 33 deletions(-) diff --git a/test/e2e-browser/helpers/opencode-native-history.ts b/test/e2e-browser/helpers/opencode-native-history.ts index fae5ccc86..392d92e9d 100644 --- a/test/e2e-browser/helpers/opencode-native-history.ts +++ b/test/e2e-browser/helpers/opencode-native-history.ts @@ -10,7 +10,7 @@ export function openCodeCredentialFailureMessage(output: string): string | null return 'OpenCode credential refresh was rejected with HTTP 401; provide a currently valid OpenAI auth grant' } if ( - /could not parse your authentication token|\b(?:http\s*)?401(?:\s+unauthorized)?\b|\binvalid (?:openai )?(?:api )?key\b/i + /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' @@ -18,6 +18,24 @@ export function openCodeCredentialFailureMessage(output: string): string | null 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-terminal-continuity-rust.spec.ts b/test/e2e-browser/specs/runtime-terminal-continuity-rust.spec.ts index 4920525f5..620fccf2f 100644 --- a/test/e2e-browser/specs/runtime-terminal-continuity-rust.spec.ts +++ b/test/e2e-browser/specs/runtime-terminal-continuity-rust.spec.ts @@ -14,7 +14,7 @@ import path from 'node:path' import { ManagedRuntimeBrowserRig, P2_OPENCODE_MODEL, P2_OPENCODE_VERSION, type ManagedRuntimeView } from '../helpers/managed-runtime.js' import { requireOpenCodeOnecliBootstrap } from '../helpers/opencode-auth-file.js' -import { OPENCODE_NATIVE_HISTORY_SCRIPT, openCodeCredentialFailureMessage, type NativeHistory } from '../helpers/opencode-native-history.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' @@ -65,28 +65,6 @@ async function waitForValue( throw new Error(`timed out waiting for ${description}${suffix}`) } -async function waitForOpenCodeTerminalCondition(params: { - page: import('@playwright/test').Page - terminal: TerminalHelper - terminalId: string - outputStart?: number - description: string - succeeds: (terminalText: string) => boolean | Promise - timeoutMs: number -}): Promise { - const deadline = Date.now() + params.timeoutMs - while (Date.now() < deadline) { - const terminalText = await params.terminal.getVisibleText(params.terminalId) - if (await params.succeeds(terminalText)) return - const outputStart = Math.min(params.outputStart ?? 0, terminalText.length) - const failure = openCodeCredentialFailureMessage(terminalText.slice(outputStart)) - if (failure) throw new Error(failure) - - await params.page.waitForTimeout(250) - } - throw new Error(`timed out waiting for ${params.description}`) -} - async function readTerminalIdentity(harness: TestHarness): Promise { const tabId = await harness.getActiveTabId() if (!tabId) return null @@ -405,24 +383,20 @@ test.describe.serial('Phase 2 managed runtime continuity', () => { const opencodeIndex = Math.max(0, (await page.locator('.xterm').count()) - 1) await waitForOpenCodeTerminalCondition({ - page, - terminal, - terminalId, + 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' - const firstTurnOutputStart = (await terminal.getVisibleText(terminalId)).length 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 waitForOpenCodeTerminalCondition({ - page, - terminal, - terminalId, - outputStart: firstTurnOutputStart, + 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', diff --git a/test/unit/tooling/testing/opencode-native-history.test.ts b/test/unit/tooling/testing/opencode-native-history.test.ts index 450b22e74..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 @@ -43,10 +43,27 @@ describe('live recovery proves new native assistant responses, never TUI echo or '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) From 425a8a1d59bfbf2d12d291a36846bbe01d907e4b Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:06:44 -0700 Subject: [PATCH 05/14] fix(mcp): keep Freshell API calls outside model proxy --- .../src/provider_secret_resolution.rs | 82 ++++++++++++++++-- docker/runtime/README.md | 4 +- package.json | 1 + packages/freshell-mcp-runtime/package.json | 1 + pnpm-lock.yaml | 9 +- scripts/testing/probe-managed-mcp-image.ts | 34 +++++++- scripts/verify-workspace-peers.mjs | 2 + test/unit/mcp/http-client.test.ts | 86 +++++++++++++++++++ tools/freshell-mcp/http-client.ts | 11 ++- 9 files changed, 212 insertions(+), 18 deletions(-) diff --git a/crates/freshell-session-host/src/provider_secret_resolution.rs b/crates/freshell-session-host/src/provider_secret_resolution.rs index a9de1b74a..5d356424b 100644 --- a/crates/freshell-session-host/src/provider_secret_resolution.rs +++ b/crates/freshell-session-host/src/provider_secret_resolution.rs @@ -193,7 +193,7 @@ fn resolve_profile( fn resolve_provider_environment( parsed: &BTreeMap, provider_names: &[&str], - allow_loopback_no_proxy: bool, + allow_opencode_proxy_settings: bool, ) -> Result, String> { let mut child = BTreeMap::new(); for (name, value) in parsed { @@ -201,20 +201,25 @@ fn resolve_provider_environment( || CERTIFICATE_CHILD_NAMES.contains(&name.as_str()) { child.insert(name.clone(), value.clone()); - } else if matches!( - name.as_str(), - "HTTPS_PROXY" | "https_proxy" | "HTTP_PROXY" | "http_proxy" - ) { + } else if matches!(name.as_str(), "HTTPS_PROXY" | "https_proxy") { reject_proxy_placeholder(value)?; validate_container_proxy(value)?; child.insert(name.clone(), value.clone()); + } 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 value != "1" { - return Err("NODE_USE_ENV_PROXY must be set to 1".into()); + 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()); } - child.insert(name.clone(), value.clone()); } else if matches!(name.as_str(), "NO_PROXY" | "no_proxy") { - if allow_loopback_no_proxy { + if allow_opencode_proxy_settings { validate_loopback_no_proxy(value)?; child.insert(name.clone(), value.clone()); } @@ -548,6 +553,65 @@ mod tests { ); } + #[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 = [ diff --git a/docker/runtime/README.md b/docker/runtime/README.md index d68739bb3..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. 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/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/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/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/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) { From 930e86b0b30b3284a6e215131e1aaf6b1e218b7b Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:10:02 -0700 Subject: [PATCH 06/14] fix(runtime): include the bundled MCP entry in image --- docker/runtime/Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 From 40c5c28bc327f87df853263aca0f4dd2859ead28 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:28:14 -0700 Subject: [PATCH 07/14] test(mcp): classify proxy test listeners --- scripts/retirement/runtime-surfaces.json | 6 ++++++ 1 file changed, 6 insertions(+) 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", From dedf43b71866838dbff90e850d176e26e7b6be21 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:02:56 -0700 Subject: [PATCH 08/14] test(electron): avoid released ephemeral port race --- test/unit/electron/port-check.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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) }) }) From 3662cba1b0bcd3da6afdcb5b31470a601306b301 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:21:27 -0700 Subject: [PATCH 09/14] test(runtime): keep OpenCode recall prompts tool-free --- ...ime-opencode-provider-qualification-rust.spec.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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 2be0a4d10..166dac5bd 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 @@ -429,9 +429,12 @@ test.describe.serial('OpenCode provider qualification', () => { // 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) @@ -465,7 +468,8 @@ test.describe.serial('OpenCode provider qualification', () => { ), 120_000) await waitForReplacementPrompt(page, harness, rig, tabId, first.paneId, afterHostCrash) 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) @@ -499,7 +503,8 @@ test.describe.serial('OpenCode provider qualification', () => { ) await waitForReplacementPrompt(page, harness, rig, tabId, first.paneId, afterProviderCrash) 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) @@ -513,7 +518,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) From 643b0cf9996b540dac5fb45d9b2cbf28902d5563 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:10:20 -0700 Subject: [PATCH 10/14] fix(runtime): preserve recovered terminal stream epochs --- src/lib/recovery/managed-runtime-recovery.ts | 13 +- ...encode-provider-qualification-rust.spec.ts | 185 ++++++++++++++---- .../unit/lib/managed-runtime-recovery.test.ts | 43 ++++ 3 files changed, 200 insertions(+), 41 deletions(-) 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/specs/runtime-opencode-provider-qualification-rust.spec.ts b/test/e2e-browser/specs/runtime-opencode-provider-qualification-rust.spec.ts index 166dac5bd..0f3c982cb 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 @@ -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( @@ -219,58 +240,101 @@ async function waitForReplacementPrompt( tabId: string, paneId: string, view: ManagedRuntimeView, + 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 + ) 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 } } @@ -390,6 +454,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) @@ -408,6 +506,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') } @@ -466,7 +565,10 @@ 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, + observedStreamChanges, observedWebSocketFrameTypes, observedWebSocketCount, + ) const beforeRecall = new Set(nativeAssistantTurns(rig, afterHostCrash, nativeSessionId).map((turn) => turn.messageId)) 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.') @@ -501,7 +603,10 @@ 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, + observedStreamChanges, observedWebSocketFrameTypes, observedWebSocketCount, + ) const beforeProviderFollowup = new Set(nativeAssistantTurns(rig, afterProviderCrash, nativeSessionId).map((turn) => turn.messageId)) 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.') 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' From 28a1e124b799b4d73a066bdcf4d15746f82785ae Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:13:47 -0700 Subject: [PATCH 11/14] test(runtime): reject stale recovered terminal streams --- ...encode-provider-qualification-rust.spec.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) 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 0f3c982cb..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 @@ -233,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, @@ -240,6 +250,7 @@ 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 }, @@ -272,6 +283,7 @@ async function waitForReplacementPrompt( || typeof paneStreamId !== 'string' || paneStreamId.length === 0 || paneStreamId === view.terminalStreamId + || paneStreamId === previousStreamId ) return null const rendered = await page.evaluate((id) => { @@ -466,7 +478,7 @@ test.describe.serial('OpenCode provider qualification', () => { page.on('websocket', (socket) => { if (new URL(socket.url()).pathname !== '/ws') return observedWebSocketCount.value += 1 - socket.on('framereceived', (payload) => { + socket.on('framereceived', ({ payload }) => { try { const message = JSON.parse(String(payload)) as Record if (typeof message.type === 'string') observedWebSocketFrameTypes.add(message.type) @@ -545,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, @@ -566,7 +579,7 @@ test.describe.serial('OpenCode provider qualification', () => { (await paneSessionId(harness, tabId, first.paneId)) === nativeSessionId ? true : null ), 120_000) await waitForReplacementPrompt( - page, harness, rig, tabId, first.paneId, afterHostCrash, + page, harness, rig, tabId, first.paneId, afterHostCrash, beforeHostCrashStreamId, observedStreamChanges, observedWebSocketFrameTypes, observedWebSocketCount, ) const beforeRecall = new Set(nativeAssistantTurns(rig, afterHostCrash, nativeSessionId).map((turn) => turn.messageId)) @@ -580,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', @@ -604,7 +618,7 @@ test.describe.serial('OpenCode provider qualification', () => { afterProviderCrash.incarnationId, ) await waitForReplacementPrompt( - page, harness, rig, tabId, first.paneId, afterProviderCrash, + page, harness, rig, tabId, first.paneId, afterProviderCrash, beforeProviderCrashStreamId, observedStreamChanges, observedWebSocketFrameTypes, observedWebSocketCount, ) const beforeProviderFollowup = new Set(nativeAssistantTurns(rig, afterProviderCrash, nativeSessionId).map((turn) => turn.messageId)) From 19ca3b52f7fa178b661aaaf441d1bbea8809fa5d Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:50:08 -0700 Subject: [PATCH 12/14] fix(runtime): satisfy clippy for provider file paths --- crates/freshell-runtime-protocol/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/freshell-runtime-protocol/src/lib.rs b/crates/freshell-runtime-protocol/src/lib.rs index 402c40276..9cf498b06 100644 --- a/crates/freshell-runtime-protocol/src/lib.rs +++ b/crates/freshell-runtime-protocol/src/lib.rs @@ -960,7 +960,7 @@ impl ProviderSecretProfile { /// 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_else(|| match self { + self.auth_relative_path().or(match self { Self::OpencodeOnecliCaFile => Some(".config/onecli/gateway-ca.pem"), _ => None, }) From cc0605b41cc87d1494c5258d3a40a015c6182959 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:36:06 -0700 Subject: [PATCH 13/14] fix(electron): omit builder-excluded package placeholders --- scripts/prepare-electron-runtime.ts | 29 ++++++++++++++++--- .../electron/prepare-electron-runtime.test.ts | 9 ++++++ 2 files changed, 34 insertions(+), 4 deletions(-) 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/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) => From 90225c3752e63a0d1f8c89d45dd173d2c3f971e5 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:49:44 -0700 Subject: [PATCH 14/14] test(server): make settings fixture paths unique --- crates/freshell-server/src/settings_store.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 ─────────────────────────────────────