mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
Merge branch 'etraut/next-turn-state-core' into etraut/next-turn-state-app-server
# Conflicts: # codex-rs/app-server/src/request_processors/turn_processor.rs
This commit is contained in:
Generated
+2
@@ -2748,6 +2748,7 @@ dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"codex-api",
|
||||
"codex-app-server-protocol",
|
||||
"codex-client",
|
||||
"codex-file-system",
|
||||
@@ -2759,6 +2760,7 @@ dependencies = [
|
||||
"codex-utils-rustls-provider",
|
||||
"ctor 0.6.3",
|
||||
"futures",
|
||||
"http 1.4.0",
|
||||
"pretty_assertions",
|
||||
"prost 0.14.3",
|
||||
"reqwest",
|
||||
|
||||
@@ -596,23 +596,17 @@ impl TurnRequestProcessor {
|
||||
.map_err(|err| invalid_request(format!("invalid turn context override: {err}")))?;
|
||||
}
|
||||
|
||||
let turn_context = resolved_overrides
|
||||
.map(op_turn_context_overrides)
|
||||
.unwrap_or_default();
|
||||
|
||||
// Start the turn by submitting the user input. Return its submission id as turn_id.
|
||||
let turn_op = if let Some(overrides) = resolved_overrides {
|
||||
Op::UserInput {
|
||||
items: mapped_items,
|
||||
environments: environment_selections,
|
||||
final_output_json_schema: params.output_schema,
|
||||
responsesapi_client_metadata: params.responsesapi_client_metadata,
|
||||
turn_context: op_turn_context_overrides(overrides),
|
||||
}
|
||||
} else {
|
||||
Op::UserInput {
|
||||
items: mapped_items,
|
||||
environments: environment_selections,
|
||||
final_output_json_schema: params.output_schema,
|
||||
responsesapi_client_metadata: params.responsesapi_client_metadata,
|
||||
turn_context: Default::default(),
|
||||
}
|
||||
let turn_op = Op::UserInput {
|
||||
items: mapped_items,
|
||||
environments: environment_selections,
|
||||
final_output_json_schema: params.output_schema,
|
||||
responsesapi_client_metadata: params.responsesapi_client_metadata,
|
||||
turn_context,
|
||||
};
|
||||
let turn_id = Uuid::now_v7().to_string();
|
||||
let turn_context_applied = if has_turn_context_overrides {
|
||||
|
||||
@@ -73,6 +73,8 @@ use codex_features::FEATURES;
|
||||
use codex_features::Stage;
|
||||
use codex_features::is_known_feature_key;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::read_codex_access_token_from_env;
|
||||
use codex_memories_write::clear_memory_roots_contents;
|
||||
use codex_models_manager::bundled_models_response;
|
||||
use codex_models_manager::manager::RefreshStrategy;
|
||||
@@ -489,6 +491,10 @@ struct ExecServerCommand {
|
||||
/// Human-readable executor name.
|
||||
#[arg(long = "name", value_name = "NAME")]
|
||||
name: Option<String>,
|
||||
|
||||
/// Use Agent Identity auth from CODEX_ACCESS_TOKEN for remote registration.
|
||||
#[arg(long = "use-agent-identity-auth", requires = "remote")]
|
||||
use_agent_identity_auth: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Subcommand)]
|
||||
@@ -1388,7 +1394,13 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
|
||||
root_remote_auth_token_env.as_deref(),
|
||||
"exec-server",
|
||||
)?;
|
||||
run_exec_server_command(cmd, &arg0_paths).await?;
|
||||
run_exec_server_command(
|
||||
cmd,
|
||||
&arg0_paths,
|
||||
&root_config_overrides,
|
||||
interactive.config_profile.clone(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Some(Subcommand::Features(FeaturesCli { sub })) => match sub {
|
||||
FeaturesSubcommand::List => {
|
||||
@@ -1485,6 +1497,8 @@ fn profile_v2_for_subcommand<'a>(
|
||||
async fn run_exec_server_command(
|
||||
cmd: ExecServerCommand,
|
||||
arg0_paths: &Arg0DispatchPaths,
|
||||
root_config_overrides: &CliConfigOverrides,
|
||||
config_profile: Option<String>,
|
||||
) -> anyhow::Result<()> {
|
||||
let codex_self_exe = arg0_paths
|
||||
.codex_self_exe
|
||||
@@ -1498,8 +1512,14 @@ async fn run_exec_server_command(
|
||||
let executor_id = cmd
|
||||
.executor_id
|
||||
.ok_or_else(|| anyhow::anyhow!("--executor-id is required when --remote is set"))?;
|
||||
let auth_provider = load_exec_server_remote_auth_provider(
|
||||
root_config_overrides,
|
||||
config_profile,
|
||||
cmd.use_agent_identity_auth,
|
||||
)
|
||||
.await?;
|
||||
let mut remote_config =
|
||||
codex_exec_server::RemoteExecutorConfig::new(base_url, executor_id)?;
|
||||
codex_exec_server::RemoteExecutorConfig::new(base_url, executor_id, auth_provider)?;
|
||||
if let Some(name) = cmd.name {
|
||||
remote_config.name = name;
|
||||
}
|
||||
@@ -1515,6 +1535,75 @@ async fn run_exec_server_command(
|
||||
.map_err(anyhow::Error::from_boxed)
|
||||
}
|
||||
|
||||
async fn load_exec_server_remote_auth_provider(
|
||||
root_config_overrides: &CliConfigOverrides,
|
||||
config_profile: Option<String>,
|
||||
use_agent_identity_auth: bool,
|
||||
) -> anyhow::Result<codex_api::SharedAuthProvider> {
|
||||
let config = load_exec_server_remote_config(root_config_overrides, config_profile).await?;
|
||||
if use_agent_identity_auth {
|
||||
let agent_identity_jwt = read_codex_access_token_from_env().ok_or_else(|| {
|
||||
anyhow::anyhow!("CODEX_ACCESS_TOKEN is required when --use-agent-identity-auth is set")
|
||||
})?;
|
||||
let auth =
|
||||
CodexAuth::from_agent_identity_jwt(&agent_identity_jwt, Some(&config.chatgpt_base_url))
|
||||
.await?;
|
||||
return Ok(codex_model_provider::auth_provider_from_auth(&auth));
|
||||
}
|
||||
|
||||
let auth = load_exec_server_remote_auth(
|
||||
&config,
|
||||
"remote exec-server registration requires ChatGPT authentication; run `codex login` first",
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !auth.is_chatgpt_auth() {
|
||||
anyhow::bail!(
|
||||
"remote exec-server registration requires ChatGPT authentication; API key and Agent Identity auth are not supported"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(codex_model_provider::auth_provider_from_auth(&auth))
|
||||
}
|
||||
|
||||
async fn load_exec_server_remote_config(
|
||||
root_config_overrides: &CliConfigOverrides,
|
||||
config_profile: Option<String>,
|
||||
) -> anyhow::Result<codex_core::config::Config> {
|
||||
let cli_kv_overrides = root_config_overrides
|
||||
.parse_overrides()
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
Ok(ConfigBuilder::default()
|
||||
.cli_overrides(cli_kv_overrides)
|
||||
.harness_overrides(ConfigOverrides {
|
||||
config_profile,
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn load_exec_server_remote_auth(
|
||||
config: &codex_core::config::Config,
|
||||
missing_auth_error: &'static str,
|
||||
) -> anyhow::Result<codex_login::CodexAuth> {
|
||||
let auth_manager =
|
||||
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await;
|
||||
|
||||
let auth = match auth_manager.auth().await {
|
||||
Some(auth) => auth,
|
||||
None => {
|
||||
auth_manager.reload().await;
|
||||
auth_manager
|
||||
.auth()
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!(missing_auth_error))?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(auth)
|
||||
}
|
||||
|
||||
async fn enable_feature_in_config(interactive: &TuiCli, feature: &str) -> anyhow::Result<()> {
|
||||
FeatureToggles::validate_feature(feature)?;
|
||||
let codex_home = find_codex_home()?;
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::collections::BTreeMap;
|
||||
const SPAWN_AGENT_INHERITED_MODEL_GUIDANCE: &str = "Spawned agents inherit your current model by default. Omit `model` to use that preferred default; set `model` only when an explicit override is needed.";
|
||||
const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str = "Optional model override for the new agent. Leave unset to inherit the same model as the parent, which is the preferred default. Only set this when the user explicitly asks for a different model or the task clearly requires one.";
|
||||
const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str = "Optional service tier override for the new agent. Leave unset unless the user explicitly asks for one.";
|
||||
const MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT_DESCRIPTION: usize = 5;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SpawnAgentToolOptions {
|
||||
@@ -711,8 +712,11 @@ The new agent's canonical task name will be provided to it along with the messag
|
||||
}
|
||||
|
||||
fn spawn_agent_models_description(models: &[ModelPreset]) -> String {
|
||||
let visible_models: Vec<&ModelPreset> =
|
||||
models.iter().filter(|model| model.show_in_picker).collect();
|
||||
let visible_models: Vec<&ModelPreset> = models
|
||||
.iter()
|
||||
.filter(|model| model.show_in_picker)
|
||||
.take(MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT_DESCRIPTION)
|
||||
.collect();
|
||||
if visible_models.is_empty() {
|
||||
return "No picker-visible model overrides are currently loaded.".to_string();
|
||||
}
|
||||
@@ -720,30 +724,40 @@ fn spawn_agent_models_description(models: &[ModelPreset]) -> String {
|
||||
let model_descriptions = visible_models
|
||||
.into_iter()
|
||||
.map(|model| {
|
||||
let default_reasoning_effort = model.default_reasoning_effort;
|
||||
let efforts = model
|
||||
.supported_reasoning_efforts
|
||||
.iter()
|
||||
.map(|preset| format!("{} ({})", preset.effort, preset.description))
|
||||
.map(|preset| {
|
||||
let effort = preset.effort;
|
||||
if effort == default_reasoning_effort {
|
||||
format!("{effort} (default)")
|
||||
} else {
|
||||
effort.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let service_tiers = if model.service_tiers.is_empty() {
|
||||
"none".to_string()
|
||||
let reasoning_efforts_suffix = if efforts.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
model
|
||||
.service_tiers
|
||||
.iter()
|
||||
.map(|tier| format!("{} ({}: {})", tier.id, tier.name, tier.description))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
format!(" Reasoning efforts: {efforts}.")
|
||||
};
|
||||
let service_tiers = model
|
||||
.service_tiers
|
||||
.iter()
|
||||
.map(|tier| tier.id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let service_tiers_suffix = if service_tiers.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" Service tiers: {service_tiers}.")
|
||||
};
|
||||
let model_slug = &model.model;
|
||||
let description = &model.description;
|
||||
format!(
|
||||
"- {} (`{}`): {} Default reasoning effort: {}. Supported reasoning efforts: {}. Supported service tiers: {}.",
|
||||
model.display_name,
|
||||
model.model,
|
||||
model.description,
|
||||
model.default_reasoning_effort,
|
||||
efforts,
|
||||
service_tiers
|
||||
"- `{model_slug}`: {description}{reasoning_efforts_suffix}{service_tiers_suffix}"
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
|
||||
@@ -74,12 +74,10 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
|
||||
description
|
||||
.contains("Available model overrides (optional; inherited parent model is preferred):")
|
||||
);
|
||||
assert!(description.contains("visible display (`visible-model`)"));
|
||||
assert!(
|
||||
description
|
||||
.contains("Supported service tiers: priority (Fast: 1.5x speed, increased usage).")
|
||||
);
|
||||
assert!(!description.contains("hidden display (`hidden-model`)"));
|
||||
assert!(description.contains(
|
||||
"- `visible-model`: visible description Reasoning efforts: medium (default). Service tiers: priority."
|
||||
));
|
||||
assert!(!description.contains("hidden-model"));
|
||||
assert!(properties.contains_key("task_name"));
|
||||
assert!(properties.contains_key("message"));
|
||||
assert!(properties.contains_key("fork_turns"));
|
||||
@@ -150,6 +148,37 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_agent_tool_caps_visible_model_summaries() {
|
||||
let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions {
|
||||
available_models: vec![
|
||||
model_preset("first", /*show_in_picker*/ true),
|
||||
model_preset("second", /*show_in_picker*/ true),
|
||||
model_preset("third", /*show_in_picker*/ true),
|
||||
model_preset("fourth", /*show_in_picker*/ true),
|
||||
model_preset("fifth", /*show_in_picker*/ true),
|
||||
model_preset("sixth", /*show_in_picker*/ true),
|
||||
],
|
||||
agent_type_description: "role help".to_string(),
|
||||
hide_agent_type_model_reasoning: false,
|
||||
include_usage_hint: true,
|
||||
usage_hint_text: None,
|
||||
max_concurrent_threads_per_session: Some(4),
|
||||
});
|
||||
|
||||
let ToolSpec::Function(ResponsesApiTool { description, .. }) = tool else {
|
||||
panic!("spawn_agent should be a function tool");
|
||||
};
|
||||
|
||||
for model in ["first", "second", "third", "fourth", "fifth"] {
|
||||
assert!(
|
||||
description.contains(&format!("`{model}-model`")),
|
||||
"expected {model} model summary in spawn_agent description: {description:?}"
|
||||
);
|
||||
}
|
||||
assert!(!description.contains("`sixth-model`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() {
|
||||
let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions {
|
||||
|
||||
@@ -123,6 +123,10 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() ->
|
||||
effort: ReasoningEffort::Low,
|
||||
description: "Quick scan".to_string(),
|
||||
},
|
||||
ReasoningEffortPreset {
|
||||
effort: ReasoningEffort::Medium,
|
||||
description: "Balanced".to_string(),
|
||||
},
|
||||
ReasoningEffortPreset {
|
||||
effort: ReasoningEffort::High,
|
||||
description: "Deep dive".to_string(),
|
||||
@@ -175,7 +179,7 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() ->
|
||||
spawn_agent_description(&body).expect("spawn_agent description should be present");
|
||||
|
||||
assert!(
|
||||
description.contains("- Visible Model (`visible-model`): Fast and capable"),
|
||||
description.contains("- `visible-model`: Fast and capable"),
|
||||
"expected visible model summary in spawn_agent description: {description:?}"
|
||||
);
|
||||
assert!(
|
||||
@@ -196,20 +200,15 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() ->
|
||||
"expected model override usage guidance in spawn_agent description: {description:?}"
|
||||
);
|
||||
assert!(
|
||||
description.contains("Default reasoning effort: medium."),
|
||||
description.contains("Reasoning efforts: low, medium (default), high."),
|
||||
"expected default reasoning effort in spawn_agent description: {description:?}"
|
||||
);
|
||||
assert!(
|
||||
description.contains("low (Quick scan), high (Deep dive)."),
|
||||
"expected reasoning efforts in spawn_agent description: {description:?}"
|
||||
);
|
||||
assert!(
|
||||
description
|
||||
.contains("Supported service tiers: priority (Fast: 1.5x speed, increased usage)."),
|
||||
description.contains("Service tiers: priority."),
|
||||
"expected service tier guidance in spawn_agent description: {description:?}"
|
||||
);
|
||||
assert!(
|
||||
!description.contains("Hidden Model"),
|
||||
!description.contains("hidden-model"),
|
||||
"hidden picker model should be omitted from spawn_agent description: {description:?}"
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -17,6 +17,7 @@ axum = { workspace = true, features = ["http1", "tokio", "ws"] }
|
||||
base64 = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-api = { workspace = true }
|
||||
codex-client = { workspace = true }
|
||||
codex-file-system = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
@@ -51,6 +52,7 @@ uuid = { workspace = true, features = ["v4"] }
|
||||
anyhow = { workspace = true }
|
||||
codex-test-binary-support = { workspace = true }
|
||||
ctor = { workspace = true }
|
||||
http = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
serial_test = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -26,7 +26,11 @@ The CLI entrypoint supports:
|
||||
|
||||
Remote mode registers the local exec-server with the executor registry,
|
||||
then reconnects to the service-provided rendezvous websocket as the executor.
|
||||
It requires a bearer token in `CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN`.
|
||||
It uses the standard Codex ChatGPT sign-in state; run `codex login` first when
|
||||
remote registration needs authentication. Containerized callers that receive an
|
||||
Agent Identity JWT in `CODEX_ACCESS_TOKEN` can opt into that auth path with
|
||||
`--use-agent-identity-auth`; Codex then registers an Agent task and sends the
|
||||
derived AgentAssertion headers on the registry request.
|
||||
|
||||
Wire framing:
|
||||
|
||||
@@ -376,7 +380,9 @@ The crate exports:
|
||||
|
||||
Callers must pass `ExecServerRuntimePaths` to `run_main()`. The top-level
|
||||
`codex exec-server` command builds these paths from the `codex` arg0 dispatch
|
||||
state.
|
||||
state. `RemoteExecutorConfig::new(...)` also takes the auth provider that
|
||||
remote registration should use; the CLI builds that provider from Codex auth
|
||||
state before starting remote mode.
|
||||
|
||||
## Example session
|
||||
|
||||
|
||||
@@ -91,7 +91,6 @@ pub use protocol::TerminateResponse;
|
||||
pub use protocol::WriteParams;
|
||||
pub use protocol::WriteResponse;
|
||||
pub use protocol::WriteStatus;
|
||||
pub use remote::CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR;
|
||||
pub use remote::RemoteExecutorConfig;
|
||||
pub use remote::run_remote_executor;
|
||||
pub use runtime_paths::ExecServerRuntimePaths;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::env;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_api::SharedAuthProvider;
|
||||
use reqwest::StatusCode;
|
||||
use serde::Deserialize;
|
||||
use tokio::time::sleep;
|
||||
@@ -14,15 +14,12 @@ use crate::ExecServerRuntimePaths;
|
||||
use crate::relay::run_multiplexed_executor;
|
||||
use crate::server::ConnectionProcessor;
|
||||
|
||||
pub const CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR: &str =
|
||||
"CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN";
|
||||
|
||||
const ERROR_BODY_PREVIEW_BYTES: usize = 4096;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ExecutorRegistryClient {
|
||||
base_url: String,
|
||||
bearer_token: String,
|
||||
auth_provider: SharedAuthProvider,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
@@ -30,17 +27,17 @@ impl std::fmt::Debug for ExecutorRegistryClient {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ExecutorRegistryClient")
|
||||
.field("base_url", &self.base_url)
|
||||
.field("bearer_token", &"<redacted>")
|
||||
.field("auth_provider", &"<redacted>")
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl ExecutorRegistryClient {
|
||||
fn new(base_url: String, bearer_token: String) -> Result<Self, ExecServerError> {
|
||||
fn new(base_url: String, auth_provider: SharedAuthProvider) -> Result<Self, ExecServerError> {
|
||||
let base_url = normalize_base_url(base_url)?;
|
||||
Ok(Self {
|
||||
base_url,
|
||||
bearer_token,
|
||||
auth_provider,
|
||||
http: reqwest::Client::new(),
|
||||
})
|
||||
}
|
||||
@@ -55,7 +52,7 @@ impl ExecutorRegistryClient {
|
||||
&self.base_url,
|
||||
&format!("/cloud/executor/{executor_id}/register"),
|
||||
))
|
||||
.bearer_auth(&self.bearer_token)
|
||||
.headers(self.auth_provider.to_auth_headers())
|
||||
.send()
|
||||
.await?;
|
||||
self.parse_json_response(response).await
|
||||
@@ -89,12 +86,12 @@ struct ExecutorRegistryExecutorRegistrationResponse {
|
||||
}
|
||||
|
||||
/// Configuration for registering an exec-server for remote use.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
#[derive(Clone)]
|
||||
pub struct RemoteExecutorConfig {
|
||||
pub base_url: String,
|
||||
pub executor_id: String,
|
||||
pub name: String,
|
||||
bearer_token: String,
|
||||
auth_provider: SharedAuthProvider,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RemoteExecutorConfig {
|
||||
@@ -103,28 +100,23 @@ impl std::fmt::Debug for RemoteExecutorConfig {
|
||||
.field("base_url", &self.base_url)
|
||||
.field("executor_id", &self.executor_id)
|
||||
.field("name", &self.name)
|
||||
.field("bearer_token", &"<redacted>")
|
||||
.field("auth_provider", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteExecutorConfig {
|
||||
pub fn new(base_url: String, executor_id: String) -> Result<Self, ExecServerError> {
|
||||
Self::with_bearer_token(base_url, executor_id, read_remote_bearer_token_from_env()?)
|
||||
}
|
||||
|
||||
pub fn with_bearer_token(
|
||||
pub fn new(
|
||||
base_url: String,
|
||||
executor_id: String,
|
||||
bearer_token: String,
|
||||
auth_provider: SharedAuthProvider,
|
||||
) -> Result<Self, ExecServerError> {
|
||||
let executor_id = normalize_executor_id(executor_id)?;
|
||||
let bearer_token = normalize_bearer_token(bearer_token)?;
|
||||
Ok(Self {
|
||||
base_url,
|
||||
executor_id,
|
||||
name: "codex-exec-server".to_string(),
|
||||
bearer_token,
|
||||
auth_provider,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -136,7 +128,8 @@ pub async fn run_remote_executor(
|
||||
runtime_paths: ExecServerRuntimePaths,
|
||||
) -> Result<(), ExecServerError> {
|
||||
ensure_rustls_crypto_provider();
|
||||
let client = ExecutorRegistryClient::new(config.base_url.clone(), config.bearer_token.clone())?;
|
||||
let client =
|
||||
ExecutorRegistryClient::new(config.base_url.clone(), config.auth_provider.clone())?;
|
||||
let processor = ConnectionProcessor::new(runtime_paths);
|
||||
let mut backoff = Duration::from_secs(1);
|
||||
|
||||
@@ -162,32 +155,6 @@ pub async fn run_remote_executor(
|
||||
}
|
||||
}
|
||||
|
||||
fn read_remote_bearer_token_from_env() -> Result<String, ExecServerError> {
|
||||
read_remote_bearer_token_from_env_with(|name| env::var(name))
|
||||
}
|
||||
|
||||
fn read_remote_bearer_token_from_env_with<F>(get_var: F) -> Result<String, ExecServerError>
|
||||
where
|
||||
F: FnOnce(&str) -> Result<String, env::VarError>,
|
||||
{
|
||||
let bearer_token = get_var(CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR).map_err(|_| {
|
||||
ExecServerError::ExecutorRegistryAuth(format!(
|
||||
"executor registry bearer token environment variable `{CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR}` is not set"
|
||||
))
|
||||
})?;
|
||||
normalize_bearer_token(bearer_token)
|
||||
}
|
||||
|
||||
fn normalize_bearer_token(bearer_token: String) -> Result<String, ExecServerError> {
|
||||
let bearer_token = bearer_token.trim().to_string();
|
||||
if bearer_token.is_empty() {
|
||||
return Err(ExecServerError::ExecutorRegistryAuth(format!(
|
||||
"executor registry bearer token environment variable `{CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR}` is empty"
|
||||
)));
|
||||
}
|
||||
Ok(bearer_token)
|
||||
}
|
||||
|
||||
fn normalize_executor_id(executor_id: String) -> Result<String, ExecServerError> {
|
||||
let executor_id = executor_id.trim().to_string();
|
||||
if executor_id.is_empty() {
|
||||
@@ -274,6 +241,11 @@ fn preview_error_body(body: &str) -> Option<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_api::AuthProvider;
|
||||
use http::HeaderMap;
|
||||
use http::HeaderValue;
|
||||
use pretty_assertions::assert_eq;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
@@ -284,25 +256,46 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct StaticRegistryAuthProvider;
|
||||
|
||||
impl AuthProvider for StaticRegistryAuthProvider {
|
||||
fn add_auth_headers(&self, headers: &mut HeaderMap) {
|
||||
let _ = headers.insert(
|
||||
http::header::AUTHORIZATION,
|
||||
HeaderValue::from_static("Bearer registry-token"),
|
||||
);
|
||||
let _ = headers.insert(
|
||||
"ChatGPT-Account-ID",
|
||||
HeaderValue::from_static("workspace-123"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn static_registry_auth_provider() -> SharedAuthProvider {
|
||||
Arc::new(StaticRegistryAuthProvider)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_executor_posts_with_bearer_token_header() {
|
||||
async fn register_executor_posts_with_auth_provider_headers() {
|
||||
let server = MockServer::start().await;
|
||||
let config = RemoteExecutorConfig::with_bearer_token(
|
||||
let config = RemoteExecutorConfig::new(
|
||||
server.uri(),
|
||||
"exec-requested".to_string(),
|
||||
"registry-token".to_string(),
|
||||
static_registry_auth_provider(),
|
||||
)
|
||||
.expect("config");
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/cloud/executor/exec-requested/register"))
|
||||
.and(header("authorization", "Bearer registry-token"))
|
||||
.and(header("chatgpt-account-id", "workspace-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"executor_id": "exec-1",
|
||||
"url": "wss://rendezvous.test/executor/exec-1?role=executor&sig=abc"
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let client = ExecutorRegistryClient::new(server.uri(), "registry-token".to_string())
|
||||
let client = ExecutorRegistryClient::new(server.uri(), static_registry_auth_provider())
|
||||
.expect("client");
|
||||
|
||||
let response = client
|
||||
@@ -320,17 +313,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_output_redacts_bearer_token() {
|
||||
let config = RemoteExecutorConfig::with_bearer_token(
|
||||
fn debug_output_redacts_auth_provider() {
|
||||
let config = RemoteExecutorConfig::new(
|
||||
"https://registry.example".to_string(),
|
||||
"exec-1".to_string(),
|
||||
"secret-token".to_string(),
|
||||
static_registry_auth_provider(),
|
||||
)
|
||||
.expect("config");
|
||||
|
||||
let debug = format!("{config:?}");
|
||||
|
||||
assert!(debug.contains("<redacted>"));
|
||||
assert!(!debug.contains("secret-token"));
|
||||
assert!(!debug.contains("workspace-123"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use anyhow::bail;
|
||||
use codex_api::AuthProvider;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
use codex_app_server_protocol::JSONRPCNotification;
|
||||
@@ -22,12 +23,15 @@ use codex_exec_server::InitializeResponse;
|
||||
use codex_exec_server::RemoteExecutorConfig;
|
||||
use futures::SinkExt;
|
||||
use futures::StreamExt;
|
||||
use http::HeaderMap;
|
||||
use http::HeaderValue;
|
||||
use pretty_assertions::assert_eq;
|
||||
use prost::Message as ProstMessage;
|
||||
use relay_proto::RelayData;
|
||||
use relay_proto::RelayMessageFrame;
|
||||
use relay_proto::RelayReset;
|
||||
use relay_proto::relay_message_frame;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::timeout;
|
||||
use tokio_tungstenite::WebSocketStream;
|
||||
@@ -46,6 +50,22 @@ const REGISTRY_TOKEN: &str = "registry-token";
|
||||
const RELAY_MESSAGE_FRAME_VERSION: u32 = 1;
|
||||
const TEST_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Debug)]
|
||||
struct StaticRegistryAuthProvider;
|
||||
|
||||
impl AuthProvider for StaticRegistryAuthProvider {
|
||||
fn add_auth_headers(&self, headers: &mut HeaderMap) {
|
||||
let _ = headers.insert(
|
||||
http::header::AUTHORIZATION,
|
||||
HeaderValue::from_static("Bearer registry-token"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn static_registry_auth_provider() -> codex_api::SharedAuthProvider {
|
||||
Arc::new(StaticRegistryAuthProvider)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn multiplexed_remote_executor_routes_independent_virtual_streams() -> Result<()> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
@@ -63,10 +83,10 @@ async fn multiplexed_remote_executor_routes_independent_virtual_streams() -> Res
|
||||
|
||||
let (codex_exe, codex_linux_sandbox_exe) = common::current_test_binary_helper_paths()?;
|
||||
let runtime_paths = ExecServerRuntimePaths::new(codex_exe, codex_linux_sandbox_exe)?;
|
||||
let config = RemoteExecutorConfig::with_bearer_token(
|
||||
let config = RemoteExecutorConfig::new(
|
||||
registry.uri(),
|
||||
EXECUTOR_ID.to_string(),
|
||||
REGISTRY_TOKEN.to_string(),
|
||||
static_registry_auth_provider(),
|
||||
)?;
|
||||
let remote_executor = tokio::spawn(codex_exec_server::run_remote_executor(
|
||||
config,
|
||||
|
||||
@@ -223,6 +223,7 @@ impl EventProcessorWithJsonOutput {
|
||||
arguments,
|
||||
result: result.map(|result| McpToolCallItemResult {
|
||||
content: result.content,
|
||||
meta: result.meta,
|
||||
structured_content: result.structured_content,
|
||||
}),
|
||||
error: error.map(|error| McpToolCallItemError {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
@@ -57,3 +58,53 @@ fn failed_turn_does_not_overwrite_output_last_message_file() {
|
||||
"keep existing contents"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_tool_call_result_preserves_meta_in_jsonl_event() {
|
||||
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
||||
|
||||
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
|
||||
codex_app_server_protocol::ItemCompletedNotification {
|
||||
item: ThreadItem::McpToolCall {
|
||||
id: "mcp-1".to_string(),
|
||||
server: "search service".to_string(),
|
||||
tool: "web_run".to_string(),
|
||||
status: McpToolCallStatus::Completed,
|
||||
arguments: json!({"search_query": [{"q": "OpenAI Codex CLI documentation"}]}),
|
||||
mcp_app_resource_uri: None,
|
||||
result: Some(Box::new(codex_app_server_protocol::McpToolCallResult {
|
||||
content: vec![json!({"type": "text", "text": "search result"})],
|
||||
structured_content: None,
|
||||
meta: Some(json!({"raw_messages": [{"ref_id": "turn0search0"}]})),
|
||||
})),
|
||||
error: None,
|
||||
duration_ms: Some(42),
|
||||
},
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
completed_at_ms: 0,
|
||||
},
|
||||
));
|
||||
|
||||
assert_eq!(collected.status, CodexStatus::Running);
|
||||
assert_eq!(collected.events.len(), 1);
|
||||
|
||||
let ThreadEvent::ItemCompleted(ItemCompletedEvent { item }) = &collected.events[0] else {
|
||||
panic!("expected item.completed event");
|
||||
};
|
||||
let ThreadItemDetails::McpToolCall(item) = &item.details else {
|
||||
panic!("expected MCP tool call item");
|
||||
};
|
||||
let result = item.result.as_ref().expect("expected MCP tool result");
|
||||
assert_eq!(
|
||||
result.meta,
|
||||
Some(json!({"raw_messages": [{"ref_id": "turn0search0"}]}))
|
||||
);
|
||||
|
||||
let serialized = serde_json::to_value(&collected.events[0]).expect("serialize event");
|
||||
assert_eq!(
|
||||
serialized["item"]["result"]["_meta"],
|
||||
json!({"raw_messages": [{"ref_id": "turn0search0"}]})
|
||||
);
|
||||
assert!(serialized["item"]["result"].get("meta").is_none());
|
||||
}
|
||||
|
||||
@@ -266,6 +266,9 @@ pub struct McpToolCallItemResult {
|
||||
// representations). Using `JsonValue` keeps the payload wire-shaped and
|
||||
// easy to export.
|
||||
pub content: Vec<JsonValue>,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub meta: Option<JsonValue>,
|
||||
pub structured_content: Option<JsonValue>,
|
||||
}
|
||||
|
||||
|
||||
@@ -540,6 +540,7 @@ fn mcp_tool_call_begin_and_end_emit_item_events() {
|
||||
arguments: json!({ "key": "value" }),
|
||||
result: Some(McpToolCallItemResult {
|
||||
content: Vec::new(),
|
||||
meta: None,
|
||||
structured_content: None,
|
||||
}),
|
||||
error: None,
|
||||
@@ -681,6 +682,7 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() {
|
||||
"type": "text",
|
||||
"text": "done",
|
||||
})],
|
||||
meta: None,
|
||||
structured_content: Some(json!({ "status": "ok" })),
|
||||
}),
|
||||
error: None,
|
||||
|
||||
@@ -60,6 +60,7 @@ export type McpToolCallItem = {
|
||||
/** Result payload returned by the MCP server for successful calls. */
|
||||
result?: {
|
||||
content: McpContentBlock[];
|
||||
_meta?: unknown;
|
||||
structured_content: unknown;
|
||||
};
|
||||
/** Error message reported for failed calls. */
|
||||
|
||||
Reference in New Issue
Block a user