diff --git a/crates/jcode-app-core/src/server/client_lifecycle.rs b/crates/jcode-app-core/src/server/client_lifecycle.rs index e450e8c61d..11ae706a8a 100644 --- a/crates/jcode-app-core/src/server/client_lifecycle.rs +++ b/crates/jcode-app-core/src/server/client_lifecycle.rs @@ -99,6 +99,13 @@ fn initial_subscribe_working_dir(request: &Request) -> std::result::Result Vec<(String, String)> { + match request { + Request::Subscribe { terminal_env, .. } => terminal_env.clone(), + _ => Vec::new(), + } +} + struct ProcessingMessage { id: u64, content: String, @@ -468,6 +475,7 @@ pub(super) async fn handle_client( return Ok(()); } }; + let mut active_terminal_env = initial_subscribe_terminal_env(&initial_request); // Per-client state let mut client_is_processing = false; @@ -493,11 +501,15 @@ pub(super) async fn handle_client( // Create a new session for this client let t0 = std::time::Instant::now(); - let mut new_agent = Agent::new_with_initial_working_dir( - Arc::clone(&provider), - registry.clone(), - Some(&initial_working_dir), - ); + let mut new_agent = + crate::hooks::with_client_terminal_env(active_terminal_env.clone(), async { + Agent::new_with_initial_working_dir( + Arc::clone(&provider), + registry.clone(), + Some(&initial_working_dir), + ) + }) + .await; let agent_new_ms = t0.elapsed().as_millis(); new_agent.set_memory_enabled(crate::config::config().features.memory); @@ -1132,6 +1144,7 @@ pub(super) async fn handle_client( &agent, &client_event_tx, &processing_done_tx, + active_terminal_env.clone(), &SwarmStatusRefs { members: &swarm_members, swarms_by_id: &swarms_by_id, @@ -1209,28 +1222,31 @@ pub(super) async fn handle_client( ) { continue; } - handle_clear_session( - id, - client_selfdev, - &mut client_session_id, - &client_connection_id, - &agent, - &provider, - ®istry, - &sessions, - &shutdown_signals, - &soft_interrupt_queues, - &client_connections, - &swarm_members, - &swarms_by_id, - &file_touch, - &channel_subscriptions, - &channel_subscriptions_by_session, - &swarm_plans, - &event_history, - &event_counter, - &swarm_event_tx, - &client_event_tx, + crate::hooks::with_client_terminal_env( + active_terminal_env.clone(), + handle_clear_session( + id, + client_selfdev, + &mut client_session_id, + &client_connection_id, + &agent, + &provider, + ®istry, + &sessions, + &shutdown_signals, + &soft_interrupt_queues, + &client_connections, + &swarm_members, + &swarms_by_id, + &file_touch, + &channel_subscriptions, + &channel_subscriptions_by_session, + &swarm_plans, + &event_history, + &event_counter, + &swarm_event_tx, + &client_event_tx, + ), ) .await; session_control = refresh_session_control_handle( @@ -1418,43 +1434,47 @@ pub(super) async fn handle_client( } } } + active_terminal_env = terminal_env.clone(); if let Some(target_session_id) = target_session_id { if crate::session::session_exists(&target_session_id) { let pre_resume_session_id = client_session_id.clone(); - agent = handle_resume_session( - id, - target_session_id.clone(), - subscribe_working_dir.as_deref(), - client_instance_id.as_deref(), - client_has_local_history, - allow_session_takeover, - &mut client_selfdev, - &mut client_session_id, - &client_connection_id, - &agent, - &provider, - ®istry, - &sessions, - &shutdown_signals, - &soft_interrupt_queues, - &client_connections, - &client_debug_state, - &swarm_members, - &swarms_by_id, - &file_touch, - &channel_subscriptions, - &channel_subscriptions_by_session, - &swarm_plans, - &swarm_coordinators, - &client_count, - &writer, - &server_name, - &server_icon, - &client_event_tx, - &mcp_pool, - &event_history, - &event_counter, - &swarm_event_tx, + agent = crate::hooks::with_client_terminal_env( + active_terminal_env.clone(), + handle_resume_session( + id, + target_session_id.clone(), + subscribe_working_dir.as_deref(), + client_instance_id.as_deref(), + client_has_local_history, + allow_session_takeover, + &mut client_selfdev, + &mut client_session_id, + &client_connection_id, + &agent, + &provider, + ®istry, + &sessions, + &shutdown_signals, + &soft_interrupt_queues, + &client_connections, + &client_debug_state, + &swarm_members, + &swarms_by_id, + &file_touch, + &channel_subscriptions, + &channel_subscriptions_by_session, + &swarm_plans, + &swarm_coordinators, + &client_count, + &writer, + &server_name, + &server_icon, + &client_event_tx, + &mcp_pool, + &event_history, + &event_counter, + &swarm_event_tx, + ), ) .await?; session_control = refresh_session_control_handle( @@ -1659,40 +1679,43 @@ pub(super) async fn handle_client( info.client_instance_id = client_instance_id.clone(); } } - agent = handle_resume_session( - id, - session_id, - resume_working_dir.as_deref(), - client_instance_id.as_deref(), - client_has_local_history, - allow_session_takeover, - &mut client_selfdev, - &mut client_session_id, - &client_connection_id, - &agent, - &provider, - ®istry, - &sessions, - &shutdown_signals, - &soft_interrupt_queues, - &client_connections, - &client_debug_state, - &swarm_members, - &swarms_by_id, - &file_touch, - &channel_subscriptions, - &channel_subscriptions_by_session, - &swarm_plans, - &swarm_coordinators, - &client_count, - &writer, - &server_name, - &server_icon, - &client_event_tx, - &mcp_pool, - &event_history, - &event_counter, - &swarm_event_tx, + agent = crate::hooks::with_client_terminal_env( + active_terminal_env.clone(), + handle_resume_session( + id, + session_id, + resume_working_dir.as_deref(), + client_instance_id.as_deref(), + client_has_local_history, + allow_session_takeover, + &mut client_selfdev, + &mut client_session_id, + &client_connection_id, + &agent, + &provider, + ®istry, + &sessions, + &shutdown_signals, + &soft_interrupt_queues, + &client_connections, + &client_debug_state, + &swarm_members, + &swarms_by_id, + &file_touch, + &channel_subscriptions, + &channel_subscriptions_by_session, + &swarm_plans, + &swarm_coordinators, + &client_count, + &writer, + &server_name, + &server_icon, + &client_event_tx, + &mcp_pool, + &event_history, + &event_counter, + &swarm_event_tx, + ), ) .await?; session_control = refresh_session_control_handle( @@ -2753,28 +2776,31 @@ pub(super) async fn handle_client( } } - cleanup_client_connection( - &sessions, - &client_session_id, - client_is_processing, - &mut processing_task, - event_handle, - &swarm_members, - &swarms_by_id, - &swarm_coordinators, - &swarm_plans, - &file_touch, - &channel_subscriptions, - &channel_subscriptions_by_session, - &client_debug_state, - &client_debug_id, - &client_connections, - &client_connection_id, - &shutdown_signals, - &soft_interrupt_queues, - &event_history, - &event_counter, - &swarm_event_tx, + crate::hooks::with_client_terminal_env( + active_terminal_env, + cleanup_client_connection( + &sessions, + &client_session_id, + client_is_processing, + &mut processing_task, + event_handle, + &swarm_members, + &swarms_by_id, + &swarm_coordinators, + &swarm_plans, + &file_touch, + &channel_subscriptions, + &channel_subscriptions_by_session, + &client_debug_state, + &client_debug_id, + &client_connections, + &client_connection_id, + &shutdown_signals, + &soft_interrupt_queues, + &event_history, + &event_counter, + &swarm_event_tx, + ), ) .await?; Ok(()) @@ -2818,6 +2844,7 @@ async fn start_processing_message( agent: &Arc>, client_event_tx: &mpsc::UnboundedSender, processing_done_tx: &mpsc::UnboundedSender<(u64, Result<()>, Option)>, + client_terminal_env: Vec<(String, String)>, swarm: &SwarmStatusRefs<'_>, ) { let ProcessingMessage { @@ -2888,12 +2915,9 @@ async fn start_processing_message( crate::logging::info(&format!("Processing message id={} spawning task", id)); *state.task = Some(tokio::spawn(async move { let event_tx = tx.clone(); - let result = match std::panic::AssertUnwindSafe(process_message_streaming_mpsc( - agent, - &content, - images, - system_reminder, - event_tx, + let result = match std::panic::AssertUnwindSafe(crate::hooks::with_client_terminal_env( + client_terminal_env, + process_message_streaming_mpsc(agent, &content, images, system_reminder, event_tx), )) .catch_unwind() .await diff --git a/crates/jcode-base/src/config.rs b/crates/jcode-base/src/config.rs index 0379be4419..7ac9ab7a6f 100644 --- a/crates/jcode-base/src/config.rs +++ b/crates/jcode-base/src/config.rs @@ -6,12 +6,13 @@ pub use jcode_config_types::{ AgentsConfig, AmbientConfig, AuthConfig, AutoJudgeConfig, AutoReviewConfig, CompactionConfig, CompactionMode, CrossProviderFailoverMode, DiagramDisplayMode, DiagramPanePosition, - DiffDisplayMode, DisplayConfig, FeatureConfig, GatewayConfig, HooksConfig, KeybindingsConfig, - LatexRenderingMode, LaunchHotkeyEntry, LaunchHotkeysConfig, MarkdownSpacingMode, - NamedProviderAuth, NamedProviderConfig, NamedProviderModelConfig, NamedProviderType, - NativeScrollbarConfig, NotificationsConfig, OverscrollStatusMode, PowerConfig, ProviderConfig, - ReasoningDisplayMode, SafetyConfig, SessionPickerResumeAction, SponsorsConfig, SwarmSpawnMode, - SwarmStripLayout, TerminalConfig, UpdateChannel, WebSearchConfig, WebSearchEngine, + DiffDisplayMode, DisplayConfig, FeatureConfig, GatewayConfig, HookCommands, HooksConfig, + KeybindingsConfig, LatexRenderingMode, LaunchHotkeyEntry, LaunchHotkeysConfig, + MarkdownSpacingMode, NamedProviderAuth, NamedProviderConfig, NamedProviderModelConfig, + NamedProviderType, NativeScrollbarConfig, NotificationsConfig, OverscrollStatusMode, + PowerConfig, ProviderConfig, ReasoningDisplayMode, SafetyConfig, SessionPickerResumeAction, + SponsorsConfig, SwarmSpawnMode, SwarmStripLayout, TerminalConfig, UpdateChannel, + WebSearchConfig, WebSearchEngine, }; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet, HashSet}; diff --git a/crates/jcode-base/src/config/env_overrides.rs b/crates/jcode-base/src/config/env_overrides.rs index e8aed3da38..c990c59ac7 100644 --- a/crates/jcode-base/src/config/env_overrides.rs +++ b/crates/jcode-base/src/config/env_overrides.rs @@ -431,13 +431,23 @@ impl Config { } // Lifecycle hooks. Empty env values disable config-file hooks. - fn hook_env_override(slot: &mut Option, key: &str) { + fn hook_env_override(slot: &mut Option, key: &str) { if let Ok(v) = std::env::var(key) { let trimmed = v.trim(); *slot = if trimmed.is_empty() { None + } else if trimmed.starts_with('[') { + #[derive(serde::Deserialize)] + struct HookOverride { + commands: HookCommands, + } + + toml::from_str::(&format!("commands = {trimmed}")) + .map(|parsed| parsed.commands) + .ok() + .or_else(|| Some(HookCommands::one(trimmed))) } else { - Some(trimmed.to_string()) + Some(HookCommands::one(trimmed)) }; } } diff --git a/crates/jcode-base/src/config_tests.rs b/crates/jcode-base/src/config_tests.rs index 4d7fa184b2..b6e745add4 100644 --- a/crates/jcode-base/src/config_tests.rs +++ b/crates/jcode-base/src/config_tests.rs @@ -1,6 +1,6 @@ use super::{ - AmbientConfig, Config, DiffDisplayMode, DisplayConfig, LatexRenderingMode, ProviderConfig, - SessionPickerResumeAction, SwarmSpawnMode, ToolConfig, config_env_fingerprint, + AmbientConfig, Config, DiffDisplayMode, DisplayConfig, HookCommands, LatexRenderingMode, + ProviderConfig, SessionPickerResumeAction, SwarmSpawnMode, ToolConfig, config_env_fingerprint, populate_context_limits_from_config_ref, }; use std::ffi::OsString; @@ -241,10 +241,27 @@ fn hooks_config_defaults_and_parses_from_toml() { "[hooks]\nturn_start = \"notify-start\"\nturn_end = \"notify-turn\"\npre_tool = \"~/bin/policy\"\npre_tool_timeout_ms = 1500\n", ) .expect("hooks config should parse"); - assert_eq!(cfg.hooks.turn_start.as_deref(), Some("notify-start")); - assert_eq!(cfg.hooks.turn_end.as_deref(), Some("notify-turn")); - assert_eq!(cfg.hooks.pre_tool.as_deref(), Some("~/bin/policy")); + assert_eq!( + cfg.hooks.turn_start.as_ref().and_then(HookCommands::first), + Some("notify-start") + ); + assert_eq!( + cfg.hooks.turn_end.as_ref().and_then(HookCommands::first), + Some("notify-turn") + ); + assert_eq!( + cfg.hooks.pre_tool.as_ref().and_then(HookCommands::first), + Some("~/bin/policy") + ); assert_eq!(cfg.hooks.pre_tool_timeout_ms, 1500); + + let cfg: Config = + toml::from_str("[hooks]\nsession_start = [\"notify-user\", \"notify-herdr\"]\n") + .expect("hook arrays should parse"); + assert_eq!( + cfg.hooks.session_start.unwrap().iter().collect::>(), + vec!["notify-user", "notify-herdr"] + ); } #[test] @@ -257,16 +274,27 @@ fn test_env_override_lifecycle_hooks() { crate::env::set_var("JCODE_HOOK_PRE_TOOL_TIMEOUT_MS", "250"); let mut cfg = Config::default(); cfg.apply_env_overrides(); - assert_eq!(cfg.hooks.turn_end.as_deref(), Some("my-notifier --fast")); + assert_eq!( + cfg.hooks.turn_end.as_ref().and_then(HookCommands::first), + Some("my-notifier --fast") + ); assert_eq!(cfg.hooks.pre_tool_timeout_ms, 250); // Empty env value disables a config-file hook. crate::env::set_var("JCODE_HOOK_TURN_END", " "); let mut cfg = Config::default(); - cfg.hooks.turn_end = Some("from-config".to_string()); + cfg.hooks.turn_end = Some(HookCommands::one("from-config")); cfg.apply_env_overrides(); assert_eq!(cfg.hooks.turn_end, None); + crate::env::set_var("JCODE_HOOK_TURN_END", "[\"first\", \"second\"]"); + let mut cfg = Config::default(); + cfg.apply_env_overrides(); + assert_eq!( + cfg.hooks.turn_end.unwrap().iter().collect::>(), + vec!["first", "second"] + ); + restore_env_var("JCODE_HOOK_TURN_END", prev_turn_end); restore_env_var("JCODE_HOOK_PRE_TOOL_TIMEOUT_MS", prev_timeout); } diff --git a/crates/jcode-base/src/hooks.rs b/crates/jcode-base/src/hooks.rs index 34d0471ac4..074934469a 100644 --- a/crates/jcode-base/src/hooks.rs +++ b/crates/jcode-base/src/hooks.rs @@ -23,6 +23,10 @@ use std::path::PathBuf; +tokio::task_local! { + static CLIENT_TERMINAL_ENV: Vec<(String, String)>; +} + /// Maximum bytes of JSON payload exported via `JCODE_HOOK_PAYLOAD`. const PAYLOAD_ENV_LIMIT: usize = 16 * 1024; /// Maximum bytes of tool input JSON exported to the pre_tool gate. @@ -76,30 +80,49 @@ impl HookEvent { } } -/// The configured command for `event`, if any. -pub fn hook_command(event: &str) -> Option { +/// Run `future` with the terminal identity of the client that initiated it. +/// +/// Shared-server request handlers use this to keep lifecycle hooks scoped to +/// the requesting pane instead of the environment inherited by the server. +pub async fn with_client_terminal_env(env: Vec<(String, String)>, future: F) -> F::Output +where + F: std::future::Future, +{ + CLIENT_TERMINAL_ENV.scope(env, future).await +} + +/// The configured commands for `event`, in declaration order. +pub fn hook_commands(event: &str) -> Vec { if hooks_suppressed() { - return None; + return Vec::new(); } let hooks = &crate::config::config().hooks; let raw = match event { - "turn_start" => hooks.turn_start.as_deref(), - "turn_end" => hooks.turn_end.as_deref(), - "session_start" => hooks.session_start.as_deref(), - "session_end" => hooks.session_end.as_deref(), - "pre_tool" => hooks.pre_tool.as_deref(), - "post_tool" => hooks.post_tool.as_deref(), + "turn_start" => hooks.turn_start.as_ref(), + "turn_end" => hooks.turn_end.as_ref(), + "session_start" => hooks.session_start.as_ref(), + "session_end" => hooks.session_end.as_ref(), + "pre_tool" => hooks.pre_tool.as_ref(), + "post_tool" => hooks.post_tool.as_ref(), _ => None, }; - raw.map(str::trim) + raw.into_iter() + .flat_map(|commands| commands.iter()) + .map(str::trim) .filter(|command| !command.is_empty()) - .map(str::to_string) + .map(str::to_owned) + .collect() +} + +/// The first configured command for `event`, retained for scalar callers. +pub fn hook_command(event: &str) -> Option { + hook_commands(event).into_iter().next() } /// Whether a hook is configured for `event`. Cheap; used by hot paths to /// skip payload construction entirely when no hook is set. pub fn hook_configured(event: &str) -> bool { - hook_command(event).is_some() + !hook_commands(event).is_empty() } /// True when running inside a hook process (recursion guard). @@ -184,6 +207,9 @@ fn build_hook_process( cmd.current_dir(cwd); } apply_event_env(&mut cmd, event); + let _ = CLIENT_TERMINAL_ENV.try_with(|env| { + crate::terminal_launch::apply_client_terminal_env(&mut cmd, env); + }); Ok(cmd) } @@ -192,28 +218,31 @@ fn build_hook_process( /// Detached and fire-and-forget: failures are logged, never propagated, and /// the hook process cannot block the agent. pub fn dispatch_observer(event: HookEvent) { - let Some(command_line) = hook_command(event.event) else { + let command_lines = hook_commands(event.event); + if command_lines.is_empty() { return; - }; + } let event_name = event.event; - match build_hook_process(&command_line, &event) { - Ok(mut cmd) => { - cmd.stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()); - match crate::platform::spawn_detached(&mut cmd) { - Ok(_) => crate::logging::debug(&format!( - "Hook '{event_name}' dispatched to '{command_line}' (session={:?})", - event.session_id - )), - Err(error) => crate::logging::warn(&format!( - "Hook '{event_name}' command '{command_line}' failed to start: {error}" - )), + for command_line in command_lines { + match build_hook_process(&command_line, &event) { + Ok(mut cmd) => { + cmd.stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + match crate::platform::spawn_detached(&mut cmd) { + Ok(_) => crate::logging::debug(&format!( + "Hook '{event_name}' dispatched to '{command_line}' (session={:?})", + event.session_id + )), + Err(error) => crate::logging::warn(&format!( + "Hook '{event_name}' command '{command_line}' failed to start: {error}" + )), + } } + Err(error) => crate::logging::warn(&format!( + "Hook '{event_name}' command '{command_line}' is invalid: {error}" + )), } - Err(error) => crate::logging::warn(&format!( - "Hook '{event_name}' command '{command_line}' is invalid: {error}" - )), } } @@ -231,9 +260,10 @@ pub async fn run_pre_tool_gate( tool_name: &str, tool_input_json: &str, ) -> GateDecision { - let Some(command_line) = hook_command("pre_tool") else { + let command_lines = hook_commands("pre_tool"); + if command_lines.is_empty() { return GateDecision::Allow; - }; + } let mut event = HookEvent::new("pre_tool") .session_id(session_id) @@ -246,7 +276,24 @@ pub async fn run_pre_tool_gate( event = event.cwd(cwd); } - let std_cmd = match build_hook_process(&command_line, &event) { + let mut decision = GateDecision::Allow; + for command_line in command_lines { + let current = run_pre_tool_command(&command_line, &event, tool_name, tool_input_json).await; + if matches!(current, GateDecision::Block { .. }) && decision == GateDecision::Allow { + decision = current; + } + } + decision +} + +async fn run_pre_tool_command( + command_line: &str, + event: &HookEvent, + tool_name: &str, + tool_input_json: &str, +) -> GateDecision { + let session_id = event.session_id.as_deref().unwrap_or("unknown"); + let std_cmd = match build_hook_process(command_line, event) { Ok(cmd) => cmd, Err(error) => { crate::logging::warn(&format!( @@ -514,4 +561,78 @@ mod tests { } assert_eq!(recorded, "turn_end|ses_obs|ok|1"); } + + #[cfg(unix)] + #[test] + fn observer_dispatch_runs_each_configured_command() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new().expect("temp dir"); + let first_record = temp.path().join("first.txt"); + let second_record = temp.path().join("second.txt"); + let first = write_executable_script( + temp.path(), + "first.sh", + &format!( + "#!/bin/sh\nprintf first > {}\n", + crate::terminal_launch::sh_escape(&first_record.to_string_lossy()) + ), + ); + let second = write_executable_script( + temp.path(), + "second.sh", + &format!( + "#!/bin/sh\nprintf second > {}\n", + crate::terminal_launch::sh_escape(&second_record.to_string_lossy()) + ), + ); + let previous = std::env::var_os("JCODE_HOOK_SESSION_START"); + crate::env::set_var( + "JCODE_HOOK_SESSION_START", + format!( + "[{:?}, {:?}]", + first.to_string_lossy(), + second.to_string_lossy() + ), + ); + + dispatch_observer(HookEvent::new("session_start").session_id("ses_multi")); + for _ in 0..100 { + if first_record.exists() && second_record.exists() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + match previous { + Some(value) => crate::env::set_var("JCODE_HOOK_SESSION_START", value), + None => crate::env::remove_var("JCODE_HOOK_SESSION_START"), + } + assert_eq!(std::fs::read_to_string(first_record).unwrap(), "first"); + assert_eq!(std::fs::read_to_string(second_record).unwrap(), "second"); + } + + #[tokio::test] + async fn concurrent_client_terminal_environments_remain_isolated() { + async fn pane_id(env: Vec<(String, String)>) -> Option { + with_client_terminal_env(env, async { + let event = HookEvent::new("session_start"); + let command = build_hook_process("hook", &event).unwrap(); + command.get_envs().find_map(|(key, value)| { + (key == "HERDR_PANE_ID") + .then(|| value.map(|value| value.to_string_lossy().into_owned())) + .flatten() + }) + }) + .await + } + + let (left, right) = tokio::join!( + pane_id(vec![("HERDR_PANE_ID".to_string(), "pane-left".to_string())]), + pane_id(vec![( + "HERDR_PANE_ID".to_string(), + "pane-right".to_string() + )]), + ); + assert_eq!(left.as_deref(), Some("pane-left")); + assert_eq!(right.as_deref(), Some("pane-right")); + } } diff --git a/crates/jcode-base/src/terminal_launch.rs b/crates/jcode-base/src/terminal_launch.rs index ef9bf3616d..f2c09dbc38 100644 --- a/crates/jcode-base/src/terminal_launch.rs +++ b/crates/jcode-base/src/terminal_launch.rs @@ -1,8 +1,8 @@ use anyhow::Result; pub use jcode_terminal_launch::{ - SpawnAttempt, TerminalCommand, build_hook_spawn_command, detected_resume_terminal, expand_home, - parse_hook_command, resume_terminal_candidates, sh_escape, shell_command, - snapshot_client_terminal_env, spawn_command_in_new_terminal_with, + SpawnAttempt, TerminalCommand, apply_client_terminal_env, build_hook_spawn_command, + detected_resume_terminal, expand_home, parse_hook_command, resume_terminal_candidates, + sh_escape, shell_command, snapshot_client_terminal_env, spawn_command_in_new_terminal_with, }; use std::path::Path; diff --git a/crates/jcode-config-types/src/lib.rs b/crates/jcode-config-types/src/lib.rs index 902ca0f87c..20555dd961 100644 --- a/crates/jcode-config-types/src/lib.rs +++ b/crates/jcode-config-types/src/lib.rs @@ -766,6 +766,59 @@ pub struct TerminalConfig { /// failures only logged. `pre_tool` is a gate: jcode waits for it and exit /// code 2 blocks the tool call (stderr becomes the error shown to the model); /// exit 0 allows; anything else fails open. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HookCommands(Vec); + +impl HookCommands { + pub fn one(command: impl Into) -> Self { + Self(vec![command.into()]) + } + + pub fn many(commands: Vec) -> Self { + Self(commands) + } + + pub fn iter(&self) -> impl Iterator { + self.0.iter().map(String::as_str) + } + + pub fn first(&self) -> Option<&str> { + self.0.first().map(String::as_str) + } +} + +impl Serialize for HookCommands { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + if let [command] = self.0.as_slice() { + command.serialize(serializer) + } else { + self.0.serialize(serializer) + } + } +} + +impl<'de> Deserialize<'de> for HookCommands { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum OneOrMany { + One(String), + Many(Vec), + } + + Ok(match OneOrMany::deserialize(deserializer)? { + OneOrMany::One(command) => Self::one(command), + OneOrMany::Many(commands) => Self::many(commands), + }) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct HooksConfig { @@ -774,27 +827,27 @@ pub struct HooksConfig { /// so integrations can detect that the agent is actively working even while /// it is only thinking/streaming text. Fields: MODEL, SOURCE /// ("chat"/"resume"/"ambient"). Env override: JCODE_HOOK_TURN_START. - pub turn_start: Option, + pub turn_start: Option, /// Runs when an agent turn completes. /// Fields: STATUS ("ok"/"error"), DURATION_MS, MODEL, LAST_ASSISTANT_TEXT. /// Env override: JCODE_HOOK_TURN_END. - pub turn_end: Option, + pub turn_end: Option, /// Runs when a session becomes active (created or resumed). /// Fields: SOURCE ("create"/"resume"). /// Env override: JCODE_HOOK_SESSION_START. - pub session_start: Option, + pub session_start: Option, /// Runs when a session closes normally. /// Env override: JCODE_HOOK_SESSION_END. - pub session_end: Option, + pub session_end: Option, /// Gate hook before each tool call. Receives TOOL_NAME and the tool input /// JSON on stdin (also truncated in TOOL_INPUT). Exit 0 allows, exit 2 /// blocks (stderr is fed back to the model), anything else fails open. /// Env override: JCODE_HOOK_PRE_TOOL. - pub pre_tool: Option, + pub pre_tool: Option, /// Runs after each tool call completes. /// Fields: TOOL_NAME, STATUS ("ok"/"error"), DURATION_MS, OUTPUT_BYTES. /// Env override: JCODE_HOOK_POST_TOOL. - pub post_tool: Option, + pub post_tool: Option, /// Max milliseconds to wait for the pre_tool gate before failing open /// (default: 5000). Env override: JCODE_HOOK_PRE_TOOL_TIMEOUT_MS. pub pre_tool_timeout_ms: u64, diff --git a/crates/jcode-terminal-launch/src/lib.rs b/crates/jcode-terminal-launch/src/lib.rs index 511d2408f1..1ab691cd2e 100644 --- a/crates/jcode-terminal-launch/src/lib.rs +++ b/crates/jcode-terminal-launch/src/lib.rs @@ -146,6 +146,23 @@ pub fn snapshot_client_terminal_env() -> Vec<(String, String)> { .collect() } +/// Replace inherited terminal identity with an authoritative client snapshot. +/// +/// Removing every known key first is important for a shared server: an empty +/// client snapshot must not leak the pane that happened to start the server. +/// Aliases let integrations explicitly distinguish client values from other +/// process environment while native names preserve existing hook behavior. +pub fn apply_client_terminal_env(cmd: &mut Command, env: &[(String, String)]) { + for key in CLIENT_TERMINAL_ENV_VARS { + cmd.env_remove(key); + cmd.env_remove(format!("JCODE_CLIENT_{key}")); + } + for (key, value) in env { + cmd.env(key, value); + cmd.env(format!("JCODE_CLIENT_{key}"), value); + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct SpawnAttempt { pub terminal: String, @@ -824,6 +841,32 @@ mod tests { static ENV_LOCK: Mutex<()> = Mutex::new(()); + #[test] + fn client_terminal_env_replaces_inherited_identity_and_exports_aliases() { + let mut command = Command::new("hook"); + command.env("HERDR_PANE_ID", "stale-pane"); + command.env("TMUX_PANE", "stale-tmux"); + apply_client_terminal_env( + &mut command, + &[("HERDR_PANE_ID".to_string(), "client-pane".to_string())], + ); + let env = command + .get_envs() + .map(|(key, value)| { + ( + key.to_string_lossy().into_owned(), + value.map(|value| value.to_string_lossy().into_owned()), + ) + }) + .collect::>(); + assert_eq!(env["HERDR_PANE_ID"].as_deref(), Some("client-pane")); + assert_eq!( + env["JCODE_CLIENT_HERDR_PANE_ID"].as_deref(), + Some("client-pane") + ); + assert_eq!(env["TMUX_PANE"], None); + } + #[test] fn spawn_metadata_env_reexports_client_terminal_env_with_native_and_client_keys() { // A spawn carrying the requesting client's terminal env (#405) should