mirror of
https://github.com/openai/codex.git
synced 2026-05-05 13:51:29 +03:00
953 lines
29 KiB
Rust
953 lines
29 KiB
Rust
#![cfg(unix)]
|
|
|
|
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use serde_json::Value as JsonValue;
|
|
use tokio::io::AsyncBufReadExt;
|
|
use tokio::io::AsyncReadExt;
|
|
use tokio::io::AsyncWriteExt;
|
|
use tokio::io::BufReader;
|
|
use tokio::net::UnixListener;
|
|
use tokio::process::Child;
|
|
use tokio::process::ChildStdin;
|
|
use tokio::process::ChildStdout;
|
|
use tokio::process::Command;
|
|
use tokio::time::Duration;
|
|
use tokio::time::timeout;
|
|
|
|
const JSONRPC_VERSION: &str = "2.0";
|
|
const EXEC_START_REQUEST_ID: i64 = 2;
|
|
const WRAPPER_MODE_ENV_VAR: &str = "CODEX_ZSH_SIDECAR_WRAPPER_MODE";
|
|
const WRAPPER_SOCKET_ENV_VAR: &str = "CODEX_ZSH_SIDECAR_WRAPPER_SOCKET";
|
|
const WRAPPER_ORIGIN_ENV_VAR: &str = "CODEX_ZSH_SIDECAR_WRAPPER_ORIGIN";
|
|
|
|
#[tokio::test]
|
|
async fn exec_start_emits_multiple_subcommand_approvals_for_compound_command() -> Result<()> {
|
|
let Some(mut harness) = SidecarHarness::start().await? else {
|
|
return Ok(());
|
|
};
|
|
|
|
harness.initialize().await?;
|
|
harness
|
|
.start_exec_with_command("/usr/bin/true && /usr/bin/true", None)
|
|
.await?;
|
|
|
|
let mut exec_start_acked = false;
|
|
let mut intercepted_subcommand_callbacks = 0usize;
|
|
let mut intercepted_true_callbacks = 0usize;
|
|
let mut saw_exec_exited = false;
|
|
let mut exit_code = None;
|
|
|
|
while !saw_exec_exited {
|
|
let value = harness.read_next_message().await?;
|
|
|
|
if let Some((id, _reason, command)) = parse_approval_request(&value) {
|
|
if command.first().is_some_and(|c| c == "/usr/bin/true") {
|
|
intercepted_subcommand_callbacks += 1;
|
|
intercepted_true_callbacks += 1;
|
|
}
|
|
harness.respond_approval(id, "approved").await?;
|
|
continue;
|
|
}
|
|
|
|
if value.get("id").and_then(JsonValue::as_i64) == Some(EXEC_START_REQUEST_ID)
|
|
&& value.get("result").is_some()
|
|
{
|
|
exec_start_acked = true;
|
|
continue;
|
|
}
|
|
|
|
if value.get("method").and_then(JsonValue::as_str) == Some("zsh/event/execExited") {
|
|
saw_exec_exited = true;
|
|
exit_code = value
|
|
.pointer("/params/exitCode")
|
|
.and_then(JsonValue::as_i64)
|
|
.map(|code| code as i32);
|
|
}
|
|
}
|
|
|
|
harness.shutdown().await?;
|
|
|
|
assert!(exec_start_acked, "expected execStart success response");
|
|
assert_eq!(exit_code, Some(0), "expected successful command exit");
|
|
assert!(
|
|
intercepted_subcommand_callbacks >= 2,
|
|
"expected at least two intercepted subcommand approvals, got {intercepted_subcommand_callbacks}"
|
|
);
|
|
assert!(
|
|
intercepted_true_callbacks >= 2,
|
|
"expected at least two intercepted /usr/bin/true approvals, got {intercepted_true_callbacks}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exec_start_uses_custom_approval_reason_when_provided() -> Result<()> {
|
|
let Some(mut harness) = SidecarHarness::start().await? else {
|
|
return Ok(());
|
|
};
|
|
|
|
harness.initialize().await?;
|
|
harness
|
|
.start_exec_with_command(
|
|
"/usr/bin/true && /usr/bin/true",
|
|
Some("test custom zsh approval reason"),
|
|
)
|
|
.await?;
|
|
|
|
let mut saw_custom_reason = false;
|
|
let mut saw_exec_exited = false;
|
|
|
|
while !saw_exec_exited {
|
|
let value = harness.read_next_message().await?;
|
|
if let Some((id, reason, _command)) = parse_approval_request(&value) {
|
|
if reason.as_deref() == Some("test custom zsh approval reason") {
|
|
saw_custom_reason = true;
|
|
}
|
|
harness.respond_approval(id, "approved").await?;
|
|
continue;
|
|
}
|
|
|
|
if value.get("method").and_then(JsonValue::as_str) == Some("zsh/event/execExited") {
|
|
saw_exec_exited = true;
|
|
}
|
|
}
|
|
|
|
harness.shutdown().await?;
|
|
assert!(
|
|
saw_custom_reason,
|
|
"expected at least one approval callback with custom reason"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exec_start_does_not_emit_exec_start_approval_callback() -> Result<()> {
|
|
let Some(mut harness) = SidecarHarness::start().await? else {
|
|
return Ok(());
|
|
};
|
|
|
|
harness.initialize().await?;
|
|
harness
|
|
.start_exec_with_command("/usr/bin/true && /usr/bin/true", None)
|
|
.await?;
|
|
|
|
let mut saw_exec_start_success = false;
|
|
let mut saw_exec_exited = false;
|
|
let mut saw_exec_start_reason = false;
|
|
|
|
while !saw_exec_exited {
|
|
let value = harness.read_next_message().await?;
|
|
if let Some((id, reason, _command)) = parse_approval_request(&value) {
|
|
if reason.as_deref() == Some("zsh sidecar execStart command approval") {
|
|
saw_exec_start_reason = true;
|
|
}
|
|
harness.respond_approval(id, "approved").await?;
|
|
continue;
|
|
}
|
|
|
|
if value.get("id").and_then(JsonValue::as_i64) == Some(EXEC_START_REQUEST_ID)
|
|
&& value.get("result").is_some()
|
|
{
|
|
saw_exec_start_success = true;
|
|
continue;
|
|
}
|
|
|
|
if value.get("method").and_then(JsonValue::as_str) == Some("zsh/event/execExited") {
|
|
saw_exec_exited = true;
|
|
}
|
|
}
|
|
|
|
harness.shutdown().await?;
|
|
assert!(
|
|
saw_exec_start_success,
|
|
"expected execStart success response"
|
|
);
|
|
assert!(
|
|
!saw_exec_start_reason,
|
|
"did not expect execStart approval callback reason"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exec_start_does_not_emit_internal_zsh_reexec_approval_callback() -> Result<()> {
|
|
let Some(mut harness) = SidecarHarness::start().await? else {
|
|
return Ok(());
|
|
};
|
|
|
|
harness.initialize().await?;
|
|
harness
|
|
.start_exec_with_command("/usr/bin/true && /usr/bin/true", None)
|
|
.await?;
|
|
|
|
let mut saw_exec_start_success = false;
|
|
let mut saw_exec_exited = false;
|
|
let mut saw_internal_zsh_reexec = false;
|
|
|
|
while !saw_exec_exited {
|
|
let value = harness.read_next_message().await?;
|
|
if let Some((id, _reason, command)) = parse_approval_request(&value) {
|
|
let command_program_name = command
|
|
.first()
|
|
.map(std::path::PathBuf::from)
|
|
.and_then(|path| path.file_name().map(std::borrow::ToOwned::to_owned))
|
|
.and_then(|name| name.to_str().map(str::to_owned))
|
|
.unwrap_or_default();
|
|
let has_command_string_flag = command
|
|
.iter()
|
|
.filter(|arg| arg.starts_with('-'))
|
|
.any(|arg| arg.chars().skip(1).any(|ch| ch == 'c'));
|
|
if command_program_name == "zsh" && has_command_string_flag {
|
|
saw_internal_zsh_reexec = true;
|
|
}
|
|
harness.respond_approval(id, "approved").await?;
|
|
continue;
|
|
}
|
|
|
|
if value.get("id").and_then(JsonValue::as_i64) == Some(EXEC_START_REQUEST_ID)
|
|
&& value.get("result").is_some()
|
|
{
|
|
saw_exec_start_success = true;
|
|
continue;
|
|
}
|
|
|
|
if value.get("method").and_then(JsonValue::as_str) == Some("zsh/event/execExited") {
|
|
saw_exec_exited = true;
|
|
}
|
|
}
|
|
|
|
harness.shutdown().await?;
|
|
assert!(
|
|
saw_exec_start_success,
|
|
"expected execStart success response"
|
|
);
|
|
assert!(
|
|
!saw_internal_zsh_reexec,
|
|
"did not expect host approval callback for internal zsh re-exec"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn denying_second_subcommand_fails_exec() -> Result<()> {
|
|
let Some(outcome) = run_compound_exec_with_second_subcommand_decision("denied").await? else {
|
|
return Ok(());
|
|
};
|
|
assert!(
|
|
outcome.saw_exec_start_success,
|
|
"expected execStart success response"
|
|
);
|
|
assert!(
|
|
outcome.subcommand_callbacks >= 2,
|
|
"expected at least two subcommand callbacks before exit, got {}",
|
|
outcome.subcommand_callbacks
|
|
);
|
|
assert_ne!(
|
|
outcome.exit_code,
|
|
Some(0),
|
|
"denying the second subcommand should cause non-zero exit"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn aborting_second_subcommand_fails_exec() -> Result<()> {
|
|
let Some(outcome) = run_compound_exec_with_second_subcommand_decision("abort").await? else {
|
|
return Ok(());
|
|
};
|
|
assert!(
|
|
outcome.saw_exec_start_success,
|
|
"expected execStart success response"
|
|
);
|
|
assert!(
|
|
outcome.subcommand_callbacks >= 2,
|
|
"expected at least two subcommand callbacks before exit, got {}",
|
|
outcome.subcommand_callbacks
|
|
);
|
|
assert_ne!(
|
|
outcome.exit_code,
|
|
Some(0),
|
|
"aborting the second subcommand should cause non-zero exit"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn approved_for_session_subcommand_decision_allows_exec() -> Result<()> {
|
|
let Some(outcome) =
|
|
run_compound_exec_with_second_subcommand_decision("approved_for_session").await?
|
|
else {
|
|
return Ok(());
|
|
};
|
|
assert!(
|
|
outcome.saw_exec_start_success,
|
|
"expected execStart success response"
|
|
);
|
|
assert!(
|
|
outcome.subcommand_callbacks >= 2,
|
|
"expected at least two subcommand callbacks before exit, got {}",
|
|
outcome.subcommand_callbacks
|
|
);
|
|
assert_eq!(
|
|
outcome.exit_code,
|
|
Some(0),
|
|
"approved_for_session should allow successful command exit"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn approved_execpolicy_amendment_subcommand_decision_allows_exec() -> Result<()> {
|
|
let Some(outcome) =
|
|
run_compound_exec_with_second_subcommand_decision("approved_execpolicy_amendment").await?
|
|
else {
|
|
return Ok(());
|
|
};
|
|
assert!(
|
|
outcome.saw_exec_start_success,
|
|
"expected execStart success response"
|
|
);
|
|
assert!(
|
|
outcome.subcommand_callbacks >= 2,
|
|
"expected at least two subcommand callbacks before exit, got {}",
|
|
outcome.subcommand_callbacks
|
|
);
|
|
assert_eq!(
|
|
outcome.exit_code,
|
|
Some(0),
|
|
"approved_execpolicy_amendment should allow successful command exit"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn approval_callback_ignores_unexpected_response_id() -> Result<()> {
|
|
let Some(mut harness) = SidecarHarness::start().await? else {
|
|
return Ok(());
|
|
};
|
|
|
|
harness.initialize().await?;
|
|
harness
|
|
.start_exec_with_command("/usr/bin/true && /usr/bin/true", None)
|
|
.await?;
|
|
|
|
let mut sent_wrong_id_once = false;
|
|
let mut saw_exec_start_success = false;
|
|
let mut saw_exec_exited = false;
|
|
let mut exit_code = None;
|
|
|
|
while !saw_exec_exited {
|
|
let value = harness.read_next_message().await?;
|
|
if let Some((id, _reason, _command)) = parse_approval_request(&value) {
|
|
if !sent_wrong_id_once {
|
|
harness
|
|
.respond_approval(
|
|
JsonValue::String("definitely-wrong-id".to_string()),
|
|
"approved",
|
|
)
|
|
.await?;
|
|
sent_wrong_id_once = true;
|
|
}
|
|
harness.respond_approval(id, "approved").await?;
|
|
continue;
|
|
}
|
|
|
|
if value.get("id").and_then(JsonValue::as_i64) == Some(EXEC_START_REQUEST_ID)
|
|
&& value.get("result").is_some()
|
|
{
|
|
saw_exec_start_success = true;
|
|
continue;
|
|
}
|
|
|
|
if value.get("method").and_then(JsonValue::as_str) == Some("zsh/event/execExited") {
|
|
saw_exec_exited = true;
|
|
exit_code = value
|
|
.pointer("/params/exitCode")
|
|
.and_then(JsonValue::as_i64)
|
|
.map(|code| code as i32);
|
|
}
|
|
}
|
|
|
|
harness.shutdown().await?;
|
|
|
|
assert!(sent_wrong_id_once, "expected wrong-id response to be sent");
|
|
assert!(
|
|
saw_exec_start_success,
|
|
"expected execStart success despite wrong callback id response"
|
|
);
|
|
assert_eq!(exit_code, Some(0), "expected successful command exit");
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn malformed_approval_response_terminates_sidecar() -> Result<()> {
|
|
let Some(mut harness) = SidecarHarness::start().await? else {
|
|
return Ok(());
|
|
};
|
|
|
|
harness.initialize().await?;
|
|
harness
|
|
.start_exec_with_command("/usr/bin/true && /usr/bin/true", None)
|
|
.await?;
|
|
|
|
loop {
|
|
let value = harness.read_next_message().await?;
|
|
if let Some((id, _reason, _command)) = parse_approval_request(&value) {
|
|
harness
|
|
.write_json_line(&serde_json::json!({
|
|
"jsonrpc": JSONRPC_VERSION,
|
|
"id": id,
|
|
"result": {}
|
|
}))
|
|
.await?;
|
|
break;
|
|
}
|
|
}
|
|
|
|
let status = timeout(Duration::from_secs(3), harness.child.wait())
|
|
.await
|
|
.context("timed out waiting for sidecar crash on malformed callback response")??;
|
|
assert!(
|
|
!status.success(),
|
|
"sidecar should fail fast on malformed callback response"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn returns_jsonrpc_error_for_unknown_method() -> Result<()> {
|
|
let Some(mut harness) = SidecarHarness::start().await? else {
|
|
return Ok(());
|
|
};
|
|
|
|
harness.initialize().await?;
|
|
harness
|
|
.write_json_line(&serde_json::json!({
|
|
"jsonrpc": JSONRPC_VERSION,
|
|
"id": 55,
|
|
"method": "zsh/notRealMethod",
|
|
"params": {}
|
|
}))
|
|
.await?;
|
|
let response = harness.wait_for_response(55).await?;
|
|
assert_eq!(
|
|
response.pointer("/error/code"),
|
|
Some(&JsonValue::from(-32601))
|
|
);
|
|
|
|
harness.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn returns_jsonrpc_invalid_params_for_exec_start_with_empty_command() -> Result<()> {
|
|
let Some(mut harness) = SidecarHarness::start().await? else {
|
|
return Ok(());
|
|
};
|
|
|
|
harness.initialize().await?;
|
|
harness
|
|
.write_json_line(&serde_json::json!({
|
|
"jsonrpc": JSONRPC_VERSION,
|
|
"id": EXEC_START_REQUEST_ID,
|
|
"method": "zsh/execStart",
|
|
"params": {
|
|
"execId": "exec-invalid",
|
|
"command": [],
|
|
"cwd": std::env::current_dir()?.to_string_lossy().to_string(),
|
|
"env": {}
|
|
}
|
|
}))
|
|
.await?;
|
|
let response = harness.wait_for_response(EXEC_START_REQUEST_ID).await?;
|
|
assert_eq!(
|
|
response.pointer("/error/code"),
|
|
Some(&JsonValue::from(-32602))
|
|
);
|
|
|
|
harness.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exec_events_are_ordered_exec_started_before_output_and_single_exit() -> Result<()> {
|
|
let Some(mut harness) = SidecarHarness::start().await? else {
|
|
return Ok(());
|
|
};
|
|
|
|
harness.initialize().await?;
|
|
harness
|
|
.start_exec_with_command("/usr/bin/printf 'hi\\n'", None)
|
|
.await?;
|
|
|
|
let mut saw_exec_started = false;
|
|
let mut saw_output_before_started = false;
|
|
let mut exec_exited_count = 0usize;
|
|
let mut saw_exec_start_success = false;
|
|
|
|
while exec_exited_count == 0 {
|
|
let value = harness.read_next_message().await?;
|
|
if let Some((id, _reason, _command)) = parse_approval_request(&value) {
|
|
harness.respond_approval(id, "approved").await?;
|
|
continue;
|
|
}
|
|
|
|
match value.get("method").and_then(JsonValue::as_str) {
|
|
Some("zsh/event/execStarted") => {
|
|
saw_exec_started = true;
|
|
}
|
|
Some("zsh/event/execStdout") | Some("zsh/event/execStderr") => {
|
|
if !saw_exec_started {
|
|
saw_output_before_started = true;
|
|
}
|
|
}
|
|
Some("zsh/event/execExited") => {
|
|
exec_exited_count += 1;
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
if value.get("id").and_then(JsonValue::as_i64) == Some(EXEC_START_REQUEST_ID)
|
|
&& value.get("result").is_some()
|
|
{
|
|
saw_exec_start_success = true;
|
|
}
|
|
}
|
|
|
|
for _ in 0..4 {
|
|
let Some(value) = harness
|
|
.read_next_message_with_timeout(Duration::from_millis(100))
|
|
.await?
|
|
else {
|
|
break;
|
|
};
|
|
if value.get("method").and_then(JsonValue::as_str) == Some("zsh/event/execExited") {
|
|
exec_exited_count += 1;
|
|
}
|
|
}
|
|
|
|
harness.shutdown().await?;
|
|
|
|
assert!(
|
|
saw_exec_start_success,
|
|
"expected execStart success response"
|
|
);
|
|
assert!(saw_exec_started, "expected execStarted event");
|
|
assert!(!saw_output_before_started, "saw output before execStarted");
|
|
assert_eq!(exec_exited_count, 1, "expected one execExited event");
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exec_interrupt_returns_unknown_exec_id_error_in_phase1() -> Result<()> {
|
|
let Some(mut harness) = SidecarHarness::start().await? else {
|
|
return Ok(());
|
|
};
|
|
|
|
harness.initialize().await?;
|
|
harness
|
|
.write_json_line(&serde_json::json!({
|
|
"jsonrpc": JSONRPC_VERSION,
|
|
"id": 77,
|
|
"method": "zsh/execInterrupt",
|
|
"params": {
|
|
"execId": "exec-does-not-exist"
|
|
}
|
|
}))
|
|
.await?;
|
|
let response = harness.wait_for_response(77).await?;
|
|
assert_eq!(
|
|
response.pointer("/error/code"),
|
|
Some(&JsonValue::from(-32002))
|
|
);
|
|
|
|
harness.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exec_stdin_and_resize_return_not_supported_error_in_phase1() -> Result<()> {
|
|
let Some(mut harness) = SidecarHarness::start().await? else {
|
|
return Ok(());
|
|
};
|
|
|
|
harness.initialize().await?;
|
|
harness
|
|
.write_json_line(&serde_json::json!({
|
|
"jsonrpc": JSONRPC_VERSION,
|
|
"id": 78,
|
|
"method": "zsh/execStdin",
|
|
"params": {
|
|
"execId": "exec-does-not-exist",
|
|
"chunkBase64": "aGk="
|
|
}
|
|
}))
|
|
.await?;
|
|
let stdin_response = harness.wait_for_response(78).await?;
|
|
assert_eq!(
|
|
stdin_response.pointer("/error/code"),
|
|
Some(&JsonValue::from(-32004))
|
|
);
|
|
|
|
harness
|
|
.write_json_line(&serde_json::json!({
|
|
"jsonrpc": JSONRPC_VERSION,
|
|
"id": 79,
|
|
"method": "zsh/execResize",
|
|
"params": {
|
|
"execId": "exec-does-not-exist",
|
|
"cols": 80,
|
|
"rows": 24
|
|
}
|
|
}))
|
|
.await?;
|
|
let resize_response = harness.wait_for_response(79).await?;
|
|
assert_eq!(
|
|
resize_response.pointer("/error/code"),
|
|
Some(&JsonValue::from(-32004))
|
|
);
|
|
|
|
harness.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn wrapper_mode_with_invalid_socket_fails_fast() -> Result<()> {
|
|
let sidecar = env!("CARGO_BIN_EXE_codex-zsh-sidecar");
|
|
let mut child = Command::new(sidecar)
|
|
.arg("/usr/bin/true")
|
|
.env(WRAPPER_MODE_ENV_VAR, "1")
|
|
.env(
|
|
WRAPPER_SOCKET_ENV_VAR,
|
|
"/tmp/definitely-not-a-real-codex-zsh-wrapper.sock",
|
|
)
|
|
.stdin(std::process::Stdio::null())
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::piped())
|
|
.spawn()
|
|
.context("spawn wrapper mode sidecar process")?;
|
|
|
|
let status = timeout(Duration::from_secs(3), child.wait())
|
|
.await
|
|
.context("timed out waiting for wrapper mode process failure")??;
|
|
let stderr = child
|
|
.stderr
|
|
.take()
|
|
.context("missing stderr for wrapper mode process")?;
|
|
let mut lines = BufReader::new(stderr).lines();
|
|
let mut stderr_text = String::new();
|
|
while let Some(line) = lines.next_line().await? {
|
|
stderr_text.push_str(&line);
|
|
stderr_text.push('\n');
|
|
}
|
|
|
|
assert!(
|
|
!status.success(),
|
|
"wrapper mode should fail when socket path is invalid"
|
|
);
|
|
assert!(
|
|
stderr_text.contains("wrapper socket"),
|
|
"expected wrapper socket failure message, got: {stderr_text}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn wrapper_mode_sends_origin_metadata_from_env() -> Result<()> {
|
|
let sidecar = env!("CARGO_BIN_EXE_codex-zsh-sidecar");
|
|
let socket_path = std::env::temp_dir().join(format!(
|
|
"czs-wrapper-test-{}.sock",
|
|
uuid::Uuid::new_v4().as_simple()
|
|
));
|
|
let _ = std::fs::remove_file(&socket_path);
|
|
let listener = UnixListener::bind(&socket_path)
|
|
.with_context(|| format!("bind test listener at {}", socket_path.display()))?;
|
|
|
|
let mut child = Command::new(sidecar)
|
|
.arg("/usr/bin/true")
|
|
.env(WRAPPER_MODE_ENV_VAR, "1")
|
|
.env(
|
|
WRAPPER_SOCKET_ENV_VAR,
|
|
socket_path.to_string_lossy().to_string(),
|
|
)
|
|
.env(WRAPPER_ORIGIN_ENV_VAR, "1")
|
|
.stdin(std::process::Stdio::null())
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::piped())
|
|
.spawn()
|
|
.context("spawn wrapper mode sidecar process")?;
|
|
|
|
let (mut stream, _) = timeout(Duration::from_secs(3), listener.accept())
|
|
.await
|
|
.context("timed out waiting for wrapper connection")??;
|
|
let mut request_buf = Vec::new();
|
|
stream
|
|
.read_to_end(&mut request_buf)
|
|
.await
|
|
.context("read wrapper request payload")?;
|
|
let request: JsonValue =
|
|
serde_json::from_slice(&request_buf).context("parse wrapper request payload")?;
|
|
assert_eq!(
|
|
request.pointer("/origin"),
|
|
Some(&JsonValue::from("login_startup"))
|
|
);
|
|
stream
|
|
.write_all(br#"{"action":"run","reason":null}"#)
|
|
.await
|
|
.context("write wrapper allow response")?;
|
|
stream.shutdown().await.context("shutdown wrapper stream")?;
|
|
|
|
let status = timeout(Duration::from_secs(3), child.wait())
|
|
.await
|
|
.context("timed out waiting for wrapper mode process exit")??;
|
|
let _ = std::fs::remove_file(&socket_path);
|
|
assert!(
|
|
status.success(),
|
|
"wrapper mode should succeed when action=run"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
struct SidecarHarness {
|
|
child: Child,
|
|
stdin: ChildStdin,
|
|
lines: tokio::io::Lines<BufReader<ChildStdout>>,
|
|
zsh_path: std::path::PathBuf,
|
|
}
|
|
|
|
impl SidecarHarness {
|
|
async fn start() -> Result<Option<Self>> {
|
|
let Some(zsh_path) = std::env::var_os("CODEX_TEST_ZSH_PATH") else {
|
|
eprintln!("skipping direct sidecar protocol test: CODEX_TEST_ZSH_PATH is not set");
|
|
return Ok(None);
|
|
};
|
|
let zsh_path = std::path::PathBuf::from(zsh_path);
|
|
if !zsh_path.is_file() {
|
|
anyhow::bail!(
|
|
"CODEX_TEST_ZSH_PATH is set but is not a file: {}",
|
|
zsh_path.display()
|
|
);
|
|
}
|
|
|
|
let sidecar = env!("CARGO_BIN_EXE_codex-zsh-sidecar");
|
|
let mut child = Command::new(sidecar)
|
|
.arg("--zsh-path")
|
|
.arg(&zsh_path)
|
|
.stdin(std::process::Stdio::piped())
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::inherit())
|
|
.spawn()
|
|
.context("spawn codex-zsh-sidecar")?;
|
|
|
|
let stdin = child.stdin.take().context("missing sidecar stdin")?;
|
|
let stdout = child.stdout.take().context("missing sidecar stdout")?;
|
|
|
|
Ok(Some(Self {
|
|
child,
|
|
stdin,
|
|
lines: BufReader::new(stdout).lines(),
|
|
zsh_path,
|
|
}))
|
|
}
|
|
|
|
async fn initialize(&mut self) -> Result<()> {
|
|
self.write_json_line(&serde_json::json!({
|
|
"jsonrpc": JSONRPC_VERSION,
|
|
"id": 1,
|
|
"method": "zsh/initialize",
|
|
"params": {
|
|
"sessionId": "test-session"
|
|
}
|
|
}))
|
|
.await?;
|
|
self.wait_for_response(1).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn start_exec_with_command(
|
|
&mut self,
|
|
shell_command: &str,
|
|
approval_reason: Option<&str>,
|
|
) -> Result<()> {
|
|
self.write_json_line(&serde_json::json!({
|
|
"jsonrpc": JSONRPC_VERSION,
|
|
"id": EXEC_START_REQUEST_ID,
|
|
"method": "zsh/execStart",
|
|
"params": {
|
|
"execId": "exec-test-1",
|
|
"command": [self.zsh_path.to_string_lossy(), "-fc", shell_command],
|
|
"cwd": std::env::current_dir()?.to_string_lossy().to_string(),
|
|
"env": {},
|
|
"approvalReason": approval_reason
|
|
}
|
|
}))
|
|
.await
|
|
}
|
|
|
|
async fn respond_approval(&mut self, id: JsonValue, decision: &str) -> Result<()> {
|
|
self.write_json_line(&serde_json::json!({
|
|
"jsonrpc": JSONRPC_VERSION,
|
|
"id": id,
|
|
"result": {
|
|
"decision": decision
|
|
}
|
|
}))
|
|
.await
|
|
}
|
|
|
|
async fn shutdown(&mut self) -> Result<()> {
|
|
self.write_json_line(&serde_json::json!({
|
|
"jsonrpc": JSONRPC_VERSION,
|
|
"id": 3,
|
|
"method": "zsh/shutdown",
|
|
"params": {
|
|
"graceMs": 100
|
|
}
|
|
}))
|
|
.await?;
|
|
self.wait_for_response(3).await?;
|
|
|
|
let status = timeout(Duration::from_secs(3), self.child.wait())
|
|
.await
|
|
.context("timed out waiting for sidecar process exit")??;
|
|
assert!(status.success(), "sidecar should exit cleanly");
|
|
Ok(())
|
|
}
|
|
|
|
async fn read_next_message(&mut self) -> Result<JsonValue> {
|
|
let line = timeout(Duration::from_secs(10), self.lines.next_line())
|
|
.await
|
|
.context("timed out reading sidecar output")??
|
|
.context("sidecar stdout closed unexpectedly")?;
|
|
serde_json::from_str(&line).with_context(|| format!("parse sidecar JSON line: {line}"))
|
|
}
|
|
|
|
async fn read_next_message_with_timeout(
|
|
&mut self,
|
|
duration: Duration,
|
|
) -> Result<Option<JsonValue>> {
|
|
let line = match timeout(duration, self.lines.next_line()).await {
|
|
Ok(line) => line?,
|
|
Err(_) => return Ok(None),
|
|
};
|
|
let Some(line) = line else {
|
|
return Ok(None);
|
|
};
|
|
let value = serde_json::from_str(&line)
|
|
.with_context(|| format!("parse sidecar JSON line: {line}"))?;
|
|
Ok(Some(value))
|
|
}
|
|
|
|
async fn wait_for_response(&mut self, id: i64) -> Result<JsonValue> {
|
|
loop {
|
|
let value = self.read_next_message().await?;
|
|
if value.get("id").and_then(JsonValue::as_i64) == Some(id) {
|
|
return Ok(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn write_json_line(&mut self, value: &JsonValue) -> Result<()> {
|
|
let encoded = serde_json::to_string(value).context("serialize JSON line")?;
|
|
self.stdin
|
|
.write_all(encoded.as_bytes())
|
|
.await
|
|
.context("write JSON line")?;
|
|
self.stdin
|
|
.write_all(b"\n")
|
|
.await
|
|
.context("write line break")?;
|
|
self.stdin.flush().await.context("flush stdin")
|
|
}
|
|
}
|
|
|
|
fn parse_approval_request(value: &JsonValue) -> Option<(JsonValue, Option<String>, Vec<String>)> {
|
|
if value.get("method").and_then(JsonValue::as_str) != Some("zsh/requestApproval") {
|
|
return None;
|
|
}
|
|
let id = value.get("id")?.clone();
|
|
let reason = value
|
|
.pointer("/params/reason")
|
|
.and_then(JsonValue::as_str)
|
|
.map(ToString::to_string);
|
|
let command = value
|
|
.pointer("/params/command")
|
|
.and_then(JsonValue::as_array)
|
|
.map(|items| {
|
|
items
|
|
.iter()
|
|
.filter_map(JsonValue::as_str)
|
|
.map(ToString::to_string)
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
Some((id, reason, command))
|
|
}
|
|
|
|
struct CompoundExecOutcome {
|
|
saw_exec_start_success: bool,
|
|
subcommand_callbacks: usize,
|
|
exit_code: Option<i32>,
|
|
}
|
|
|
|
async fn run_compound_exec_with_second_subcommand_decision(
|
|
second_subcommand_decision: &str,
|
|
) -> Result<Option<CompoundExecOutcome>> {
|
|
let Some(mut harness) = SidecarHarness::start().await? else {
|
|
return Ok(None);
|
|
};
|
|
|
|
harness.initialize().await?;
|
|
harness
|
|
.start_exec_with_command("/usr/bin/true && /usr/bin/true", None)
|
|
.await?;
|
|
|
|
let mut subcommand_callbacks = 0usize;
|
|
let mut saw_exec_start_success = false;
|
|
let mut saw_exec_exited = false;
|
|
let mut exit_code = None;
|
|
|
|
while !saw_exec_exited {
|
|
let value = harness.read_next_message().await?;
|
|
|
|
if let Some((id, _reason, command)) = parse_approval_request(&value) {
|
|
if command.first().is_some_and(|c| c == "/usr/bin/true") {
|
|
subcommand_callbacks += 1;
|
|
if subcommand_callbacks == 2 {
|
|
harness
|
|
.respond_approval(id, second_subcommand_decision)
|
|
.await?;
|
|
} else {
|
|
harness.respond_approval(id, "approved").await?;
|
|
}
|
|
} else {
|
|
harness.respond_approval(id, "approved").await?;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if value.get("id").and_then(JsonValue::as_i64) == Some(EXEC_START_REQUEST_ID)
|
|
&& value.get("result").is_some()
|
|
{
|
|
saw_exec_start_success = true;
|
|
continue;
|
|
}
|
|
|
|
if value.get("method").and_then(JsonValue::as_str) == Some("zsh/event/execExited") {
|
|
saw_exec_exited = true;
|
|
exit_code = value
|
|
.pointer("/params/exitCode")
|
|
.and_then(JsonValue::as_i64)
|
|
.map(|code| code as i32);
|
|
}
|
|
}
|
|
|
|
harness.shutdown().await?;
|
|
Ok(Some(CompoundExecOutcome {
|
|
saw_exec_start_success,
|
|
subcommand_callbacks,
|
|
exit_code,
|
|
}))
|
|
}
|