diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 7b846c80f1..552513ab27 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; @@ -143,10 +145,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. @@ -241,25 +261,36 @@ 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) } CliCommand::SendMessageV2 { user_message } => { let endpoint = resolve_endpoint(codex_bin, url)?; - send_message_v2_endpoint(&endpoint, &config_overrides, user_message, &dynamic_tools) + send_message_v2_endpoint( + &endpoint, + &config_overrides, + user_message, + &dynamic_tools, + skill_selection.as_ref(), + ) } CliCommand::ResumeMessageV2 { thread_id, @@ -272,24 +303,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, @@ -302,6 +352,7 @@ pub fn run() -> Result<()> { first_message, follow_up_message, &dynamic_tools, + skill_selection.as_ref(), ) } CliCommand::TriggerZshForkMultiCmdApproval { @@ -321,21 +372,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) } @@ -505,7 +560,13 @@ pub fn send_message_v2( dynamic_tools: &Option>, ) -> Result<()> { let endpoint = Endpoint::SpawnCodex(codex_bin.to_path_buf()); - send_message_v2_endpoint(&endpoint, config_overrides, user_message, dynamic_tools) + send_message_v2_endpoint( + &endpoint, + config_overrides, + user_message, + dynamic_tools, + None, + ) } fn send_message_v2_endpoint( @@ -513,6 +574,7 @@ fn send_message_v2_endpoint( config_overrides: &[String], user_message: String, dynamic_tools: &Option>, + skill_selection: Option<&SkillSelection>, ) -> Result<()> { send_message_v2_with_policies( endpoint, @@ -521,6 +583,7 @@ fn send_message_v2_endpoint( None, None, dynamic_tools, + skill_selection, ) } @@ -625,6 +688,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")?; @@ -641,10 +705,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:?}"); @@ -679,6 +740,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."; @@ -692,6 +754,7 @@ fn trigger_cmd_approval( access: ReadOnlyAccess::FullAccess, }), dynamic_tools, + skill_selection, ) } @@ -700,6 +763,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."; @@ -713,6 +777,7 @@ fn trigger_patch_approval( access: ReadOnlyAccess::FullAccess, }), dynamic_tools, + skill_selection, ) } @@ -720,6 +785,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( @@ -729,6 +795,7 @@ fn no_trigger_cmd_approval( None, None, dynamic_tools, + skill_selection, ) } @@ -739,6 +806,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)?; @@ -752,11 +820,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; @@ -776,6 +840,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)?; @@ -790,11 +855,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)?; @@ -803,11 +864,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)?; @@ -903,6 +960,47 @@ 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)) => 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); @@ -949,6 +1047,7 @@ struct CodexClient { #[derive(Debug, Clone, Copy)] enum CommandApprovalBehavior { + Prompt, AlwaysAccept, AbortOn(usize), } @@ -999,7 +1098,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(), @@ -1020,7 +1119,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(), @@ -1522,19 +1621,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(()) } @@ -1562,14 +1662,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, @@ -1644,6 +1769,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 {