This commit is contained in:
celia-oai
2026-02-24 22:38:56 -08:00
parent cd3edc28d8
commit bb76c41e7f

View File

@@ -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<String>,
/// 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<String>,
/// 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<PathBuf>,
#[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<Vec<DynamicToolSpec>>,
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<Vec<DynamicToolSpec>>,
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<String>,
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
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<String>,
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
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<Vec<DynamicToolSpec>>,
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<AskForApproval>,
sandbox_policy: Option<SandboxPolicy>,
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
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<Vec<DynamicToolSpec>>,
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<SkillSelection>, 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<String>,
skill_path: Option<PathBuf>,
) -> Result<Option<SkillSelection>> {
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<V2UserInput> {
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<String>) -> Result<Option<Vec<DynamicToolSpec>>> {
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<ExecPolicyAmendment>,
) -> Result<CommandExecutionApprovalDecision> {
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<FileChangeApprovalDecision> {
if prompt_for_yes_no("Approve file-change request? [y/n] ")? {
Ok(FileChangeApprovalDecision::Accept)
} else {
Ok(FileChangeApprovalDecision::Decline)
}
}
fn send_server_request_response<T>(&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<bool> {
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<CommandExecutionApprovalDecision> {
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 {