mirror of
https://github.com/openai/codex.git
synced 2026-03-15 10:26:29 +03:00
Compare commits
6 Commits
pr14624
...
dev/shaqay
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bb1a1325f | ||
|
|
b4b6c667f3 | ||
|
|
0ba4cfa4f4 | ||
|
|
09a265122d | ||
|
|
3cf1306968 | ||
|
|
fd4beb8b37 |
@@ -11,6 +11,7 @@ use app_test_support::McpProcess;
|
||||
use app_test_support::create_final_assistant_message_sse_response;
|
||||
use app_test_support::create_mock_responses_server_sequence;
|
||||
use app_test_support::create_mock_responses_server_sequence_unchecked;
|
||||
use app_test_support::create_shell_command_sse_response;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server_protocol::CommandAction;
|
||||
use codex_app_server_protocol::CommandExecutionApprovalDecision;
|
||||
@@ -34,11 +35,9 @@ use codex_core::features::Feature;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::sleep;
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -63,14 +62,19 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> {
|
||||
};
|
||||
eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display());
|
||||
|
||||
// Keep the exec command in flight until we interrupt it. A fast command
|
||||
// Keep the shell command in flight until we interrupt it. A fast command
|
||||
// like `echo hi` can finish before the interrupt arrives on faster runners,
|
||||
// which turns this into a test for post-command follow-up behavior instead
|
||||
// of interrupting an active zsh-fork command.
|
||||
let release_marker_escaped = release_marker.to_string_lossy().replace('\'', r#"'\''"#);
|
||||
let wait_for_interrupt =
|
||||
format!("while [ ! -f '{release_marker_escaped}' ]; do sleep 0.01; done");
|
||||
let response = create_zsh_fork_exec_command_sse_response(&wait_for_interrupt, "call-zsh-fork")?;
|
||||
let response = create_shell_command_sse_response(
|
||||
vec!["/bin/sh".to_string(), "-c".to_string(), wait_for_interrupt],
|
||||
None,
|
||||
Some(5000),
|
||||
"call-zsh-fork",
|
||||
)?;
|
||||
let no_op_response = responses::sse(vec![
|
||||
responses::ev_response_created("resp-2"),
|
||||
responses::ev_completed("resp-2"),
|
||||
@@ -87,7 +91,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> {
|
||||
"never",
|
||||
&BTreeMap::from([
|
||||
(Feature::ShellZshFork, true),
|
||||
(Feature::UnifiedExec, true),
|
||||
(Feature::UnifiedExec, false),
|
||||
(Feature::ShellSnapshot, false),
|
||||
]),
|
||||
&zsh_path,
|
||||
@@ -159,7 +163,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> {
|
||||
assert_eq!(id, "call-zsh-fork");
|
||||
assert_eq!(status, CommandExecutionStatus::InProgress);
|
||||
assert!(command.starts_with(&zsh_path.display().to_string()));
|
||||
assert!(command.contains(" -lc "));
|
||||
assert!(command.contains("/bin/sh -c"));
|
||||
assert!(command.contains("sleep 0.01"));
|
||||
assert!(command.contains(&release_marker.display().to_string()));
|
||||
assert_eq!(cwd, workspace);
|
||||
@@ -187,8 +191,14 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> {
|
||||
eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display());
|
||||
|
||||
let responses = vec![
|
||||
create_zsh_fork_exec_command_sse_response(
|
||||
"python3 -c 'print(42)'",
|
||||
create_shell_command_sse_response(
|
||||
vec![
|
||||
"python3".to_string(),
|
||||
"-c".to_string(),
|
||||
"print(42)".to_string(),
|
||||
],
|
||||
None,
|
||||
Some(5000),
|
||||
"call-zsh-fork-decline",
|
||||
)?,
|
||||
create_final_assistant_message_sse_response("done")?,
|
||||
@@ -200,7 +210,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> {
|
||||
"untrusted",
|
||||
&BTreeMap::from([
|
||||
(Feature::ShellZshFork, true),
|
||||
(Feature::UnifiedExec, true),
|
||||
(Feature::UnifiedExec, false),
|
||||
(Feature::ShellSnapshot, false),
|
||||
]),
|
||||
&zsh_path,
|
||||
@@ -316,8 +326,14 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> {
|
||||
};
|
||||
eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display());
|
||||
|
||||
let responses = vec![create_zsh_fork_exec_command_sse_response(
|
||||
"python3 -c 'print(42)'",
|
||||
let responses = vec![create_shell_command_sse_response(
|
||||
vec![
|
||||
"python3".to_string(),
|
||||
"-c".to_string(),
|
||||
"print(42)".to_string(),
|
||||
],
|
||||
None,
|
||||
Some(5000),
|
||||
"call-zsh-fork-cancel",
|
||||
)?];
|
||||
let server = create_mock_responses_server_sequence(responses).await;
|
||||
@@ -327,7 +343,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> {
|
||||
"untrusted",
|
||||
&BTreeMap::from([
|
||||
(Feature::ShellZshFork, true),
|
||||
(Feature::UnifiedExec, true),
|
||||
(Feature::UnifiedExec, false),
|
||||
(Feature::ShellSnapshot, false),
|
||||
]),
|
||||
&zsh_path,
|
||||
@@ -425,204 +441,6 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_start_shell_zsh_fork_interrupt_kills_approved_subcommand_v2() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let tmp = TempDir::new()?;
|
||||
let codex_home = tmp.path().join("codex_home");
|
||||
std::fs::create_dir(&codex_home)?;
|
||||
let workspace = tmp.path().join("workspace");
|
||||
std::fs::create_dir(&workspace)?;
|
||||
let launch_marker = workspace.join("approved-subcommand.started");
|
||||
let leaked_marker = workspace.join("approved-subcommand.leaked");
|
||||
let launch_marker_display = launch_marker.display().to_string();
|
||||
assert!(
|
||||
!launch_marker_display.contains('\''),
|
||||
"test workspace path should not contain single quotes: {launch_marker_display}"
|
||||
);
|
||||
let leaked_marker_display = leaked_marker.display().to_string();
|
||||
assert!(
|
||||
!leaked_marker_display.contains('\''),
|
||||
"test workspace path should not contain single quotes: {leaked_marker_display}"
|
||||
);
|
||||
|
||||
let Some(zsh_path) = find_test_zsh_path()? else {
|
||||
eprintln!("skipping zsh fork interrupt cleanup test: no zsh executable found");
|
||||
return Ok(());
|
||||
};
|
||||
if !supports_exec_wrapper_intercept(&zsh_path) {
|
||||
eprintln!(
|
||||
"skipping zsh fork interrupt cleanup test: zsh does not support EXEC_WRAPPER intercepts ({})",
|
||||
zsh_path.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let zsh_path_display = zsh_path.display().to_string();
|
||||
eprintln!("using zsh path for zsh-fork test: {zsh_path_display}");
|
||||
|
||||
let shell_command = format!(
|
||||
"/bin/sh -c 'echo started > \"{launch_marker_display}\" && /bin/sleep 0.5 && echo leaked > \"{leaked_marker_display}\" && exec /bin/sleep 100'"
|
||||
);
|
||||
let tool_call_arguments = serde_json::to_string(&json!({
|
||||
"cmd": shell_command,
|
||||
"yield_time_ms": 30_000,
|
||||
}))?;
|
||||
let response = responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(
|
||||
"call-zsh-fork-interrupt-cleanup",
|
||||
"exec_command",
|
||||
&tool_call_arguments,
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
]);
|
||||
let no_op_response = responses::sse(vec![
|
||||
responses::ev_response_created("resp-2"),
|
||||
responses::ev_completed("resp-2"),
|
||||
]);
|
||||
let server =
|
||||
create_mock_responses_server_sequence_unchecked(vec![response, no_op_response]).await;
|
||||
create_config_toml(
|
||||
&codex_home,
|
||||
&server.uri(),
|
||||
"untrusted",
|
||||
&BTreeMap::from([
|
||||
(Feature::ShellZshFork, true),
|
||||
(Feature::UnifiedExec, true),
|
||||
(Feature::ShellSnapshot, false),
|
||||
]),
|
||||
&zsh_path,
|
||||
)?;
|
||||
|
||||
let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let start_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
cwd: Some(workspace.to_string_lossy().into_owned()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let start_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
|
||||
|
||||
let turn_id = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "run the long-lived command".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
cwd: Some(workspace.clone()),
|
||||
approval_policy: Some(codex_app_server_protocol::AskForApproval::UnlessTrusted),
|
||||
sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![workspace.clone().try_into()?],
|
||||
read_only_access: codex_app_server_protocol::ReadOnlyAccess::FullAccess,
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
}),
|
||||
model: Some("mock-model".to_string()),
|
||||
effort: Some(codex_protocol::openai_models::ReasoningEffort::Medium),
|
||||
summary: Some(codex_protocol::config_types::ReasoningSummary::Auto),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
|
||||
)
|
||||
.await??;
|
||||
let TurnStartResponse { turn } = to_response::<TurnStartResponse>(turn_resp)?;
|
||||
|
||||
let mut saw_target_approval = false;
|
||||
while !saw_target_approval {
|
||||
let server_req = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_request_message(),
|
||||
)
|
||||
.await??;
|
||||
let ServerRequest::CommandExecutionRequestApproval { request_id, params } = server_req
|
||||
else {
|
||||
panic!("expected CommandExecutionRequestApproval request");
|
||||
};
|
||||
let approval_command = params.command.clone().unwrap_or_default();
|
||||
saw_target_approval = approval_command.contains("/bin/sh")
|
||||
&& approval_command.contains(&launch_marker_display)
|
||||
&& !approval_command.contains(&zsh_path_display);
|
||||
mcp.send_response(
|
||||
request_id,
|
||||
serde_json::to_value(CommandExecutionRequestApprovalResponse {
|
||||
decision: CommandExecutionApprovalDecision::Accept,
|
||||
})?,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let started_command = timeout(DEFAULT_READ_TIMEOUT, async {
|
||||
loop {
|
||||
let notif = mcp
|
||||
.read_stream_until_notification_message("item/started")
|
||||
.await?;
|
||||
let started: ItemStartedNotification =
|
||||
serde_json::from_value(notif.params.clone().expect("item/started params"))?;
|
||||
if let ThreadItem::CommandExecution { .. } = started.item {
|
||||
return Ok::<ThreadItem, anyhow::Error>(started.item);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
let ThreadItem::CommandExecution {
|
||||
id,
|
||||
process_id,
|
||||
status,
|
||||
command,
|
||||
cwd,
|
||||
..
|
||||
} = started_command
|
||||
else {
|
||||
unreachable!("loop ensures we break on command execution items");
|
||||
};
|
||||
assert_eq!(id, "call-zsh-fork-interrupt-cleanup");
|
||||
assert_eq!(status, CommandExecutionStatus::InProgress);
|
||||
assert!(command.starts_with(&zsh_path.display().to_string()));
|
||||
assert!(command.contains(" -lc "));
|
||||
assert!(command.contains(&launch_marker_display));
|
||||
assert_eq!(cwd, workspace);
|
||||
assert!(process_id.is_some(), "process id should be present");
|
||||
|
||||
timeout(DEFAULT_READ_TIMEOUT, async {
|
||||
loop {
|
||||
if launch_marker.exists() {
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
sleep(std::time::Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
|
||||
mcp.interrupt_turn_and_wait_for_aborted(
|
||||
thread.id.clone(),
|
||||
turn.id.clone(),
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
|
||||
sleep(std::time::Duration::from_millis(750)).await;
|
||||
assert!(
|
||||
!leaked_marker.exists(),
|
||||
"expected interrupt to stop approved subcommand before it wrote {leaked_marker_display}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -654,15 +472,16 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
|
||||
first_file.display(),
|
||||
second_file.display()
|
||||
);
|
||||
let tool_call_arguments = serde_json::to_string(&json!({
|
||||
"cmd": shell_command,
|
||||
"yield_time_ms": 5000,
|
||||
let tool_call_arguments = serde_json::to_string(&serde_json::json!({
|
||||
"command": shell_command,
|
||||
"workdir": serde_json::Value::Null,
|
||||
"timeout_ms": 5000
|
||||
}))?;
|
||||
let response = responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(
|
||||
"call-zsh-fork-subcommand-decline",
|
||||
"exec_command",
|
||||
"shell_command",
|
||||
&tool_call_arguments,
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
@@ -683,7 +502,7 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
|
||||
"untrusted",
|
||||
&BTreeMap::from([
|
||||
(Feature::ShellZshFork, true),
|
||||
(Feature::UnifiedExec, true),
|
||||
(Feature::UnifiedExec, false),
|
||||
(Feature::ShellSnapshot, false),
|
||||
]),
|
||||
&zsh_path,
|
||||
@@ -925,21 +744,6 @@ async fn create_zsh_test_mcp_process(codex_home: &Path, zdotdir: &Path) -> Resul
|
||||
McpProcess::new_with_env(codex_home, &[("ZDOTDIR", Some(zdotdir.as_str()))]).await
|
||||
}
|
||||
|
||||
fn create_zsh_fork_exec_command_sse_response(
|
||||
command: &str,
|
||||
call_id: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let tool_call_arguments = serde_json::to_string(&json!({
|
||||
"cmd": command,
|
||||
"yield_time_ms": 5000,
|
||||
}))?;
|
||||
Ok(responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(call_id, "exec_command", &tool_call_arguments),
|
||||
responses::ev_completed("resp-1"),
|
||||
]))
|
||||
}
|
||||
|
||||
fn create_config_toml(
|
||||
codex_home: &Path,
|
||||
server_uri: &str,
|
||||
|
||||
@@ -103,7 +103,6 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec<Stri
|
||||
¶ms,
|
||||
invocation.session.user_shell(),
|
||||
invocation.turn.tools_config.allow_login_shell,
|
||||
invocation.turn.tools_config.unified_exec_backend,
|
||||
)
|
||||
.ok()?;
|
||||
Some((command, invocation.turn.resolve_path(params.workdir)))
|
||||
|
||||
@@ -19,7 +19,6 @@ use crate::tools::handlers::parse_arguments_with_base_path;
|
||||
use crate::tools::handlers::resolve_workdir_base_path;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use crate::tools::spec::UnifiedExecBackendConfig;
|
||||
use crate::unified_exec::ExecCommandRequest;
|
||||
use crate::unified_exec::UnifiedExecContext;
|
||||
use crate::unified_exec::UnifiedExecProcessManager;
|
||||
@@ -109,7 +108,6 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
¶ms,
|
||||
invocation.session.user_shell(),
|
||||
invocation.turn.tools_config.allow_login_shell,
|
||||
invocation.turn.tools_config.unified_exec_backend,
|
||||
) {
|
||||
Ok(command) => command,
|
||||
Err(_) => return true,
|
||||
@@ -157,7 +155,6 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
&args,
|
||||
session.user_shell(),
|
||||
turn.tools_config.allow_login_shell,
|
||||
turn.tools_config.unified_exec_backend,
|
||||
)
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
|
||||
@@ -324,23 +321,12 @@ pub(crate) fn get_command(
|
||||
args: &ExecCommandArgs,
|
||||
session_shell: Arc<Shell>,
|
||||
allow_login_shell: bool,
|
||||
unified_exec_backend: UnifiedExecBackendConfig,
|
||||
) -> Result<Vec<String>, String> {
|
||||
if unified_exec_backend == UnifiedExecBackendConfig::ZshFork && args.shell.is_some() {
|
||||
return Err(
|
||||
"shell override is not supported when the zsh-fork backend is enabled.".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let model_shell = if unified_exec_backend == UnifiedExecBackendConfig::ZshFork {
|
||||
None
|
||||
} else {
|
||||
args.shell.as_ref().map(|shell_str| {
|
||||
let mut shell = get_shell_by_model_provided_path(&PathBuf::from(shell_str));
|
||||
shell.shell_snapshot = crate::shell::empty_shell_snapshot_receiver();
|
||||
shell
|
||||
})
|
||||
};
|
||||
let model_shell = args.shell.as_ref().map(|shell_str| {
|
||||
let mut shell = get_shell_by_model_provided_path(&PathBuf::from(shell_str));
|
||||
shell.shell_snapshot = crate::shell::empty_shell_snapshot_receiver();
|
||||
shell
|
||||
});
|
||||
|
||||
let shell = model_shell.as_ref().unwrap_or(session_shell.as_ref());
|
||||
let use_login_shell = match args.login {
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
use super::*;
|
||||
use crate::shell::ShellType;
|
||||
use crate::shell::default_user_shell;
|
||||
use crate::shell::empty_shell_snapshot_receiver;
|
||||
use crate::tools::handlers::parse_arguments_with_base_path;
|
||||
use crate::tools::handlers::resolve_workdir_base_path;
|
||||
use crate::tools::spec::UnifiedExecBackendConfig;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
|
||||
@@ -22,13 +18,8 @@ fn test_get_command_uses_default_shell_when_unspecified() -> anyhow::Result<()>
|
||||
|
||||
assert!(args.shell.is_none());
|
||||
|
||||
let command = get_command(
|
||||
&args,
|
||||
Arc::new(default_user_shell()),
|
||||
true,
|
||||
UnifiedExecBackendConfig::Direct,
|
||||
)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let command =
|
||||
get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?;
|
||||
|
||||
assert_eq!(command.len(), 3);
|
||||
assert_eq!(command[2], "echo hello");
|
||||
@@ -43,13 +34,8 @@ fn test_get_command_respects_explicit_bash_shell() -> anyhow::Result<()> {
|
||||
|
||||
assert_eq!(args.shell.as_deref(), Some("/bin/bash"));
|
||||
|
||||
let command = get_command(
|
||||
&args,
|
||||
Arc::new(default_user_shell()),
|
||||
true,
|
||||
UnifiedExecBackendConfig::Direct,
|
||||
)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let command =
|
||||
get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?;
|
||||
|
||||
assert_eq!(command.last(), Some(&"echo hello".to_string()));
|
||||
if command
|
||||
@@ -69,13 +55,8 @@ fn test_get_command_respects_explicit_powershell_shell() -> anyhow::Result<()> {
|
||||
|
||||
assert_eq!(args.shell.as_deref(), Some("powershell"));
|
||||
|
||||
let command = get_command(
|
||||
&args,
|
||||
Arc::new(default_user_shell()),
|
||||
true,
|
||||
UnifiedExecBackendConfig::Direct,
|
||||
)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let command =
|
||||
get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?;
|
||||
|
||||
assert_eq!(command[2], "echo hello");
|
||||
Ok(())
|
||||
@@ -89,13 +70,8 @@ fn test_get_command_respects_explicit_cmd_shell() -> anyhow::Result<()> {
|
||||
|
||||
assert_eq!(args.shell.as_deref(), Some("cmd"));
|
||||
|
||||
let command = get_command(
|
||||
&args,
|
||||
Arc::new(default_user_shell()),
|
||||
true,
|
||||
UnifiedExecBackendConfig::Direct,
|
||||
)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let command =
|
||||
get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?;
|
||||
|
||||
assert_eq!(command[2], "echo hello");
|
||||
Ok(())
|
||||
@@ -106,13 +82,8 @@ fn test_get_command_rejects_explicit_login_when_disallowed() -> anyhow::Result<(
|
||||
let json = r#"{"cmd": "echo hello", "login": true}"#;
|
||||
|
||||
let args: ExecCommandArgs = parse_arguments(json)?;
|
||||
let err = get_command(
|
||||
&args,
|
||||
Arc::new(default_user_shell()),
|
||||
false,
|
||||
UnifiedExecBackendConfig::Direct,
|
||||
)
|
||||
.expect_err("explicit login should be rejected");
|
||||
let err = get_command(&args, Arc::new(default_user_shell()), false)
|
||||
.expect_err("explicit login should be rejected");
|
||||
|
||||
assert!(
|
||||
err.contains("login shell is disabled by config"),
|
||||
@@ -121,30 +92,6 @@ fn test_get_command_rejects_explicit_login_when_disallowed() -> anyhow::Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_command_rejects_model_shell_override_for_zsh_fork_backend() -> anyhow::Result<()> {
|
||||
let json = r#"{"cmd": "echo hello", "shell": "/bin/bash"}"#;
|
||||
let args: ExecCommandArgs = parse_arguments(json)?;
|
||||
|
||||
let session_shell = Arc::new(Shell {
|
||||
shell_type: ShellType::Zsh,
|
||||
shell_path: PathBuf::from("/tmp/configured-zsh-fork-shell"),
|
||||
shell_snapshot: empty_shell_snapshot_receiver(),
|
||||
});
|
||||
let err = get_command(
|
||||
&args,
|
||||
session_shell,
|
||||
true,
|
||||
UnifiedExecBackendConfig::ZshFork,
|
||||
)
|
||||
.expect_err("shell override should be rejected for zsh-fork backend");
|
||||
assert!(
|
||||
err.contains("shell override is not supported"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_command_args_resolve_relative_additional_permissions_against_workdir() -> anyhow::Result<()>
|
||||
{
|
||||
|
||||
@@ -50,10 +50,10 @@ use codex_shell_escalation::ShellCommandExecutor;
|
||||
use codex_shell_escalation::Stopwatch;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -91,7 +91,7 @@ pub(super) async fn try_run_zsh_fork(
|
||||
req: &ShellRequest,
|
||||
attempt: &SandboxAttempt<'_>,
|
||||
ctx: &ToolCtx,
|
||||
shell_command: &[String],
|
||||
command: &[String],
|
||||
) -> Result<Option<ExecToolCallOutput>, ToolError> {
|
||||
let Some(shell_zsh_path) = ctx.session.services.shell_zsh_path.as_ref() else {
|
||||
tracing::warn!("ZshFork backend specified, but shell_zsh_path is not configured.");
|
||||
@@ -106,10 +106,8 @@ pub(super) async fn try_run_zsh_fork(
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let ParsedShellCommand { script, login, .. } = extract_shell_script(shell_command)?;
|
||||
|
||||
let spec = build_command_spec(
|
||||
shell_command,
|
||||
command,
|
||||
&req.cwd,
|
||||
&req.env,
|
||||
req.timeout_ms.into(),
|
||||
@@ -121,14 +119,14 @@ pub(super) async fn try_run_zsh_fork(
|
||||
.env_for(spec, req.network.as_ref())
|
||||
.map_err(|err| ToolError::Codex(err.into()))?;
|
||||
let crate::sandboxing::ExecRequest {
|
||||
command: sandbox_command,
|
||||
command,
|
||||
cwd: sandbox_cwd,
|
||||
env: sandbox_env,
|
||||
network: sandbox_network,
|
||||
expiration: _sandbox_expiration,
|
||||
sandbox,
|
||||
windows_sandbox_level,
|
||||
windows_sandbox_private_desktop,
|
||||
windows_sandbox_private_desktop: _windows_sandbox_private_desktop,
|
||||
sandbox_permissions,
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
@@ -136,14 +134,16 @@ pub(super) async fn try_run_zsh_fork(
|
||||
justification,
|
||||
arg0,
|
||||
} = sandbox_exec_request;
|
||||
let host_zsh_path =
|
||||
resolve_host_zsh_path(sandbox_env.get("PATH").map(String::as_str), &sandbox_cwd);
|
||||
let ParsedShellCommand { script, login, .. } = extract_shell_script(&command)?;
|
||||
let effective_timeout = Duration::from_millis(
|
||||
req.timeout_ms
|
||||
.unwrap_or(crate::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS),
|
||||
);
|
||||
let exec_policy = Arc::new(RwLock::new(
|
||||
ctx.session.services.exec_policy.current().as_ref().clone(),
|
||||
));
|
||||
let command_executor = CoreShellCommandExecutor {
|
||||
command: sandbox_command,
|
||||
command,
|
||||
cwd: sandbox_cwd,
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
@@ -152,7 +152,6 @@ pub(super) async fn try_run_zsh_fork(
|
||||
env: sandbox_env,
|
||||
network: sandbox_network,
|
||||
windows_sandbox_level,
|
||||
windows_sandbox_private_desktop,
|
||||
sandbox_permissions,
|
||||
justification,
|
||||
arg0,
|
||||
@@ -165,8 +164,6 @@ pub(super) async fn try_run_zsh_fork(
|
||||
.clone(),
|
||||
codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(),
|
||||
use_legacy_landlock: ctx.turn.features.use_legacy_landlock(),
|
||||
shell_zsh_path: ctx.session.services.shell_zsh_path.clone(),
|
||||
host_zsh_path: host_zsh_path.clone(),
|
||||
};
|
||||
let main_execve_wrapper_exe = ctx
|
||||
.session
|
||||
@@ -195,6 +192,7 @@ pub(super) async fn try_run_zsh_fork(
|
||||
req.additional_permissions_preapproved,
|
||||
);
|
||||
let escalation_policy = CoreShellActionProvider {
|
||||
policy: Arc::clone(&exec_policy),
|
||||
session: Arc::clone(&ctx.session),
|
||||
turn: Arc::clone(&ctx.turn),
|
||||
call_id: ctx.call_id.clone(),
|
||||
@@ -207,7 +205,6 @@ pub(super) async fn try_run_zsh_fork(
|
||||
approval_sandbox_permissions,
|
||||
prompt_permissions: req.additional_permissions.clone(),
|
||||
stopwatch: stopwatch.clone(),
|
||||
host_zsh_path,
|
||||
};
|
||||
|
||||
let escalate_server = EscalateServer::new(
|
||||
@@ -228,7 +225,6 @@ pub(crate) async fn prepare_unified_exec_zsh_fork(
|
||||
req: &crate::tools::runtimes::unified_exec::UnifiedExecRequest,
|
||||
_attempt: &SandboxAttempt<'_>,
|
||||
ctx: &ToolCtx,
|
||||
shell_command: &[String],
|
||||
exec_request: ExecRequest,
|
||||
) -> Result<Option<PreparedUnifiedExecZshFork>, ToolError> {
|
||||
let Some(shell_zsh_path) = ctx.session.services.shell_zsh_path.as_ref() else {
|
||||
@@ -244,7 +240,7 @@ pub(crate) async fn prepare_unified_exec_zsh_fork(
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let parsed = match extract_shell_script(shell_command) {
|
||||
let parsed = match extract_shell_script(&exec_request.command) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => {
|
||||
tracing::warn!("ZshFork unified exec fallback: {err:?}");
|
||||
@@ -260,37 +256,22 @@ pub(crate) async fn prepare_unified_exec_zsh_fork(
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let ExecRequest {
|
||||
command,
|
||||
cwd,
|
||||
env,
|
||||
network,
|
||||
expiration: _expiration,
|
||||
sandbox,
|
||||
windows_sandbox_level,
|
||||
windows_sandbox_private_desktop,
|
||||
sandbox_permissions,
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
justification,
|
||||
arg0,
|
||||
} = &exec_request;
|
||||
let host_zsh_path = resolve_host_zsh_path(env.get("PATH").map(String::as_str), cwd);
|
||||
let exec_policy = Arc::new(RwLock::new(
|
||||
ctx.session.services.exec_policy.current().as_ref().clone(),
|
||||
));
|
||||
let command_executor = CoreShellCommandExecutor {
|
||||
command: command.clone(),
|
||||
cwd: cwd.clone(),
|
||||
sandbox_policy: sandbox_policy.clone(),
|
||||
file_system_sandbox_policy: file_system_sandbox_policy.clone(),
|
||||
network_sandbox_policy: *network_sandbox_policy,
|
||||
sandbox: *sandbox,
|
||||
env: env.clone(),
|
||||
network: network.clone(),
|
||||
windows_sandbox_level: *windows_sandbox_level,
|
||||
windows_sandbox_private_desktop: *windows_sandbox_private_desktop,
|
||||
sandbox_permissions: *sandbox_permissions,
|
||||
justification: justification.clone(),
|
||||
arg0: arg0.clone(),
|
||||
command: exec_request.command.clone(),
|
||||
cwd: exec_request.cwd.clone(),
|
||||
sandbox_policy: exec_request.sandbox_policy.clone(),
|
||||
file_system_sandbox_policy: exec_request.file_system_sandbox_policy.clone(),
|
||||
network_sandbox_policy: exec_request.network_sandbox_policy,
|
||||
sandbox: exec_request.sandbox,
|
||||
env: exec_request.env.clone(),
|
||||
network: exec_request.network.clone(),
|
||||
windows_sandbox_level: exec_request.windows_sandbox_level,
|
||||
sandbox_permissions: exec_request.sandbox_permissions,
|
||||
justification: exec_request.justification.clone(),
|
||||
arg0: exec_request.arg0.clone(),
|
||||
sandbox_policy_cwd: ctx.turn.cwd.clone(),
|
||||
macos_seatbelt_profile_extensions: ctx
|
||||
.turn
|
||||
@@ -300,8 +281,6 @@ pub(crate) async fn prepare_unified_exec_zsh_fork(
|
||||
.clone(),
|
||||
codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(),
|
||||
use_legacy_landlock: ctx.turn.features.use_legacy_landlock(),
|
||||
shell_zsh_path: ctx.session.services.shell_zsh_path.clone(),
|
||||
host_zsh_path: host_zsh_path.clone(),
|
||||
};
|
||||
let main_execve_wrapper_exe = ctx
|
||||
.session
|
||||
@@ -314,6 +293,7 @@ pub(crate) async fn prepare_unified_exec_zsh_fork(
|
||||
)
|
||||
})?;
|
||||
let escalation_policy = CoreShellActionProvider {
|
||||
policy: Arc::clone(&exec_policy),
|
||||
session: Arc::clone(&ctx.session),
|
||||
turn: Arc::clone(&ctx.turn),
|
||||
call_id: ctx.call_id.clone(),
|
||||
@@ -329,7 +309,6 @@ pub(crate) async fn prepare_unified_exec_zsh_fork(
|
||||
),
|
||||
prompt_permissions: req.additional_permissions.clone(),
|
||||
stopwatch: Stopwatch::unlimited(),
|
||||
host_zsh_path,
|
||||
};
|
||||
|
||||
let escalate_server = EscalateServer::new(
|
||||
@@ -349,6 +328,7 @@ pub(crate) async fn prepare_unified_exec_zsh_fork(
|
||||
}
|
||||
|
||||
struct CoreShellActionProvider {
|
||||
policy: Arc<RwLock<Policy>>,
|
||||
session: Arc<crate::codex::Session>,
|
||||
turn: Arc<crate::codex::TurnContext>,
|
||||
call_id: String,
|
||||
@@ -361,7 +341,6 @@ struct CoreShellActionProvider {
|
||||
approval_sandbox_permissions: SandboxPermissions,
|
||||
prompt_permissions: Option<PermissionProfile>,
|
||||
stopwatch: Stopwatch,
|
||||
host_zsh_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
@@ -399,66 +378,6 @@ fn execve_prompt_is_rejected_by_policy(
|
||||
}
|
||||
}
|
||||
|
||||
fn paths_match(lhs: &Path, rhs: &Path) -> bool {
|
||||
lhs == rhs
|
||||
|| match (lhs.canonicalize(), rhs.canonicalize()) {
|
||||
(Ok(lhs), Ok(rhs)) => lhs == rhs,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_host_zsh_path(_path_env: Option<&str>, _cwd: &Path) -> Option<PathBuf> {
|
||||
fn canonicalize_best_effort(path: PathBuf) -> PathBuf {
|
||||
path.canonicalize().unwrap_or(path)
|
||||
}
|
||||
|
||||
fn is_executable_file(path: &Path) -> bool {
|
||||
std::fs::metadata(path).is_ok_and(|metadata| {
|
||||
metadata.is_file() && {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
metadata.permissions().mode() & 0o111 != 0
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn find_zsh_in_dirs(dirs: impl IntoIterator<Item = PathBuf>) -> Option<PathBuf> {
|
||||
dirs.into_iter().find_map(|dir| {
|
||||
let candidate = dir.join("zsh");
|
||||
is_executable_file(&candidate).then(|| canonicalize_best_effort(candidate))
|
||||
})
|
||||
}
|
||||
|
||||
// Keep nested-zsh rewrites limited to canonical host shell installations.
|
||||
// PATH shadowing from repos, Nix environments, or tool shims should not be
|
||||
// treated as the host shell.
|
||||
find_zsh_in_dirs(
|
||||
["/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"]
|
||||
.into_iter()
|
||||
.map(PathBuf::from),
|
||||
)
|
||||
}
|
||||
|
||||
fn is_unconfigured_zsh_exec(
|
||||
program: &AbsolutePathBuf,
|
||||
shell_zsh_path: Option<&Path>,
|
||||
host_zsh_path: Option<&Path>,
|
||||
) -> bool {
|
||||
let Some(shell_zsh_path) = shell_zsh_path else {
|
||||
return false;
|
||||
};
|
||||
let Some(host_zsh_path) = host_zsh_path else {
|
||||
return false;
|
||||
};
|
||||
paths_match(program.as_path(), host_zsh_path) && !paths_match(program.as_path(), shell_zsh_path)
|
||||
}
|
||||
|
||||
impl CoreShellActionProvider {
|
||||
fn decision_driven_by_policy(matched_rules: &[RuleMatch], decision: Decision) -> bool {
|
||||
matched_rules.iter().any(|rule_match| {
|
||||
@@ -570,10 +489,6 @@ impl CoreShellActionProvider {
|
||||
command,
|
||||
workdir,
|
||||
None,
|
||||
// Intercepted exec prompts happen after the original tool call has
|
||||
// started, so we do not attach an execpolicy amendment payload here.
|
||||
// Amendments are currently surfaced only from the top-level tool
|
||||
// request path.
|
||||
None,
|
||||
None,
|
||||
additional_permissions,
|
||||
@@ -798,35 +713,28 @@ impl EscalationPolicy for CoreShellActionProvider {
|
||||
.await;
|
||||
}
|
||||
|
||||
let policy = self.session.services.exec_policy.current();
|
||||
let evaluation = evaluate_intercepted_exec_policy(
|
||||
policy.as_ref(),
|
||||
program,
|
||||
argv,
|
||||
InterceptedExecPolicyContext {
|
||||
approval_policy: self.approval_policy,
|
||||
sandbox_policy: &self.sandbox_policy,
|
||||
file_system_sandbox_policy: &self.file_system_sandbox_policy,
|
||||
sandbox_permissions: self.approval_sandbox_permissions,
|
||||
enable_shell_wrapper_parsing: ENABLE_INTERCEPTED_EXEC_POLICY_SHELL_WRAPPER_PARSING,
|
||||
},
|
||||
);
|
||||
let evaluation = {
|
||||
let policy = self.policy.read().await;
|
||||
evaluate_intercepted_exec_policy(
|
||||
&policy,
|
||||
program,
|
||||
argv,
|
||||
InterceptedExecPolicyContext {
|
||||
approval_policy: self.approval_policy,
|
||||
sandbox_policy: &self.sandbox_policy,
|
||||
file_system_sandbox_policy: &self.file_system_sandbox_policy,
|
||||
sandbox_permissions: self.approval_sandbox_permissions,
|
||||
enable_shell_wrapper_parsing:
|
||||
ENABLE_INTERCEPTED_EXEC_POLICY_SHELL_WRAPPER_PARSING,
|
||||
},
|
||||
)
|
||||
};
|
||||
// When true, means the Evaluation was due to *.rules, not the
|
||||
// fallback function.
|
||||
let decision_driven_by_policy =
|
||||
Self::decision_driven_by_policy(&evaluation.matched_rules, evaluation.decision);
|
||||
// Keep zsh-fork interception alive across nested shells: if an
|
||||
// intercepted exec targets the known host `zsh` path instead of the
|
||||
// configured zsh-fork binary, force it through escalation so the
|
||||
// executor can rewrite the program path back to the configured shell.
|
||||
let force_zsh_fork_reexec = is_unconfigured_zsh_exec(
|
||||
program,
|
||||
self.session.services.shell_zsh_path.as_deref(),
|
||||
self.host_zsh_path.as_deref(),
|
||||
);
|
||||
let needs_escalation = self.sandbox_permissions.requires_escalated_permissions()
|
||||
|| decision_driven_by_policy
|
||||
|| force_zsh_fork_reexec;
|
||||
let needs_escalation =
|
||||
self.sandbox_permissions.requires_escalated_permissions() || decision_driven_by_policy;
|
||||
|
||||
let decision_source = if decision_driven_by_policy {
|
||||
DecisionSource::PrefixRule
|
||||
@@ -967,7 +875,6 @@ struct CoreShellCommandExecutor {
|
||||
env: HashMap<String, String>,
|
||||
network: Option<codex_network_proxy::NetworkProxy>,
|
||||
windows_sandbox_level: WindowsSandboxLevel,
|
||||
windows_sandbox_private_desktop: bool,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
justification: Option<String>,
|
||||
arg0: Option<String>,
|
||||
@@ -976,8 +883,6 @@ struct CoreShellCommandExecutor {
|
||||
macos_seatbelt_profile_extensions: Option<MacOsSeatbeltProfileExtensions>,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
use_legacy_landlock: bool,
|
||||
shell_zsh_path: Option<PathBuf>,
|
||||
host_zsh_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
struct PrepareSandboxedExecParams<'a> {
|
||||
@@ -1020,7 +925,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
|
||||
expiration: ExecExpiration::Cancellation(cancel_rx),
|
||||
sandbox: self.sandbox,
|
||||
windows_sandbox_level: self.windows_sandbox_level,
|
||||
windows_sandbox_private_desktop: self.windows_sandbox_private_desktop,
|
||||
windows_sandbox_private_desktop: false,
|
||||
sandbox_permissions: self.sandbox_permissions,
|
||||
sandbox_policy: self.sandbox_policy.clone(),
|
||||
file_system_sandbox_policy: self.file_system_sandbox_policy.clone(),
|
||||
@@ -1051,8 +956,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
|
||||
env: HashMap<String, String>,
|
||||
execution: EscalationExecution,
|
||||
) -> anyhow::Result<PreparedExec> {
|
||||
let program = self.rewrite_intercepted_program_for_zsh_fork(program);
|
||||
let command = join_program_and_argv(&program, argv);
|
||||
let command = join_program_and_argv(program, argv);
|
||||
let Some(first_arg) = argv.first() else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"intercepted exec request must contain argv[0]"
|
||||
@@ -1123,33 +1027,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
|
||||
}
|
||||
|
||||
impl CoreShellCommandExecutor {
|
||||
fn rewrite_intercepted_program_for_zsh_fork(
|
||||
&self,
|
||||
program: &AbsolutePathBuf,
|
||||
) -> AbsolutePathBuf {
|
||||
let Some(shell_zsh_path) = self.shell_zsh_path.as_ref() else {
|
||||
return program.clone();
|
||||
};
|
||||
if !is_unconfigured_zsh_exec(
|
||||
program,
|
||||
Some(shell_zsh_path.as_path()),
|
||||
self.host_zsh_path.as_deref(),
|
||||
) {
|
||||
return program.clone();
|
||||
}
|
||||
match AbsolutePathBuf::from_absolute_path(shell_zsh_path) {
|
||||
Ok(rewritten) => rewritten,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"failed to rewrite intercepted zsh path {} to configured shell {}: {err}",
|
||||
program.display(),
|
||||
shell_zsh_path.display(),
|
||||
);
|
||||
program.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn prepare_sandboxed_exec(
|
||||
&self,
|
||||
params: PrepareSandboxedExecParams<'_>,
|
||||
@@ -1204,7 +1082,7 @@ impl CoreShellCommandExecutor {
|
||||
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.as_ref(),
|
||||
use_legacy_landlock: self.use_legacy_landlock,
|
||||
windows_sandbox_level: self.windows_sandbox_level,
|
||||
windows_sandbox_private_desktop: self.windows_sandbox_private_desktop,
|
||||
windows_sandbox_private_desktop: false,
|
||||
})?;
|
||||
if let Some(network) = exec_request.network.as_ref() {
|
||||
network.apply_to_env(&mut exec_request.env);
|
||||
@@ -1227,21 +1105,23 @@ struct ParsedShellCommand {
|
||||
}
|
||||
|
||||
fn extract_shell_script(command: &[String]) -> Result<ParsedShellCommand, ToolError> {
|
||||
if let [program, flag, script, ..] = command {
|
||||
if flag == "-c" {
|
||||
return Ok(ParsedShellCommand {
|
||||
program: program.to_owned(),
|
||||
script: script.to_owned(),
|
||||
login: false,
|
||||
});
|
||||
// Commands reaching zsh-fork can be wrapped by environment/sandbox helpers, so
|
||||
// we search for the first `-c`/`-lc` triple anywhere in the argv rather
|
||||
// than assuming it is the first positional form.
|
||||
if let Some((program, script, login)) = command.windows(3).find_map(|parts| match parts {
|
||||
[program, flag, script] if flag == "-c" => {
|
||||
Some((program.to_owned(), script.to_owned(), false))
|
||||
}
|
||||
if flag == "-lc" {
|
||||
return Ok(ParsedShellCommand {
|
||||
program: program.to_owned(),
|
||||
script: script.to_owned(),
|
||||
login: true,
|
||||
});
|
||||
[program, flag, script] if flag == "-lc" => {
|
||||
Some((program.to_owned(), script.to_owned(), true))
|
||||
}
|
||||
_ => None,
|
||||
}) {
|
||||
return Ok(ParsedShellCommand {
|
||||
program,
|
||||
script,
|
||||
login,
|
||||
});
|
||||
}
|
||||
|
||||
Err(ToolError::Rejected(
|
||||
|
||||
@@ -6,10 +6,8 @@ use super::ParsedShellCommand;
|
||||
use super::commands_for_intercepted_exec_policy;
|
||||
use super::evaluate_intercepted_exec_policy;
|
||||
use super::extract_shell_script;
|
||||
use super::is_unconfigured_zsh_exec;
|
||||
use super::join_program_and_argv;
|
||||
use super::map_exec_result;
|
||||
use super::resolve_host_zsh_path;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::config::Constrained;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -52,7 +50,6 @@ use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -206,16 +203,39 @@ fn extract_shell_script_preserves_login_flag() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_shell_script_rejects_wrapped_command_prefixes() {
|
||||
let err = extract_shell_script(&[
|
||||
"/usr/bin/env".into(),
|
||||
"CODEX_EXECVE_WRAPPER=1".into(),
|
||||
"/bin/zsh".into(),
|
||||
"-lc".into(),
|
||||
"echo hello".into(),
|
||||
])
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, super::ToolError::Rejected(_)));
|
||||
fn extract_shell_script_supports_wrapped_command_prefixes() {
|
||||
assert_eq!(
|
||||
extract_shell_script(&[
|
||||
"/usr/bin/env".into(),
|
||||
"CODEX_EXECVE_WRAPPER=1".into(),
|
||||
"/bin/zsh".into(),
|
||||
"-lc".into(),
|
||||
"echo hello".into()
|
||||
])
|
||||
.unwrap(),
|
||||
ParsedShellCommand {
|
||||
program: "/bin/zsh".to_string(),
|
||||
script: "echo hello".to_string(),
|
||||
login: true,
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
extract_shell_script(&[
|
||||
"sandbox-exec".into(),
|
||||
"-p".into(),
|
||||
"sandbox_policy".into(),
|
||||
"/bin/zsh".into(),
|
||||
"-c".into(),
|
||||
"pwd".into(),
|
||||
])
|
||||
.unwrap(),
|
||||
ParsedShellCommand {
|
||||
program: "/bin/zsh".to_string(),
|
||||
script: "pwd".to_string(),
|
||||
login: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -254,80 +274,6 @@ fn join_program_and_argv_replaces_original_argv_zero() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_unconfigured_zsh_exec_matches_non_configured_zsh_paths() {
|
||||
let program = AbsolutePathBuf::try_from(host_absolute_path(&["bin", "zsh"])).unwrap();
|
||||
let host = PathBuf::from(host_absolute_path(&["bin", "zsh"]));
|
||||
let configured = PathBuf::from(host_absolute_path(&["tmp", "codex-zsh"]));
|
||||
assert!(is_unconfigured_zsh_exec(
|
||||
&program,
|
||||
Some(configured.as_path()),
|
||||
Some(host.as_path()),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_unconfigured_zsh_exec_ignores_non_zsh_or_configured_paths() {
|
||||
let configured = PathBuf::from(host_absolute_path(&["tmp", "codex-zsh"]));
|
||||
let host = PathBuf::from(host_absolute_path(&["bin", "zsh"]));
|
||||
let configured_program = AbsolutePathBuf::try_from(configured.clone()).unwrap();
|
||||
assert!(!is_unconfigured_zsh_exec(
|
||||
&configured_program,
|
||||
Some(configured.as_path()),
|
||||
Some(host.as_path()),
|
||||
));
|
||||
|
||||
let non_zsh =
|
||||
AbsolutePathBuf::try_from(host_absolute_path(&["usr", "bin", "python3"])).unwrap();
|
||||
assert!(!is_unconfigured_zsh_exec(
|
||||
&non_zsh,
|
||||
Some(configured.as_path()),
|
||||
Some(host.as_path()),
|
||||
));
|
||||
assert!(!is_unconfigured_zsh_exec(
|
||||
&non_zsh,
|
||||
None,
|
||||
Some(host.as_path()),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_unconfigured_zsh_exec_does_not_match_non_host_zsh_named_binaries() {
|
||||
let program = AbsolutePathBuf::try_from(host_absolute_path(&["tmp", "repo", "zsh"])).unwrap();
|
||||
let configured = PathBuf::from(host_absolute_path(&["tmp", "codex-zsh"]));
|
||||
let host = PathBuf::from(host_absolute_path(&["bin", "zsh"]));
|
||||
assert!(!is_unconfigured_zsh_exec(
|
||||
&program,
|
||||
Some(configured.as_path()),
|
||||
Some(host.as_path()),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_host_zsh_path_ignores_repo_local_path_shadowing() {
|
||||
let shadow_dir = tempfile::tempdir().expect("create shadow dir");
|
||||
let cwd_dir = tempfile::tempdir().expect("create cwd dir");
|
||||
let fake_zsh = shadow_dir.path().join("zsh");
|
||||
std::fs::write(&fake_zsh, "#!/bin/sh\nexit 0\n").expect("write fake zsh");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut permissions = std::fs::metadata(&fake_zsh)
|
||||
.expect("metadata for fake zsh")
|
||||
.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
std::fs::set_permissions(&fake_zsh, permissions).expect("chmod fake zsh");
|
||||
}
|
||||
|
||||
let path_env =
|
||||
std::env::join_paths([shadow_dir.path(), Path::new("/usr/bin"), Path::new("/bin")])
|
||||
.expect("join PATH")
|
||||
.into_string()
|
||||
.expect("PATH should be UTF-8");
|
||||
let resolved = resolve_host_zsh_path(Some(&path_env), cwd_dir.path());
|
||||
assert_ne!(resolved.as_deref(), Some(fake_zsh.as_path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_for_intercepted_exec_policy_parses_plain_shell_wrappers() {
|
||||
let program = AbsolutePathBuf::try_from(host_absolute_path(&["bin", "bash"])).unwrap();
|
||||
@@ -714,7 +660,6 @@ async fn prepare_escalated_exec_turn_default_preserves_macos_seatbelt_extensions
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
windows_sandbox_private_desktop: false,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
justification: None,
|
||||
arg0: None,
|
||||
@@ -725,8 +670,6 @@ async fn prepare_escalated_exec_turn_default_preserves_macos_seatbelt_extensions
|
||||
}),
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_legacy_landlock: false,
|
||||
shell_zsh_path: None,
|
||||
host_zsh_path: None,
|
||||
};
|
||||
|
||||
let prepared = executor
|
||||
@@ -769,7 +712,6 @@ async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions()
|
||||
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
|
||||
network_sandbox_policy: NetworkSandboxPolicy::Enabled,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
windows_sandbox_private_desktop: false,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
justification: None,
|
||||
arg0: None,
|
||||
@@ -777,8 +719,6 @@ async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions()
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_legacy_landlock: false,
|
||||
shell_zsh_path: None,
|
||||
host_zsh_path: None,
|
||||
};
|
||||
|
||||
let permissions = Permissions {
|
||||
@@ -847,7 +787,6 @@ async fn prepare_escalated_exec_permission_profile_unions_turn_and_requested_mac
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy),
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
windows_sandbox_private_desktop: false,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
justification: None,
|
||||
arg0: None,
|
||||
@@ -858,8 +797,6 @@ async fn prepare_escalated_exec_permission_profile_unions_turn_and_requested_mac
|
||||
}),
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_legacy_landlock: false,
|
||||
shell_zsh_path: None,
|
||||
host_zsh_path: None,
|
||||
};
|
||||
|
||||
let prepared = executor
|
||||
|
||||
@@ -36,10 +36,9 @@ pub(crate) async fn maybe_prepare_unified_exec(
|
||||
req: &UnifiedExecRequest,
|
||||
attempt: &SandboxAttempt<'_>,
|
||||
ctx: &ToolCtx,
|
||||
shell_command: &[String],
|
||||
exec_request: ExecRequest,
|
||||
) -> Result<Option<PreparedUnifiedExecSpawn>, ToolError> {
|
||||
imp::maybe_prepare_unified_exec(req, attempt, ctx, shell_command, exec_request).await
|
||||
imp::maybe_prepare_unified_exec(req, attempt, ctx, exec_request).await
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -52,29 +51,21 @@ mod imp {
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ZshForkSpawnLifecycle {
|
||||
escalation_session: Option<EscalationSession>,
|
||||
escalation_session: EscalationSession,
|
||||
}
|
||||
|
||||
impl SpawnLifecycle for ZshForkSpawnLifecycle {
|
||||
fn inherited_fds(&self) -> Vec<i32> {
|
||||
self.escalation_session
|
||||
.as_ref()
|
||||
.and_then(|escalation_session| {
|
||||
escalation_session.env().get(ESCALATE_SOCKET_ENV_VAR)
|
||||
})
|
||||
.env()
|
||||
.get(ESCALATE_SOCKET_ENV_VAR)
|
||||
.and_then(|fd| fd.parse().ok())
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn after_spawn(&mut self) {
|
||||
if let Some(escalation_session) = self.escalation_session.as_ref() {
|
||||
escalation_session.close_client_socket();
|
||||
}
|
||||
}
|
||||
|
||||
fn after_exit(&mut self) {
|
||||
self.escalation_session = None;
|
||||
self.escalation_session.close_client_socket();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,17 +82,10 @@ mod imp {
|
||||
req: &UnifiedExecRequest,
|
||||
attempt: &SandboxAttempt<'_>,
|
||||
ctx: &ToolCtx,
|
||||
shell_command: &[String],
|
||||
exec_request: ExecRequest,
|
||||
) -> Result<Option<PreparedUnifiedExecSpawn>, ToolError> {
|
||||
let Some(prepared) = unix_escalation::prepare_unified_exec_zsh_fork(
|
||||
req,
|
||||
attempt,
|
||||
ctx,
|
||||
shell_command,
|
||||
exec_request,
|
||||
)
|
||||
.await?
|
||||
let Some(prepared) =
|
||||
unix_escalation::prepare_unified_exec_zsh_fork(req, attempt, ctx, exec_request).await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -109,7 +93,7 @@ mod imp {
|
||||
Ok(Some(PreparedUnifiedExecSpawn {
|
||||
exec_request: prepared.exec_request,
|
||||
spawn_lifecycle: Box::new(ZshForkSpawnLifecycle {
|
||||
escalation_session: Some(prepared.escalation_session),
|
||||
escalation_session: prepared.escalation_session,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
@@ -133,10 +117,9 @@ mod imp {
|
||||
req: &UnifiedExecRequest,
|
||||
attempt: &SandboxAttempt<'_>,
|
||||
ctx: &ToolCtx,
|
||||
shell_command: &[String],
|
||||
exec_request: ExecRequest,
|
||||
) -> Result<Option<PreparedUnifiedExecSpawn>, ToolError> {
|
||||
let _ = (req, attempt, ctx, shell_command, exec_request);
|
||||
let _ = (req, attempt, ctx, exec_request);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,11 +222,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
|
||||
let exec_env = attempt
|
||||
.env_for(spec, req.network.as_ref())
|
||||
.map_err(|err| ToolError::Codex(err.into()))?;
|
||||
match zsh_fork_backend::maybe_prepare_unified_exec(
|
||||
req, attempt, ctx, &command, exec_env,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
match zsh_fork_backend::maybe_prepare_unified_exec(req, attempt, ctx, exec_env).await? {
|
||||
Some(prepared) => {
|
||||
return self
|
||||
.manager
|
||||
|
||||
@@ -314,6 +314,8 @@ impl ToolsConfig {
|
||||
);
|
||||
let shell_type = if !features.enabled(Feature::ShellTool) {
|
||||
ConfigShellToolType::Disabled
|
||||
} else if features.enabled(Feature::ShellZshFork) {
|
||||
ConfigShellToolType::ShellCommand
|
||||
} else if features.enabled(Feature::UnifiedExec) && unified_exec_allowed {
|
||||
// If ConPTY not supported (for old Windows versions), fallback on ShellCommand.
|
||||
if codex_utils_pty::conpty_supported() {
|
||||
@@ -324,8 +326,6 @@ impl ToolsConfig {
|
||||
} else if model_info.shell_type == ConfigShellToolType::UnifiedExec && !unified_exec_allowed
|
||||
{
|
||||
ConfigShellToolType::ShellCommand
|
||||
} else if features.enabled(Feature::ShellZshFork) {
|
||||
ConfigShellToolType::ShellCommand
|
||||
} else {
|
||||
model_info.shell_type
|
||||
};
|
||||
@@ -583,7 +583,6 @@ fn create_approval_parameters(
|
||||
fn create_exec_command_tool(
|
||||
allow_login_shell: bool,
|
||||
exec_permission_approvals_enabled: bool,
|
||||
unified_exec_backend: UnifiedExecBackendConfig,
|
||||
) -> ToolSpec {
|
||||
let mut properties = BTreeMap::from([
|
||||
(
|
||||
@@ -601,6 +600,12 @@ fn create_exec_command_tool(
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"shell".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Shell binary to launch. Defaults to the user's default shell.".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"tty".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
@@ -628,16 +633,6 @@ fn create_exec_command_tool(
|
||||
},
|
||||
),
|
||||
]);
|
||||
if unified_exec_backend != UnifiedExecBackendConfig::ZshFork {
|
||||
properties.insert(
|
||||
"shell".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Shell binary to launch. Defaults to the user's default shell.".to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
if allow_login_shell {
|
||||
properties.insert(
|
||||
"login".to_string(),
|
||||
@@ -2534,7 +2529,6 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
create_exec_command_tool(
|
||||
config.allow_login_shell,
|
||||
exec_permission_approvals_enabled,
|
||||
config.unified_exec_backend,
|
||||
),
|
||||
true,
|
||||
config.code_mode_enabled,
|
||||
|
||||
@@ -446,7 +446,7 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() {
|
||||
// Build expected from the same helpers used by the builder.
|
||||
let mut expected: BTreeMap<String, ToolSpec> = BTreeMap::from([]);
|
||||
for spec in [
|
||||
create_exec_command_tool(true, false, UnifiedExecBackendConfig::Direct),
|
||||
create_exec_command_tool(true, false),
|
||||
create_write_stdin_tool(),
|
||||
PLAN_TOOL.clone(),
|
||||
create_request_user_input_tool(CollaborationModesConfig::default()),
|
||||
@@ -1364,7 +1364,7 @@ fn test_build_specs_default_shell_present() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_zsh_fork_uses_unified_exec_when_enabled() {
|
||||
fn shell_zsh_fork_prefers_shell_command_over_unified_exec() {
|
||||
let config = test_config();
|
||||
let model_info = ModelsManager::construct_model_info_offline_for_tests("o3", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
@@ -1382,7 +1382,7 @@ fn shell_zsh_fork_uses_unified_exec_when_enabled() {
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
});
|
||||
|
||||
assert_eq!(tools_config.shell_type, ConfigShellToolType::UnifiedExec);
|
||||
assert_eq!(tools_config.shell_type, ConfigShellToolType::ShellCommand);
|
||||
assert_eq!(
|
||||
tools_config.shell_command_backend,
|
||||
ShellCommandBackendConfig::ZshFork
|
||||
@@ -1391,19 +1391,6 @@ fn shell_zsh_fork_uses_unified_exec_when_enabled() {
|
||||
tools_config.unified_exec_backend,
|
||||
UnifiedExecBackendConfig::ZshFork
|
||||
);
|
||||
|
||||
let (tools, _) = build_specs(&tools_config, Some(HashMap::new()), None, &[]).build();
|
||||
let exec_spec = find_tool(&tools, "exec_command");
|
||||
let ToolSpec::Function(exec_tool) = &exec_spec.spec else {
|
||||
panic!("exec_command should be a function tool spec");
|
||||
};
|
||||
let JsonSchema::Object { properties, .. } = &exec_tool.parameters else {
|
||||
panic!("exec_command parameters should be an object schema");
|
||||
};
|
||||
assert!(
|
||||
!properties.contains_key("shell"),
|
||||
"exec_command should omit `shell` when zsh-fork backend forces the configured shell",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#![allow(clippy::module_inception)]
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tokio::sync::Mutex;
|
||||
@@ -37,13 +36,6 @@ pub(crate) trait SpawnLifecycle: std::fmt::Debug + Send + Sync {
|
||||
}
|
||||
|
||||
fn after_spawn(&mut self) {}
|
||||
|
||||
/// Releases resources that needed to stay alive until the child process was
|
||||
/// fully launched or until the session was torn down.
|
||||
///
|
||||
/// This hook must tolerate being called during normal process exit as well
|
||||
/// as early termination paths, and it is guaranteed to run at most once.
|
||||
fn after_exit(&mut self) {}
|
||||
}
|
||||
|
||||
pub(crate) type SpawnLifecycleHandle = Box<dyn SpawnLifecycle>;
|
||||
@@ -74,8 +66,7 @@ pub(crate) struct UnifiedExecProcess {
|
||||
output_drained: Arc<Notify>,
|
||||
output_task: JoinHandle<()>,
|
||||
sandbox_type: SandboxType,
|
||||
_spawn_lifecycle: Arc<StdMutex<SpawnLifecycleHandle>>,
|
||||
spawn_lifecycle_released: Arc<AtomicBool>,
|
||||
_spawn_lifecycle: SpawnLifecycleHandle,
|
||||
}
|
||||
|
||||
impl UnifiedExecProcess {
|
||||
@@ -83,7 +74,7 @@ impl UnifiedExecProcess {
|
||||
process_handle: ExecCommandSession,
|
||||
initial_output_rx: tokio::sync::broadcast::Receiver<Vec<u8>>,
|
||||
sandbox_type: SandboxType,
|
||||
spawn_lifecycle: Arc<StdMutex<SpawnLifecycleHandle>>,
|
||||
spawn_lifecycle: SpawnLifecycleHandle,
|
||||
) -> Self {
|
||||
let output_buffer = Arc::new(Mutex::new(HeadTailBuffer::default()));
|
||||
let output_notify = Arc::new(Notify::new());
|
||||
@@ -128,19 +119,6 @@ impl UnifiedExecProcess {
|
||||
output_task,
|
||||
sandbox_type,
|
||||
_spawn_lifecycle: spawn_lifecycle,
|
||||
spawn_lifecycle_released: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
fn release_spawn_lifecycle(&self) {
|
||||
if self
|
||||
.spawn_lifecycle_released
|
||||
.swap(true, std::sync::atomic::Ordering::AcqRel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Ok(mut lifecycle) = self._spawn_lifecycle.lock() {
|
||||
lifecycle.after_exit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +157,6 @@ impl UnifiedExecProcess {
|
||||
}
|
||||
|
||||
pub(super) fn terminate(&self) {
|
||||
self.release_spawn_lifecycle();
|
||||
self.output_closed.store(true, Ordering::Release);
|
||||
self.output_closed_notify.notify_waiters();
|
||||
self.process_handle.terminate();
|
||||
@@ -255,19 +232,12 @@ impl UnifiedExecProcess {
|
||||
mut exit_rx,
|
||||
} = spawned;
|
||||
let output_rx = codex_utils_pty::combine_output_receivers(stdout_rx, stderr_rx);
|
||||
let spawn_lifecycle = Arc::new(StdMutex::new(spawn_lifecycle));
|
||||
let managed = Self::new(
|
||||
process_handle,
|
||||
output_rx,
|
||||
sandbox_type,
|
||||
Arc::clone(&spawn_lifecycle),
|
||||
);
|
||||
let managed = Self::new(process_handle, output_rx, sandbox_type, spawn_lifecycle);
|
||||
|
||||
let exit_ready = matches!(exit_rx.try_recv(), Ok(_) | Err(TryRecvError::Closed));
|
||||
|
||||
if exit_ready {
|
||||
managed.signal_exit();
|
||||
managed.release_spawn_lifecycle();
|
||||
managed.check_for_sandbox_denial().await?;
|
||||
return Ok(managed);
|
||||
}
|
||||
@@ -277,22 +247,14 @@ impl UnifiedExecProcess {
|
||||
.is_ok()
|
||||
{
|
||||
managed.signal_exit();
|
||||
managed.release_spawn_lifecycle();
|
||||
managed.check_for_sandbox_denial().await?;
|
||||
return Ok(managed);
|
||||
}
|
||||
|
||||
tokio::spawn({
|
||||
let cancellation_token = managed.cancellation_token.clone();
|
||||
let spawn_lifecycle = Arc::clone(&spawn_lifecycle);
|
||||
let spawn_lifecycle_released = Arc::clone(&managed.spawn_lifecycle_released);
|
||||
async move {
|
||||
let _ = exit_rx.await;
|
||||
if !spawn_lifecycle_released.swap(true, Ordering::AcqRel)
|
||||
&& let Ok(mut lifecycle) = spawn_lifecycle.lock()
|
||||
{
|
||||
lifecycle.after_exit();
|
||||
}
|
||||
cancellation_token.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use anyhow::Context;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
pub async fn wait_for_pid_file(path: &Path) -> anyhow::Result<String> {
|
||||
@@ -25,7 +24,6 @@ pub async fn wait_for_pid_file(path: &Path) -> anyhow::Result<String> {
|
||||
pub fn process_is_alive(pid: &str) -> anyhow::Result<bool> {
|
||||
let status = std::process::Command::new("kill")
|
||||
.args(["-0", pid])
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.context("failed to probe process liveness with kill -0")?;
|
||||
Ok(status.success())
|
||||
|
||||
@@ -18,10 +18,6 @@ pub struct ZshForkRuntime {
|
||||
}
|
||||
|
||||
impl ZshForkRuntime {
|
||||
pub fn zsh_path(&self) -> &Path {
|
||||
&self.zsh_path
|
||||
}
|
||||
|
||||
fn apply_to_config(
|
||||
&self,
|
||||
config: &mut Config,
|
||||
@@ -95,29 +91,6 @@ where
|
||||
builder.build(server).await
|
||||
}
|
||||
|
||||
pub async fn build_unified_exec_zsh_fork_test<F>(
|
||||
server: &wiremock::MockServer,
|
||||
runtime: ZshForkRuntime,
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
pre_build_hook: F,
|
||||
) -> Result<TestCodex>
|
||||
where
|
||||
F: FnOnce(&Path) + Send + 'static,
|
||||
{
|
||||
let mut builder = test_codex()
|
||||
.with_pre_build_hook(pre_build_hook)
|
||||
.with_config(move |config| {
|
||||
runtime.apply_to_config(config, approval_policy, sandbox_policy);
|
||||
config.use_experimental_unified_exec_tool = true;
|
||||
config
|
||||
.features
|
||||
.enable(Feature::UnifiedExec)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
builder.build(server).await
|
||||
}
|
||||
|
||||
fn find_test_zsh_path() -> Result<Option<PathBuf>> {
|
||||
let repo_root = codex_utils_cargo_bin::repo_root()?;
|
||||
let dotslash_zsh = repo_root.join("codex-rs/app-server/tests/suite/zsh");
|
||||
|
||||
@@ -35,7 +35,6 @@ use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_with_timeout;
|
||||
use core_test_support::zsh_fork::build_unified_exec_zsh_fork_test;
|
||||
use core_test_support::zsh_fork::build_zsh_fork_test;
|
||||
use core_test_support::zsh_fork::restrictive_workspace_write_policy;
|
||||
use core_test_support::zsh_fork::zsh_fork_runtime;
|
||||
@@ -1986,158 +1985,6 @@ async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[cfg(unix)]
|
||||
async fn unified_exec_zsh_fork_execpolicy_amendment_skips_later_subcommands() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let Some(runtime) = zsh_fork_runtime("unified exec zsh-fork execpolicy amendment test")? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let approval_policy = AskForApproval::UnlessTrusted;
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
let server = start_mock_server().await;
|
||||
let test = build_unified_exec_zsh_fork_test(
|
||||
&server,
|
||||
runtime,
|
||||
approval_policy,
|
||||
sandbox_policy.clone(),
|
||||
|_| {},
|
||||
)
|
||||
.await?;
|
||||
let allow_prefix_path = test.cwd.path().join("allow-prefix-zsh-fork.txt");
|
||||
let _ = fs::remove_file(&allow_prefix_path);
|
||||
|
||||
let call_id = "allow-prefix-zsh-fork";
|
||||
let command = "touch allow-prefix-zsh-fork.txt && touch allow-prefix-zsh-fork.txt";
|
||||
let event = exec_command_event(
|
||||
call_id,
|
||||
command,
|
||||
Some(1_000),
|
||||
SandboxPermissions::UseDefault,
|
||||
None,
|
||||
)?;
|
||||
let _ = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-zsh-fork-allow-prefix-1"),
|
||||
event,
|
||||
ev_completed("resp-zsh-fork-allow-prefix-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let results = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-zsh-fork-allow-prefix-1", "done"),
|
||||
ev_completed("resp-zsh-fork-allow-prefix-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
submit_turn(
|
||||
&test,
|
||||
"allow-prefix-zsh-fork",
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let expected_execpolicy_amendment = ExecPolicyAmendment::new(vec![
|
||||
"touch".to_string(),
|
||||
"allow-prefix-zsh-fork.txt".to_string(),
|
||||
]);
|
||||
let mut saw_parent_approval = false;
|
||||
let mut saw_subcommand_approval = false;
|
||||
loop {
|
||||
let event = wait_for_event(&test.codex, |event| {
|
||||
matches!(
|
||||
event,
|
||||
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
|
||||
)
|
||||
})
|
||||
.await;
|
||||
|
||||
match event {
|
||||
EventMsg::TurnComplete(_) => break,
|
||||
EventMsg::ExecApprovalRequest(approval) => {
|
||||
let command_parts = approval.command.clone();
|
||||
let last_arg = command_parts.last().map(String::as_str).unwrap_or_default();
|
||||
if last_arg == command {
|
||||
assert!(
|
||||
!saw_parent_approval,
|
||||
"unexpected duplicate parent approval: {command_parts:?}"
|
||||
);
|
||||
saw_parent_approval = true;
|
||||
test.codex
|
||||
.submit(Op::ExecApproval {
|
||||
id: approval.effective_approval_id(),
|
||||
turn_id: None,
|
||||
decision: ReviewDecision::Approved,
|
||||
})
|
||||
.await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_touch_subcommand = command_parts
|
||||
.iter()
|
||||
.any(|part| part == "allow-prefix-zsh-fork.txt")
|
||||
&& command_parts
|
||||
.first()
|
||||
.is_some_and(|part| part.ends_with("/touch") || part == "touch");
|
||||
if is_touch_subcommand {
|
||||
assert!(
|
||||
!saw_subcommand_approval,
|
||||
"execpolicy amendment should suppress later matching subcommand approvals: {command_parts:?}"
|
||||
);
|
||||
saw_subcommand_approval = true;
|
||||
assert_eq!(
|
||||
approval.proposed_execpolicy_amendment,
|
||||
Some(expected_execpolicy_amendment.clone())
|
||||
);
|
||||
test.codex
|
||||
.submit(Op::ExecApproval {
|
||||
id: approval.effective_approval_id(),
|
||||
turn_id: None,
|
||||
decision: ReviewDecision::ApprovedExecpolicyAmendment {
|
||||
proposed_execpolicy_amendment: expected_execpolicy_amendment
|
||||
.clone(),
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
test.codex
|
||||
.submit(Op::ExecApproval {
|
||||
id: approval.effective_approval_id(),
|
||||
turn_id: None,
|
||||
decision: ReviewDecision::Approved,
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
assert!(saw_parent_approval, "expected parent unified-exec approval");
|
||||
assert!(
|
||||
saw_subcommand_approval,
|
||||
"expected at least one intercepted touch approval"
|
||||
);
|
||||
|
||||
let result = parse_result(&results.single_request().function_call_output(call_id));
|
||||
assert_eq!(result.exit_code.unwrap_or(0), 0);
|
||||
assert!(
|
||||
allow_prefix_path.exists(),
|
||||
"expected touch command to complete after approving the first intercepted subcommand; output: {}",
|
||||
result.stdout
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[cfg(unix)]
|
||||
async fn matched_prefix_rule_runs_unsandboxed_under_zsh_fork() -> Result<()> {
|
||||
|
||||
@@ -20,7 +20,6 @@ use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use core_test_support::zsh_fork::build_unified_exec_zsh_fork_test;
|
||||
use core_test_support::zsh_fork::build_zsh_fork_test;
|
||||
use core_test_support::zsh_fork::restrictive_workspace_write_policy;
|
||||
use core_test_support::zsh_fork::zsh_fork_runtime;
|
||||
@@ -51,13 +50,6 @@ fn shell_command_arguments(command: &str) -> Result<String> {
|
||||
}))?)
|
||||
}
|
||||
|
||||
fn exec_command_arguments(command: &str) -> Result<String> {
|
||||
Ok(serde_json::to_string(&json!({
|
||||
"cmd": command,
|
||||
"yield_time_ms": 500,
|
||||
}))?)
|
||||
}
|
||||
|
||||
async fn submit_turn_with_policies(
|
||||
test: &TestCodex,
|
||||
prompt: &str,
|
||||
@@ -97,38 +89,6 @@ echo 'zsh-fork-stderr' >&2
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_repo_skill_with_shell_script_contents(
|
||||
repo_root: &Path,
|
||||
name: &str,
|
||||
script_name: &str,
|
||||
script_contents: &str,
|
||||
) -> Result<PathBuf> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let skill_dir = repo_root.join(".agents").join("skills").join(name);
|
||||
let scripts_dir = skill_dir.join("scripts");
|
||||
fs::create_dir_all(&scripts_dir)?;
|
||||
fs::write(repo_root.join(".git"), "gitdir: here")?;
|
||||
fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
format!(
|
||||
r#"---
|
||||
name: {name}
|
||||
description: {name} skill
|
||||
---
|
||||
"#
|
||||
),
|
||||
)?;
|
||||
|
||||
let script_path = scripts_dir.join(script_name);
|
||||
fs::write(&script_path, script_contents)?;
|
||||
let mut permissions = fs::metadata(&script_path)?.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions)?;
|
||||
Ok(script_path)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_skill_with_shell_script_contents(
|
||||
home: &Path,
|
||||
@@ -581,168 +541,6 @@ permissions:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unified_exec_zsh_fork_prompts_for_skill_script_execution() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let Some(runtime) = zsh_fork_runtime("unified exec zsh-fork skill prompt test")? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let tool_call_id = "uexec-zsh-fork-skill-call";
|
||||
let test = build_unified_exec_zsh_fork_test(
|
||||
&server,
|
||||
runtime,
|
||||
AskForApproval::OnRequest,
|
||||
SandboxPolicy::new_workspace_write_policy(),
|
||||
|home| {
|
||||
write_skill_with_shell_script(home, "mbolin-test-skill", "hello-mbolin.sh").unwrap();
|
||||
write_skill_metadata(
|
||||
home,
|
||||
"mbolin-test-skill",
|
||||
r#"
|
||||
permissions:
|
||||
file_system:
|
||||
read:
|
||||
- "./data"
|
||||
write:
|
||||
- "./output"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (script_path_str, command) = skill_script_command(&test, "hello-mbolin.sh")?;
|
||||
let arguments = exec_command_arguments(&command)?;
|
||||
let mocks =
|
||||
mount_function_call_agent_response(&server, tool_call_id, &arguments, "exec_command").await;
|
||||
|
||||
submit_turn_with_policies(
|
||||
&test,
|
||||
"use $mbolin-test-skill",
|
||||
AskForApproval::OnRequest,
|
||||
SandboxPolicy::new_workspace_write_policy(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let approval = wait_for_exec_approval_request(&test)
|
||||
.await
|
||||
.expect("expected exec approval request before completion");
|
||||
assert_eq!(approval.call_id, tool_call_id);
|
||||
assert_eq!(approval.command, vec![script_path_str.clone()]);
|
||||
assert_eq!(
|
||||
approval.available_decisions,
|
||||
Some(vec![
|
||||
ReviewDecision::Approved,
|
||||
ReviewDecision::ApprovedForSession,
|
||||
ReviewDecision::Abort,
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
approval.additional_permissions,
|
||||
Some(PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![absolute_path(
|
||||
&test.codex_home_path().join("skills/mbolin-test-skill/data"),
|
||||
)]),
|
||||
write: Some(vec![absolute_path(
|
||||
&test
|
||||
.codex_home_path()
|
||||
.join("skills/mbolin-test-skill/output"),
|
||||
)]),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
);
|
||||
|
||||
test.codex
|
||||
.submit(Op::ExecApproval {
|
||||
id: approval.effective_approval_id(),
|
||||
turn_id: None,
|
||||
decision: ReviewDecision::Denied,
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_for_turn_complete(&test).await;
|
||||
|
||||
let call_output = mocks
|
||||
.completion
|
||||
.single_request()
|
||||
.function_call_output(tool_call_id);
|
||||
let output = call_output["output"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
output.contains("Execution denied: User denied execution"),
|
||||
"expected rejection marker in function_call_output: {output:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unified_exec_zsh_fork_keeps_skill_loading_pinned_to_turn_cwd() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let Some(runtime) = zsh_fork_runtime("unified exec zsh-fork turn cwd skill test")? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let tool_call_id = "uexec-zsh-fork-repo-skill-call";
|
||||
let test = build_unified_exec_zsh_fork_test(
|
||||
&server,
|
||||
runtime,
|
||||
AskForApproval::OnRequest,
|
||||
SandboxPolicy::new_workspace_write_policy(),
|
||||
|_| {},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let repo_root = test.cwd_path().join("repo");
|
||||
let script_path = write_repo_skill_with_shell_script_contents(
|
||||
&repo_root,
|
||||
"repo-skill",
|
||||
"repo-skill.sh",
|
||||
"#!/bin/sh\necho 'repo-skill-output'\n",
|
||||
)?;
|
||||
let script_path_quoted = shlex::try_join([script_path.to_string_lossy().as_ref()])?;
|
||||
let repo_root_quoted = shlex::try_join([repo_root.to_string_lossy().as_ref()])?;
|
||||
let command = format!("cd {repo_root_quoted} && {script_path_quoted}");
|
||||
let arguments = exec_command_arguments(&command)?;
|
||||
let mocks =
|
||||
mount_function_call_agent_response(&server, tool_call_id, &arguments, "exec_command").await;
|
||||
|
||||
submit_turn_with_policies(
|
||||
&test,
|
||||
"run the repo skill after changing directories",
|
||||
AskForApproval::OnRequest,
|
||||
SandboxPolicy::new_workspace_write_policy(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let approval = wait_for_exec_approval_request(&test).await;
|
||||
assert!(
|
||||
approval.is_none(),
|
||||
"changing directories inside unified exec should not load repo-local skills from the shell cwd",
|
||||
);
|
||||
|
||||
let call_output = mocks
|
||||
.completion
|
||||
.single_request()
|
||||
.function_call_output(tool_call_id);
|
||||
let output = call_output["output"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
output.contains("repo-skill-output"),
|
||||
"expected repo skill script to run without skill-specific approval when only the shell cwd changes: {output:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Permissionless skills should inherit the turn sandbox without prompting.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
#[cfg(unix)]
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use anyhow::Context;
|
||||
@@ -34,10 +32,6 @@ use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use core_test_support::wait_for_event_with_timeout;
|
||||
#[cfg(unix)]
|
||||
use core_test_support::zsh_fork::build_unified_exec_zsh_fork_test;
|
||||
#[cfg(unix)]
|
||||
use core_test_support::zsh_fork::zsh_fork_runtime;
|
||||
use pretty_assertions::assert_eq;
|
||||
use regex_lite::Regex;
|
||||
use serde_json::Value;
|
||||
@@ -161,27 +155,6 @@ fn collect_tool_outputs(bodies: &[Value]) -> Result<HashMap<String, ParsedUnifie
|
||||
Ok(outputs)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn process_text_binary_path(pid: &str) -> Result<PathBuf> {
|
||||
let output = std::process::Command::new("lsof")
|
||||
.args(["-Fn", "-a", "-p", pid, "-d", "txt"])
|
||||
.output()
|
||||
.with_context(|| format!("failed to inspect process {pid} executable mapping with lsof"))?;
|
||||
if !output.status.success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"lsof failed for pid {pid} with status {:?}",
|
||||
output.status.code()
|
||||
));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).context("lsof output was not UTF-8")?;
|
||||
let path = stdout
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix('n'))
|
||||
.ok_or_else(|| anyhow::anyhow!("lsof did not report a text binary path for pid {pid}"))?;
|
||||
Ok(PathBuf::from(path))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -1350,6 +1323,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> {
|
||||
.into_iter()
|
||||
.map(|request| request.body_json())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let outputs = collect_tool_outputs(&bodies)?;
|
||||
let metadata = outputs
|
||||
.get(call_id)
|
||||
@@ -1471,6 +1445,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> {
|
||||
.into_iter()
|
||||
.map(|request| request.body_json())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let outputs = collect_tool_outputs(&bodies)?;
|
||||
let output = outputs
|
||||
.get(call_id)
|
||||
@@ -1564,6 +1539,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> {
|
||||
.into_iter()
|
||||
.map(|request| request.body_json())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let outputs = collect_tool_outputs(&bodies)?;
|
||||
let output = outputs
|
||||
.get(call_id)
|
||||
@@ -1648,6 +1624,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> {
|
||||
.into_iter()
|
||||
.map(|request| request.body_json())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let outputs = collect_tool_outputs(&bodies)?;
|
||||
let output = outputs
|
||||
.get(call_id)
|
||||
@@ -1846,350 +1823,6 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[cfg(unix)]
|
||||
async fn unified_exec_zsh_fork_keeps_python_repl_attached_to_zsh_session() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let Some(runtime) = zsh_fork_runtime("unified exec zsh-fork tty session test")? else {
|
||||
return Ok(());
|
||||
};
|
||||
let configured_zsh_path =
|
||||
fs::canonicalize(runtime.zsh_path()).unwrap_or_else(|_| runtime.zsh_path().to_path_buf());
|
||||
|
||||
let python = match which("python3") {
|
||||
Ok(path) => path,
|
||||
Err(_) => {
|
||||
eprintln!("python3 not found in PATH, skipping zsh-fork python repl test.");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let test = build_unified_exec_zsh_fork_test(
|
||||
&server,
|
||||
runtime,
|
||||
AskForApproval::Never,
|
||||
SandboxPolicy::new_workspace_write_policy(),
|
||||
|_| {},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let start_call_id = "uexec-zsh-fork-python-start";
|
||||
let send_call_id = "uexec-zsh-fork-python-pid";
|
||||
let exit_call_id = "uexec-zsh-fork-python-exit";
|
||||
|
||||
let start_command = format!("{}; :", python.display());
|
||||
let start_args = serde_json::json!({
|
||||
"cmd": start_command,
|
||||
"yield_time_ms": 500,
|
||||
"tty": true,
|
||||
});
|
||||
let send_args = serde_json::json!({
|
||||
"chars": "import os; print('CODEX_PY_PID=' + str(os.getpid()))\r\n",
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 500,
|
||||
});
|
||||
let exit_args = serde_json::json!({
|
||||
"chars": "import sys; sys.exit(0)\r\n",
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 500,
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(
|
||||
start_call_id,
|
||||
"exec_command",
|
||||
&serde_json::to_string(&start_args)?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_function_call(
|
||||
send_call_id,
|
||||
"write_stdin",
|
||||
&serde_json::to_string(&send_args)?,
|
||||
),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "python is running"),
|
||||
ev_completed("resp-3"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-4"),
|
||||
ev_function_call(
|
||||
exit_call_id,
|
||||
"write_stdin",
|
||||
&serde_json::to_string(&exit_args)?,
|
||||
),
|
||||
ev_completed("resp-4"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-2", "all done"),
|
||||
ev_completed("resp-5"),
|
||||
]),
|
||||
];
|
||||
let request_log = mount_sse_sequence(&server, responses).await;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "test unified exec zsh-fork tty behavior".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::new_workspace_write_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
summary: None,
|
||||
service_tier: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
|
||||
let requests = request_log.requests();
|
||||
assert!(!requests.is_empty(), "expected at least one POST request");
|
||||
let bodies = requests
|
||||
.into_iter()
|
||||
.map(|request| request.body_json())
|
||||
.collect::<Vec<_>>();
|
||||
let outputs = collect_tool_outputs(&bodies)?;
|
||||
|
||||
let start_output = outputs
|
||||
.get(start_call_id)
|
||||
.expect("missing start output for exec_command");
|
||||
let process_id = start_output
|
||||
.process_id
|
||||
.clone()
|
||||
.expect("expected process id from exec_command");
|
||||
assert!(
|
||||
start_output.exit_code.is_none(),
|
||||
"initial exec_command should leave the PTY session running"
|
||||
);
|
||||
|
||||
let send_output = outputs
|
||||
.get(send_call_id)
|
||||
.expect("missing write_stdin output");
|
||||
let normalized = send_output.output.replace("\r\n", "\n");
|
||||
let python_pid = Regex::new(r"CODEX_PY_PID=(\d+)")
|
||||
.expect("valid python pid marker regex")
|
||||
.captures(&normalized)
|
||||
.and_then(|captures| captures.get(1))
|
||||
.map(|value| value.as_str().to_string())
|
||||
.with_context(|| format!("missing python pid in output {normalized:?}"))?;
|
||||
assert!(
|
||||
process_is_alive(&python_pid)?,
|
||||
"python process should still be alive after printing its pid, got output {normalized:?}"
|
||||
);
|
||||
assert_eq!(send_output.process_id.as_deref(), Some(process_id.as_str()));
|
||||
assert!(
|
||||
send_output.exit_code.is_none(),
|
||||
"write_stdin should not report an exit code while the process is still running"
|
||||
);
|
||||
|
||||
let zsh_pid = std::process::Command::new("ps")
|
||||
.args(["-o", "ppid=", "-p", &python_pid])
|
||||
.output()
|
||||
.context("failed to look up python parent pid")?;
|
||||
let zsh_pid = String::from_utf8(zsh_pid.stdout)
|
||||
.context("python parent pid output is not UTF-8")?
|
||||
.trim()
|
||||
.to_string();
|
||||
assert!(
|
||||
!zsh_pid.is_empty(),
|
||||
"expected python parent pid to identify the zsh session"
|
||||
);
|
||||
assert!(
|
||||
process_is_alive(&zsh_pid)?,
|
||||
"expected zsh parent process {zsh_pid} to still be alive"
|
||||
);
|
||||
|
||||
let zsh_command = std::process::Command::new("ps")
|
||||
.args(["-o", "command=", "-p", &zsh_pid])
|
||||
.output()
|
||||
.context("failed to look up zsh parent command")?;
|
||||
let zsh_command =
|
||||
String::from_utf8(zsh_command.stdout).context("zsh parent command output is not UTF-8")?;
|
||||
assert!(
|
||||
zsh_command.contains("zsh"),
|
||||
"expected python parent command to be zsh, got {zsh_command:?}"
|
||||
);
|
||||
let zsh_text_binary = process_text_binary_path(&zsh_pid)?;
|
||||
let zsh_text_binary = fs::canonicalize(&zsh_text_binary).unwrap_or(zsh_text_binary);
|
||||
assert_eq!(
|
||||
zsh_text_binary, configured_zsh_path,
|
||||
"python parent shell should run with configured zsh-fork binary, got {:?} ({zsh_command:?})",
|
||||
zsh_text_binary,
|
||||
);
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "shut down the python repl".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::new_workspace_write_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
summary: None,
|
||||
service_tier: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
|
||||
let requests = request_log.requests();
|
||||
assert!(!requests.is_empty(), "expected at least one POST request");
|
||||
let bodies = requests
|
||||
.into_iter()
|
||||
.map(|request| request.body_json())
|
||||
.collect::<Vec<_>>();
|
||||
let outputs = collect_tool_outputs(&bodies)?;
|
||||
let exit_output = outputs
|
||||
.get(exit_call_id)
|
||||
.expect("missing exit output after requesting python shutdown");
|
||||
assert!(
|
||||
exit_output.exit_code.is_none() || exit_output.exit_code == Some(0),
|
||||
"exit request should either leave cleanup to the background watcher or report success directly, got {exit_output:?}"
|
||||
);
|
||||
wait_for_process_exit(&python_pid).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[cfg(unix)]
|
||||
async fn unified_exec_zsh_fork_rewrites_nested_zsh_exec_to_configured_binary() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let Some(runtime) = zsh_fork_runtime("unified exec zsh-fork nested zsh rewrite test")? else {
|
||||
return Ok(());
|
||||
};
|
||||
let configured_zsh_path =
|
||||
fs::canonicalize(runtime.zsh_path()).unwrap_or_else(|_| runtime.zsh_path().to_path_buf());
|
||||
let host_zsh = match which("zsh") {
|
||||
Ok(path) => path,
|
||||
Err(_) => {
|
||||
eprintln!("zsh not found in PATH, skipping nested zsh rewrite test.");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let test = build_unified_exec_zsh_fork_test(
|
||||
&server,
|
||||
runtime,
|
||||
AskForApproval::Never,
|
||||
SandboxPolicy::new_workspace_write_policy(),
|
||||
|_| {},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let start_call_id = "uexec-zsh-fork-nested-start";
|
||||
let nested_command = format!(
|
||||
"exec {} -lc 'echo CODEX_NESTED_ZSH_PID=$$; sleep 3; :'",
|
||||
host_zsh.display(),
|
||||
);
|
||||
let start_args = serde_json::json!({
|
||||
"cmd": nested_command,
|
||||
"yield_time_ms": 500,
|
||||
"tty": true,
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-nested-1"),
|
||||
ev_function_call(
|
||||
start_call_id,
|
||||
"exec_command",
|
||||
&serde_json::to_string(&start_args)?,
|
||||
),
|
||||
ev_completed("resp-nested-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-nested-1", "done"),
|
||||
ev_completed("resp-nested-2"),
|
||||
]),
|
||||
];
|
||||
let request_log = mount_sse_sequence(&server, responses).await;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "test nested zsh rewrite behavior".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::new_workspace_write_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
summary: None,
|
||||
service_tier: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
|
||||
let requests = request_log.requests();
|
||||
let bodies = requests
|
||||
.into_iter()
|
||||
.map(|request| request.body_json())
|
||||
.collect::<Vec<_>>();
|
||||
let outputs = collect_tool_outputs(&bodies)?;
|
||||
|
||||
let start_output = outputs
|
||||
.get(start_call_id)
|
||||
.expect("missing start output for nested zsh exec_command");
|
||||
let normalized = start_output.output.replace("\r\n", "\n");
|
||||
let nested_zsh_pid = Regex::new(r"CODEX_NESTED_ZSH_PID=(\d+)")
|
||||
.expect("valid nested zsh pid regex")
|
||||
.captures(&normalized)
|
||||
.and_then(|captures| captures.get(1))
|
||||
.map(|value| value.as_str().to_string())
|
||||
.with_context(|| format!("missing nested zsh pid marker in output {normalized:?}"))?;
|
||||
assert!(
|
||||
process_is_alive(&nested_zsh_pid)?,
|
||||
"nested zsh process should be running before release, got output {normalized:?}"
|
||||
);
|
||||
|
||||
let nested_text_binary = process_text_binary_path(&nested_zsh_pid)?;
|
||||
let nested_text_binary = fs::canonicalize(&nested_text_binary).unwrap_or(nested_text_binary);
|
||||
assert_eq!(
|
||||
nested_text_binary, configured_zsh_path,
|
||||
"nested zsh exec should be rewritten to configured zsh-fork binary, got {:?}",
|
||||
nested_text_binary,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -12,8 +12,9 @@ python -m pip install -e .
|
||||
```
|
||||
|
||||
Published SDK builds pin an exact `codex-cli-bin` runtime dependency. For local
|
||||
repo development, pass `AppServerConfig(codex_bin=...)` to point at a local
|
||||
build explicitly.
|
||||
repo development, either pass `AppServerConfig(codex_bin=...)` to point at a
|
||||
local build explicitly, or use the repo examples/notebook bootstrap which
|
||||
installs the pinned runtime package automatically.
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -54,7 +55,8 @@ wheel.
|
||||
|
||||
For local repo development, the checked-in `sdk/python-runtime` package is only
|
||||
a template for staged release artifacts. Editable installs should use an
|
||||
explicit `codex_bin` override instead.
|
||||
explicit `codex_bin` override for manual SDK usage; the repo examples and
|
||||
notebook bootstrap the pinned runtime package automatically.
|
||||
|
||||
## Maintainer workflow
|
||||
|
||||
|
||||
344
sdk/python/_runtime_setup.py
Normal file
344
sdk/python/_runtime_setup.py
Normal file
@@ -0,0 +1,344 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
PACKAGE_NAME = "codex-cli-bin"
|
||||
PINNED_RUNTIME_VERSION = "0.115.0-alpha.11"
|
||||
REPO_SLUG = "openai/codex"
|
||||
|
||||
|
||||
class RuntimeSetupError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def pinned_runtime_version() -> str:
|
||||
return PINNED_RUNTIME_VERSION
|
||||
|
||||
|
||||
def ensure_runtime_package_installed(
|
||||
python_executable: str | Path,
|
||||
sdk_python_dir: Path,
|
||||
install_target: Path | None = None,
|
||||
) -> str:
|
||||
requested_version = pinned_runtime_version()
|
||||
installed_version = None
|
||||
if install_target is None:
|
||||
installed_version = _installed_runtime_version(python_executable)
|
||||
normalized_requested = _normalized_package_version(requested_version)
|
||||
|
||||
if installed_version is not None and _normalized_package_version(installed_version) == normalized_requested:
|
||||
return requested_version
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="codex-python-runtime-") as temp_root_str:
|
||||
temp_root = Path(temp_root_str)
|
||||
archive_path = _download_release_archive(requested_version, temp_root)
|
||||
runtime_binary = _extract_runtime_binary(archive_path, temp_root)
|
||||
staged_runtime_dir = _stage_runtime_package(
|
||||
sdk_python_dir,
|
||||
requested_version,
|
||||
runtime_binary,
|
||||
temp_root / "runtime-stage",
|
||||
)
|
||||
_install_runtime_package(python_executable, staged_runtime_dir, install_target)
|
||||
|
||||
if install_target is not None:
|
||||
return requested_version
|
||||
|
||||
if Path(python_executable).resolve() == Path(sys.executable).resolve():
|
||||
importlib.invalidate_caches()
|
||||
|
||||
installed_version = _installed_runtime_version(python_executable)
|
||||
if installed_version is None or _normalized_package_version(installed_version) != normalized_requested:
|
||||
raise RuntimeSetupError(
|
||||
f"Expected {PACKAGE_NAME} {requested_version} in {python_executable}, "
|
||||
f"but found {installed_version!r} after installation."
|
||||
)
|
||||
return requested_version
|
||||
|
||||
|
||||
def platform_asset_name() -> str:
|
||||
system = platform.system().lower()
|
||||
machine = platform.machine().lower()
|
||||
|
||||
if system == "darwin":
|
||||
if machine in {"arm64", "aarch64"}:
|
||||
return "codex-aarch64-apple-darwin.tar.gz"
|
||||
if machine in {"x86_64", "amd64"}:
|
||||
return "codex-x86_64-apple-darwin.tar.gz"
|
||||
elif system == "linux":
|
||||
if machine in {"aarch64", "arm64"}:
|
||||
return "codex-aarch64-unknown-linux-musl.tar.gz"
|
||||
if machine in {"x86_64", "amd64"}:
|
||||
return "codex-x86_64-unknown-linux-musl.tar.gz"
|
||||
elif system == "windows":
|
||||
if machine in {"aarch64", "arm64"}:
|
||||
return "codex-aarch64-pc-windows-msvc.exe.zip"
|
||||
if machine in {"x86_64", "amd64"}:
|
||||
return "codex-x86_64-pc-windows-msvc.exe.zip"
|
||||
|
||||
raise RuntimeSetupError(
|
||||
f"Unsupported runtime artifact platform: system={platform.system()!r}, "
|
||||
f"machine={platform.machine()!r}"
|
||||
)
|
||||
|
||||
|
||||
def runtime_binary_name() -> str:
|
||||
return "codex.exe" if platform.system().lower() == "windows" else "codex"
|
||||
|
||||
|
||||
def _installed_runtime_version(python_executable: str | Path) -> str | None:
|
||||
snippet = (
|
||||
"import importlib.metadata, json, sys\n"
|
||||
"try:\n"
|
||||
" from codex_cli_bin import bundled_codex_path\n"
|
||||
" bundled_codex_path()\n"
|
||||
" print(json.dumps({'version': importlib.metadata.version('codex-cli-bin')}))\n"
|
||||
"except Exception:\n"
|
||||
" sys.exit(1)\n"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[str(python_executable), "-c", snippet],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
return json.loads(result.stdout)["version"]
|
||||
|
||||
|
||||
def _release_metadata(version: str) -> dict[str, object]:
|
||||
url = f"https://api.github.com/repos/{REPO_SLUG}/releases/tags/rust-v{version}"
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers=_github_api_headers("application/vnd.github+json"),
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request) as response:
|
||||
return json.load(response)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise RuntimeSetupError(
|
||||
f"Failed to resolve release metadata for rust-v{version} from {REPO_SLUG}: "
|
||||
f"{exc.code} {exc.reason}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _download_release_archive(version: str, temp_root: Path) -> Path:
|
||||
asset_name = platform_asset_name()
|
||||
metadata = _release_metadata(version)
|
||||
assets = metadata.get("assets")
|
||||
if not isinstance(assets, list):
|
||||
raise RuntimeSetupError(f"Release rust-v{version} returned malformed assets metadata.")
|
||||
asset = next(
|
||||
(
|
||||
item
|
||||
for item in assets
|
||||
if isinstance(item, dict) and item.get("name") == asset_name
|
||||
),
|
||||
None,
|
||||
)
|
||||
if asset is None:
|
||||
raise RuntimeSetupError(
|
||||
f"Release rust-v{version} does not contain asset {asset_name} for this platform."
|
||||
)
|
||||
|
||||
archive_path = temp_root / asset_name
|
||||
api_url = asset.get("url")
|
||||
browser_download_url = asset.get("browser_download_url")
|
||||
if not isinstance(api_url, str):
|
||||
api_url = None
|
||||
if not isinstance(browser_download_url, str):
|
||||
browser_download_url = None
|
||||
|
||||
if api_url is not None:
|
||||
token = _github_token()
|
||||
if token is not None:
|
||||
request = urllib.request.Request(
|
||||
api_url,
|
||||
headers=_github_api_headers("application/octet-stream"),
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request) as response, archive_path.open("wb") as fh:
|
||||
shutil.copyfileobj(response, fh)
|
||||
return archive_path
|
||||
except urllib.error.HTTPError:
|
||||
pass
|
||||
|
||||
if browser_download_url is not None:
|
||||
request = urllib.request.Request(
|
||||
browser_download_url,
|
||||
headers={"User-Agent": "codex-python-runtime-setup"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request) as response, archive_path.open("wb") as fh:
|
||||
shutil.copyfileobj(response, fh)
|
||||
return archive_path
|
||||
except urllib.error.HTTPError:
|
||||
pass
|
||||
|
||||
if shutil.which("gh") is None:
|
||||
raise RuntimeSetupError(
|
||||
f"Unable to download {asset_name} for rust-v{version}. "
|
||||
"Provide GH_TOKEN/GITHUB_TOKEN or install/authenticate GitHub CLI."
|
||||
)
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"gh",
|
||||
"release",
|
||||
"download",
|
||||
f"rust-v{version}",
|
||||
"--repo",
|
||||
REPO_SLUG,
|
||||
"--pattern",
|
||||
asset_name,
|
||||
"--dir",
|
||||
str(temp_root),
|
||||
],
|
||||
check=True,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise RuntimeSetupError(
|
||||
f"gh release download failed for rust-v{version} asset {asset_name}.\n"
|
||||
f"STDOUT:\n{exc.stdout}\nSTDERR:\n{exc.stderr}"
|
||||
) from exc
|
||||
return archive_path
|
||||
|
||||
|
||||
def _extract_runtime_binary(archive_path: Path, temp_root: Path) -> Path:
|
||||
extract_dir = temp_root / "extracted"
|
||||
extract_dir.mkdir(parents=True, exist_ok=True)
|
||||
if archive_path.name.endswith(".tar.gz"):
|
||||
with tarfile.open(archive_path, "r:gz") as tar:
|
||||
try:
|
||||
tar.extractall(extract_dir, filter="data")
|
||||
except TypeError:
|
||||
tar.extractall(extract_dir)
|
||||
elif archive_path.suffix == ".zip":
|
||||
with zipfile.ZipFile(archive_path) as zip_file:
|
||||
zip_file.extractall(extract_dir)
|
||||
else:
|
||||
raise RuntimeSetupError(f"Unsupported release archive format: {archive_path.name}")
|
||||
|
||||
binary_name = runtime_binary_name()
|
||||
archive_stem = archive_path.name.removesuffix(".tar.gz").removesuffix(".zip")
|
||||
candidates = [
|
||||
path
|
||||
for path in extract_dir.rglob("*")
|
||||
if path.is_file()
|
||||
and (
|
||||
path.name == binary_name
|
||||
or path.name == archive_stem
|
||||
or path.name.startswith("codex-")
|
||||
)
|
||||
]
|
||||
if not candidates:
|
||||
raise RuntimeSetupError(
|
||||
f"Failed to find {binary_name} in extracted runtime archive {archive_path.name}."
|
||||
)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _stage_runtime_package(
|
||||
sdk_python_dir: Path,
|
||||
runtime_version: str,
|
||||
runtime_binary: Path,
|
||||
staging_dir: Path,
|
||||
) -> Path:
|
||||
script_module = _load_update_script_module(sdk_python_dir)
|
||||
return script_module.stage_python_runtime_package( # type: ignore[no-any-return]
|
||||
staging_dir,
|
||||
runtime_version,
|
||||
runtime_binary.resolve(),
|
||||
)
|
||||
|
||||
|
||||
def _install_runtime_package(
|
||||
python_executable: str | Path,
|
||||
staged_runtime_dir: Path,
|
||||
install_target: Path | None,
|
||||
) -> None:
|
||||
args = [
|
||||
str(python_executable),
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--force-reinstall",
|
||||
"--no-deps",
|
||||
]
|
||||
if install_target is not None:
|
||||
install_target.mkdir(parents=True, exist_ok=True)
|
||||
args.extend(["--target", str(install_target)])
|
||||
args.append(str(staged_runtime_dir))
|
||||
try:
|
||||
subprocess.run(
|
||||
args,
|
||||
check=True,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise RuntimeSetupError(
|
||||
f"Failed to install {PACKAGE_NAME} into {python_executable} from {staged_runtime_dir}.\n"
|
||||
f"STDOUT:\n{exc.stdout}\nSTDERR:\n{exc.stderr}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _load_update_script_module(sdk_python_dir: Path):
|
||||
script_path = sdk_python_dir / "scripts" / "update_sdk_artifacts.py"
|
||||
spec = importlib.util.spec_from_file_location("update_sdk_artifacts", script_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeSetupError(f"Failed to load {script_path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _github_api_headers(accept: str) -> dict[str, str]:
|
||||
headers = {
|
||||
"Accept": accept,
|
||||
"User-Agent": "codex-python-runtime-setup",
|
||||
}
|
||||
token = _github_token()
|
||||
if token is not None:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
return headers
|
||||
|
||||
|
||||
def _github_token() -> str | None:
|
||||
for env_name in ("GH_TOKEN", "GITHUB_TOKEN"):
|
||||
token = os.environ.get(env_name)
|
||||
if token:
|
||||
return token
|
||||
return None
|
||||
|
||||
|
||||
def _normalized_package_version(version: str) -> str:
|
||||
return version.strip().replace("-alpha.", "a").replace("-beta.", "b")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PACKAGE_NAME",
|
||||
"PINNED_RUNTIME_VERSION",
|
||||
"RuntimeSetupError",
|
||||
"ensure_runtime_package_installed",
|
||||
"pinned_runtime_version",
|
||||
"platform_asset_name",
|
||||
]
|
||||
180
sdk/python/docs/api-reference.md
Normal file
180
sdk/python/docs/api-reference.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Codex App Server SDK — API Reference
|
||||
|
||||
Public surface of `codex_app_server` for app-server v2.
|
||||
|
||||
This SDK surface is experimental. The current implementation intentionally allows only one active `Turn.stream()` or `Turn.run()` consumer per client instance at a time.
|
||||
|
||||
## Package Entry
|
||||
|
||||
```python
|
||||
from codex_app_server import (
|
||||
Codex,
|
||||
AsyncCodex,
|
||||
Thread,
|
||||
AsyncThread,
|
||||
Turn,
|
||||
AsyncTurn,
|
||||
TurnResult,
|
||||
InitializeResult,
|
||||
Input,
|
||||
InputItem,
|
||||
TextInput,
|
||||
ImageInput,
|
||||
LocalImageInput,
|
||||
SkillInput,
|
||||
MentionInput,
|
||||
ThreadItem,
|
||||
TurnStatus,
|
||||
)
|
||||
```
|
||||
|
||||
- Version: `codex_app_server.__version__`
|
||||
- Requires Python >= 3.10
|
||||
|
||||
## Codex (sync)
|
||||
|
||||
```python
|
||||
Codex(config: AppServerConfig | None = None)
|
||||
```
|
||||
|
||||
Properties/methods:
|
||||
|
||||
- `metadata -> InitializeResult`
|
||||
- `close() -> None`
|
||||
- `thread_start(*, approval_policy=None, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, personality=None, sandbox=None) -> Thread`
|
||||
- `thread_list(*, archived=None, cursor=None, cwd=None, limit=None, model_providers=None, sort_key=None, source_kinds=None) -> ThreadListResponse`
|
||||
- `thread_resume(thread_id: str, *, approval_policy=None, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, personality=None, sandbox=None) -> Thread`
|
||||
- `thread_fork(thread_id: str, *, approval_policy=None, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, sandbox=None) -> Thread`
|
||||
- `thread_archive(thread_id: str) -> ThreadArchiveResponse`
|
||||
- `thread_unarchive(thread_id: str) -> Thread`
|
||||
- `models(*, include_hidden: bool = False) -> ModelListResponse`
|
||||
|
||||
Context manager:
|
||||
|
||||
```python
|
||||
with Codex() as codex:
|
||||
...
|
||||
```
|
||||
|
||||
## AsyncCodex (async parity)
|
||||
|
||||
```python
|
||||
AsyncCodex(config: AppServerConfig | None = None)
|
||||
```
|
||||
|
||||
Properties/methods:
|
||||
|
||||
- `metadata -> InitializeResult`
|
||||
- `close() -> Awaitable[None]`
|
||||
- `thread_start(*, approval_policy=None, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, personality=None, sandbox=None) -> Awaitable[AsyncThread]`
|
||||
- `thread_list(*, archived=None, cursor=None, cwd=None, limit=None, model_providers=None, sort_key=None, source_kinds=None) -> Awaitable[ThreadListResponse]`
|
||||
- `thread_resume(thread_id: str, *, approval_policy=None, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, personality=None, sandbox=None) -> Awaitable[AsyncThread]`
|
||||
- `thread_fork(thread_id: str, *, approval_policy=None, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, sandbox=None) -> Awaitable[AsyncThread]`
|
||||
- `thread_archive(thread_id: str) -> Awaitable[ThreadArchiveResponse]`
|
||||
- `thread_unarchive(thread_id: str) -> Awaitable[AsyncThread]`
|
||||
- `models(*, include_hidden: bool = False) -> Awaitable[ModelListResponse]`
|
||||
|
||||
Async context manager:
|
||||
|
||||
```python
|
||||
async with AsyncCodex() as codex:
|
||||
...
|
||||
```
|
||||
|
||||
## Thread / AsyncThread
|
||||
|
||||
`Thread` and `AsyncThread` share the same shape and intent.
|
||||
|
||||
### Thread
|
||||
|
||||
- `turn(input: Input, *, approval_policy=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, summary=None) -> Turn`
|
||||
- `read(*, include_turns: bool = False) -> ThreadReadResponse`
|
||||
- `set_name(name: str) -> ThreadSetNameResponse`
|
||||
- `compact() -> ThreadCompactStartResponse`
|
||||
|
||||
### AsyncThread
|
||||
|
||||
- `turn(input: Input, *, approval_policy=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, summary=None) -> Awaitable[AsyncTurn]`
|
||||
- `read(*, include_turns: bool = False) -> Awaitable[ThreadReadResponse]`
|
||||
- `set_name(name: str) -> Awaitable[ThreadSetNameResponse]`
|
||||
- `compact() -> Awaitable[ThreadCompactStartResponse]`
|
||||
|
||||
## Turn / AsyncTurn
|
||||
|
||||
### Turn
|
||||
|
||||
- `steer(input: Input) -> TurnSteerResponse`
|
||||
- `interrupt() -> TurnInterruptResponse`
|
||||
- `stream() -> Iterator[Notification]`
|
||||
- `run() -> TurnResult`
|
||||
|
||||
Behavior notes:
|
||||
|
||||
- `stream()` and `run()` are exclusive per client instance in the current experimental build
|
||||
- starting a second turn consumer on the same `Codex` instance raises `RuntimeError`
|
||||
|
||||
### AsyncTurn
|
||||
|
||||
- `steer(input: Input) -> Awaitable[TurnSteerResponse]`
|
||||
- `interrupt() -> Awaitable[TurnInterruptResponse]`
|
||||
- `stream() -> AsyncIterator[Notification]`
|
||||
- `run() -> Awaitable[TurnResult]`
|
||||
|
||||
Behavior notes:
|
||||
|
||||
- `stream()` and `run()` are exclusive per client instance in the current experimental build
|
||||
- starting a second turn consumer on the same `AsyncCodex` instance raises `RuntimeError`
|
||||
|
||||
## TurnResult
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class TurnResult:
|
||||
thread_id: str
|
||||
turn_id: str
|
||||
status: TurnStatus
|
||||
error: TurnError | None
|
||||
text: str
|
||||
items: list[ThreadItem]
|
||||
usage: ThreadTokenUsageUpdatedNotification | None
|
||||
```
|
||||
|
||||
## Inputs
|
||||
|
||||
```python
|
||||
@dataclass class TextInput: text: str
|
||||
@dataclass class ImageInput: url: str
|
||||
@dataclass class LocalImageInput: path: str
|
||||
@dataclass class SkillInput: name: str; path: str
|
||||
@dataclass class MentionInput: name: str; path: str
|
||||
|
||||
InputItem = TextInput | ImageInput | LocalImageInput | SkillInput | MentionInput
|
||||
Input = list[InputItem] | InputItem
|
||||
```
|
||||
|
||||
## Retry + errors
|
||||
|
||||
```python
|
||||
from codex_app_server import (
|
||||
retry_on_overload,
|
||||
JsonRpcError,
|
||||
MethodNotFoundError,
|
||||
InvalidParamsError,
|
||||
ServerBusyError,
|
||||
is_retryable_error,
|
||||
)
|
||||
```
|
||||
|
||||
- `retry_on_overload(...)` retries transient overload errors with exponential backoff + jitter.
|
||||
- `is_retryable_error(exc)` checks if an exception is transient/overload-like.
|
||||
|
||||
## Example
|
||||
|
||||
```python
|
||||
from codex_app_server import Codex, TextInput
|
||||
|
||||
with Codex() as codex:
|
||||
thread = codex.thread_start(model="gpt-5", config={"model_reasoning_effort": "high"})
|
||||
result = thread.turn(TextInput("Say hello in one sentence.")).run()
|
||||
print(result.text)
|
||||
```
|
||||
@@ -8,24 +8,42 @@
|
||||
|
||||
## `run()` vs `stream()`
|
||||
|
||||
- `Turn.run()` is the easiest path. It consumes events until completion and returns `TurnResult`.
|
||||
- `Turn.stream()` yields raw notifications (`Notification`) so you can react event-by-event.
|
||||
- `Turn.run()` / `AsyncTurn.run()` is the easiest path. It consumes events until completion and returns `TurnResult`.
|
||||
- `Turn.stream()` / `AsyncTurn.stream()` yields raw notifications (`Notification`) so you can react event-by-event.
|
||||
|
||||
Choose `run()` for most apps. Choose `stream()` for progress UIs, custom timeout logic, or custom parsing.
|
||||
|
||||
## Sync vs async clients
|
||||
|
||||
- `Codex` is the minimal sync SDK and best default.
|
||||
- `AsyncAppServerClient` wraps the sync transport with `asyncio.to_thread(...)` for async-friendly call sites.
|
||||
- `Codex` is the sync public API.
|
||||
- `AsyncCodex` is an async replica of the same public API shape.
|
||||
|
||||
If your app is not already async, stay with `Codex`.
|
||||
|
||||
## `thread(...)` vs `thread_resume(...)`
|
||||
## Public kwargs are snake_case
|
||||
|
||||
- `codex.thread(thread_id)` only binds a local helper to an existing thread ID.
|
||||
- `codex.thread_resume(thread_id, ...)` performs a `thread/resume` RPC and can apply overrides (model, instructions, sandbox, etc.).
|
||||
Public API keyword names are snake_case. The SDK still maps them to wire camelCase under the hood.
|
||||
|
||||
Use `thread(...)` for simple continuation. Use `thread_resume(...)` when you need explicit resume semantics or override fields.
|
||||
If you are migrating older code, update these names:
|
||||
|
||||
- `approvalPolicy` -> `approval_policy`
|
||||
- `baseInstructions` -> `base_instructions`
|
||||
- `developerInstructions` -> `developer_instructions`
|
||||
- `modelProvider` -> `model_provider`
|
||||
- `modelProviders` -> `model_providers`
|
||||
- `sortKey` -> `sort_key`
|
||||
- `sourceKinds` -> `source_kinds`
|
||||
- `outputSchema` -> `output_schema`
|
||||
- `sandboxPolicy` -> `sandbox_policy`
|
||||
|
||||
## Why only `thread_start(...)` and `thread_resume(...)`?
|
||||
|
||||
The public API keeps only explicit lifecycle calls:
|
||||
|
||||
- `thread_start(...)` to create new threads
|
||||
- `thread_resume(thread_id, ...)` to continue existing threads
|
||||
|
||||
This avoids duplicate ways to do the same operation and keeps behavior explicit.
|
||||
|
||||
## Why does constructor fail?
|
||||
|
||||
@@ -61,7 +79,7 @@ python scripts/update_sdk_artifacts.py \
|
||||
A turn is complete only when `turn/completed` arrives for that turn ID.
|
||||
|
||||
- `run()` waits for this automatically.
|
||||
- With `stream()`, make sure you keep consuming notifications until completion.
|
||||
- With `stream()`, keep consuming notifications until completion.
|
||||
|
||||
## How do I retry safely?
|
||||
|
||||
@@ -72,6 +90,6 @@ Do not blindly retry all errors. For `InvalidParamsError` or `MethodNotFoundErro
|
||||
## Common pitfalls
|
||||
|
||||
- Starting a new thread for every prompt when you wanted continuity.
|
||||
- Forgetting to `close()` (or not using `with Codex() as codex:`).
|
||||
- Forgetting to `close()` (or not using context managers).
|
||||
- Ignoring `TurnResult.status` and `TurnResult.error`.
|
||||
- Mixing SDK input classes with raw dicts incorrectly in minimal API paths.
|
||||
- Mixing SDK input classes with raw dicts incorrectly.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Getting Started
|
||||
|
||||
This is the fastest path from install to a multi-turn thread using the minimal SDK surface.
|
||||
This is the fastest path from install to a multi-turn thread using the public SDK surface.
|
||||
|
||||
The SDK is experimental. Treat the API, bundled runtime strategy, and packaging details as unstable until the first public release.
|
||||
|
||||
## 1) Install
|
||||
|
||||
@@ -15,9 +17,9 @@ Requirements:
|
||||
|
||||
- Python `>=3.10`
|
||||
- installed `codex-cli-bin` runtime package, or an explicit `codex_bin` override
|
||||
- Local Codex auth/session configured
|
||||
- local Codex auth/session configured
|
||||
|
||||
## 2) Run your first turn
|
||||
## 2) Run your first turn (sync)
|
||||
|
||||
```python
|
||||
from codex_app_server import Codex, TextInput
|
||||
@@ -25,7 +27,7 @@ from codex_app_server import Codex, TextInput
|
||||
with Codex() as codex:
|
||||
print("Server:", codex.metadata.server_name, codex.metadata.server_version)
|
||||
|
||||
thread = codex.thread_start(model="gpt-5")
|
||||
thread = codex.thread_start(model="gpt-5", config={"model_reasoning_effort": "high"})
|
||||
result = thread.turn(TextInput("Say hello in one sentence.")).run()
|
||||
|
||||
print("Thread:", result.thread_id)
|
||||
@@ -39,6 +41,7 @@ What happened:
|
||||
- `Codex()` started and initialized `codex app-server`.
|
||||
- `thread_start(...)` created a thread.
|
||||
- `turn(...).run()` consumed events until `turn/completed` and returned a `TurnResult`.
|
||||
- one client can have only one active `Turn.stream()` / `Turn.run()` consumer at a time in the current experimental build
|
||||
|
||||
## 3) Continue the same thread (multi-turn)
|
||||
|
||||
@@ -46,7 +49,7 @@ What happened:
|
||||
from codex_app_server import Codex, TextInput
|
||||
|
||||
with Codex() as codex:
|
||||
thread = codex.thread_start(model="gpt-5")
|
||||
thread = codex.thread_start(model="gpt-5", config={"model_reasoning_effort": "high"})
|
||||
|
||||
first = thread.turn(TextInput("Summarize Rust ownership in 2 bullets.")).run()
|
||||
second = thread.turn(TextInput("Now explain it to a Python developer.")).run()
|
||||
@@ -55,7 +58,25 @@ with Codex() as codex:
|
||||
print("second:", second.text)
|
||||
```
|
||||
|
||||
## 4) Resume an existing thread
|
||||
## 4) Async parity
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from codex_app_server import AsyncCodex, TextInput
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with AsyncCodex() as codex:
|
||||
thread = await codex.thread_start(model="gpt-5", config={"model_reasoning_effort": "high"})
|
||||
turn = await thread.turn(TextInput("Continue where we left off."))
|
||||
result = await turn.run()
|
||||
print(result.text)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## 5) Resume an existing thread
|
||||
|
||||
```python
|
||||
from codex_app_server import Codex, TextInput
|
||||
@@ -63,12 +84,12 @@ from codex_app_server import Codex, TextInput
|
||||
THREAD_ID = "thr_123" # replace with a real id
|
||||
|
||||
with Codex() as codex:
|
||||
thread = codex.thread(THREAD_ID)
|
||||
thread = codex.thread_resume(THREAD_ID)
|
||||
result = thread.turn(TextInput("Continue where we left off.")).run()
|
||||
print(result.text)
|
||||
```
|
||||
|
||||
## 5) Next stops
|
||||
## 6) Next stops
|
||||
|
||||
- API surface and signatures: `docs/api-reference.md`
|
||||
- Common decisions/pitfalls: `docs/faq.md`
|
||||
|
||||
30
sdk/python/examples/01_quickstart_constructor/async.py
Normal file
30
sdk/python/examples/01_quickstart_constructor/async.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
import asyncio
|
||||
|
||||
from codex_app_server import AsyncCodex, TextInput
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with AsyncCodex(config=runtime_config()) as codex:
|
||||
print("Server:", codex.metadata.server_name, codex.metadata.server_version)
|
||||
|
||||
thread = await codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
turn = await thread.turn(TextInput("Say hello in one sentence."))
|
||||
result = await turn.run()
|
||||
|
||||
print("Status:", result.status)
|
||||
print("Text:", result.text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
20
sdk/python/examples/01_quickstart_constructor/sync.py
Normal file
20
sdk/python/examples/01_quickstart_constructor/sync.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
from codex_app_server import Codex, TextInput
|
||||
|
||||
with Codex(config=runtime_config()) as codex:
|
||||
print("Server:", codex.metadata.server_name, codex.metadata.server_version)
|
||||
|
||||
thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
result = thread.turn(TextInput("Say hello in one sentence.")).run()
|
||||
print("Status:", result.status)
|
||||
print("Text:", result.text)
|
||||
45
sdk/python/examples/02_turn_run/async.py
Normal file
45
sdk/python/examples/02_turn_run/async.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
import asyncio
|
||||
|
||||
from codex_app_server import AsyncCodex, TextInput
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with AsyncCodex(config=runtime_config()) as codex:
|
||||
thread = await codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
turn = await thread.turn(TextInput("Give 3 bullets about SIMD."))
|
||||
result = await turn.run()
|
||||
persisted = await thread.read(include_turns=True)
|
||||
persisted_turn = next(
|
||||
(turn for turn in persisted.thread.turns or [] if turn.id == result.turn_id),
|
||||
None,
|
||||
)
|
||||
|
||||
print("thread_id:", result.thread_id)
|
||||
print("turn_id:", result.turn_id)
|
||||
print("status:", result.status)
|
||||
if result.error is not None:
|
||||
print("error:", result.error)
|
||||
print("text:", result.text)
|
||||
print(
|
||||
"persisted.items.count:",
|
||||
0 if persisted_turn is None else len(persisted_turn.items or []),
|
||||
)
|
||||
if result.usage is None:
|
||||
raise RuntimeError("missing usage for completed turn")
|
||||
print("usage.thread_id:", result.usage.thread_id)
|
||||
print("usage.turn_id:", result.usage.turn_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
36
sdk/python/examples/02_turn_run/sync.py
Normal file
36
sdk/python/examples/02_turn_run/sync.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
from codex_app_server import Codex, TextInput
|
||||
|
||||
with Codex(config=runtime_config()) as codex:
|
||||
thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
result = thread.turn(TextInput("Give 3 bullets about SIMD.")).run()
|
||||
persisted = thread.read(include_turns=True)
|
||||
persisted_turn = next(
|
||||
(turn for turn in persisted.thread.turns or [] if turn.id == result.turn_id),
|
||||
None,
|
||||
)
|
||||
|
||||
print("thread_id:", result.thread_id)
|
||||
print("turn_id:", result.turn_id)
|
||||
print("status:", result.status)
|
||||
if result.error is not None:
|
||||
print("error:", result.error)
|
||||
print("text:", result.text)
|
||||
print(
|
||||
"persisted.items.count:",
|
||||
0 if persisted_turn is None else len(persisted_turn.items or []),
|
||||
)
|
||||
if result.usage is None:
|
||||
raise RuntimeError("missing usage for completed turn")
|
||||
print("usage.thread_id:", result.usage.thread_id)
|
||||
print("usage.turn_id:", result.usage.turn_id)
|
||||
44
sdk/python/examples/03_turn_stream_events/async.py
Normal file
44
sdk/python/examples/03_turn_stream_events/async.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
import asyncio
|
||||
|
||||
from codex_app_server import AsyncCodex, TextInput
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with AsyncCodex(config=runtime_config()) as codex:
|
||||
thread = await codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
turn = await thread.turn(TextInput("Count from 1 to 200 with commas, then one summary sentence."))
|
||||
|
||||
# Best effort controls: models can finish quickly, so races are expected.
|
||||
try:
|
||||
_ = await turn.steer(TextInput("Keep it brief and stop after 20 numbers."))
|
||||
print("steer: sent")
|
||||
except Exception as exc:
|
||||
print("steer: skipped", type(exc).__name__)
|
||||
|
||||
try:
|
||||
_ = await turn.interrupt()
|
||||
print("interrupt: sent")
|
||||
except Exception as exc:
|
||||
print("interrupt: skipped", type(exc).__name__)
|
||||
|
||||
event_count = 0
|
||||
async for event in turn.stream():
|
||||
event_count += 1
|
||||
print(event.method, event.payload)
|
||||
|
||||
print("events.count:", event_count)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
36
sdk/python/examples/03_turn_stream_events/sync.py
Normal file
36
sdk/python/examples/03_turn_stream_events/sync.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
from codex_app_server import Codex, TextInput
|
||||
|
||||
with Codex(config=runtime_config()) as codex:
|
||||
thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
turn = thread.turn(TextInput("Count from 1 to 200 with commas, then one summary sentence."))
|
||||
|
||||
# Best effort controls: models can finish quickly, so races are expected.
|
||||
try:
|
||||
_ = turn.steer(TextInput("Keep it brief and stop after 20 numbers."))
|
||||
print("steer: sent")
|
||||
except Exception as exc:
|
||||
print("steer: skipped", type(exc).__name__)
|
||||
|
||||
try:
|
||||
_ = turn.interrupt()
|
||||
print("interrupt: sent")
|
||||
except Exception as exc:
|
||||
print("interrupt: skipped", type(exc).__name__)
|
||||
|
||||
event_count = 0
|
||||
for event in turn.stream():
|
||||
event_count += 1
|
||||
print(event.method, event.payload)
|
||||
|
||||
print("events.count:", event_count)
|
||||
28
sdk/python/examples/04_models_and_metadata/async.py
Normal file
28
sdk/python/examples/04_models_and_metadata/async.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
import asyncio
|
||||
|
||||
from codex_app_server import AsyncCodex
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with AsyncCodex(config=runtime_config()) as codex:
|
||||
print("metadata:", codex.metadata)
|
||||
|
||||
models = await codex.models(include_hidden=True)
|
||||
print("models.count:", len(models.data))
|
||||
if models.data:
|
||||
print("first model id:", models.data[0].id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
20
sdk/python/examples/04_models_and_metadata/sync.py
Normal file
20
sdk/python/examples/04_models_and_metadata/sync.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
from codex_app_server import Codex
|
||||
|
||||
with Codex(config=runtime_config()) as codex:
|
||||
print("metadata:", codex.metadata)
|
||||
|
||||
models = codex.models()
|
||||
print("models.count:", len(models.data))
|
||||
if models.data:
|
||||
print("first model id:", models.data[0].id)
|
||||
32
sdk/python/examples/05_existing_thread/async.py
Normal file
32
sdk/python/examples/05_existing_thread/async.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
import asyncio
|
||||
|
||||
from codex_app_server import AsyncCodex, TextInput
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with AsyncCodex(config=runtime_config()) as codex:
|
||||
original = await codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
|
||||
first_turn = await original.turn(TextInput("Tell me one fact about Saturn."))
|
||||
first = await first_turn.run()
|
||||
print("Created thread:", first.thread_id)
|
||||
|
||||
resumed = await codex.thread_resume(first.thread_id)
|
||||
second_turn = await resumed.turn(TextInput("Continue with one more fact."))
|
||||
second = await second_turn.run()
|
||||
print(second.text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
23
sdk/python/examples/05_existing_thread/sync.py
Normal file
23
sdk/python/examples/05_existing_thread/sync.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
from codex_app_server import Codex, TextInput
|
||||
|
||||
with Codex(config=runtime_config()) as codex:
|
||||
# Create an initial thread and turn so we have a real thread to resume.
|
||||
original = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
first = original.turn(TextInput("Tell me one fact about Saturn.")).run()
|
||||
print("Created thread:", first.thread_id)
|
||||
|
||||
# Resume the existing thread by ID.
|
||||
resumed = codex.thread_resume(first.thread_id)
|
||||
second = resumed.turn(TextInput("Continue with one more fact.")).run()
|
||||
print(second.text)
|
||||
@@ -0,0 +1,70 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
import asyncio
|
||||
|
||||
from codex_app_server import AsyncCodex, TextInput
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with AsyncCodex(config=runtime_config()) as codex:
|
||||
thread = await codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
first = await (await thread.turn(TextInput("One sentence about structured planning."))).run()
|
||||
second = await (await thread.turn(TextInput("Now restate it for a junior engineer."))).run()
|
||||
|
||||
reopened = await codex.thread_resume(thread.id)
|
||||
listing_active = await codex.thread_list(limit=20, archived=False)
|
||||
reading = await reopened.read(include_turns=True)
|
||||
|
||||
_ = await reopened.set_name("sdk-lifecycle-demo")
|
||||
_ = await codex.thread_archive(reopened.id)
|
||||
listing_archived = await codex.thread_list(limit=20, archived=True)
|
||||
unarchived = await codex.thread_unarchive(reopened.id)
|
||||
|
||||
resumed_info = "n/a"
|
||||
try:
|
||||
resumed = await codex.thread_resume(
|
||||
unarchived.id,
|
||||
model="gpt-5.4",
|
||||
config={"model_reasoning_effort": "high"},
|
||||
)
|
||||
resumed_result = await (await resumed.turn(TextInput("Continue in one short sentence."))).run()
|
||||
resumed_info = f"{resumed_result.turn_id} {resumed_result.status}"
|
||||
except Exception as exc:
|
||||
resumed_info = f"skipped({type(exc).__name__})"
|
||||
|
||||
forked_info = "n/a"
|
||||
try:
|
||||
forked = await codex.thread_fork(unarchived.id, model="gpt-5.4")
|
||||
forked_result = await (await forked.turn(TextInput("Take a different angle in one short sentence."))).run()
|
||||
forked_info = f"{forked_result.turn_id} {forked_result.status}"
|
||||
except Exception as exc:
|
||||
forked_info = f"skipped({type(exc).__name__})"
|
||||
|
||||
compact_info = "sent"
|
||||
try:
|
||||
_ = await unarchived.compact()
|
||||
except Exception as exc:
|
||||
compact_info = f"skipped({type(exc).__name__})"
|
||||
|
||||
print("Lifecycle OK:", thread.id)
|
||||
print("first:", first.turn_id, first.status)
|
||||
print("second:", second.turn_id, second.status)
|
||||
print("read.turns:", len(reading.thread.turns or []))
|
||||
print("list.active:", len(listing_active.data))
|
||||
print("list.archived:", len(listing_archived.data))
|
||||
print("resumed:", resumed_info)
|
||||
print("forked:", forked_info)
|
||||
print("compact:", compact_info)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
63
sdk/python/examples/06_thread_lifecycle_and_controls/sync.py
Normal file
63
sdk/python/examples/06_thread_lifecycle_and_controls/sync.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
from codex_app_server import Codex, TextInput
|
||||
|
||||
|
||||
with Codex(config=runtime_config()) as codex:
|
||||
thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
first = thread.turn(TextInput("One sentence about structured planning.")).run()
|
||||
second = thread.turn(TextInput("Now restate it for a junior engineer.")).run()
|
||||
|
||||
reopened = codex.thread_resume(thread.id)
|
||||
listing_active = codex.thread_list(limit=20, archived=False)
|
||||
reading = reopened.read(include_turns=True)
|
||||
|
||||
_ = reopened.set_name("sdk-lifecycle-demo")
|
||||
_ = codex.thread_archive(reopened.id)
|
||||
listing_archived = codex.thread_list(limit=20, archived=True)
|
||||
unarchived = codex.thread_unarchive(reopened.id)
|
||||
|
||||
resumed_info = "n/a"
|
||||
try:
|
||||
resumed = codex.thread_resume(
|
||||
unarchived.id,
|
||||
model="gpt-5.4",
|
||||
config={"model_reasoning_effort": "high"},
|
||||
)
|
||||
resumed_result = resumed.turn(TextInput("Continue in one short sentence.")).run()
|
||||
resumed_info = f"{resumed_result.turn_id} {resumed_result.status}"
|
||||
except Exception as exc:
|
||||
resumed_info = f"skipped({type(exc).__name__})"
|
||||
|
||||
forked_info = "n/a"
|
||||
try:
|
||||
forked = codex.thread_fork(unarchived.id, model="gpt-5.4")
|
||||
forked_result = forked.turn(TextInput("Take a different angle in one short sentence.")).run()
|
||||
forked_info = f"{forked_result.turn_id} {forked_result.status}"
|
||||
except Exception as exc:
|
||||
forked_info = f"skipped({type(exc).__name__})"
|
||||
|
||||
compact_info = "sent"
|
||||
try:
|
||||
_ = unarchived.compact()
|
||||
except Exception as exc:
|
||||
compact_info = f"skipped({type(exc).__name__})"
|
||||
|
||||
print("Lifecycle OK:", thread.id)
|
||||
print("first:", first.turn_id, first.status)
|
||||
print("second:", second.turn_id, second.status)
|
||||
print("read.turns:", len(reading.thread.turns or []))
|
||||
print("list.active:", len(listing_active.data))
|
||||
print("list.archived:", len(listing_archived.data))
|
||||
print("resumed:", resumed_info)
|
||||
print("forked:", forked_info)
|
||||
print("compact:", compact_info)
|
||||
35
sdk/python/examples/07_image_and_text/async.py
Normal file
35
sdk/python/examples/07_image_and_text/async.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
import asyncio
|
||||
|
||||
from codex_app_server import AsyncCodex, ImageInput, TextInput
|
||||
|
||||
REMOTE_IMAGE_URL = "https://raw.githubusercontent.com/github/explore/main/topics/python/python.png"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with AsyncCodex(config=runtime_config()) as codex:
|
||||
thread = await codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
turn = await thread.turn(
|
||||
[
|
||||
TextInput("What is in this image? Give 3 bullets."),
|
||||
ImageInput(REMOTE_IMAGE_URL),
|
||||
]
|
||||
)
|
||||
result = await turn.run()
|
||||
|
||||
print("Status:", result.status)
|
||||
print(result.text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
26
sdk/python/examples/07_image_and_text/sync.py
Normal file
26
sdk/python/examples/07_image_and_text/sync.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
from codex_app_server import Codex, ImageInput, TextInput
|
||||
|
||||
REMOTE_IMAGE_URL = "https://raw.githubusercontent.com/github/explore/main/topics/python/python.png"
|
||||
|
||||
with Codex(config=runtime_config()) as codex:
|
||||
thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
result = thread.turn(
|
||||
[
|
||||
TextInput("What is in this image? Give 3 bullets."),
|
||||
ImageInput(REMOTE_IMAGE_URL),
|
||||
]
|
||||
).run()
|
||||
|
||||
print("Status:", result.status)
|
||||
print(result.text)
|
||||
38
sdk/python/examples/08_local_image_and_text/async.py
Normal file
38
sdk/python/examples/08_local_image_and_text/async.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
import asyncio
|
||||
|
||||
from codex_app_server import AsyncCodex, LocalImageInput, TextInput
|
||||
|
||||
IMAGE_PATH = Path(__file__).resolve().parents[1] / "assets" / "sample_scene.png"
|
||||
if not IMAGE_PATH.exists():
|
||||
raise FileNotFoundError(f"Missing bundled image: {IMAGE_PATH}")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with AsyncCodex(config=runtime_config()) as codex:
|
||||
thread = await codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
|
||||
turn = await thread.turn(
|
||||
[
|
||||
TextInput("Read this local image and summarize what you see in 2 bullets."),
|
||||
LocalImageInput(str(IMAGE_PATH.resolve())),
|
||||
]
|
||||
)
|
||||
result = await turn.run()
|
||||
|
||||
print("Status:", result.status)
|
||||
print(result.text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
29
sdk/python/examples/08_local_image_and_text/sync.py
Normal file
29
sdk/python/examples/08_local_image_and_text/sync.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
from codex_app_server import Codex, LocalImageInput, TextInput
|
||||
|
||||
IMAGE_PATH = Path(__file__).resolve().parents[1] / "assets" / "sample_scene.png"
|
||||
if not IMAGE_PATH.exists():
|
||||
raise FileNotFoundError(f"Missing bundled image: {IMAGE_PATH}")
|
||||
|
||||
with Codex(config=runtime_config()) as codex:
|
||||
thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
|
||||
result = thread.turn(
|
||||
[
|
||||
TextInput("Read this local image and summarize what you see in 2 bullets."),
|
||||
LocalImageInput(str(IMAGE_PATH.resolve())),
|
||||
]
|
||||
).run()
|
||||
|
||||
print("Status:", result.status)
|
||||
print(result.text)
|
||||
23
sdk/python/examples/09_async_parity/sync.py
Normal file
23
sdk/python/examples/09_async_parity/sync.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
from codex_app_server import Codex, TextInput
|
||||
|
||||
with Codex(config=runtime_config()) as codex:
|
||||
print("Server:", codex.metadata.server_name, codex.metadata.server_version)
|
||||
|
||||
thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
turn = thread.turn(TextInput("Say hello in one sentence."))
|
||||
result = turn.run()
|
||||
|
||||
print("Thread:", result.thread_id)
|
||||
print("Turn:", result.turn_id)
|
||||
print("Text:", result.text.strip())
|
||||
91
sdk/python/examples/10_error_handling_and_retry/async.py
Normal file
91
sdk/python/examples/10_error_handling_and_retry/async.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import TypeVar
|
||||
|
||||
from codex_app_server import (
|
||||
AsyncCodex,
|
||||
JsonRpcError,
|
||||
ServerBusyError,
|
||||
TextInput,
|
||||
TurnStatus,
|
||||
is_retryable_error,
|
||||
)
|
||||
|
||||
ResultT = TypeVar("ResultT")
|
||||
|
||||
|
||||
async def retry_on_overload_async(
|
||||
op: Callable[[], Awaitable[ResultT]],
|
||||
*,
|
||||
max_attempts: int = 3,
|
||||
initial_delay_s: float = 0.25,
|
||||
max_delay_s: float = 2.0,
|
||||
jitter_ratio: float = 0.2,
|
||||
) -> ResultT:
|
||||
if max_attempts < 1:
|
||||
raise ValueError("max_attempts must be >= 1")
|
||||
|
||||
delay = initial_delay_s
|
||||
attempt = 0
|
||||
while True:
|
||||
attempt += 1
|
||||
try:
|
||||
return await op()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if attempt >= max_attempts or not is_retryable_error(exc):
|
||||
raise
|
||||
jitter = delay * jitter_ratio
|
||||
sleep_for = min(max_delay_s, delay) + random.uniform(-jitter, jitter)
|
||||
if sleep_for > 0:
|
||||
await asyncio.sleep(sleep_for)
|
||||
delay = min(max_delay_s, delay * 2)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with AsyncCodex(config=runtime_config()) as codex:
|
||||
thread = await codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
|
||||
try:
|
||||
result = await retry_on_overload_async(
|
||||
_run_turn(thread, "Summarize retry best practices in 3 bullets."),
|
||||
max_attempts=3,
|
||||
initial_delay_s=0.25,
|
||||
max_delay_s=2.0,
|
||||
)
|
||||
except ServerBusyError as exc:
|
||||
print("Server overloaded after retries:", exc.message)
|
||||
print("Text:")
|
||||
return
|
||||
except JsonRpcError as exc:
|
||||
print(f"JSON-RPC error {exc.code}: {exc.message}")
|
||||
print("Text:")
|
||||
return
|
||||
|
||||
if result.status == TurnStatus.failed:
|
||||
print("Turn failed:", result.error)
|
||||
|
||||
print("Text:", result.text)
|
||||
|
||||
|
||||
def _run_turn(thread, prompt: str):
|
||||
async def _inner():
|
||||
turn = await thread.turn(TextInput(prompt))
|
||||
return await turn.run()
|
||||
|
||||
return _inner
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
40
sdk/python/examples/10_error_handling_and_retry/sync.py
Normal file
40
sdk/python/examples/10_error_handling_and_retry/sync.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
from codex_app_server import (
|
||||
Codex,
|
||||
JsonRpcError,
|
||||
ServerBusyError,
|
||||
TextInput,
|
||||
TurnStatus,
|
||||
retry_on_overload,
|
||||
)
|
||||
|
||||
with Codex(config=runtime_config()) as codex:
|
||||
thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
|
||||
try:
|
||||
result = retry_on_overload(
|
||||
lambda: thread.turn(TextInput("Summarize retry best practices in 3 bullets.")).run(),
|
||||
max_attempts=3,
|
||||
initial_delay_s=0.25,
|
||||
max_delay_s=2.0,
|
||||
)
|
||||
except ServerBusyError as exc:
|
||||
print("Server overloaded after retries:", exc.message)
|
||||
print("Text:")
|
||||
except JsonRpcError as exc:
|
||||
print(f"JSON-RPC error {exc.code}: {exc.message}")
|
||||
print("Text:")
|
||||
else:
|
||||
if result.status == TurnStatus.failed:
|
||||
print("Turn failed:", result.error)
|
||||
print("Text:", result.text)
|
||||
96
sdk/python/examples/11_cli_mini_app/async.py
Normal file
96
sdk/python/examples/11_cli_mini_app/async.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
import asyncio
|
||||
|
||||
from codex_app_server import (
|
||||
AsyncCodex,
|
||||
TextInput,
|
||||
ThreadTokenUsageUpdatedNotification,
|
||||
TurnCompletedNotificationPayload,
|
||||
)
|
||||
|
||||
|
||||
def _status_value(status: object | None) -> str:
|
||||
return str(getattr(status, "value", status))
|
||||
|
||||
|
||||
def _format_usage(usage: object | None) -> str:
|
||||
if usage is None:
|
||||
return "usage> (none)"
|
||||
|
||||
last = getattr(usage, "last", None)
|
||||
total = getattr(usage, "total", None)
|
||||
if last is None or total is None:
|
||||
return f"usage> {usage}"
|
||||
|
||||
return (
|
||||
"usage>\n"
|
||||
f" last: input={last.inputTokens} output={last.outputTokens} reasoning={last.reasoningOutputTokens} total={last.totalTokens} cached={last.cachedInputTokens}\n"
|
||||
f" total: input={total.inputTokens} output={total.outputTokens} reasoning={total.reasoningOutputTokens} total={total.totalTokens} cached={total.cachedInputTokens}"
|
||||
)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
print("Codex async mini CLI. Type /exit to quit.")
|
||||
|
||||
async with AsyncCodex(config=runtime_config()) as codex:
|
||||
thread = await codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
print("Thread:", thread.id)
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_input = (await asyncio.to_thread(input, "you> ")).strip()
|
||||
except EOFError:
|
||||
break
|
||||
|
||||
if not user_input:
|
||||
continue
|
||||
if user_input in {"/exit", "/quit"}:
|
||||
break
|
||||
|
||||
turn = await thread.turn(TextInput(user_input))
|
||||
usage = None
|
||||
status = None
|
||||
error = None
|
||||
printed_delta = False
|
||||
|
||||
print("assistant> ", end="", flush=True)
|
||||
async for event in turn.stream():
|
||||
payload = event.payload
|
||||
if event.method == "item/agentMessage/delta":
|
||||
delta = getattr(payload, "delta", "")
|
||||
if delta:
|
||||
print(delta, end="", flush=True)
|
||||
printed_delta = True
|
||||
continue
|
||||
if isinstance(payload, ThreadTokenUsageUpdatedNotification):
|
||||
usage = payload.token_usage
|
||||
continue
|
||||
if isinstance(payload, TurnCompletedNotificationPayload):
|
||||
status = payload.turn.status
|
||||
error = payload.turn.error
|
||||
|
||||
if printed_delta:
|
||||
print()
|
||||
else:
|
||||
print("[no text]")
|
||||
|
||||
status_text = _status_value(status)
|
||||
print(f"assistant.status> {status_text}")
|
||||
if status_text == "failed":
|
||||
print("assistant.error>", error)
|
||||
|
||||
print(_format_usage(usage))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
89
sdk/python/examples/11_cli_mini_app/sync.py
Normal file
89
sdk/python/examples/11_cli_mini_app/sync.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
from codex_app_server import (
|
||||
Codex,
|
||||
TextInput,
|
||||
ThreadTokenUsageUpdatedNotification,
|
||||
TurnCompletedNotificationPayload,
|
||||
)
|
||||
|
||||
print("Codex mini CLI. Type /exit to quit.")
|
||||
|
||||
|
||||
def _status_value(status: object | None) -> str:
|
||||
return str(getattr(status, "value", status))
|
||||
|
||||
|
||||
def _format_usage(usage: object | None) -> str:
|
||||
if usage is None:
|
||||
return "usage> (none)"
|
||||
|
||||
last = getattr(usage, "last", None)
|
||||
total = getattr(usage, "total", None)
|
||||
if last is None or total is None:
|
||||
return f"usage> {usage}"
|
||||
|
||||
return (
|
||||
"usage>\n"
|
||||
f" last: input={last.inputTokens} output={last.outputTokens} reasoning={last.reasoningOutputTokens} total={last.totalTokens} cached={last.cachedInputTokens}\n"
|
||||
f" total: input={total.inputTokens} output={total.outputTokens} reasoning={total.reasoningOutputTokens} total={total.totalTokens} cached={total.cachedInputTokens}"
|
||||
)
|
||||
|
||||
|
||||
with Codex(config=runtime_config()) as codex:
|
||||
thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
print("Thread:", thread.id)
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_input = input("you> ").strip()
|
||||
except EOFError:
|
||||
break
|
||||
|
||||
if not user_input:
|
||||
continue
|
||||
if user_input in {"/exit", "/quit"}:
|
||||
break
|
||||
|
||||
turn = thread.turn(TextInput(user_input))
|
||||
usage = None
|
||||
status = None
|
||||
error = None
|
||||
printed_delta = False
|
||||
|
||||
print("assistant> ", end="", flush=True)
|
||||
for event in turn.stream():
|
||||
payload = event.payload
|
||||
if event.method == "item/agentMessage/delta":
|
||||
delta = getattr(payload, "delta", "")
|
||||
if delta:
|
||||
print(delta, end="", flush=True)
|
||||
printed_delta = True
|
||||
continue
|
||||
if isinstance(payload, ThreadTokenUsageUpdatedNotification):
|
||||
usage = payload.token_usage
|
||||
continue
|
||||
if isinstance(payload, TurnCompletedNotificationPayload):
|
||||
status = payload.turn.status
|
||||
error = payload.turn.error
|
||||
|
||||
if printed_delta:
|
||||
print()
|
||||
else:
|
||||
print("[no text]")
|
||||
|
||||
status_text = _status_value(status)
|
||||
print(f"assistant.status> {status_text}")
|
||||
if status_text == "failed":
|
||||
print("assistant.error>", error)
|
||||
|
||||
print(_format_usage(usage))
|
||||
75
sdk/python/examples/12_turn_params_kitchen_sink/async.py
Normal file
75
sdk/python/examples/12_turn_params_kitchen_sink/async.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
import asyncio
|
||||
|
||||
from codex_app_server import (
|
||||
AskForApproval,
|
||||
AsyncCodex,
|
||||
Personality,
|
||||
ReasoningEffort,
|
||||
ReasoningSummary,
|
||||
SandboxPolicy,
|
||||
TextInput,
|
||||
)
|
||||
|
||||
OUTPUT_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": {"type": "string"},
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
},
|
||||
"required": ["summary", "actions"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
SANDBOX_POLICY = SandboxPolicy.model_validate(
|
||||
{
|
||||
"type": "readOnly",
|
||||
"access": {"type": "fullAccess"},
|
||||
}
|
||||
)
|
||||
SUMMARY = ReasoningSummary.model_validate("concise")
|
||||
|
||||
PROMPT = (
|
||||
"Analyze a safe rollout plan for enabling a feature flag in production. "
|
||||
"Return JSON matching the requested schema."
|
||||
)
|
||||
APPROVAL_POLICY = AskForApproval.model_validate("never")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with AsyncCodex(config=runtime_config()) as codex:
|
||||
thread = await codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
|
||||
turn = await thread.turn(
|
||||
TextInput(PROMPT),
|
||||
approval_policy=APPROVAL_POLICY,
|
||||
cwd=str(Path.cwd()),
|
||||
effort=ReasoningEffort.medium,
|
||||
model="gpt-5.4",
|
||||
output_schema=OUTPUT_SCHEMA,
|
||||
personality=Personality.pragmatic,
|
||||
sandbox_policy=SANDBOX_POLICY,
|
||||
summary=SUMMARY,
|
||||
)
|
||||
result = await turn.run()
|
||||
|
||||
print("Status:", result.status)
|
||||
print("Text:", result.text)
|
||||
print("Usage:", result.usage)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
67
sdk/python/examples/12_turn_params_kitchen_sink/sync.py
Normal file
67
sdk/python/examples/12_turn_params_kitchen_sink/sync.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
from codex_app_server import (
|
||||
AskForApproval,
|
||||
Codex,
|
||||
Personality,
|
||||
ReasoningEffort,
|
||||
ReasoningSummary,
|
||||
SandboxPolicy,
|
||||
TextInput,
|
||||
)
|
||||
|
||||
OUTPUT_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": {"type": "string"},
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
},
|
||||
"required": ["summary", "actions"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
SANDBOX_POLICY = SandboxPolicy.model_validate(
|
||||
{
|
||||
"type": "readOnly",
|
||||
"access": {"type": "fullAccess"},
|
||||
}
|
||||
)
|
||||
SUMMARY = ReasoningSummary.model_validate("concise")
|
||||
|
||||
PROMPT = (
|
||||
"Analyze a safe rollout plan for enabling a feature flag in production. "
|
||||
"Return JSON matching the requested schema."
|
||||
)
|
||||
APPROVAL_POLICY = AskForApproval.model_validate("never")
|
||||
|
||||
with Codex(config=runtime_config()) as codex:
|
||||
thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
|
||||
turn = thread.turn(
|
||||
TextInput(PROMPT),
|
||||
approval_policy=APPROVAL_POLICY,
|
||||
cwd=str(Path.cwd()),
|
||||
effort=ReasoningEffort.medium,
|
||||
model="gpt-5.4",
|
||||
output_schema=OUTPUT_SCHEMA,
|
||||
personality=Personality.pragmatic,
|
||||
sandbox_policy=SANDBOX_POLICY,
|
||||
summary=SUMMARY,
|
||||
)
|
||||
result = turn.run()
|
||||
|
||||
print("Status:", result.status)
|
||||
print("Text:", result.text)
|
||||
print("Usage:", result.usage)
|
||||
121
sdk/python/examples/13_model_select_and_turn_params/async.py
Normal file
121
sdk/python/examples/13_model_select_and_turn_params/async.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
import asyncio
|
||||
|
||||
from codex_app_server import (
|
||||
AskForApproval,
|
||||
AsyncCodex,
|
||||
Personality,
|
||||
ReasoningEffort,
|
||||
ReasoningSummary,
|
||||
SandboxPolicy,
|
||||
TextInput,
|
||||
)
|
||||
|
||||
REASONING_RANK = {
|
||||
"none": 0,
|
||||
"minimal": 1,
|
||||
"low": 2,
|
||||
"medium": 3,
|
||||
"high": 4,
|
||||
"xhigh": 5,
|
||||
}
|
||||
PREFERRED_MODEL = "gpt-5.4"
|
||||
|
||||
|
||||
def _pick_highest_model(models):
|
||||
visible = [m for m in models if not m.hidden] or models
|
||||
preferred = next((m for m in visible if m.model == PREFERRED_MODEL or m.id == PREFERRED_MODEL), None)
|
||||
if preferred is not None:
|
||||
return preferred
|
||||
known_names = {m.id for m in visible} | {m.model for m in visible}
|
||||
top_candidates = [m for m in visible if not (m.upgrade and m.upgrade in known_names)]
|
||||
pool = top_candidates or visible
|
||||
return max(pool, key=lambda m: (m.model, m.id))
|
||||
|
||||
|
||||
def _pick_highest_turn_effort(model) -> ReasoningEffort:
|
||||
if not model.supported_reasoning_efforts:
|
||||
return ReasoningEffort.medium
|
||||
|
||||
best = max(
|
||||
model.supported_reasoning_efforts,
|
||||
key=lambda option: REASONING_RANK.get(option.reasoning_effort.value, -1),
|
||||
)
|
||||
return ReasoningEffort(best.reasoning_effort.value)
|
||||
|
||||
|
||||
OUTPUT_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": {"type": "string"},
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
},
|
||||
"required": ["summary", "actions"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
SANDBOX_POLICY = SandboxPolicy.model_validate(
|
||||
{
|
||||
"type": "readOnly",
|
||||
"access": {"type": "fullAccess"},
|
||||
}
|
||||
)
|
||||
APPROVAL_POLICY = AskForApproval.model_validate("never")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with AsyncCodex(config=runtime_config()) as codex:
|
||||
models = await codex.models(include_hidden=True)
|
||||
selected_model = _pick_highest_model(models.data)
|
||||
selected_effort = _pick_highest_turn_effort(selected_model)
|
||||
|
||||
print("selected.model:", selected_model.model)
|
||||
print("selected.effort:", selected_effort.value)
|
||||
|
||||
thread = await codex.thread_start(
|
||||
model=selected_model.model,
|
||||
config={"model_reasoning_effort": selected_effort.value},
|
||||
)
|
||||
|
||||
first_turn = await thread.turn(
|
||||
TextInput("Give one short sentence about reliable production releases."),
|
||||
model=selected_model.model,
|
||||
effort=selected_effort,
|
||||
)
|
||||
first = await first_turn.run()
|
||||
|
||||
print("agent.message:", first.text)
|
||||
print("usage:", first.usage)
|
||||
|
||||
second_turn = await thread.turn(
|
||||
TextInput("Return JSON for a safe feature-flag rollout plan."),
|
||||
approval_policy=APPROVAL_POLICY,
|
||||
cwd=str(Path.cwd()),
|
||||
effort=selected_effort,
|
||||
model=selected_model.model,
|
||||
output_schema=OUTPUT_SCHEMA,
|
||||
personality=Personality.pragmatic,
|
||||
sandbox_policy=SANDBOX_POLICY,
|
||||
summary=ReasoningSummary.model_validate("concise"),
|
||||
)
|
||||
second = await second_turn.run()
|
||||
|
||||
print("agent.message.params:", second.text)
|
||||
print("usage.params:", second.usage)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
112
sdk/python/examples/13_model_select_and_turn_params/sync.py
Normal file
112
sdk/python/examples/13_model_select_and_turn_params/sync.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
from codex_app_server import (
|
||||
AskForApproval,
|
||||
Codex,
|
||||
Personality,
|
||||
ReasoningEffort,
|
||||
ReasoningSummary,
|
||||
SandboxPolicy,
|
||||
TextInput,
|
||||
)
|
||||
|
||||
REASONING_RANK = {
|
||||
"none": 0,
|
||||
"minimal": 1,
|
||||
"low": 2,
|
||||
"medium": 3,
|
||||
"high": 4,
|
||||
"xhigh": 5,
|
||||
}
|
||||
PREFERRED_MODEL = "gpt-5.4"
|
||||
|
||||
|
||||
def _pick_highest_model(models):
|
||||
visible = [m for m in models if not m.hidden] or models
|
||||
preferred = next((m for m in visible if m.model == PREFERRED_MODEL or m.id == PREFERRED_MODEL), None)
|
||||
if preferred is not None:
|
||||
return preferred
|
||||
known_names = {m.id for m in visible} | {m.model for m in visible}
|
||||
top_candidates = [m for m in visible if not (m.upgrade and m.upgrade in known_names)]
|
||||
pool = top_candidates or visible
|
||||
return max(pool, key=lambda m: (m.model, m.id))
|
||||
|
||||
|
||||
def _pick_highest_turn_effort(model) -> ReasoningEffort:
|
||||
if not model.supported_reasoning_efforts:
|
||||
return ReasoningEffort.medium
|
||||
|
||||
best = max(
|
||||
model.supported_reasoning_efforts,
|
||||
key=lambda option: REASONING_RANK.get(option.reasoning_effort.value, -1),
|
||||
)
|
||||
return ReasoningEffort(best.reasoning_effort.value)
|
||||
|
||||
|
||||
OUTPUT_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": {"type": "string"},
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
},
|
||||
"required": ["summary", "actions"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
SANDBOX_POLICY = SandboxPolicy.model_validate(
|
||||
{
|
||||
"type": "readOnly",
|
||||
"access": {"type": "fullAccess"},
|
||||
}
|
||||
)
|
||||
APPROVAL_POLICY = AskForApproval.model_validate("never")
|
||||
|
||||
|
||||
with Codex(config=runtime_config()) as codex:
|
||||
models = codex.models(include_hidden=True)
|
||||
selected_model = _pick_highest_model(models.data)
|
||||
selected_effort = _pick_highest_turn_effort(selected_model)
|
||||
|
||||
print("selected.model:", selected_model.model)
|
||||
print("selected.effort:", selected_effort.value)
|
||||
|
||||
thread = codex.thread_start(
|
||||
model=selected_model.model,
|
||||
config={"model_reasoning_effort": selected_effort.value},
|
||||
)
|
||||
|
||||
first = thread.turn(
|
||||
TextInput("Give one short sentence about reliable production releases."),
|
||||
model=selected_model.model,
|
||||
effort=selected_effort,
|
||||
).run()
|
||||
|
||||
print("agent.message:", first.text)
|
||||
print("usage:", first.usage)
|
||||
|
||||
second = thread.turn(
|
||||
TextInput("Return JSON for a safe feature-flag rollout plan."),
|
||||
approval_policy=APPROVAL_POLICY,
|
||||
cwd=str(Path.cwd()),
|
||||
effort=selected_effort,
|
||||
model=selected_model.model,
|
||||
output_schema=OUTPUT_SCHEMA,
|
||||
personality=Personality.pragmatic,
|
||||
sandbox_policy=SANDBOX_POLICY,
|
||||
summary=ReasoningSummary.model_validate("concise"),
|
||||
).run()
|
||||
|
||||
print("agent.message.params:", second.text)
|
||||
print("usage.params:", second.usage)
|
||||
83
sdk/python/examples/README.md
Normal file
83
sdk/python/examples/README.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Python SDK Examples
|
||||
|
||||
Each example folder contains runnable versions:
|
||||
|
||||
- `sync.py` (public sync surface: `Codex`)
|
||||
- `async.py` (public async surface: `AsyncCodex`)
|
||||
|
||||
All examples intentionally use only public SDK exports from `codex_app_server`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python `>=3.10`
|
||||
- Install SDK dependencies for the same Python interpreter you will use to run examples
|
||||
|
||||
Recommended setup (from `sdk/python`):
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python -m pip install -U pip
|
||||
python -m pip install -e .
|
||||
```
|
||||
|
||||
When running examples from this repo checkout, the SDK source uses the local
|
||||
tree and does not bundle a runtime binary. The helper in `examples/_bootstrap.py`
|
||||
uses the installed `codex-cli-bin` runtime package.
|
||||
|
||||
If the pinned `codex-cli-bin` runtime is not already installed, the bootstrap
|
||||
will download the matching GitHub release artifact, stage a temporary local
|
||||
`codex-cli-bin` package, install it into your active interpreter, and clean up
|
||||
the temporary files afterward.
|
||||
|
||||
Current pinned runtime version: `0.115.0-alpha.11`
|
||||
|
||||
## Run examples
|
||||
|
||||
From `sdk/python`:
|
||||
|
||||
```bash
|
||||
python examples/<example-folder>/sync.py
|
||||
python examples/<example-folder>/async.py
|
||||
```
|
||||
|
||||
The examples bootstrap local imports from `sdk/python/src` automatically, so no
|
||||
SDK wheel install is required. You only need the Python dependencies for your
|
||||
active interpreter and an installed `codex-cli-bin` runtime package (either
|
||||
already present or automatically provisioned by the bootstrap).
|
||||
|
||||
## Recommended first run
|
||||
|
||||
```bash
|
||||
python examples/01_quickstart_constructor/sync.py
|
||||
python examples/01_quickstart_constructor/async.py
|
||||
```
|
||||
|
||||
## Index
|
||||
|
||||
- `01_quickstart_constructor/`
|
||||
- first run / sanity check
|
||||
- `02_turn_run/`
|
||||
- inspect full turn output fields
|
||||
- `03_turn_stream_events/`
|
||||
- stream and print raw notifications
|
||||
- `04_models_and_metadata/`
|
||||
- read server metadata and model list
|
||||
- `05_existing_thread/`
|
||||
- resume a real existing thread (created in-script)
|
||||
- `06_thread_lifecycle_and_controls/`
|
||||
- thread lifecycle + control calls
|
||||
- `07_image_and_text/`
|
||||
- remote image URL + text multimodal turn
|
||||
- `08_local_image_and_text/`
|
||||
- local image + text multimodal turn using bundled sample image
|
||||
- `09_async_parity/`
|
||||
- parity-style sync flow (see async parity in other examples)
|
||||
- `10_error_handling_and_retry/`
|
||||
- overload retry pattern + typed error handling structure
|
||||
- `11_cli_mini_app/`
|
||||
- interactive chat loop
|
||||
- `12_turn_params_kitchen_sink/`
|
||||
- one turn using most optional `turn(...)` params (sync + async)
|
||||
- `13_model_select_and_turn_params/`
|
||||
- list models, pick highest model + highest supported reasoning effort, run turns, print message and usage
|
||||
51
sdk/python/examples/_bootstrap.py
Normal file
51
sdk/python/examples/_bootstrap.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_SDK_PYTHON_DIR = Path(__file__).resolve().parents[1]
|
||||
_SDK_PYTHON_STR = str(_SDK_PYTHON_DIR)
|
||||
if _SDK_PYTHON_STR not in sys.path:
|
||||
sys.path.insert(0, _SDK_PYTHON_STR)
|
||||
|
||||
from _runtime_setup import ensure_runtime_package_installed
|
||||
|
||||
|
||||
def _ensure_runtime_dependencies(sdk_python_dir: Path) -> None:
|
||||
if importlib.util.find_spec("pydantic") is not None:
|
||||
return
|
||||
|
||||
python = sys.executable
|
||||
raise RuntimeError(
|
||||
"Missing required dependency: pydantic.\n"
|
||||
f"Interpreter: {python}\n"
|
||||
"Install dependencies with the same interpreter used to run this example:\n"
|
||||
f" {python} -m pip install -e {sdk_python_dir}\n"
|
||||
"If you installed with `pip` from another Python, reinstall using the command above."
|
||||
)
|
||||
|
||||
|
||||
def ensure_local_sdk_src() -> Path:
|
||||
"""Add sdk/python/src to sys.path so examples run without installing the package."""
|
||||
sdk_python_dir = _SDK_PYTHON_DIR
|
||||
src_dir = sdk_python_dir / "src"
|
||||
package_dir = src_dir / "codex_app_server"
|
||||
if not package_dir.exists():
|
||||
raise RuntimeError(f"Could not locate local SDK package at {package_dir}")
|
||||
|
||||
_ensure_runtime_dependencies(sdk_python_dir)
|
||||
|
||||
src_str = str(src_dir)
|
||||
if src_str not in sys.path:
|
||||
sys.path.insert(0, src_str)
|
||||
return src_dir
|
||||
|
||||
|
||||
def runtime_config():
|
||||
"""Return an example-friendly AppServerConfig for repo-source SDK usage."""
|
||||
from codex_app_server import AppServerConfig
|
||||
|
||||
ensure_runtime_package_installed(sys.executable, _SDK_PYTHON_DIR)
|
||||
return AppServerConfig()
|
||||
BIN
sdk/python/examples/assets/sample_scene.png
Normal file
BIN
sdk/python/examples/assets/sample_scene.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
548
sdk/python/notebooks/sdk_walkthrough.ipynb
Normal file
548
sdk/python/notebooks/sdk_walkthrough.ipynb
Normal file
@@ -0,0 +1,548 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Codex Python SDK Walkthrough\n",
|
||||
"\n",
|
||||
"Public SDK surface only (`codex_app_server` root exports)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Cell 1: bootstrap local SDK imports + pinned runtime package\n",
|
||||
"import os\n",
|
||||
"import sys\n",
|
||||
"from pathlib import Path\n",
|
||||
"\n",
|
||||
"if sys.version_info < (3, 10):\n",
|
||||
" raise RuntimeError(\n",
|
||||
" f'Notebook requires Python 3.10+; current interpreter is {sys.version.split()[0]}.'\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
"try:\n",
|
||||
" _ = os.getcwd()\n",
|
||||
"except FileNotFoundError:\n",
|
||||
" os.chdir(str(Path.home()))\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def _is_sdk_python_dir(path: Path) -> bool:\n",
|
||||
" return (path / 'pyproject.toml').exists() and (path / 'src' / 'codex_app_server').exists()\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def _iter_home_fallback_candidates(home: Path):\n",
|
||||
" # bounded depth scan under home to support launching notebooks from unrelated cwd values\n",
|
||||
" patterns = ('sdk/python', '*/sdk/python', '*/*/sdk/python', '*/*/*/sdk/python')\n",
|
||||
" for pattern in patterns:\n",
|
||||
" yield from home.glob(pattern)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def _find_sdk_python_dir(start: Path) -> Path | None:\n",
|
||||
" checked = set()\n",
|
||||
"\n",
|
||||
" def _consider(candidate: Path) -> Path | None:\n",
|
||||
" resolved = candidate.resolve()\n",
|
||||
" if resolved in checked:\n",
|
||||
" return None\n",
|
||||
" checked.add(resolved)\n",
|
||||
" if _is_sdk_python_dir(resolved):\n",
|
||||
" return resolved\n",
|
||||
" return None\n",
|
||||
"\n",
|
||||
" for candidate in [start, *start.parents]:\n",
|
||||
" found = _consider(candidate)\n",
|
||||
" if found is not None:\n",
|
||||
" return found\n",
|
||||
"\n",
|
||||
" for candidate in [start / 'sdk' / 'python', *(parent / 'sdk' / 'python' for parent in start.parents)]:\n",
|
||||
" found = _consider(candidate)\n",
|
||||
" if found is not None:\n",
|
||||
" return found\n",
|
||||
"\n",
|
||||
" env_dir = os.environ.get('CODEX_PYTHON_SDK_DIR')\n",
|
||||
" if env_dir:\n",
|
||||
" found = _consider(Path(env_dir).expanduser())\n",
|
||||
" if found is not None:\n",
|
||||
" return found\n",
|
||||
"\n",
|
||||
" for entry in sys.path:\n",
|
||||
" if not entry:\n",
|
||||
" continue\n",
|
||||
" entry_path = Path(entry).expanduser()\n",
|
||||
" for candidate in (entry_path, entry_path / 'sdk' / 'python'):\n",
|
||||
" found = _consider(candidate)\n",
|
||||
" if found is not None:\n",
|
||||
" return found\n",
|
||||
"\n",
|
||||
" home = Path.home()\n",
|
||||
" for candidate in _iter_home_fallback_candidates(home):\n",
|
||||
" found = _consider(candidate)\n",
|
||||
" if found is not None:\n",
|
||||
" return found\n",
|
||||
"\n",
|
||||
" return None\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"repo_python_dir = _find_sdk_python_dir(Path.cwd())\n",
|
||||
"if repo_python_dir is None:\n",
|
||||
" raise RuntimeError('Could not locate sdk/python. Set CODEX_PYTHON_SDK_DIR to your sdk/python path.')\n",
|
||||
"\n",
|
||||
"repo_python_str = str(repo_python_dir)\n",
|
||||
"if repo_python_str not in sys.path:\n",
|
||||
" sys.path.insert(0, repo_python_str)\n",
|
||||
"\n",
|
||||
"from _runtime_setup import ensure_runtime_package_installed\n",
|
||||
"\n",
|
||||
"runtime_version = ensure_runtime_package_installed(\n",
|
||||
" sys.executable,\n",
|
||||
" repo_python_dir,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"src_dir = repo_python_dir / 'src'\n",
|
||||
"src_str = str(src_dir)\n",
|
||||
"if src_str not in sys.path:\n",
|
||||
" sys.path.insert(0, src_str)\n",
|
||||
"\n",
|
||||
"# Force fresh imports after SDK upgrades in the same notebook kernel.\n",
|
||||
"for module_name in list(sys.modules):\n",
|
||||
" if module_name == 'codex_app_server' or module_name.startswith('codex_app_server.'):\n",
|
||||
" sys.modules.pop(module_name, None)\n",
|
||||
"\n",
|
||||
"print('Kernel:', sys.executable)\n",
|
||||
"print('SDK source:', src_dir)\n",
|
||||
"print('Runtime package:', runtime_version)\n",
|
||||
"\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Cell 2: imports (public only)\n",
|
||||
"from codex_app_server import (\n",
|
||||
" AsyncCodex,\n",
|
||||
" Codex,\n",
|
||||
" ImageInput,\n",
|
||||
" LocalImageInput,\n",
|
||||
" TextInput,\n",
|
||||
" retry_on_overload,\n",
|
||||
")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Cell 3: simple sync conversation\n",
|
||||
"with Codex() as codex:\n",
|
||||
" thread = codex.thread_start(model='gpt-5', config={'model_reasoning_effort': 'high'})\n",
|
||||
" turn = thread.turn(TextInput('Explain gradient descent in 3 bullets.'))\n",
|
||||
" result = turn.run()\n",
|
||||
"\n",
|
||||
" print('server:', codex.metadata)\n",
|
||||
" print('status:', result.status)\n",
|
||||
" print(result.text)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Cell 4: multi-turn continuity in same thread\n",
|
||||
"with Codex() as codex:\n",
|
||||
" thread = codex.thread_start(model='gpt-5', config={'model_reasoning_effort': 'high'})\n",
|
||||
"\n",
|
||||
" first = thread.turn(TextInput('Give a short summary of transformers.')).run()\n",
|
||||
" second = thread.turn(TextInput('Now explain that to a high-school student.')).run()\n",
|
||||
"\n",
|
||||
" print('first status:', first.status)\n",
|
||||
" print('second status:', second.status)\n",
|
||||
" print('second text:', second.text)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Cell 5: full thread lifecycle and branching (sync)\n",
|
||||
"with Codex() as codex:\n",
|
||||
" thread = codex.thread_start(model='gpt-5', config={'model_reasoning_effort': 'high'})\n",
|
||||
" first = thread.turn(TextInput('One sentence about structured planning.')).run()\n",
|
||||
" second = thread.turn(TextInput('Now restate it for a junior engineer.')).run()\n",
|
||||
"\n",
|
||||
" reopened = codex.thread_resume(thread.id)\n",
|
||||
" listing_active = codex.thread_list(limit=20, archived=False)\n",
|
||||
" reading = reopened.read(include_turns=True)\n",
|
||||
"\n",
|
||||
" _ = reopened.set_name('sdk-lifecycle-demo')\n",
|
||||
" _ = codex.thread_archive(reopened.id)\n",
|
||||
" listing_archived = codex.thread_list(limit=20, archived=True)\n",
|
||||
" unarchived = codex.thread_unarchive(reopened.id)\n",
|
||||
"\n",
|
||||
" resumed_info = 'n/a'\n",
|
||||
" try:\n",
|
||||
" resumed = codex.thread_resume(\n",
|
||||
" unarchived.id,\n",
|
||||
" model='gpt-5',\n",
|
||||
" config={'model_reasoning_effort': 'high'},\n",
|
||||
" )\n",
|
||||
" resumed_result = resumed.turn(TextInput('Continue in one short sentence.')).run()\n",
|
||||
" resumed_info = f'{resumed_result.turn_id} {resumed_result.status}'\n",
|
||||
" except Exception as e:\n",
|
||||
" resumed_info = f'skipped({type(e).__name__})'\n",
|
||||
"\n",
|
||||
" forked_info = 'n/a'\n",
|
||||
" try:\n",
|
||||
" forked = codex.thread_fork(unarchived.id, model='gpt-5')\n",
|
||||
" forked_result = forked.turn(TextInput('Take a different angle in one short sentence.')).run()\n",
|
||||
" forked_info = f'{forked_result.turn_id} {forked_result.status}'\n",
|
||||
" except Exception as e:\n",
|
||||
" forked_info = f'skipped({type(e).__name__})'\n",
|
||||
"\n",
|
||||
" compact_info = 'sent'\n",
|
||||
" try:\n",
|
||||
" _ = unarchived.compact()\n",
|
||||
" except Exception as e:\n",
|
||||
" compact_info = f'skipped({type(e).__name__})'\n",
|
||||
"\n",
|
||||
" print('Lifecycle OK:', thread.id)\n",
|
||||
" print('first:', first.turn_id, first.status)\n",
|
||||
" print('second:', second.turn_id, second.status)\n",
|
||||
" print('read.turns:', len(reading.thread.turns or []))\n",
|
||||
" print('list.active:', len(listing_active.data))\n",
|
||||
" print('list.archived:', len(listing_archived.data))\n",
|
||||
" print('resumed:', resumed_info)\n",
|
||||
" print('forked:', forked_info)\n",
|
||||
" print('compact:', compact_info)\n",
|
||||
"\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Cell 5b: one turn with most optional turn params\n",
|
||||
"from pathlib import Path\n",
|
||||
"from codex_app_server import (\n",
|
||||
" AskForApproval,\n",
|
||||
" Personality,\n",
|
||||
" ReasoningEffort,\n",
|
||||
" ReasoningSummary,\n",
|
||||
" SandboxPolicy,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"output_schema = {\n",
|
||||
" 'type': 'object',\n",
|
||||
" 'properties': {\n",
|
||||
" 'summary': {'type': 'string'},\n",
|
||||
" 'actions': {'type': 'array', 'items': {'type': 'string'}},\n",
|
||||
" },\n",
|
||||
" 'required': ['summary', 'actions'],\n",
|
||||
" 'additionalProperties': False,\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"sandbox_policy = SandboxPolicy.model_validate({'type': 'readOnly', 'access': {'type': 'fullAccess'}})\n",
|
||||
"summary = ReasoningSummary.model_validate('concise')\n",
|
||||
"\n",
|
||||
"with Codex() as codex:\n",
|
||||
" thread = codex.thread_start(model='gpt-5', config={'model_reasoning_effort': 'high'})\n",
|
||||
" turn = thread.turn(\n",
|
||||
" TextInput('Propose a safe production feature-flag rollout. Return JSON matching the schema.'),\n",
|
||||
" approval_policy=AskForApproval.never,\n",
|
||||
" cwd=str(Path.cwd()),\n",
|
||||
" effort=ReasoningEffort.medium,\n",
|
||||
" model='gpt-5',\n",
|
||||
" output_schema=output_schema,\n",
|
||||
" personality=Personality.pragmatic,\n",
|
||||
" sandbox_policy=sandbox_policy,\n",
|
||||
" summary=summary,\n",
|
||||
" )\n",
|
||||
" result = turn.run()\n",
|
||||
"\n",
|
||||
" print('status:', result.status)\n",
|
||||
" print(result.text)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Cell 5c: choose highest model + highest supported reasoning, then run turns\n",
|
||||
"from pathlib import Path\n",
|
||||
"from codex_app_server import (\n",
|
||||
" AskForApproval,\n",
|
||||
" Personality,\n",
|
||||
" ReasoningEffort,\n",
|
||||
" ReasoningSummary,\n",
|
||||
" SandboxPolicy,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"reasoning_rank = {\n",
|
||||
" 'none': 0,\n",
|
||||
" 'minimal': 1,\n",
|
||||
" 'low': 2,\n",
|
||||
" 'medium': 3,\n",
|
||||
" 'high': 4,\n",
|
||||
" 'xhigh': 5,\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def pick_highest_model(models):\n",
|
||||
" visible = [m for m in models if not m.hidden] or models\n",
|
||||
" known_names = {m.id for m in visible} | {m.model for m in visible}\n",
|
||||
" top_candidates = [m for m in visible if not (m.upgrade and m.upgrade in known_names)]\n",
|
||||
" pool = top_candidates or visible\n",
|
||||
" return max(pool, key=lambda m: (m.model, m.id))\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def pick_highest_turn_effort(model) -> ReasoningEffort:\n",
|
||||
" if not model.supported_reasoning_efforts:\n",
|
||||
" return ReasoningEffort.medium\n",
|
||||
" best = max(model.supported_reasoning_efforts, key=lambda opt: reasoning_rank.get(opt.reasoning_effort.value, -1))\n",
|
||||
" return ReasoningEffort(best.reasoning_effort.value)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"output_schema = {\n",
|
||||
" 'type': 'object',\n",
|
||||
" 'properties': {\n",
|
||||
" 'summary': {'type': 'string'},\n",
|
||||
" 'actions': {'type': 'array', 'items': {'type': 'string'}},\n",
|
||||
" },\n",
|
||||
" 'required': ['summary', 'actions'],\n",
|
||||
" 'additionalProperties': False,\n",
|
||||
"}\n",
|
||||
"sandbox_policy = SandboxPolicy.model_validate({'type': 'readOnly', 'access': {'type': 'fullAccess'}})\n",
|
||||
"\n",
|
||||
"with Codex() as codex:\n",
|
||||
" models = codex.models(include_hidden=True)\n",
|
||||
" selected_model = pick_highest_model(models.data)\n",
|
||||
" selected_effort = pick_highest_turn_effort(selected_model)\n",
|
||||
"\n",
|
||||
" print('selected.model:', selected_model.model)\n",
|
||||
" print('selected.effort:', selected_effort.value)\n",
|
||||
"\n",
|
||||
" thread = codex.thread_start(model=selected_model.model, config={'model_reasoning_effort': selected_effort.value})\n",
|
||||
"\n",
|
||||
" first = thread.turn(\n",
|
||||
" TextInput('Give one short sentence about reliable production releases.'),\n",
|
||||
" model=selected_model.model,\n",
|
||||
" effort=selected_effort,\n",
|
||||
" ).run()\n",
|
||||
" print('agent.message:', first.text)\n",
|
||||
" print('usage:', first.usage)\n",
|
||||
"\n",
|
||||
" second = thread.turn(\n",
|
||||
" TextInput('Return JSON for a safe feature-flag rollout plan.'),\n",
|
||||
" approval_policy=AskForApproval.never,\n",
|
||||
" cwd=str(Path.cwd()),\n",
|
||||
" effort=selected_effort,\n",
|
||||
" model=selected_model.model,\n",
|
||||
" output_schema=output_schema,\n",
|
||||
" personality=Personality.pragmatic,\n",
|
||||
" sandbox_policy=sandbox_policy,\n",
|
||||
" summary=ReasoningSummary.model_validate('concise'),\n",
|
||||
" ).run()\n",
|
||||
" print('agent.message.params:', second.text)\n",
|
||||
" print('usage.params:', second.usage)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Cell 6: multimodal with remote image\n",
|
||||
"remote_image_url = 'https://raw.githubusercontent.com/github/explore/main/topics/python/python.png'\n",
|
||||
"\n",
|
||||
"with Codex() as codex:\n",
|
||||
" thread = codex.thread_start(model='gpt-5', config={'model_reasoning_effort': 'high'})\n",
|
||||
" result = thread.turn([\n",
|
||||
" TextInput('What do you see in this image? 3 bullets.'),\n",
|
||||
" ImageInput(remote_image_url),\n",
|
||||
" ]).run()\n",
|
||||
"\n",
|
||||
" print('status:', result.status)\n",
|
||||
" print(result.text)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Cell 7: multimodal with local image (bundled asset)\n",
|
||||
"local_image_path = repo_python_dir / 'examples' / 'assets' / 'sample_scene.png'\n",
|
||||
"if not local_image_path.exists():\n",
|
||||
" raise FileNotFoundError(f'Missing bundled image: {local_image_path}')\n",
|
||||
"\n",
|
||||
"with Codex() as codex:\n",
|
||||
" thread = codex.thread_start(model='gpt-5', config={'model_reasoning_effort': 'high'})\n",
|
||||
" result = thread.turn([\n",
|
||||
" TextInput('Describe this local image in 2 bullets.'),\n",
|
||||
" LocalImageInput(str(local_image_path.resolve())),\n",
|
||||
" ]).run()\n",
|
||||
"\n",
|
||||
" print('status:', result.status)\n",
|
||||
" print(result.text)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Cell 8: retry-on-overload pattern\n",
|
||||
"with Codex() as codex:\n",
|
||||
" thread = codex.thread_start(model='gpt-5', config={'model_reasoning_effort': 'high'})\n",
|
||||
"\n",
|
||||
" result = retry_on_overload(\n",
|
||||
" lambda: thread.turn(TextInput('List 5 failure modes in distributed systems.')).run(),\n",
|
||||
" max_attempts=3,\n",
|
||||
" initial_delay_s=0.25,\n",
|
||||
" max_delay_s=2.0,\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
" print('status:', result.status)\n",
|
||||
" print(result.text)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Cell 9: full thread lifecycle and branching (async)\n",
|
||||
"import asyncio\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"async def async_lifecycle_demo():\n",
|
||||
" async with AsyncCodex() as codex:\n",
|
||||
" thread = await codex.thread_start(model='gpt-5', config={'model_reasoning_effort': 'high'})\n",
|
||||
" first = await (await thread.turn(TextInput('One sentence about structured planning.'))).run()\n",
|
||||
" second = await (await thread.turn(TextInput('Now restate it for a junior engineer.'))).run()\n",
|
||||
"\n",
|
||||
" reopened = await codex.thread_resume(thread.id)\n",
|
||||
" listing_active = await codex.thread_list(limit=20, archived=False)\n",
|
||||
" reading = await reopened.read(include_turns=True)\n",
|
||||
"\n",
|
||||
" _ = await reopened.set_name('sdk-lifecycle-demo')\n",
|
||||
" _ = await codex.thread_archive(reopened.id)\n",
|
||||
" listing_archived = await codex.thread_list(limit=20, archived=True)\n",
|
||||
" unarchived = await codex.thread_unarchive(reopened.id)\n",
|
||||
"\n",
|
||||
" resumed_info = 'n/a'\n",
|
||||
" try:\n",
|
||||
" resumed = await codex.thread_resume(\n",
|
||||
" unarchived.id,\n",
|
||||
" model='gpt-5',\n",
|
||||
" config={'model_reasoning_effort': 'high'},\n",
|
||||
" )\n",
|
||||
" resumed_result = await (await resumed.turn(TextInput('Continue in one short sentence.'))).run()\n",
|
||||
" resumed_info = f'{resumed_result.turn_id} {resumed_result.status}'\n",
|
||||
" except Exception as e:\n",
|
||||
" resumed_info = f'skipped({type(e).__name__})'\n",
|
||||
"\n",
|
||||
" forked_info = 'n/a'\n",
|
||||
" try:\n",
|
||||
" forked = await codex.thread_fork(unarchived.id, model='gpt-5')\n",
|
||||
" forked_result = await (await forked.turn(TextInput('Take a different angle in one short sentence.'))).run()\n",
|
||||
" forked_info = f'{forked_result.turn_id} {forked_result.status}'\n",
|
||||
" except Exception as e:\n",
|
||||
" forked_info = f'skipped({type(e).__name__})'\n",
|
||||
"\n",
|
||||
" compact_info = 'sent'\n",
|
||||
" try:\n",
|
||||
" _ = await unarchived.compact()\n",
|
||||
" except Exception as e:\n",
|
||||
" compact_info = f'skipped({type(e).__name__})'\n",
|
||||
"\n",
|
||||
" print('Lifecycle OK:', thread.id)\n",
|
||||
" print('first:', first.turn_id, first.status)\n",
|
||||
" print('second:', second.turn_id, second.status)\n",
|
||||
" print('read.turns:', len(reading.thread.turns or []))\n",
|
||||
" print('list.active:', len(listing_active.data))\n",
|
||||
" print('list.archived:', len(listing_archived.data))\n",
|
||||
" print('resumed:', resumed_info)\n",
|
||||
" print('forked:', forked_info)\n",
|
||||
" print('compact:', compact_info)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"await async_lifecycle_demo()\n",
|
||||
"\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Cell 10: async stream + steer + interrupt (best effort)\n",
|
||||
"import asyncio\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"async def async_stream_demo():\n",
|
||||
" async with AsyncCodex() as codex:\n",
|
||||
" thread = await codex.thread_start(model='gpt-5', config={'model_reasoning_effort': 'high'})\n",
|
||||
" turn = await thread.turn(TextInput('Count from 1 to 200 with commas, then one summary sentence.'))\n",
|
||||
"\n",
|
||||
" try:\n",
|
||||
" _ = await turn.steer(TextInput('Keep it brief and stop after 20 numbers.'))\n",
|
||||
" print('steer: sent')\n",
|
||||
" except Exception as e:\n",
|
||||
" print('steer: skipped', type(e).__name__)\n",
|
||||
"\n",
|
||||
" try:\n",
|
||||
" _ = await turn.interrupt()\n",
|
||||
" print('interrupt: sent')\n",
|
||||
" except Exception as e:\n",
|
||||
" print('interrupt: skipped', type(e).__name__)\n",
|
||||
"\n",
|
||||
" event_count = 0\n",
|
||||
" async for event in turn.stream():\n",
|
||||
" event_count += 1\n",
|
||||
" print(event.method, event.payload)\n",
|
||||
"\n",
|
||||
" print('events.count:', event_count)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"await async_stream_demo()\n",
|
||||
"\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python",
|
||||
"version": "3.10+"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -1,10 +1,115 @@
|
||||
from .async_client import AsyncAppServerClient
|
||||
from .client import AppServerClient, AppServerConfig
|
||||
from .errors import AppServerError, JsonRpcError, TransportClosedError
|
||||
from .errors import (
|
||||
AppServerError,
|
||||
AppServerRpcError,
|
||||
InternalRpcError,
|
||||
InvalidParamsError,
|
||||
InvalidRequestError,
|
||||
JsonRpcError,
|
||||
MethodNotFoundError,
|
||||
ParseError,
|
||||
RetryLimitExceededError,
|
||||
ServerBusyError,
|
||||
TransportClosedError,
|
||||
is_retryable_error,
|
||||
)
|
||||
from .generated.v2_types import (
|
||||
ThreadItem,
|
||||
ThreadTokenUsageUpdatedNotification,
|
||||
TurnCompletedNotificationPayload,
|
||||
)
|
||||
from .public_api import (
|
||||
AsyncCodex,
|
||||
AsyncThread,
|
||||
AsyncTurn,
|
||||
Codex,
|
||||
ImageInput,
|
||||
InitializeResult,
|
||||
Input,
|
||||
InputItem,
|
||||
LocalImageInput,
|
||||
MentionInput,
|
||||
SkillInput,
|
||||
TextInput,
|
||||
Thread,
|
||||
Turn,
|
||||
TurnResult,
|
||||
)
|
||||
from .public_types import (
|
||||
AskForApproval,
|
||||
Personality,
|
||||
PlanType,
|
||||
ReasoningEffort,
|
||||
ReasoningSummary,
|
||||
SandboxMode,
|
||||
SandboxPolicy,
|
||||
ServiceTier,
|
||||
ThreadForkParams,
|
||||
ThreadListParams,
|
||||
ThreadResumeParams,
|
||||
ThreadSortKey,
|
||||
ThreadSourceKind,
|
||||
ThreadStartParams,
|
||||
TurnStartParams,
|
||||
TurnStatus,
|
||||
TurnSteerParams,
|
||||
)
|
||||
from .retry import retry_on_overload
|
||||
|
||||
__version__ = "0.2.0"
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"AppServerClient",
|
||||
"AsyncAppServerClient",
|
||||
"AppServerConfig",
|
||||
"Codex",
|
||||
"AsyncCodex",
|
||||
"Thread",
|
||||
"AsyncThread",
|
||||
"Turn",
|
||||
"AsyncTurn",
|
||||
"TurnResult",
|
||||
"InitializeResult",
|
||||
"Input",
|
||||
"InputItem",
|
||||
"TextInput",
|
||||
"ImageInput",
|
||||
"LocalImageInput",
|
||||
"SkillInput",
|
||||
"MentionInput",
|
||||
"ThreadItem",
|
||||
"ThreadTokenUsageUpdatedNotification",
|
||||
"TurnCompletedNotificationPayload",
|
||||
"AskForApproval",
|
||||
"Personality",
|
||||
"PlanType",
|
||||
"ReasoningEffort",
|
||||
"ReasoningSummary",
|
||||
"SandboxMode",
|
||||
"SandboxPolicy",
|
||||
"ServiceTier",
|
||||
"ThreadStartParams",
|
||||
"ThreadResumeParams",
|
||||
"ThreadListParams",
|
||||
"ThreadSortKey",
|
||||
"ThreadSourceKind",
|
||||
"ThreadForkParams",
|
||||
"TurnStatus",
|
||||
"TurnStartParams",
|
||||
"TurnSteerParams",
|
||||
"retry_on_overload",
|
||||
"AppServerError",
|
||||
"JsonRpcError",
|
||||
"TransportClosedError",
|
||||
"JsonRpcError",
|
||||
"AppServerRpcError",
|
||||
"ParseError",
|
||||
"InvalidRequestError",
|
||||
"MethodNotFoundError",
|
||||
"InvalidParamsError",
|
||||
"InternalRpcError",
|
||||
"ServerBusyError",
|
||||
"RetryLimitExceededError",
|
||||
"is_retryable_error",
|
||||
]
|
||||
|
||||
208
sdk/python/src/codex_app_server/async_client.py
Normal file
208
sdk/python/src/codex_app_server/async_client.py
Normal file
@@ -0,0 +1,208 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Iterator
|
||||
from typing import AsyncIterator, Callable, Iterable, ParamSpec, TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .client import AppServerClient, AppServerConfig
|
||||
from .generated.v2_all import (
|
||||
AgentMessageDeltaNotification,
|
||||
ModelListResponse,
|
||||
ThreadArchiveResponse,
|
||||
ThreadCompactStartResponse,
|
||||
ThreadForkParams as V2ThreadForkParams,
|
||||
ThreadForkResponse,
|
||||
ThreadListParams as V2ThreadListParams,
|
||||
ThreadListResponse,
|
||||
ThreadReadResponse,
|
||||
ThreadResumeParams as V2ThreadResumeParams,
|
||||
ThreadResumeResponse,
|
||||
ThreadSetNameResponse,
|
||||
ThreadStartParams as V2ThreadStartParams,
|
||||
ThreadStartResponse,
|
||||
ThreadUnarchiveResponse,
|
||||
TurnCompletedNotification,
|
||||
TurnInterruptResponse,
|
||||
TurnStartParams as V2TurnStartParams,
|
||||
TurnStartResponse,
|
||||
TurnSteerResponse,
|
||||
)
|
||||
from .models import InitializeResponse, JsonObject, Notification
|
||||
|
||||
ModelT = TypeVar("ModelT", bound=BaseModel)
|
||||
ParamsT = ParamSpec("ParamsT")
|
||||
ReturnT = TypeVar("ReturnT")
|
||||
|
||||
|
||||
class AsyncAppServerClient:
|
||||
"""Async wrapper around AppServerClient using thread offloading."""
|
||||
|
||||
def __init__(self, config: AppServerConfig | None = None) -> None:
|
||||
self._sync = AppServerClient(config=config)
|
||||
# Single stdio transport cannot be read safely from multiple threads.
|
||||
self._transport_lock = asyncio.Lock()
|
||||
|
||||
async def __aenter__(self) -> "AsyncAppServerClient":
|
||||
await self.start()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, _exc_type, _exc, _tb) -> None:
|
||||
await self.close()
|
||||
|
||||
async def _call_sync(
|
||||
self,
|
||||
fn: Callable[ParamsT, ReturnT],
|
||||
/,
|
||||
*args: ParamsT.args,
|
||||
**kwargs: ParamsT.kwargs,
|
||||
) -> ReturnT:
|
||||
async with self._transport_lock:
|
||||
return await asyncio.to_thread(fn, *args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _next_from_iterator(
|
||||
iterator: Iterator[AgentMessageDeltaNotification],
|
||||
) -> tuple[bool, AgentMessageDeltaNotification | None]:
|
||||
try:
|
||||
return True, next(iterator)
|
||||
except StopIteration:
|
||||
return False, None
|
||||
|
||||
async def start(self) -> None:
|
||||
await self._call_sync(self._sync.start)
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._call_sync(self._sync.close)
|
||||
|
||||
async def initialize(self) -> InitializeResponse:
|
||||
return await self._call_sync(self._sync.initialize)
|
||||
|
||||
def acquire_turn_consumer(self, turn_id: str) -> None:
|
||||
self._sync.acquire_turn_consumer(turn_id)
|
||||
|
||||
def release_turn_consumer(self, turn_id: str) -> None:
|
||||
self._sync.release_turn_consumer(turn_id)
|
||||
|
||||
async def request(
|
||||
self,
|
||||
method: str,
|
||||
params: JsonObject | None,
|
||||
*,
|
||||
response_model: type[ModelT],
|
||||
) -> ModelT:
|
||||
return await self._call_sync(
|
||||
self._sync.request,
|
||||
method,
|
||||
params,
|
||||
response_model=response_model,
|
||||
)
|
||||
|
||||
async def thread_start(self, params: V2ThreadStartParams | JsonObject | None = None) -> ThreadStartResponse:
|
||||
return await self._call_sync(self._sync.thread_start, params)
|
||||
|
||||
async def thread_resume(
|
||||
self,
|
||||
thread_id: str,
|
||||
params: V2ThreadResumeParams | JsonObject | None = None,
|
||||
) -> ThreadResumeResponse:
|
||||
return await self._call_sync(self._sync.thread_resume, thread_id, params)
|
||||
|
||||
async def thread_list(self, params: V2ThreadListParams | JsonObject | None = None) -> ThreadListResponse:
|
||||
return await self._call_sync(self._sync.thread_list, params)
|
||||
|
||||
async def thread_read(self, thread_id: str, include_turns: bool = False) -> ThreadReadResponse:
|
||||
return await self._call_sync(self._sync.thread_read, thread_id, include_turns)
|
||||
|
||||
async def thread_fork(
|
||||
self,
|
||||
thread_id: str,
|
||||
params: V2ThreadForkParams | JsonObject | None = None,
|
||||
) -> ThreadForkResponse:
|
||||
return await self._call_sync(self._sync.thread_fork, thread_id, params)
|
||||
|
||||
async def thread_archive(self, thread_id: str) -> ThreadArchiveResponse:
|
||||
return await self._call_sync(self._sync.thread_archive, thread_id)
|
||||
|
||||
async def thread_unarchive(self, thread_id: str) -> ThreadUnarchiveResponse:
|
||||
return await self._call_sync(self._sync.thread_unarchive, thread_id)
|
||||
|
||||
async def thread_set_name(self, thread_id: str, name: str) -> ThreadSetNameResponse:
|
||||
return await self._call_sync(self._sync.thread_set_name, thread_id, name)
|
||||
|
||||
async def thread_compact(self, thread_id: str) -> ThreadCompactStartResponse:
|
||||
return await self._call_sync(self._sync.thread_compact, thread_id)
|
||||
|
||||
async def turn_start(
|
||||
self,
|
||||
thread_id: str,
|
||||
input_items: list[JsonObject] | JsonObject | str,
|
||||
params: V2TurnStartParams | JsonObject | None = None,
|
||||
) -> TurnStartResponse:
|
||||
return await self._call_sync(self._sync.turn_start, thread_id, input_items, params)
|
||||
|
||||
async def turn_interrupt(self, thread_id: str, turn_id: str) -> TurnInterruptResponse:
|
||||
return await self._call_sync(self._sync.turn_interrupt, thread_id, turn_id)
|
||||
|
||||
async def turn_steer(
|
||||
self,
|
||||
thread_id: str,
|
||||
expected_turn_id: str,
|
||||
input_items: list[JsonObject] | JsonObject | str,
|
||||
) -> TurnSteerResponse:
|
||||
return await self._call_sync(
|
||||
self._sync.turn_steer,
|
||||
thread_id,
|
||||
expected_turn_id,
|
||||
input_items,
|
||||
)
|
||||
|
||||
async def model_list(self, include_hidden: bool = False) -> ModelListResponse:
|
||||
return await self._call_sync(self._sync.model_list, include_hidden)
|
||||
|
||||
async def request_with_retry_on_overload(
|
||||
self,
|
||||
method: str,
|
||||
params: JsonObject | None,
|
||||
*,
|
||||
response_model: type[ModelT],
|
||||
max_attempts: int = 3,
|
||||
initial_delay_s: float = 0.25,
|
||||
max_delay_s: float = 2.0,
|
||||
) -> ModelT:
|
||||
return await self._call_sync(
|
||||
self._sync.request_with_retry_on_overload,
|
||||
method,
|
||||
params,
|
||||
response_model=response_model,
|
||||
max_attempts=max_attempts,
|
||||
initial_delay_s=initial_delay_s,
|
||||
max_delay_s=max_delay_s,
|
||||
)
|
||||
|
||||
async def next_notification(self) -> Notification:
|
||||
return await self._call_sync(self._sync.next_notification)
|
||||
|
||||
async def wait_for_turn_completed(self, turn_id: str) -> TurnCompletedNotification:
|
||||
return await self._call_sync(self._sync.wait_for_turn_completed, turn_id)
|
||||
|
||||
async def stream_until_methods(self, methods: Iterable[str] | str) -> list[Notification]:
|
||||
return await self._call_sync(self._sync.stream_until_methods, methods)
|
||||
|
||||
async def stream_text(
|
||||
self,
|
||||
thread_id: str,
|
||||
text: str,
|
||||
params: V2TurnStartParams | JsonObject | None = None,
|
||||
) -> AsyncIterator[AgentMessageDeltaNotification]:
|
||||
async with self._transport_lock:
|
||||
iterator = self._sync.stream_text(thread_id, text, params)
|
||||
while True:
|
||||
has_value, chunk = await asyncio.to_thread(
|
||||
self._next_from_iterator,
|
||||
iterator,
|
||||
)
|
||||
if not has_value:
|
||||
break
|
||||
yield chunk
|
||||
@@ -1,25 +1,23 @@
|
||||
"""Stable aliases over full v2 autogenerated models (datamodel-code-generator)."""
|
||||
"""Stable aliases over the canonical generated v2 models."""
|
||||
|
||||
from .v2_all.ModelListResponse import ModelListResponse
|
||||
from .v2_all.ThreadCompactStartResponse import ThreadCompactStartResponse
|
||||
from .v2_all.ThreadListResponse import ThreadListResponse
|
||||
from .v2_all.ThreadReadResponse import ThreadReadResponse
|
||||
from .v2_all.ThreadTokenUsageUpdatedNotification import (
|
||||
from .v2_all import (
|
||||
ModelListResponse,
|
||||
ThreadCompactStartResponse,
|
||||
ThreadItem,
|
||||
ThreadListResponse,
|
||||
ThreadReadResponse,
|
||||
ThreadTokenUsageUpdatedNotification,
|
||||
)
|
||||
from .v2_all.TurnCompletedNotification import ThreadItem153 as ThreadItem
|
||||
from .v2_all.TurnCompletedNotification import (
|
||||
TurnCompletedNotification as TurnCompletedNotificationPayload,
|
||||
TurnSteerResponse,
|
||||
)
|
||||
from .v2_all.TurnSteerResponse import TurnSteerResponse
|
||||
|
||||
__all__ = [
|
||||
"ModelListResponse",
|
||||
"ThreadCompactStartResponse",
|
||||
"ThreadItem",
|
||||
"ThreadListResponse",
|
||||
"ThreadReadResponse",
|
||||
"ThreadTokenUsageUpdatedNotification",
|
||||
"TurnCompletedNotificationPayload",
|
||||
"TurnSteerResponse",
|
||||
"ThreadItem",
|
||||
]
|
||||
|
||||
795
sdk/python/src/codex_app_server/public_api.py
Normal file
795
sdk/python/src/codex_app_server/public_api.py
Normal file
@@ -0,0 +1,795 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import AsyncIterator, Iterator
|
||||
|
||||
from .async_client import AsyncAppServerClient
|
||||
from .client import AppServerClient, AppServerConfig
|
||||
from .generated.v2_all import (
|
||||
AgentMessageDeltaNotification,
|
||||
RawResponseItemCompletedNotification,
|
||||
ThreadArchiveResponse,
|
||||
ThreadSetNameResponse,
|
||||
TurnError,
|
||||
TurnInterruptResponse,
|
||||
)
|
||||
from .generated.v2_types import (
|
||||
ModelListResponse,
|
||||
ThreadCompactStartResponse,
|
||||
ThreadItem,
|
||||
ThreadListResponse,
|
||||
ThreadReadResponse,
|
||||
ThreadTokenUsageUpdatedNotification,
|
||||
TurnCompletedNotificationPayload,
|
||||
TurnSteerResponse,
|
||||
)
|
||||
from .models import InitializeResponse, JsonObject, Notification
|
||||
from .public_types import (
|
||||
AskForApproval,
|
||||
Personality,
|
||||
ReasoningEffort,
|
||||
ReasoningSummary,
|
||||
SandboxMode,
|
||||
SandboxPolicy,
|
||||
ServiceTier,
|
||||
ThreadForkParams,
|
||||
ThreadListParams,
|
||||
ThreadResumeParams,
|
||||
ThreadSortKey,
|
||||
ThreadSourceKind,
|
||||
ThreadStartParams,
|
||||
TurnStartParams,
|
||||
TurnStatus,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TurnResult:
|
||||
thread_id: str
|
||||
turn_id: str
|
||||
status: TurnStatus
|
||||
error: TurnError | None
|
||||
text: str
|
||||
items: list[ThreadItem]
|
||||
usage: ThreadTokenUsageUpdatedNotification | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TextInput:
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ImageInput:
|
||||
url: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LocalImageInput:
|
||||
path: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SkillInput:
|
||||
name: str
|
||||
path: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MentionInput:
|
||||
name: str
|
||||
path: str
|
||||
|
||||
|
||||
InputItem = TextInput | ImageInput | LocalImageInput | SkillInput | MentionInput
|
||||
Input = list[InputItem] | InputItem
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class InitializeResult:
|
||||
server_name: str
|
||||
server_version: str
|
||||
user_agent: str
|
||||
|
||||
|
||||
def _to_wire_item(item: InputItem) -> JsonObject:
|
||||
if isinstance(item, TextInput):
|
||||
return {"type": "text", "text": item.text}
|
||||
if isinstance(item, ImageInput):
|
||||
return {"type": "image", "url": item.url}
|
||||
if isinstance(item, LocalImageInput):
|
||||
return {"type": "localImage", "path": item.path}
|
||||
if isinstance(item, SkillInput):
|
||||
return {"type": "skill", "name": item.name, "path": item.path}
|
||||
if isinstance(item, MentionInput):
|
||||
return {"type": "mention", "name": item.name, "path": item.path}
|
||||
raise TypeError(f"unsupported input item: {type(item)!r}")
|
||||
|
||||
|
||||
def _to_wire_input(input: Input) -> list[JsonObject]:
|
||||
if isinstance(input, list):
|
||||
return [_to_wire_item(i) for i in input]
|
||||
return [_to_wire_item(input)]
|
||||
|
||||
|
||||
def _split_user_agent(user_agent: str) -> tuple[str | None, str | None]:
|
||||
raw = user_agent.strip()
|
||||
if not raw:
|
||||
return None, None
|
||||
if "/" in raw:
|
||||
name, version = raw.split("/", 1)
|
||||
return (name or None), (version or None)
|
||||
parts = raw.split(maxsplit=1)
|
||||
if len(parts) == 2:
|
||||
return parts[0], parts[1]
|
||||
return raw, None
|
||||
|
||||
|
||||
def _enum_value(value: object) -> object:
|
||||
return getattr(value, "value", value)
|
||||
|
||||
|
||||
def _assistant_output_text_chunks(
|
||||
notification: RawResponseItemCompletedNotification,
|
||||
) -> list[str]:
|
||||
item = notification.item.root
|
||||
if _enum_value(getattr(item, "type", None)) != "message":
|
||||
return []
|
||||
if getattr(item, "role", None) != "assistant":
|
||||
return []
|
||||
|
||||
chunks: list[str] = []
|
||||
for content in getattr(item, "content", []) or []:
|
||||
content_item = getattr(content, "root", content)
|
||||
if _enum_value(getattr(content_item, "type", None)) != "output_text":
|
||||
continue
|
||||
text = getattr(content_item, "text", None)
|
||||
if isinstance(text, str) and text:
|
||||
chunks.append(text)
|
||||
return chunks
|
||||
|
||||
|
||||
def _build_turn_result(
|
||||
completed: TurnCompletedNotificationPayload | None,
|
||||
usage: ThreadTokenUsageUpdatedNotification | None,
|
||||
delta_chunks: list[str],
|
||||
raw_text_chunks: list[str],
|
||||
) -> TurnResult:
|
||||
if completed is None:
|
||||
raise RuntimeError("turn completed event not received")
|
||||
if completed.turn.status == TurnStatus.completed and usage is None:
|
||||
raise RuntimeError(
|
||||
"thread/tokenUsage/updated notification not received for completed turn"
|
||||
)
|
||||
|
||||
text = "".join(delta_chunks) if delta_chunks else "".join(raw_text_chunks)
|
||||
return TurnResult(
|
||||
thread_id=completed.thread_id,
|
||||
turn_id=completed.turn.id,
|
||||
status=completed.turn.status,
|
||||
error=completed.turn.error,
|
||||
text=text,
|
||||
items=list(completed.turn.items or []),
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
|
||||
class Codex:
|
||||
"""Minimal typed SDK surface for app-server v2."""
|
||||
|
||||
def __init__(self, config: AppServerConfig | None = None) -> None:
|
||||
self._client = AppServerClient(config=config)
|
||||
try:
|
||||
self._client.start()
|
||||
self._init = self._parse_initialize(self._client.initialize())
|
||||
except Exception:
|
||||
self._client.close()
|
||||
raise
|
||||
|
||||
def __enter__(self) -> "Codex":
|
||||
return self
|
||||
|
||||
def __exit__(self, _exc_type, _exc, _tb) -> None:
|
||||
self.close()
|
||||
|
||||
@staticmethod
|
||||
def _parse_initialize(payload: InitializeResponse) -> InitializeResult:
|
||||
user_agent = (payload.userAgent or "").strip()
|
||||
server = payload.serverInfo
|
||||
|
||||
server_name: str | None = None
|
||||
server_version: str | None = None
|
||||
|
||||
if server is not None:
|
||||
server_name = (server.name or "").strip() or None
|
||||
server_version = (server.version or "").strip() or None
|
||||
|
||||
if (server_name is None or server_version is None) and user_agent:
|
||||
parsed_name, parsed_version = _split_user_agent(user_agent)
|
||||
if server_name is None:
|
||||
server_name = parsed_name
|
||||
if server_version is None:
|
||||
server_version = parsed_version
|
||||
|
||||
normalized_server_name = (server_name or "").strip()
|
||||
normalized_server_version = (server_version or "").strip()
|
||||
if not user_agent or not normalized_server_name or not normalized_server_version:
|
||||
raise RuntimeError(
|
||||
"initialize response missing required metadata "
|
||||
f"(user_agent={user_agent!r}, server_name={normalized_server_name!r}, server_version={normalized_server_version!r})"
|
||||
)
|
||||
|
||||
return InitializeResult(
|
||||
server_name=normalized_server_name,
|
||||
server_version=normalized_server_version,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
@property
|
||||
def metadata(self) -> InitializeResult:
|
||||
return self._init
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
# BEGIN GENERATED: Codex.flat_methods
|
||||
def thread_start(
|
||||
self,
|
||||
*,
|
||||
approval_policy: AskForApproval | None = None,
|
||||
base_instructions: str | None = None,
|
||||
config: JsonObject | None = None,
|
||||
cwd: str | None = None,
|
||||
developer_instructions: str | None = None,
|
||||
ephemeral: bool | None = None,
|
||||
model: str | None = None,
|
||||
model_provider: str | None = None,
|
||||
personality: Personality | None = None,
|
||||
sandbox: SandboxMode | None = None,
|
||||
service_name: str | None = None,
|
||||
service_tier: ServiceTier | None = None,
|
||||
) -> Thread:
|
||||
params = ThreadStartParams(
|
||||
approval_policy=approval_policy,
|
||||
base_instructions=base_instructions,
|
||||
config=config,
|
||||
cwd=cwd,
|
||||
developer_instructions=developer_instructions,
|
||||
ephemeral=ephemeral,
|
||||
model=model,
|
||||
model_provider=model_provider,
|
||||
personality=personality,
|
||||
sandbox=sandbox,
|
||||
service_name=service_name,
|
||||
service_tier=service_tier,
|
||||
)
|
||||
started = self._client.thread_start(params)
|
||||
return Thread(self._client, started.thread.id)
|
||||
|
||||
def thread_list(
|
||||
self,
|
||||
*,
|
||||
archived: bool | None = None,
|
||||
cursor: str | None = None,
|
||||
cwd: str | None = None,
|
||||
limit: int | None = None,
|
||||
model_providers: list[str] | None = None,
|
||||
search_term: str | None = None,
|
||||
sort_key: ThreadSortKey | None = None,
|
||||
source_kinds: list[ThreadSourceKind] | None = None,
|
||||
) -> ThreadListResponse:
|
||||
params = ThreadListParams(
|
||||
archived=archived,
|
||||
cursor=cursor,
|
||||
cwd=cwd,
|
||||
limit=limit,
|
||||
model_providers=model_providers,
|
||||
search_term=search_term,
|
||||
sort_key=sort_key,
|
||||
source_kinds=source_kinds,
|
||||
)
|
||||
return self._client.thread_list(params)
|
||||
|
||||
def thread_resume(
|
||||
self,
|
||||
thread_id: str,
|
||||
*,
|
||||
approval_policy: AskForApproval | None = None,
|
||||
base_instructions: str | None = None,
|
||||
config: JsonObject | None = None,
|
||||
cwd: str | None = None,
|
||||
developer_instructions: str | None = None,
|
||||
model: str | None = None,
|
||||
model_provider: str | None = None,
|
||||
personality: Personality | None = None,
|
||||
sandbox: SandboxMode | None = None,
|
||||
service_tier: ServiceTier | None = None,
|
||||
) -> Thread:
|
||||
params = ThreadResumeParams(
|
||||
thread_id=thread_id,
|
||||
approval_policy=approval_policy,
|
||||
base_instructions=base_instructions,
|
||||
config=config,
|
||||
cwd=cwd,
|
||||
developer_instructions=developer_instructions,
|
||||
model=model,
|
||||
model_provider=model_provider,
|
||||
personality=personality,
|
||||
sandbox=sandbox,
|
||||
service_tier=service_tier,
|
||||
)
|
||||
resumed = self._client.thread_resume(thread_id, params)
|
||||
return Thread(self._client, resumed.thread.id)
|
||||
|
||||
def thread_fork(
|
||||
self,
|
||||
thread_id: str,
|
||||
*,
|
||||
approval_policy: AskForApproval | None = None,
|
||||
base_instructions: str | None = None,
|
||||
config: JsonObject | None = None,
|
||||
cwd: str | None = None,
|
||||
developer_instructions: str | None = None,
|
||||
ephemeral: bool | None = None,
|
||||
model: str | None = None,
|
||||
model_provider: str | None = None,
|
||||
sandbox: SandboxMode | None = None,
|
||||
service_tier: ServiceTier | None = None,
|
||||
) -> Thread:
|
||||
params = ThreadForkParams(
|
||||
thread_id=thread_id,
|
||||
approval_policy=approval_policy,
|
||||
base_instructions=base_instructions,
|
||||
config=config,
|
||||
cwd=cwd,
|
||||
developer_instructions=developer_instructions,
|
||||
ephemeral=ephemeral,
|
||||
model=model,
|
||||
model_provider=model_provider,
|
||||
sandbox=sandbox,
|
||||
service_tier=service_tier,
|
||||
)
|
||||
forked = self._client.thread_fork(thread_id, params)
|
||||
return Thread(self._client, forked.thread.id)
|
||||
|
||||
def thread_archive(self, thread_id: str) -> ThreadArchiveResponse:
|
||||
return self._client.thread_archive(thread_id)
|
||||
|
||||
def thread_unarchive(self, thread_id: str) -> Thread:
|
||||
unarchived = self._client.thread_unarchive(thread_id)
|
||||
return Thread(self._client, unarchived.thread.id)
|
||||
# END GENERATED: Codex.flat_methods
|
||||
|
||||
def models(self, *, include_hidden: bool = False) -> ModelListResponse:
|
||||
return self._client.model_list(include_hidden=include_hidden)
|
||||
|
||||
|
||||
class AsyncCodex:
|
||||
"""Async mirror of :class:`Codex` with matching method shapes."""
|
||||
|
||||
def __init__(self, config: AppServerConfig | None = None) -> None:
|
||||
self._client = AsyncAppServerClient(config=config)
|
||||
self._init: InitializeResult | None = None
|
||||
self._initialized = False
|
||||
self._init_lock = asyncio.Lock()
|
||||
|
||||
async def __aenter__(self) -> "AsyncCodex":
|
||||
await self._ensure_initialized()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, _exc_type, _exc, _tb) -> None:
|
||||
await self.close()
|
||||
|
||||
async def _ensure_initialized(self) -> None:
|
||||
if self._initialized:
|
||||
return
|
||||
async with self._init_lock:
|
||||
if self._initialized:
|
||||
return
|
||||
try:
|
||||
await self._client.start()
|
||||
payload = await self._client.initialize()
|
||||
self._init = Codex._parse_initialize(payload)
|
||||
self._initialized = True
|
||||
except Exception:
|
||||
await self._client.close()
|
||||
self._init = None
|
||||
self._initialized = False
|
||||
raise
|
||||
|
||||
@property
|
||||
def metadata(self) -> InitializeResult:
|
||||
if self._init is None:
|
||||
raise RuntimeError(
|
||||
"AsyncCodex is not initialized yet. Use `async with AsyncCodex()` or call an async API first."
|
||||
)
|
||||
return self._init
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._client.close()
|
||||
self._init = None
|
||||
self._initialized = False
|
||||
|
||||
# BEGIN GENERATED: AsyncCodex.flat_methods
|
||||
async def thread_start(
|
||||
self,
|
||||
*,
|
||||
approval_policy: AskForApproval | None = None,
|
||||
base_instructions: str | None = None,
|
||||
config: JsonObject | None = None,
|
||||
cwd: str | None = None,
|
||||
developer_instructions: str | None = None,
|
||||
ephemeral: bool | None = None,
|
||||
model: str | None = None,
|
||||
model_provider: str | None = None,
|
||||
personality: Personality | None = None,
|
||||
sandbox: SandboxMode | None = None,
|
||||
service_name: str | None = None,
|
||||
service_tier: ServiceTier | None = None,
|
||||
) -> AsyncThread:
|
||||
await self._ensure_initialized()
|
||||
params = ThreadStartParams(
|
||||
approval_policy=approval_policy,
|
||||
base_instructions=base_instructions,
|
||||
config=config,
|
||||
cwd=cwd,
|
||||
developer_instructions=developer_instructions,
|
||||
ephemeral=ephemeral,
|
||||
model=model,
|
||||
model_provider=model_provider,
|
||||
personality=personality,
|
||||
sandbox=sandbox,
|
||||
service_name=service_name,
|
||||
service_tier=service_tier,
|
||||
)
|
||||
started = await self._client.thread_start(params)
|
||||
return AsyncThread(self, started.thread.id)
|
||||
|
||||
async def thread_list(
|
||||
self,
|
||||
*,
|
||||
archived: bool | None = None,
|
||||
cursor: str | None = None,
|
||||
cwd: str | None = None,
|
||||
limit: int | None = None,
|
||||
model_providers: list[str] | None = None,
|
||||
search_term: str | None = None,
|
||||
sort_key: ThreadSortKey | None = None,
|
||||
source_kinds: list[ThreadSourceKind] | None = None,
|
||||
) -> ThreadListResponse:
|
||||
await self._ensure_initialized()
|
||||
params = ThreadListParams(
|
||||
archived=archived,
|
||||
cursor=cursor,
|
||||
cwd=cwd,
|
||||
limit=limit,
|
||||
model_providers=model_providers,
|
||||
search_term=search_term,
|
||||
sort_key=sort_key,
|
||||
source_kinds=source_kinds,
|
||||
)
|
||||
return await self._client.thread_list(params)
|
||||
|
||||
async def thread_resume(
|
||||
self,
|
||||
thread_id: str,
|
||||
*,
|
||||
approval_policy: AskForApproval | None = None,
|
||||
base_instructions: str | None = None,
|
||||
config: JsonObject | None = None,
|
||||
cwd: str | None = None,
|
||||
developer_instructions: str | None = None,
|
||||
model: str | None = None,
|
||||
model_provider: str | None = None,
|
||||
personality: Personality | None = None,
|
||||
sandbox: SandboxMode | None = None,
|
||||
service_tier: ServiceTier | None = None,
|
||||
) -> AsyncThread:
|
||||
await self._ensure_initialized()
|
||||
params = ThreadResumeParams(
|
||||
thread_id=thread_id,
|
||||
approval_policy=approval_policy,
|
||||
base_instructions=base_instructions,
|
||||
config=config,
|
||||
cwd=cwd,
|
||||
developer_instructions=developer_instructions,
|
||||
model=model,
|
||||
model_provider=model_provider,
|
||||
personality=personality,
|
||||
sandbox=sandbox,
|
||||
service_tier=service_tier,
|
||||
)
|
||||
resumed = await self._client.thread_resume(thread_id, params)
|
||||
return AsyncThread(self, resumed.thread.id)
|
||||
|
||||
async def thread_fork(
|
||||
self,
|
||||
thread_id: str,
|
||||
*,
|
||||
approval_policy: AskForApproval | None = None,
|
||||
base_instructions: str | None = None,
|
||||
config: JsonObject | None = None,
|
||||
cwd: str | None = None,
|
||||
developer_instructions: str | None = None,
|
||||
ephemeral: bool | None = None,
|
||||
model: str | None = None,
|
||||
model_provider: str | None = None,
|
||||
sandbox: SandboxMode | None = None,
|
||||
service_tier: ServiceTier | None = None,
|
||||
) -> AsyncThread:
|
||||
await self._ensure_initialized()
|
||||
params = ThreadForkParams(
|
||||
thread_id=thread_id,
|
||||
approval_policy=approval_policy,
|
||||
base_instructions=base_instructions,
|
||||
config=config,
|
||||
cwd=cwd,
|
||||
developer_instructions=developer_instructions,
|
||||
ephemeral=ephemeral,
|
||||
model=model,
|
||||
model_provider=model_provider,
|
||||
sandbox=sandbox,
|
||||
service_tier=service_tier,
|
||||
)
|
||||
forked = await self._client.thread_fork(thread_id, params)
|
||||
return AsyncThread(self, forked.thread.id)
|
||||
|
||||
async def thread_archive(self, thread_id: str) -> ThreadArchiveResponse:
|
||||
await self._ensure_initialized()
|
||||
return await self._client.thread_archive(thread_id)
|
||||
|
||||
async def thread_unarchive(self, thread_id: str) -> AsyncThread:
|
||||
await self._ensure_initialized()
|
||||
unarchived = await self._client.thread_unarchive(thread_id)
|
||||
return AsyncThread(self, unarchived.thread.id)
|
||||
# END GENERATED: AsyncCodex.flat_methods
|
||||
|
||||
async def models(self, *, include_hidden: bool = False) -> ModelListResponse:
|
||||
await self._ensure_initialized()
|
||||
return await self._client.model_list(include_hidden=include_hidden)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Thread:
|
||||
_client: AppServerClient
|
||||
id: str
|
||||
|
||||
# BEGIN GENERATED: Thread.flat_methods
|
||||
def turn(
|
||||
self,
|
||||
input: Input,
|
||||
*,
|
||||
approval_policy: AskForApproval | None = None,
|
||||
cwd: str | None = None,
|
||||
effort: ReasoningEffort | None = None,
|
||||
model: str | None = None,
|
||||
output_schema: JsonObject | None = None,
|
||||
personality: Personality | None = None,
|
||||
sandbox_policy: SandboxPolicy | None = None,
|
||||
service_tier: ServiceTier | None = None,
|
||||
summary: ReasoningSummary | None = None,
|
||||
) -> Turn:
|
||||
wire_input = _to_wire_input(input)
|
||||
params = TurnStartParams(
|
||||
thread_id=self.id,
|
||||
input=wire_input,
|
||||
approval_policy=approval_policy,
|
||||
cwd=cwd,
|
||||
effort=effort,
|
||||
model=model,
|
||||
output_schema=output_schema,
|
||||
personality=personality,
|
||||
sandbox_policy=sandbox_policy,
|
||||
service_tier=service_tier,
|
||||
summary=summary,
|
||||
)
|
||||
turn = self._client.turn_start(self.id, wire_input, params=params)
|
||||
return Turn(self._client, self.id, turn.turn.id)
|
||||
# END GENERATED: Thread.flat_methods
|
||||
|
||||
def read(self, *, include_turns: bool = False) -> ThreadReadResponse:
|
||||
return self._client.thread_read(self.id, include_turns=include_turns)
|
||||
|
||||
def set_name(self, name: str) -> ThreadSetNameResponse:
|
||||
return self._client.thread_set_name(self.id, name)
|
||||
|
||||
def compact(self) -> ThreadCompactStartResponse:
|
||||
return self._client.thread_compact(self.id)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AsyncThread:
|
||||
_codex: AsyncCodex
|
||||
id: str
|
||||
|
||||
# BEGIN GENERATED: AsyncThread.flat_methods
|
||||
async def turn(
|
||||
self,
|
||||
input: Input,
|
||||
*,
|
||||
approval_policy: AskForApproval | None = None,
|
||||
cwd: str | None = None,
|
||||
effort: ReasoningEffort | None = None,
|
||||
model: str | None = None,
|
||||
output_schema: JsonObject | None = None,
|
||||
personality: Personality | None = None,
|
||||
sandbox_policy: SandboxPolicy | None = None,
|
||||
service_tier: ServiceTier | None = None,
|
||||
summary: ReasoningSummary | None = None,
|
||||
) -> AsyncTurn:
|
||||
await self._codex._ensure_initialized()
|
||||
wire_input = _to_wire_input(input)
|
||||
params = TurnStartParams(
|
||||
thread_id=self.id,
|
||||
input=wire_input,
|
||||
approval_policy=approval_policy,
|
||||
cwd=cwd,
|
||||
effort=effort,
|
||||
model=model,
|
||||
output_schema=output_schema,
|
||||
personality=personality,
|
||||
sandbox_policy=sandbox_policy,
|
||||
service_tier=service_tier,
|
||||
summary=summary,
|
||||
)
|
||||
turn = await self._codex._client.turn_start(
|
||||
self.id,
|
||||
wire_input,
|
||||
params=params,
|
||||
)
|
||||
return AsyncTurn(self._codex, self.id, turn.turn.id)
|
||||
# END GENERATED: AsyncThread.flat_methods
|
||||
|
||||
async def read(self, *, include_turns: bool = False) -> ThreadReadResponse:
|
||||
await self._codex._ensure_initialized()
|
||||
return await self._codex._client.thread_read(self.id, include_turns=include_turns)
|
||||
|
||||
async def set_name(self, name: str) -> ThreadSetNameResponse:
|
||||
await self._codex._ensure_initialized()
|
||||
return await self._codex._client.thread_set_name(self.id, name)
|
||||
|
||||
async def compact(self) -> ThreadCompactStartResponse:
|
||||
await self._codex._ensure_initialized()
|
||||
return await self._codex._client.thread_compact(self.id)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Turn:
|
||||
_client: AppServerClient
|
||||
thread_id: str
|
||||
id: str
|
||||
|
||||
def steer(self, input: Input) -> TurnSteerResponse:
|
||||
return self._client.turn_steer(self.thread_id, self.id, _to_wire_input(input))
|
||||
|
||||
def interrupt(self) -> TurnInterruptResponse:
|
||||
return self._client.turn_interrupt(self.thread_id, self.id)
|
||||
|
||||
def stream(self) -> Iterator[Notification]:
|
||||
# TODO: replace this client-wide experimental guard with per-turn event demux.
|
||||
self._client.acquire_turn_consumer(self.id)
|
||||
try:
|
||||
while True:
|
||||
event = self._client.next_notification()
|
||||
yield event
|
||||
if (
|
||||
event.method == "turn/completed"
|
||||
and isinstance(event.payload, TurnCompletedNotificationPayload)
|
||||
and event.payload.turn.id == self.id
|
||||
):
|
||||
break
|
||||
finally:
|
||||
self._client.release_turn_consumer(self.id)
|
||||
|
||||
def run(self) -> TurnResult:
|
||||
completed: TurnCompletedNotificationPayload | None = None
|
||||
usage: ThreadTokenUsageUpdatedNotification | None = None
|
||||
delta_chunks: list[str] = []
|
||||
raw_text_chunks: list[str] = []
|
||||
|
||||
stream = self.stream()
|
||||
try:
|
||||
for event in stream:
|
||||
payload = event.payload
|
||||
if (
|
||||
isinstance(payload, AgentMessageDeltaNotification)
|
||||
and payload.turn_id == self.id
|
||||
):
|
||||
delta_chunks.append(payload.delta)
|
||||
continue
|
||||
if (
|
||||
isinstance(payload, RawResponseItemCompletedNotification)
|
||||
and payload.turn_id == self.id
|
||||
):
|
||||
raw_text_chunks.extend(_assistant_output_text_chunks(payload))
|
||||
continue
|
||||
if (
|
||||
isinstance(payload, ThreadTokenUsageUpdatedNotification)
|
||||
and payload.turn_id == self.id
|
||||
):
|
||||
usage = payload
|
||||
continue
|
||||
if (
|
||||
isinstance(payload, TurnCompletedNotificationPayload)
|
||||
and payload.turn.id == self.id
|
||||
):
|
||||
completed = payload
|
||||
finally:
|
||||
stream.close()
|
||||
|
||||
return _build_turn_result(completed, usage, delta_chunks, raw_text_chunks)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AsyncTurn:
|
||||
_codex: AsyncCodex
|
||||
thread_id: str
|
||||
id: str
|
||||
|
||||
async def steer(self, input: Input) -> TurnSteerResponse:
|
||||
await self._codex._ensure_initialized()
|
||||
return await self._codex._client.turn_steer(
|
||||
self.thread_id,
|
||||
self.id,
|
||||
_to_wire_input(input),
|
||||
)
|
||||
|
||||
async def interrupt(self) -> TurnInterruptResponse:
|
||||
await self._codex._ensure_initialized()
|
||||
return await self._codex._client.turn_interrupt(self.thread_id, self.id)
|
||||
|
||||
async def stream(self) -> AsyncIterator[Notification]:
|
||||
await self._codex._ensure_initialized()
|
||||
# TODO: replace this client-wide experimental guard with per-turn event demux.
|
||||
self._codex._client.acquire_turn_consumer(self.id)
|
||||
try:
|
||||
while True:
|
||||
event = await self._codex._client.next_notification()
|
||||
yield event
|
||||
if (
|
||||
event.method == "turn/completed"
|
||||
and isinstance(event.payload, TurnCompletedNotificationPayload)
|
||||
and event.payload.turn.id == self.id
|
||||
):
|
||||
break
|
||||
finally:
|
||||
self._codex._client.release_turn_consumer(self.id)
|
||||
|
||||
async def run(self) -> TurnResult:
|
||||
completed: TurnCompletedNotificationPayload | None = None
|
||||
usage: ThreadTokenUsageUpdatedNotification | None = None
|
||||
delta_chunks: list[str] = []
|
||||
raw_text_chunks: list[str] = []
|
||||
|
||||
stream = self.stream()
|
||||
try:
|
||||
async for event in stream:
|
||||
payload = event.payload
|
||||
if (
|
||||
isinstance(payload, AgentMessageDeltaNotification)
|
||||
and payload.turn_id == self.id
|
||||
):
|
||||
delta_chunks.append(payload.delta)
|
||||
continue
|
||||
if (
|
||||
isinstance(payload, RawResponseItemCompletedNotification)
|
||||
and payload.turn_id == self.id
|
||||
):
|
||||
raw_text_chunks.extend(_assistant_output_text_chunks(payload))
|
||||
continue
|
||||
if (
|
||||
isinstance(payload, ThreadTokenUsageUpdatedNotification)
|
||||
and payload.turn_id == self.id
|
||||
):
|
||||
usage = payload
|
||||
continue
|
||||
if (
|
||||
isinstance(payload, TurnCompletedNotificationPayload)
|
||||
and payload.turn.id == self.id
|
||||
):
|
||||
completed = payload
|
||||
finally:
|
||||
await stream.aclose()
|
||||
|
||||
return _build_turn_result(completed, usage, delta_chunks, raw_text_chunks)
|
||||
41
sdk/python/src/codex_app_server/public_types.py
Normal file
41
sdk/python/src/codex_app_server/public_types.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Shallow public aliases over the generated v2 wire models."""
|
||||
|
||||
from .generated.v2_all import (
|
||||
AskForApproval,
|
||||
Personality,
|
||||
PlanType,
|
||||
ReasoningEffort,
|
||||
ReasoningSummary,
|
||||
SandboxMode,
|
||||
SandboxPolicy,
|
||||
ServiceTier,
|
||||
ThreadForkParams,
|
||||
ThreadListParams,
|
||||
ThreadResumeParams,
|
||||
ThreadSortKey,
|
||||
ThreadSourceKind,
|
||||
ThreadStartParams,
|
||||
TurnStartParams,
|
||||
TurnStatus,
|
||||
TurnSteerParams,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AskForApproval",
|
||||
"Personality",
|
||||
"PlanType",
|
||||
"ReasoningEffort",
|
||||
"ReasoningSummary",
|
||||
"SandboxMode",
|
||||
"SandboxPolicy",
|
||||
"ServiceTier",
|
||||
"ThreadForkParams",
|
||||
"ThreadListParams",
|
||||
"ThreadResumeParams",
|
||||
"ThreadSortKey",
|
||||
"ThreadSourceKind",
|
||||
"ThreadStartParams",
|
||||
"TurnStartParams",
|
||||
"TurnStatus",
|
||||
"TurnSteerParams",
|
||||
]
|
||||
64
sdk/python/tests/test_async_client_behavior.py
Normal file
64
sdk/python/tests/test_async_client_behavior.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from codex_app_server.async_client import AsyncAppServerClient
|
||||
|
||||
|
||||
def test_async_client_serializes_transport_calls() -> None:
|
||||
async def scenario() -> int:
|
||||
client = AsyncAppServerClient()
|
||||
active = 0
|
||||
max_active = 0
|
||||
|
||||
def fake_model_list(include_hidden: bool = False) -> bool:
|
||||
nonlocal active, max_active
|
||||
active += 1
|
||||
max_active = max(max_active, active)
|
||||
time.sleep(0.05)
|
||||
active -= 1
|
||||
return include_hidden
|
||||
|
||||
client._sync.model_list = fake_model_list # type: ignore[method-assign]
|
||||
await asyncio.gather(client.model_list(), client.model_list())
|
||||
return max_active
|
||||
|
||||
assert asyncio.run(scenario()) == 1
|
||||
|
||||
|
||||
def test_async_stream_text_is_incremental_and_blocks_parallel_calls() -> None:
|
||||
async def scenario() -> tuple[str, list[str], bool]:
|
||||
client = AsyncAppServerClient()
|
||||
|
||||
def fake_stream_text(thread_id: str, text: str, params=None): # type: ignore[no-untyped-def]
|
||||
yield "first"
|
||||
time.sleep(0.03)
|
||||
yield "second"
|
||||
yield "third"
|
||||
|
||||
def fake_model_list(include_hidden: bool = False) -> str:
|
||||
return "done"
|
||||
|
||||
client._sync.stream_text = fake_stream_text # type: ignore[method-assign]
|
||||
client._sync.model_list = fake_model_list # type: ignore[method-assign]
|
||||
|
||||
stream = client.stream_text("thread-1", "hello")
|
||||
first = await anext(stream)
|
||||
|
||||
blocked_before_stream_done = False
|
||||
competing_call = asyncio.create_task(client.model_list())
|
||||
await asyncio.sleep(0.01)
|
||||
blocked_before_stream_done = not competing_call.done()
|
||||
|
||||
remaining: list[str] = []
|
||||
async for item in stream:
|
||||
remaining.append(item)
|
||||
|
||||
await competing_call
|
||||
return first, remaining, blocked_before_stream_done
|
||||
|
||||
first, remaining, blocked = asyncio.run(scenario())
|
||||
assert first == "first"
|
||||
assert remaining == ["second", "third"]
|
||||
assert blocked
|
||||
286
sdk/python/tests/test_public_api_runtime_behavior.py
Normal file
286
sdk/python/tests/test_public_api_runtime_behavior.py
Normal file
@@ -0,0 +1,286 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import codex_app_server.public_api as public_api_module
|
||||
from codex_app_server.client import AppServerClient
|
||||
from codex_app_server.generated.v2_all import (
|
||||
AgentMessageDeltaNotification,
|
||||
RawResponseItemCompletedNotification,
|
||||
ThreadTokenUsageUpdatedNotification,
|
||||
)
|
||||
from codex_app_server.models import InitializeResponse, Notification
|
||||
from codex_app_server.public_api import AsyncCodex, AsyncTurn, Codex, Turn
|
||||
from codex_app_server.public_types import TurnStatus
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _delta_notification(
|
||||
*,
|
||||
thread_id: str = "thread-1",
|
||||
turn_id: str = "turn-1",
|
||||
text: str = "delta-text",
|
||||
) -> Notification:
|
||||
return Notification(
|
||||
method="item/agentMessage/delta",
|
||||
payload=AgentMessageDeltaNotification.model_validate(
|
||||
{
|
||||
"delta": text,
|
||||
"itemId": "item-1",
|
||||
"threadId": thread_id,
|
||||
"turnId": turn_id,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _raw_response_notification(
|
||||
*,
|
||||
thread_id: str = "thread-1",
|
||||
turn_id: str = "turn-1",
|
||||
text: str = "raw-text",
|
||||
) -> Notification:
|
||||
return Notification(
|
||||
method="rawResponseItem/completed",
|
||||
payload=RawResponseItemCompletedNotification.model_validate(
|
||||
{
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": text}],
|
||||
},
|
||||
"threadId": thread_id,
|
||||
"turnId": turn_id,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _usage_notification(
|
||||
*,
|
||||
thread_id: str = "thread-1",
|
||||
turn_id: str = "turn-1",
|
||||
) -> Notification:
|
||||
return Notification(
|
||||
method="thread/tokenUsage/updated",
|
||||
payload=ThreadTokenUsageUpdatedNotification.model_validate(
|
||||
{
|
||||
"threadId": thread_id,
|
||||
"turnId": turn_id,
|
||||
"tokenUsage": {
|
||||
"last": {
|
||||
"cachedInputTokens": 0,
|
||||
"inputTokens": 1,
|
||||
"outputTokens": 2,
|
||||
"reasoningOutputTokens": 0,
|
||||
"totalTokens": 3,
|
||||
},
|
||||
"total": {
|
||||
"cachedInputTokens": 0,
|
||||
"inputTokens": 1,
|
||||
"outputTokens": 2,
|
||||
"reasoningOutputTokens": 0,
|
||||
"totalTokens": 3,
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _completed_notification(
|
||||
*,
|
||||
thread_id: str = "thread-1",
|
||||
turn_id: str = "turn-1",
|
||||
status: str = "completed",
|
||||
) -> Notification:
|
||||
return Notification(
|
||||
method="turn/completed",
|
||||
payload=public_api_module.TurnCompletedNotificationPayload.model_validate(
|
||||
{
|
||||
"threadId": thread_id,
|
||||
"turn": {
|
||||
"id": turn_id,
|
||||
"items": [],
|
||||
"status": status,
|
||||
},
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_codex_init_failure_closes_client(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
closed: list[bool] = []
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, config=None) -> None: # noqa: ANN001,ARG002
|
||||
self._closed = False
|
||||
|
||||
def start(self) -> None:
|
||||
return None
|
||||
|
||||
def initialize(self) -> InitializeResponse:
|
||||
return InitializeResponse.model_validate({})
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed = True
|
||||
closed.append(True)
|
||||
|
||||
monkeypatch.setattr(public_api_module, "AppServerClient", FakeClient)
|
||||
|
||||
with pytest.raises(RuntimeError, match="missing required metadata"):
|
||||
Codex()
|
||||
|
||||
assert closed == [True]
|
||||
|
||||
|
||||
def test_async_codex_init_failure_closes_client() -> None:
|
||||
async def scenario() -> None:
|
||||
codex = AsyncCodex()
|
||||
close_calls = 0
|
||||
|
||||
async def fake_start() -> None:
|
||||
return None
|
||||
|
||||
async def fake_initialize() -> InitializeResponse:
|
||||
return InitializeResponse.model_validate({})
|
||||
|
||||
async def fake_close() -> None:
|
||||
nonlocal close_calls
|
||||
close_calls += 1
|
||||
|
||||
codex._client.start = fake_start # type: ignore[method-assign]
|
||||
codex._client.initialize = fake_initialize # type: ignore[method-assign]
|
||||
codex._client.close = fake_close # type: ignore[method-assign]
|
||||
|
||||
with pytest.raises(RuntimeError, match="missing required metadata"):
|
||||
await codex.models()
|
||||
|
||||
assert close_calls == 1
|
||||
assert codex._initialized is False
|
||||
assert codex._init is None
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_async_codex_initializes_only_once_under_concurrency() -> None:
|
||||
async def scenario() -> None:
|
||||
codex = AsyncCodex()
|
||||
start_calls = 0
|
||||
initialize_calls = 0
|
||||
ready = asyncio.Event()
|
||||
|
||||
async def fake_start() -> None:
|
||||
nonlocal start_calls
|
||||
start_calls += 1
|
||||
|
||||
async def fake_initialize() -> InitializeResponse:
|
||||
nonlocal initialize_calls
|
||||
initialize_calls += 1
|
||||
ready.set()
|
||||
await asyncio.sleep(0.02)
|
||||
return InitializeResponse.model_validate(
|
||||
{
|
||||
"userAgent": "codex-cli/1.2.3",
|
||||
"serverInfo": {"name": "codex-cli", "version": "1.2.3"},
|
||||
}
|
||||
)
|
||||
|
||||
async def fake_model_list(include_hidden: bool = False): # noqa: ANN202,ARG001
|
||||
await ready.wait()
|
||||
return object()
|
||||
|
||||
codex._client.start = fake_start # type: ignore[method-assign]
|
||||
codex._client.initialize = fake_initialize # type: ignore[method-assign]
|
||||
codex._client.model_list = fake_model_list # type: ignore[method-assign]
|
||||
|
||||
await asyncio.gather(codex.models(), codex.models())
|
||||
|
||||
assert start_calls == 1
|
||||
assert initialize_calls == 1
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_turn_stream_rejects_second_active_consumer() -> None:
|
||||
client = AppServerClient()
|
||||
notifications: deque[Notification] = deque(
|
||||
[
|
||||
_delta_notification(turn_id="turn-1"),
|
||||
_completed_notification(turn_id="turn-1"),
|
||||
]
|
||||
)
|
||||
client.next_notification = notifications.popleft # type: ignore[method-assign]
|
||||
|
||||
first_stream = Turn(client, "thread-1", "turn-1").stream()
|
||||
assert next(first_stream).method == "item/agentMessage/delta"
|
||||
|
||||
second_stream = Turn(client, "thread-1", "turn-2").stream()
|
||||
with pytest.raises(RuntimeError, match="Concurrent turn consumers are not yet supported"):
|
||||
next(second_stream)
|
||||
|
||||
first_stream.close()
|
||||
|
||||
|
||||
def test_async_turn_stream_rejects_second_active_consumer() -> None:
|
||||
async def scenario() -> None:
|
||||
codex = AsyncCodex()
|
||||
|
||||
async def fake_ensure_initialized() -> None:
|
||||
return None
|
||||
|
||||
notifications: deque[Notification] = deque(
|
||||
[
|
||||
_delta_notification(turn_id="turn-1"),
|
||||
_completed_notification(turn_id="turn-1"),
|
||||
]
|
||||
)
|
||||
|
||||
async def fake_next_notification() -> Notification:
|
||||
return notifications.popleft()
|
||||
|
||||
codex._ensure_initialized = fake_ensure_initialized # type: ignore[method-assign]
|
||||
codex._client.next_notification = fake_next_notification # type: ignore[method-assign]
|
||||
|
||||
first_stream = AsyncTurn(codex, "thread-1", "turn-1").stream()
|
||||
assert (await anext(first_stream)).method == "item/agentMessage/delta"
|
||||
|
||||
second_stream = AsyncTurn(codex, "thread-1", "turn-2").stream()
|
||||
with pytest.raises(RuntimeError, match="Concurrent turn consumers are not yet supported"):
|
||||
await anext(second_stream)
|
||||
|
||||
await first_stream.aclose()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_turn_run_falls_back_to_completed_raw_response_text() -> None:
|
||||
client = AppServerClient()
|
||||
notifications: deque[Notification] = deque(
|
||||
[
|
||||
_raw_response_notification(text="hello from raw response"),
|
||||
_usage_notification(),
|
||||
_completed_notification(),
|
||||
]
|
||||
)
|
||||
client.next_notification = notifications.popleft # type: ignore[method-assign]
|
||||
|
||||
result = Turn(client, "thread-1", "turn-1").run()
|
||||
|
||||
assert result.status == TurnStatus.completed
|
||||
assert result.text == "hello from raw response"
|
||||
|
||||
|
||||
def test_retry_examples_compare_status_with_enum() -> None:
|
||||
for path in (
|
||||
ROOT / "examples" / "10_error_handling_and_retry" / "sync.py",
|
||||
ROOT / "examples" / "10_error_handling_and_retry" / "async.py",
|
||||
):
|
||||
source = path.read_text()
|
||||
assert '== "failed"' not in source
|
||||
assert "TurnStatus.failed" in source
|
||||
211
sdk/python/tests/test_public_api_signatures.py
Normal file
211
sdk/python/tests/test_public_api_signatures.py
Normal file
@@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.resources as resources
|
||||
import inspect
|
||||
from typing import Any
|
||||
|
||||
from codex_app_server import AppServerConfig
|
||||
from codex_app_server.models import InitializeResponse
|
||||
from codex_app_server.public_api import AsyncCodex, AsyncThread, Codex, Thread
|
||||
|
||||
|
||||
def _keyword_only_names(fn: object) -> list[str]:
|
||||
signature = inspect.signature(fn)
|
||||
return [
|
||||
param.name
|
||||
for param in signature.parameters.values()
|
||||
if param.kind == inspect.Parameter.KEYWORD_ONLY
|
||||
]
|
||||
|
||||
|
||||
def _assert_no_any_annotations(fn: object) -> None:
|
||||
signature = inspect.signature(fn)
|
||||
for param in signature.parameters.values():
|
||||
if param.annotation is Any:
|
||||
raise AssertionError(f"{fn} has public parameter typed as Any: {param.name}")
|
||||
if signature.return_annotation is Any:
|
||||
raise AssertionError(f"{fn} has public return annotation typed as Any")
|
||||
|
||||
|
||||
def test_root_exports_app_server_config() -> None:
|
||||
assert AppServerConfig.__name__ == "AppServerConfig"
|
||||
|
||||
|
||||
def test_package_includes_py_typed_marker() -> None:
|
||||
marker = resources.files("codex_app_server").joinpath("py.typed")
|
||||
assert marker.is_file()
|
||||
|
||||
|
||||
def test_generated_public_signatures_are_snake_case_and_typed() -> None:
|
||||
expected = {
|
||||
Codex.thread_start: [
|
||||
"approval_policy",
|
||||
"base_instructions",
|
||||
"config",
|
||||
"cwd",
|
||||
"developer_instructions",
|
||||
"ephemeral",
|
||||
"model",
|
||||
"model_provider",
|
||||
"personality",
|
||||
"sandbox",
|
||||
"service_name",
|
||||
"service_tier",
|
||||
],
|
||||
Codex.thread_list: [
|
||||
"archived",
|
||||
"cursor",
|
||||
"cwd",
|
||||
"limit",
|
||||
"model_providers",
|
||||
"search_term",
|
||||
"sort_key",
|
||||
"source_kinds",
|
||||
],
|
||||
Codex.thread_resume: [
|
||||
"approval_policy",
|
||||
"base_instructions",
|
||||
"config",
|
||||
"cwd",
|
||||
"developer_instructions",
|
||||
"model",
|
||||
"model_provider",
|
||||
"personality",
|
||||
"sandbox",
|
||||
"service_tier",
|
||||
],
|
||||
Codex.thread_fork: [
|
||||
"approval_policy",
|
||||
"base_instructions",
|
||||
"config",
|
||||
"cwd",
|
||||
"developer_instructions",
|
||||
"ephemeral",
|
||||
"model",
|
||||
"model_provider",
|
||||
"sandbox",
|
||||
"service_tier",
|
||||
],
|
||||
Thread.turn: [
|
||||
"approval_policy",
|
||||
"cwd",
|
||||
"effort",
|
||||
"model",
|
||||
"output_schema",
|
||||
"personality",
|
||||
"sandbox_policy",
|
||||
"service_tier",
|
||||
"summary",
|
||||
],
|
||||
AsyncCodex.thread_start: [
|
||||
"approval_policy",
|
||||
"base_instructions",
|
||||
"config",
|
||||
"cwd",
|
||||
"developer_instructions",
|
||||
"ephemeral",
|
||||
"model",
|
||||
"model_provider",
|
||||
"personality",
|
||||
"sandbox",
|
||||
"service_name",
|
||||
"service_tier",
|
||||
],
|
||||
AsyncCodex.thread_list: [
|
||||
"archived",
|
||||
"cursor",
|
||||
"cwd",
|
||||
"limit",
|
||||
"model_providers",
|
||||
"search_term",
|
||||
"sort_key",
|
||||
"source_kinds",
|
||||
],
|
||||
AsyncCodex.thread_resume: [
|
||||
"approval_policy",
|
||||
"base_instructions",
|
||||
"config",
|
||||
"cwd",
|
||||
"developer_instructions",
|
||||
"model",
|
||||
"model_provider",
|
||||
"personality",
|
||||
"sandbox",
|
||||
"service_tier",
|
||||
],
|
||||
AsyncCodex.thread_fork: [
|
||||
"approval_policy",
|
||||
"base_instructions",
|
||||
"config",
|
||||
"cwd",
|
||||
"developer_instructions",
|
||||
"ephemeral",
|
||||
"model",
|
||||
"model_provider",
|
||||
"sandbox",
|
||||
"service_tier",
|
||||
],
|
||||
AsyncThread.turn: [
|
||||
"approval_policy",
|
||||
"cwd",
|
||||
"effort",
|
||||
"model",
|
||||
"output_schema",
|
||||
"personality",
|
||||
"sandbox_policy",
|
||||
"service_tier",
|
||||
"summary",
|
||||
],
|
||||
}
|
||||
|
||||
for fn, expected_kwargs in expected.items():
|
||||
actual = _keyword_only_names(fn)
|
||||
assert actual == expected_kwargs, f"unexpected kwargs for {fn}: {actual}"
|
||||
assert all(name == name.lower() for name in actual), f"non snake_case kwargs in {fn}: {actual}"
|
||||
_assert_no_any_annotations(fn)
|
||||
|
||||
|
||||
def test_lifecycle_methods_are_codex_scoped() -> None:
|
||||
assert hasattr(Codex, "thread_resume")
|
||||
assert hasattr(Codex, "thread_fork")
|
||||
assert hasattr(Codex, "thread_archive")
|
||||
assert hasattr(Codex, "thread_unarchive")
|
||||
assert hasattr(AsyncCodex, "thread_resume")
|
||||
assert hasattr(AsyncCodex, "thread_fork")
|
||||
assert hasattr(AsyncCodex, "thread_archive")
|
||||
assert hasattr(AsyncCodex, "thread_unarchive")
|
||||
assert not hasattr(Codex, "thread")
|
||||
assert not hasattr(AsyncCodex, "thread")
|
||||
|
||||
assert not hasattr(Thread, "resume")
|
||||
assert not hasattr(Thread, "fork")
|
||||
assert not hasattr(Thread, "archive")
|
||||
assert not hasattr(Thread, "unarchive")
|
||||
assert not hasattr(AsyncThread, "resume")
|
||||
assert not hasattr(AsyncThread, "fork")
|
||||
assert not hasattr(AsyncThread, "archive")
|
||||
assert not hasattr(AsyncThread, "unarchive")
|
||||
|
||||
for fn in (
|
||||
Codex.thread_archive,
|
||||
Codex.thread_unarchive,
|
||||
AsyncCodex.thread_archive,
|
||||
AsyncCodex.thread_unarchive,
|
||||
):
|
||||
_assert_no_any_annotations(fn)
|
||||
|
||||
|
||||
def test_initialize_metadata_parses_user_agent_shape() -> None:
|
||||
parsed = Codex._parse_initialize(InitializeResponse.model_validate({"userAgent": "codex-cli/1.2.3"}))
|
||||
assert parsed.user_agent == "codex-cli/1.2.3"
|
||||
assert parsed.server_name == "codex-cli"
|
||||
assert parsed.server_version == "1.2.3"
|
||||
|
||||
|
||||
def test_initialize_metadata_requires_non_empty_information() -> None:
|
||||
try:
|
||||
Codex._parse_initialize(InitializeResponse.model_validate({}))
|
||||
except RuntimeError as exc:
|
||||
assert "missing required metadata" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected RuntimeError when initialize metadata is missing")
|
||||
415
sdk/python/tests/test_real_app_server_integration.py
Normal file
415
sdk/python/tests/test_real_app_server_integration.py
Normal file
@@ -0,0 +1,415 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
EXAMPLES_DIR = ROOT / "examples"
|
||||
NOTEBOOK_PATH = ROOT / "notebooks" / "sdk_walkthrough.ipynb"
|
||||
|
||||
root_str = str(ROOT)
|
||||
if root_str not in sys.path:
|
||||
sys.path.insert(0, root_str)
|
||||
|
||||
from _runtime_setup import ensure_runtime_package_installed, pinned_runtime_version
|
||||
|
||||
RUN_REAL_CODEX_TESTS = os.environ.get("RUN_REAL_CODEX_TESTS") == "1"
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not RUN_REAL_CODEX_TESTS,
|
||||
reason="set RUN_REAL_CODEX_TESTS=1 to run real Codex integration coverage",
|
||||
)
|
||||
|
||||
# 11_cli_mini_app is interactive; we still run it by feeding '/exit'.
|
||||
EXAMPLE_CASES: list[tuple[str, str]] = [
|
||||
("01_quickstart_constructor", "sync.py"),
|
||||
("01_quickstart_constructor", "async.py"),
|
||||
("02_turn_run", "sync.py"),
|
||||
("02_turn_run", "async.py"),
|
||||
("03_turn_stream_events", "sync.py"),
|
||||
("03_turn_stream_events", "async.py"),
|
||||
("04_models_and_metadata", "sync.py"),
|
||||
("04_models_and_metadata", "async.py"),
|
||||
("05_existing_thread", "sync.py"),
|
||||
("05_existing_thread", "async.py"),
|
||||
("06_thread_lifecycle_and_controls", "sync.py"),
|
||||
("06_thread_lifecycle_and_controls", "async.py"),
|
||||
("07_image_and_text", "sync.py"),
|
||||
("07_image_and_text", "async.py"),
|
||||
("08_local_image_and_text", "sync.py"),
|
||||
("08_local_image_and_text", "async.py"),
|
||||
("09_async_parity", "sync.py"),
|
||||
# 09_async_parity async path is represented by 01 async + dedicated async-based cases above.
|
||||
("10_error_handling_and_retry", "sync.py"),
|
||||
("10_error_handling_and_retry", "async.py"),
|
||||
("11_cli_mini_app", "sync.py"),
|
||||
("11_cli_mini_app", "async.py"),
|
||||
("12_turn_params_kitchen_sink", "sync.py"),
|
||||
("12_turn_params_kitchen_sink", "async.py"),
|
||||
("13_model_select_and_turn_params", "sync.py"),
|
||||
("13_model_select_and_turn_params", "async.py"),
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedRuntimeEnv:
|
||||
python: str
|
||||
env: dict[str, str]
|
||||
runtime_version: str
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def runtime_env(tmp_path_factory: pytest.TempPathFactory) -> PreparedRuntimeEnv:
|
||||
runtime_version = pinned_runtime_version()
|
||||
temp_root = tmp_path_factory.mktemp("python-runtime-env")
|
||||
isolated_site = temp_root / "site-packages"
|
||||
python = sys.executable
|
||||
|
||||
_run_command(
|
||||
[
|
||||
python,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--target",
|
||||
str(isolated_site),
|
||||
"pydantic>=2.12",
|
||||
],
|
||||
cwd=ROOT,
|
||||
env=os.environ.copy(),
|
||||
timeout_s=240,
|
||||
)
|
||||
ensure_runtime_package_installed(
|
||||
python,
|
||||
ROOT,
|
||||
install_target=isolated_site,
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = os.pathsep.join([str(isolated_site), str(ROOT / "src")])
|
||||
env["CODEX_PYTHON_SDK_DIR"] = str(ROOT)
|
||||
return PreparedRuntimeEnv(python=python, env=env, runtime_version=runtime_version)
|
||||
|
||||
|
||||
def _run_command(
|
||||
args: list[str],
|
||||
*,
|
||||
cwd: Path,
|
||||
env: dict[str, str],
|
||||
timeout_s: int,
|
||||
stdin: str | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
args,
|
||||
cwd=str(cwd),
|
||||
env=env,
|
||||
input=stdin,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=timeout_s,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _run_python(
|
||||
runtime_env: PreparedRuntimeEnv,
|
||||
source: str,
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
timeout_s: int = 180,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return _run_command(
|
||||
[str(runtime_env.python), "-c", source],
|
||||
cwd=cwd or ROOT,
|
||||
env=runtime_env.env,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
|
||||
|
||||
def _run_json_python(
|
||||
runtime_env: PreparedRuntimeEnv,
|
||||
source: str,
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
timeout_s: int = 180,
|
||||
) -> dict[str, object]:
|
||||
result = _run_python(runtime_env, source, cwd=cwd, timeout_s=timeout_s)
|
||||
assert result.returncode == 0, (
|
||||
f"Python snippet failed.\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _run_example(
|
||||
runtime_env: PreparedRuntimeEnv,
|
||||
folder: str,
|
||||
script: str,
|
||||
*,
|
||||
timeout_s: int = 180,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
path = EXAMPLES_DIR / folder / script
|
||||
assert path.exists(), f"Missing example script: {path}"
|
||||
|
||||
stdin = "/exit\n" if folder == "11_cli_mini_app" else None
|
||||
return _run_command(
|
||||
[str(runtime_env.python), str(path)],
|
||||
cwd=ROOT,
|
||||
env=runtime_env.env,
|
||||
timeout_s=timeout_s,
|
||||
stdin=stdin,
|
||||
)
|
||||
|
||||
|
||||
def _notebook_cell_source(cell_index: int) -> str:
|
||||
notebook = json.loads(NOTEBOOK_PATH.read_text())
|
||||
return "".join(notebook["cells"][cell_index]["source"])
|
||||
|
||||
|
||||
def test_real_initialize_and_model_list(runtime_env: PreparedRuntimeEnv) -> None:
|
||||
data = _run_json_python(
|
||||
runtime_env,
|
||||
textwrap.dedent(
|
||||
"""
|
||||
import json
|
||||
from codex_app_server import Codex
|
||||
|
||||
with Codex() as codex:
|
||||
models = codex.models(include_hidden=True)
|
||||
print(json.dumps({
|
||||
"user_agent": codex.metadata.user_agent,
|
||||
"server_name": codex.metadata.server_name,
|
||||
"server_version": codex.metadata.server_version,
|
||||
"model_count": len(models.data),
|
||||
}))
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
assert isinstance(data["user_agent"], str) and data["user_agent"].strip()
|
||||
assert isinstance(data["server_name"], str) and data["server_name"].strip()
|
||||
assert isinstance(data["server_version"], str) and data["server_version"].strip()
|
||||
assert isinstance(data["model_count"], int)
|
||||
|
||||
|
||||
def test_real_thread_and_turn_start_smoke(runtime_env: PreparedRuntimeEnv) -> None:
|
||||
data = _run_json_python(
|
||||
runtime_env,
|
||||
textwrap.dedent(
|
||||
"""
|
||||
import json
|
||||
from codex_app_server import Codex, TextInput
|
||||
|
||||
with Codex() as codex:
|
||||
thread = codex.thread_start(
|
||||
model="gpt-5.4",
|
||||
config={"model_reasoning_effort": "high"},
|
||||
)
|
||||
result = thread.turn(TextInput("hello")).run()
|
||||
print(json.dumps({
|
||||
"thread_id": result.thread_id,
|
||||
"turn_id": result.turn_id,
|
||||
"items_count": len(result.items),
|
||||
"has_usage": result.usage is not None,
|
||||
"usage_thread_id": None if result.usage is None else result.usage.thread_id,
|
||||
"usage_turn_id": None if result.usage is None else result.usage.turn_id,
|
||||
}))
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
assert isinstance(data["thread_id"], str) and data["thread_id"].strip()
|
||||
assert isinstance(data["turn_id"], str) and data["turn_id"].strip()
|
||||
assert isinstance(data["items_count"], int)
|
||||
assert data["has_usage"] is True
|
||||
assert data["usage_thread_id"] == data["thread_id"]
|
||||
assert data["usage_turn_id"] == data["turn_id"]
|
||||
|
||||
|
||||
def test_real_async_thread_turn_usage_and_ids_smoke(
|
||||
runtime_env: PreparedRuntimeEnv,
|
||||
) -> None:
|
||||
data = _run_json_python(
|
||||
runtime_env,
|
||||
textwrap.dedent(
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
from codex_app_server import AsyncCodex, TextInput
|
||||
|
||||
async def main():
|
||||
async with AsyncCodex() as codex:
|
||||
thread = await codex.thread_start(
|
||||
model="gpt-5.4",
|
||||
config={"model_reasoning_effort": "high"},
|
||||
)
|
||||
result = await (await thread.turn(TextInput("say ok"))).run()
|
||||
print(json.dumps({
|
||||
"thread_id": result.thread_id,
|
||||
"turn_id": result.turn_id,
|
||||
"items_count": len(result.items),
|
||||
"has_usage": result.usage is not None,
|
||||
"usage_thread_id": None if result.usage is None else result.usage.thread_id,
|
||||
"usage_turn_id": None if result.usage is None else result.usage.turn_id,
|
||||
}))
|
||||
|
||||
asyncio.run(main())
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
assert isinstance(data["thread_id"], str) and data["thread_id"].strip()
|
||||
assert isinstance(data["turn_id"], str) and data["turn_id"].strip()
|
||||
assert isinstance(data["items_count"], int)
|
||||
assert data["has_usage"] is True
|
||||
assert data["usage_thread_id"] == data["thread_id"]
|
||||
assert data["usage_turn_id"] == data["turn_id"]
|
||||
|
||||
|
||||
def test_notebook_bootstrap_resolves_sdk_and_runtime_from_unrelated_cwd(
|
||||
runtime_env: PreparedRuntimeEnv,
|
||||
) -> None:
|
||||
cell_1_source = _notebook_cell_source(1)
|
||||
env = runtime_env.env.copy()
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_cwd:
|
||||
result = _run_command(
|
||||
[str(runtime_env.python), "-c", cell_1_source],
|
||||
cwd=Path(temp_cwd),
|
||||
env=env,
|
||||
timeout_s=180,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, (
|
||||
f"Notebook bootstrap failed from unrelated cwd.\n"
|
||||
f"STDOUT:\n{result.stdout}\n"
|
||||
f"STDERR:\n{result.stderr}"
|
||||
)
|
||||
assert "SDK source:" in result.stdout
|
||||
assert f"Runtime package: {runtime_env.runtime_version}" in result.stdout
|
||||
|
||||
|
||||
def test_notebook_sync_cell_smoke(runtime_env: PreparedRuntimeEnv) -> None:
|
||||
source = "\n\n".join(
|
||||
[
|
||||
_notebook_cell_source(1),
|
||||
_notebook_cell_source(2),
|
||||
_notebook_cell_source(3),
|
||||
]
|
||||
)
|
||||
result = _run_python(runtime_env, source, timeout_s=240)
|
||||
assert result.returncode == 0, (
|
||||
f"Notebook sync smoke failed.\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
|
||||
)
|
||||
assert "status:" in result.stdout
|
||||
assert "server:" in result.stdout
|
||||
|
||||
|
||||
def test_real_streaming_smoke_turn_completed(runtime_env: PreparedRuntimeEnv) -> None:
|
||||
data = _run_json_python(
|
||||
runtime_env,
|
||||
textwrap.dedent(
|
||||
"""
|
||||
import json
|
||||
from codex_app_server import Codex, TextInput
|
||||
|
||||
with Codex() as codex:
|
||||
thread = codex.thread_start(
|
||||
model="gpt-5.4",
|
||||
config={"model_reasoning_effort": "high"},
|
||||
)
|
||||
turn = thread.turn(TextInput("Reply with one short sentence."))
|
||||
saw_delta = False
|
||||
saw_completed = False
|
||||
for event in turn.stream():
|
||||
if event.method == "item/agentMessage/delta":
|
||||
saw_delta = True
|
||||
if event.method == "turn/completed":
|
||||
saw_completed = True
|
||||
print(json.dumps({
|
||||
"saw_delta": saw_delta,
|
||||
"saw_completed": saw_completed,
|
||||
}))
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
assert data["saw_completed"] is True
|
||||
assert isinstance(data["saw_delta"], bool)
|
||||
|
||||
|
||||
def test_real_turn_interrupt_smoke(runtime_env: PreparedRuntimeEnv) -> None:
|
||||
data = _run_json_python(
|
||||
runtime_env,
|
||||
textwrap.dedent(
|
||||
"""
|
||||
import json
|
||||
from codex_app_server import Codex, TextInput
|
||||
|
||||
with Codex() as codex:
|
||||
thread = codex.thread_start(
|
||||
model="gpt-5.4",
|
||||
config={"model_reasoning_effort": "high"},
|
||||
)
|
||||
turn = thread.turn(TextInput("Count from 1 to 200 with commas."))
|
||||
turn.interrupt()
|
||||
follow_up = thread.turn(TextInput("Say 'ok' only.")).run()
|
||||
print(json.dumps({"status": follow_up.status.value}))
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
assert data["status"] in {"completed", "failed"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("folder", "script"), EXAMPLE_CASES)
|
||||
def test_real_examples_run_and_assert(
|
||||
runtime_env: PreparedRuntimeEnv,
|
||||
folder: str,
|
||||
script: str,
|
||||
) -> None:
|
||||
result = _run_example(runtime_env, folder, script)
|
||||
|
||||
assert result.returncode == 0, (
|
||||
f"Example failed: {folder}/{script}\n"
|
||||
f"STDOUT:\n{result.stdout}\n"
|
||||
f"STDERR:\n{result.stderr}"
|
||||
)
|
||||
|
||||
out = result.stdout
|
||||
|
||||
if folder == "01_quickstart_constructor":
|
||||
assert "Status:" in out and "Text:" in out
|
||||
assert "Server: None None" not in out
|
||||
elif folder == "02_turn_run":
|
||||
assert "thread_id:" in out and "turn_id:" in out and "status:" in out
|
||||
assert "usage: None" not in out
|
||||
elif folder == "03_turn_stream_events":
|
||||
assert "turn/completed" in out
|
||||
elif folder == "04_models_and_metadata":
|
||||
assert "models.count:" in out
|
||||
assert "server_name=None" not in out
|
||||
assert "server_version=None" not in out
|
||||
elif folder == "05_existing_thread":
|
||||
assert "Created thread:" in out
|
||||
elif folder == "06_thread_lifecycle_and_controls":
|
||||
assert "Lifecycle OK:" in out
|
||||
elif folder in {"07_image_and_text", "08_local_image_and_text"}:
|
||||
assert "completed" in out.lower() or "Status:" in out
|
||||
elif folder == "09_async_parity":
|
||||
assert "Thread:" in out and "Turn:" in out
|
||||
elif folder == "10_error_handling_and_retry":
|
||||
assert "Text:" in out
|
||||
elif folder == "11_cli_mini_app":
|
||||
assert "Thread:" in out
|
||||
elif folder == "12_turn_params_kitchen_sink":
|
||||
assert "Status:" in out and "Usage:" in out
|
||||
elif folder == "13_model_select_and_turn_params":
|
||||
assert "selected.model:" in out and "agent.message.params:" in out and "usage.params:" in out
|
||||
assert "usage.params: None" not in out
|
||||
Reference in New Issue
Block a user