From bb76c41e7f96d6418879a07eed8ae03506ef6bb0 Mon Sep 17 00:00:00 2001 From: celia-oai Date: Tue, 24 Feb 2026 22:38:56 -0800 Subject: [PATCH] changes --- codex-rs/app-server-test-client/src/lib.rs | 236 ++++++++++++++++++--- 1 file changed, 204 insertions(+), 32 deletions(-) diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index f5a7fa9c44..9d490d6a5c 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -2,6 +2,7 @@ use std::collections::VecDeque; use std::ffi::OsString; use std::fs; use std::fs::OpenOptions; +use std::io; use std::io::BufRead; use std::io::BufReader; use std::io::Write; @@ -32,6 +33,7 @@ use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; use codex_app_server_protocol::CommandExecutionStatus; use codex_app_server_protocol::DynamicToolSpec; +use codex_app_server_protocol::ExecPolicyAmendment; use codex_app_server_protocol::FileChangeApprovalDecision; use codex_app_server_protocol::FileChangeRequestApprovalParams; use codex_app_server_protocol::FileChangeRequestApprovalResponse; @@ -146,10 +148,28 @@ struct Cli { #[arg(long, value_name = "json-or-@file", global = true)] dynamic_tools: Option, + /// Attach a skill input item to V2 turn/start requests. + /// + /// Must be paired with --skill-path. + #[arg(long, value_name = "skill-name", global = true)] + skill_name: Option, + + /// Path to the SKILL.md file for --skill-name. + /// + /// Must be paired with --skill-name. + #[arg(long, value_name = "path-to-skill-md", global = true)] + skill_path: Option, + #[command(subcommand)] command: CliCommand, } +#[derive(Clone, Debug)] +struct SkillSelection { + name: String, + path: PathBuf, +} + #[derive(Subcommand)] enum CliCommand { /// Start `codex app-server` on a websocket endpoint in the background. @@ -247,19 +267,24 @@ pub fn run() -> Result<()> { url, config_overrides, dynamic_tools, + skill_name, + skill_path, command, } = Cli::parse(); let dynamic_tools = parse_dynamic_tools_arg(&dynamic_tools)?; + let skill_selection = parse_skill_selection(skill_name, skill_path)?; match command { CliCommand::Serve { listen, kill } => { ensure_dynamic_tools_unused(&dynamic_tools, "serve")?; + ensure_skill_unused(&skill_selection, "serve")?; let codex_bin = codex_bin.unwrap_or_else(|| PathBuf::from("codex")); serve(&codex_bin, &config_overrides, &listen, kill) } CliCommand::SendMessage { user_message } => { ensure_dynamic_tools_unused(&dynamic_tools, "send-message")?; + ensure_skill_unused(&skill_selection, "send-message")?; let endpoint = resolve_endpoint(codex_bin, url)?; send_message(&endpoint, &config_overrides, user_message) } @@ -274,6 +299,7 @@ pub fn run() -> Result<()> { user_message, experimental_api, &dynamic_tools, + skill_selection.as_ref(), ) } CliCommand::ResumeMessageV2 { @@ -287,24 +313,43 @@ pub fn run() -> Result<()> { thread_id, user_message, &dynamic_tools, + skill_selection.as_ref(), ) } CliCommand::ThreadResume { thread_id } => { ensure_dynamic_tools_unused(&dynamic_tools, "thread-resume")?; + ensure_skill_unused(&skill_selection, "thread-resume")?; let endpoint = resolve_endpoint(codex_bin, url)?; thread_resume_follow(&endpoint, &config_overrides, thread_id) } CliCommand::TriggerCmdApproval { user_message } => { let endpoint = resolve_endpoint(codex_bin, url)?; - trigger_cmd_approval(&endpoint, &config_overrides, user_message, &dynamic_tools) + trigger_cmd_approval( + &endpoint, + &config_overrides, + user_message, + &dynamic_tools, + skill_selection.as_ref(), + ) } CliCommand::TriggerPatchApproval { user_message } => { let endpoint = resolve_endpoint(codex_bin, url)?; - trigger_patch_approval(&endpoint, &config_overrides, user_message, &dynamic_tools) + trigger_patch_approval( + &endpoint, + &config_overrides, + user_message, + &dynamic_tools, + skill_selection.as_ref(), + ) } CliCommand::NoTriggerCmdApproval => { let endpoint = resolve_endpoint(codex_bin, url)?; - no_trigger_cmd_approval(&endpoint, &config_overrides, &dynamic_tools) + no_trigger_cmd_approval( + &endpoint, + &config_overrides, + &dynamic_tools, + skill_selection.as_ref(), + ) } CliCommand::SendFollowUpV2 { first_message, @@ -317,6 +362,7 @@ pub fn run() -> Result<()> { first_message, follow_up_message, &dynamic_tools, + skill_selection.as_ref(), ) } CliCommand::TriggerZshForkMultiCmdApproval { @@ -336,21 +382,25 @@ pub fn run() -> Result<()> { } CliCommand::TestLogin => { ensure_dynamic_tools_unused(&dynamic_tools, "test-login")?; + ensure_skill_unused(&skill_selection, "test-login")?; let endpoint = resolve_endpoint(codex_bin, url)?; test_login(&endpoint, &config_overrides) } CliCommand::GetAccountRateLimits => { ensure_dynamic_tools_unused(&dynamic_tools, "get-account-rate-limits")?; + ensure_skill_unused(&skill_selection, "get-account-rate-limits")?; let endpoint = resolve_endpoint(codex_bin, url)?; get_account_rate_limits(&endpoint, &config_overrides) } CliCommand::ModelList => { ensure_dynamic_tools_unused(&dynamic_tools, "model-list")?; + ensure_skill_unused(&skill_selection, "model-list")?; let endpoint = resolve_endpoint(codex_bin, url)?; model_list(&endpoint, &config_overrides) } CliCommand::ThreadList { limit } => { ensure_dynamic_tools_unused(&dynamic_tools, "thread-list")?; + ensure_skill_unused(&skill_selection, "thread-list")?; let endpoint = resolve_endpoint(codex_bin, url)?; thread_list(&endpoint, &config_overrides, limit) } @@ -526,6 +576,7 @@ pub fn send_message_v2( user_message, true, dynamic_tools, + None, ) } @@ -535,6 +586,7 @@ fn send_message_v2_endpoint( user_message: String, experimental_api: bool, dynamic_tools: &Option>, + skill_selection: Option<&SkillSelection>, ) -> Result<()> { if dynamic_tools.is_some() && !experimental_api { bail!("--dynamic-tools requires --experimental-api for send-message-v2"); @@ -548,6 +600,7 @@ fn send_message_v2_endpoint( None, None, dynamic_tools, + skill_selection, ) } @@ -652,6 +705,7 @@ fn resume_message_v2( thread_id: String, user_message: String, dynamic_tools: &Option>, + skill_selection: Option<&SkillSelection>, ) -> Result<()> { ensure_dynamic_tools_unused(dynamic_tools, "resume-message-v2")?; @@ -668,10 +722,7 @@ fn resume_message_v2( let turn_response = client.turn_start(TurnStartParams { thread_id: resume_response.thread.id.clone(), - input: vec![V2UserInput::Text { - text: user_message, - text_elements: Vec::new(), - }], + input: build_v2_input(user_message, skill_selection), ..Default::default() })?; println!("< turn/start response: {turn_response:?}"); @@ -706,6 +757,7 @@ fn trigger_cmd_approval( config_overrides: &[String], user_message: Option, dynamic_tools: &Option>, + skill_selection: Option<&SkillSelection>, ) -> Result<()> { let default_prompt = "Run `touch /tmp/should-trigger-approval` so I can confirm the file exists."; @@ -720,6 +772,7 @@ fn trigger_cmd_approval( access: ReadOnlyAccess::FullAccess, }), dynamic_tools, + skill_selection, ) } @@ -728,6 +781,7 @@ fn trigger_patch_approval( config_overrides: &[String], user_message: Option, dynamic_tools: &Option>, + skill_selection: Option<&SkillSelection>, ) -> Result<()> { let default_prompt = "Create a file named APPROVAL_DEMO.txt containing a short hello message using apply_patch."; @@ -742,6 +796,7 @@ fn trigger_patch_approval( access: ReadOnlyAccess::FullAccess, }), dynamic_tools, + skill_selection, ) } @@ -749,6 +804,7 @@ fn no_trigger_cmd_approval( endpoint: &Endpoint, config_overrides: &[String], dynamic_tools: &Option>, + skill_selection: Option<&SkillSelection>, ) -> Result<()> { let prompt = "Run `touch should_not_trigger_approval.txt`"; send_message_v2_with_policies( @@ -759,6 +815,7 @@ fn no_trigger_cmd_approval( None, None, dynamic_tools, + skill_selection, ) } @@ -770,6 +827,7 @@ fn send_message_v2_with_policies( approval_policy: Option, sandbox_policy: Option, dynamic_tools: &Option>, + skill_selection: Option<&SkillSelection>, ) -> Result<()> { let mut client = CodexClient::connect(endpoint, config_overrides)?; @@ -783,11 +841,7 @@ fn send_message_v2_with_policies( println!("< thread/start response: {thread_response:?}"); let mut turn_params = TurnStartParams { thread_id: thread_response.thread.id.clone(), - input: vec![V2UserInput::Text { - text: user_message, - // Test client sends plain text without UI element ranges. - text_elements: Vec::new(), - }], + input: build_v2_input(user_message, skill_selection), ..Default::default() }; turn_params.approval_policy = approval_policy; @@ -807,6 +861,7 @@ fn send_follow_up_v2( first_message: String, follow_up_message: String, dynamic_tools: &Option>, + skill_selection: Option<&SkillSelection>, ) -> Result<()> { let mut client = CodexClient::connect(endpoint, config_overrides)?; @@ -821,11 +876,7 @@ fn send_follow_up_v2( let first_turn_params = TurnStartParams { thread_id: thread_response.thread.id.clone(), - input: vec![V2UserInput::Text { - text: first_message, - // Test client sends plain text without UI element ranges. - text_elements: Vec::new(), - }], + input: build_v2_input(first_message, skill_selection), ..Default::default() }; let first_turn_response = client.turn_start(first_turn_params)?; @@ -834,11 +885,7 @@ fn send_follow_up_v2( let follow_up_params = TurnStartParams { thread_id: thread_response.thread.id.clone(), - input: vec![V2UserInput::Text { - text: follow_up_message, - // Test client sends plain text without UI element ranges. - text_elements: Vec::new(), - }], + input: build_v2_input(follow_up_message, skill_selection), ..Default::default() }; let follow_up_response = client.turn_start(follow_up_params)?; @@ -934,6 +981,51 @@ fn ensure_dynamic_tools_unused( Ok(()) } +fn ensure_skill_unused(skill_selection: &Option, command: &str) -> Result<()> { + if skill_selection.is_some() { + bail!( + "skill input is only supported for v2 turn/start commands; remove --skill-name/--skill-path for {command}" + ); + } + Ok(()) +} + +fn parse_skill_selection( + skill_name: Option, + skill_path: Option, +) -> Result> { + match (skill_name, skill_path) { + (None, None) => Ok(None), + (Some(name), Some(path)) => { + let path = fs::canonicalize(&path) + .with_context(|| format!("canonicalize --skill-path {}", path.display()))?; + Ok(Some(SkillSelection { name, path })) + } + (Some(_), None) => bail!("--skill-name requires --skill-path"), + (None, Some(_)) => bail!("--skill-path requires --skill-name"), + } +} + +fn build_v2_input( + user_message: String, + skill_selection: Option<&SkillSelection>, +) -> Vec { + let mut input = vec![V2UserInput::Text { + text: user_message, + // Test client sends plain text without UI element ranges. + text_elements: Vec::new(), + }]; + + if let Some(skill_selection) = skill_selection { + input.push(V2UserInput::Skill { + name: skill_selection.name.clone(), + path: skill_selection.path.clone(), + }); + } + + input +} + fn parse_dynamic_tools_arg(dynamic_tools: &Option) -> Result>> { let Some(raw_arg) = dynamic_tools.as_deref() else { return Ok(None); @@ -980,6 +1072,7 @@ struct CodexClient { #[derive(Debug, Clone, Copy)] enum CommandApprovalBehavior { + Prompt, AlwaysAccept, AbortOn(usize), } @@ -1030,7 +1123,7 @@ impl CodexClient { stdout: BufReader::new(stdout), }, pending_notifications: VecDeque::new(), - command_approval_behavior: CommandApprovalBehavior::AlwaysAccept, + command_approval_behavior: CommandApprovalBehavior::Prompt, command_approval_count: 0, command_approval_item_ids: Vec::new(), command_execution_statuses: Vec::new(), @@ -1051,7 +1144,7 @@ impl CodexClient { socket: Box::new(socket), }, pending_notifications: VecDeque::new(), - command_approval_behavior: CommandApprovalBehavior::AlwaysAccept, + command_approval_behavior: CommandApprovalBehavior::Prompt, command_approval_count: 0, command_approval_item_ids: Vec::new(), command_execution_statuses: Vec::new(), @@ -1571,19 +1664,20 @@ impl CodexClient { } let decision = match self.command_approval_behavior { + CommandApprovalBehavior::Prompt => { + self.command_approval_decision(proposed_execpolicy_amendment)? + } CommandApprovalBehavior::AlwaysAccept => CommandExecutionApprovalDecision::Accept, CommandApprovalBehavior::AbortOn(index) if self.command_approval_count == index => { CommandExecutionApprovalDecision::Cancel } CommandApprovalBehavior::AbortOn(_) => CommandExecutionApprovalDecision::Accept, }; - let response = CommandExecutionRequestApprovalResponse { - decision: decision.clone(), - }; + let response = CommandExecutionRequestApprovalResponse { decision }; self.send_server_request_response(request_id, &response)?; println!( "< commandExecution decision for approval #{} on item {item_id}: {:?}", - self.command_approval_count, decision + self.command_approval_count, response.decision ); Ok(()) } @@ -1627,14 +1721,39 @@ impl CodexClient { println!("< grant root: {}", grant_root.display()); } - let response = FileChangeRequestApprovalResponse { - decision: FileChangeApprovalDecision::Accept, - }; + let decision = self.file_change_approval_decision()?; + let response = FileChangeRequestApprovalResponse { decision }; self.send_server_request_response(request_id, &response)?; - println!("< approved fileChange request for item {item_id}"); + println!( + "< responded to fileChange request for item {item_id}: {:?}", + response.decision + ); Ok(()) } + fn command_approval_decision( + &self, + proposed_execpolicy_amendment: Option, + ) -> Result { + if let Some(execpolicy_amendment) = proposed_execpolicy_amendment { + return prompt_for_command_approval_with_amendment(execpolicy_amendment); + } + + if prompt_for_yes_no("Approve command execution request? [y/n] ")? { + Ok(CommandExecutionApprovalDecision::Accept) + } else { + Ok(CommandExecutionApprovalDecision::Decline) + } + } + + fn file_change_approval_decision(&self) -> Result { + if prompt_for_yes_no("Approve file-change request? [y/n] ")? { + Ok(FileChangeApprovalDecision::Accept) + } else { + Ok(FileChangeApprovalDecision::Decline) + } + } + fn send_server_request_response(&mut self, request_id: RequestId, response: &T) -> Result<()> where T: Serialize, @@ -1709,6 +1828,59 @@ fn print_multiline_with_prefix(prefix: &str, payload: &str) { } } +fn prompt_for_yes_no(prompt: &str) -> Result { + loop { + print!("{prompt}"); + io::stdout() + .flush() + .context("failed to flush approval prompt")?; + + let mut line = String::new(); + io::stdin() + .read_line(&mut line) + .context("failed to read approval input")?; + let input = line.trim().to_ascii_lowercase(); + if matches!(input.as_str(), "y" | "yes") { + return Ok(true); + } + if matches!(input.as_str(), "n" | "no") { + return Ok(false); + } + println!("please answer y or n"); + } +} + +fn prompt_for_command_approval_with_amendment( + execpolicy_amendment: ExecPolicyAmendment, +) -> Result { + loop { + print!("Approve command execution request? [y/n/a] (a=always allow) "); + io::stdout() + .flush() + .context("failed to flush approval prompt")?; + + let mut line = String::new(); + io::stdin() + .read_line(&mut line) + .context("failed to read approval input")?; + let input = line.trim().to_ascii_lowercase(); + if matches!(input.as_str(), "y" | "yes") { + return Ok(CommandExecutionApprovalDecision::Accept); + } + if matches!(input.as_str(), "n" | "no") { + return Ok(CommandExecutionApprovalDecision::Decline); + } + if matches!(input.as_str(), "a" | "always" | "always allow") { + return Ok( + CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { + execpolicy_amendment: execpolicy_amendment.clone(), + }, + ); + } + println!("please answer y, n, or a"); + } +} + impl Drop for CodexClient { fn drop(&mut self) { let ClientTransport::Stdio { child, stdin, .. } = &mut self.transport else {