mirror of
https://github.com/openai/codex.git
synced 2026-05-06 20:36:33 +00:00
Compare commits
6 Commits
pakrym/spl
...
ruslan/exp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f354d6bb8 | ||
|
|
01d0307375 | ||
|
|
a4fadaee80 | ||
|
|
f62fbddb64 | ||
|
|
6853ce9136 | ||
|
|
c8e2b46acd |
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -1440,6 +1440,7 @@ dependencies = [
|
||||
"codex-utils-cli",
|
||||
"codex-utils-json-to-toml",
|
||||
"codex-utils-pty",
|
||||
"codex-utils-rustls-provider",
|
||||
"core_test_support",
|
||||
"futures",
|
||||
"opentelemetry",
|
||||
|
||||
@@ -6213,6 +6213,12 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"experimental_app_server_remote_control_url": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"forced_chatgpt_workspace_id": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
@@ -2857,6 +2857,12 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"experimental_app_server_remote_control_url": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"forced_chatgpt_workspace_id": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
@@ -233,6 +233,12 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"experimental_app_server_remote_control_url": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"forced_chatgpt_workspace_id": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
@@ -20,4 +20,4 @@ export type Config = {model: string | null, review_model: string | null, model_c
|
||||
* [UNSTABLE] Optional default for where approval requests are routed for
|
||||
* review.
|
||||
*/
|
||||
approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: ServiceTier | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null });
|
||||
approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: ServiceTier | null, analytics: AnalyticsConfig | null, experimental_app_server_remote_control_url: string | null} & ({ [key in string]?: number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null });
|
||||
|
||||
@@ -717,6 +717,8 @@ pub struct Config {
|
||||
#[experimental("config/read.apps")]
|
||||
#[serde(default)]
|
||||
pub apps: Option<AppsConfig>,
|
||||
#[experimental("config/read.experimental_app_server_remote_control_url")]
|
||||
pub experimental_app_server_remote_control_url: Option<String>,
|
||||
#[serde(default, flatten)]
|
||||
pub additional: HashMap<String, JsonValue>,
|
||||
}
|
||||
@@ -6782,6 +6784,7 @@ mod tests {
|
||||
service_tier: None,
|
||||
analytics: None,
|
||||
apps: None,
|
||||
experimental_app_server_remote_control_url: None,
|
||||
additional: HashMap::new(),
|
||||
});
|
||||
|
||||
@@ -6815,6 +6818,7 @@ mod tests {
|
||||
service_tier: None,
|
||||
analytics: None,
|
||||
apps: None,
|
||||
experimental_app_server_remote_control_url: None,
|
||||
additional: HashMap::new(),
|
||||
});
|
||||
|
||||
@@ -6870,6 +6874,7 @@ mod tests {
|
||||
service_tier: None,
|
||||
analytics: None,
|
||||
apps: None,
|
||||
experimental_app_server_remote_control_url: None,
|
||||
additional: HashMap::new(),
|
||||
});
|
||||
|
||||
@@ -6919,6 +6924,7 @@ mod tests {
|
||||
service_tier: None,
|
||||
analytics: None,
|
||||
apps: None,
|
||||
experimental_app_server_remote_control_url: None,
|
||||
additional: HashMap::new(),
|
||||
});
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ codex-rmcp-client = { workspace = true }
|
||||
codex-state = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-json-to-toml = { workspace = true }
|
||||
codex-utils-rustls-provider = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
futures = { workspace = true }
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
`codex app-server` is the interface Codex uses to power rich interfaces such as the [Codex VS Code extension](https://marketplace.visualstudio.com/items?itemName=openai.chatgpt).
|
||||
|
||||
For remote-control-only deployments, use `codexd`. It runs the same app-server runtime in a headless daemon mode, connects outbound to the ChatGPT remote control server using ChatGPT auth, and does not expose a local stdio or websocket transport.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Protocol](#protocol)
|
||||
|
||||
@@ -86,6 +86,7 @@ fn transport_name(transport: AppServerTransport) -> &'static str {
|
||||
match transport {
|
||||
AppServerTransport::Stdio => "stdio",
|
||||
AppServerTransport::WebSocket { .. } => "websocket",
|
||||
AppServerTransport::Headless => "headless",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::io::ErrorKind;
|
||||
use std::io::Result as IoResult;
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
@@ -27,8 +28,10 @@ use crate::transport::ConnectionState;
|
||||
use crate::transport::OutboundConnectionState;
|
||||
use crate::transport::TransportEvent;
|
||||
use crate::transport::route_outgoing_envelope;
|
||||
use crate::transport::start_remote_control;
|
||||
use crate::transport::start_stdio_connection;
|
||||
use crate::transport::start_websocket_acceptor;
|
||||
use crate::transport::validate_remote_control_auth;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_app_server_protocol::ConfigWarningNotification;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
@@ -88,6 +91,37 @@ enum LogFormat {
|
||||
Json,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct TransportRuntimeMode {
|
||||
single_client_mode: bool,
|
||||
shutdown_when_no_connections: bool,
|
||||
graceful_ctrl_c_restart_enabled: bool,
|
||||
ctrl_c_shutdown_enabled: bool,
|
||||
}
|
||||
|
||||
fn transport_runtime_mode(transport: AppServerTransport) -> TransportRuntimeMode {
|
||||
match transport {
|
||||
AppServerTransport::Stdio => TransportRuntimeMode {
|
||||
single_client_mode: true,
|
||||
shutdown_when_no_connections: true,
|
||||
graceful_ctrl_c_restart_enabled: false,
|
||||
ctrl_c_shutdown_enabled: false,
|
||||
},
|
||||
AppServerTransport::WebSocket { .. } => TransportRuntimeMode {
|
||||
single_client_mode: false,
|
||||
shutdown_when_no_connections: false,
|
||||
graceful_ctrl_c_restart_enabled: true,
|
||||
ctrl_c_shutdown_enabled: false,
|
||||
},
|
||||
AppServerTransport::Headless => TransportRuntimeMode {
|
||||
single_client_mode: false,
|
||||
shutdown_when_no_connections: false,
|
||||
graceful_ctrl_c_restart_enabled: false,
|
||||
ctrl_c_shutdown_enabled: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type StderrLogLayer = Box<dyn Layer<Registry> + Send + Sync + 'static>;
|
||||
|
||||
/// Control-plane messages from the processor/transport side to the outbound router task.
|
||||
@@ -353,37 +387,6 @@ pub async fn run_main_with_transport(
|
||||
let (outbound_control_tx, mut outbound_control_rx) =
|
||||
mpsc::channel::<OutboundControlEvent>(CHANNEL_CAPACITY);
|
||||
|
||||
enum TransportRuntime {
|
||||
Stdio,
|
||||
WebSocket {
|
||||
accept_handle: JoinHandle<()>,
|
||||
shutdown_token: CancellationToken,
|
||||
},
|
||||
}
|
||||
|
||||
let mut stdio_handles = Vec::<JoinHandle<()>>::new();
|
||||
let transport_runtime = match transport {
|
||||
AppServerTransport::Stdio => {
|
||||
start_stdio_connection(transport_event_tx.clone(), &mut stdio_handles).await?;
|
||||
TransportRuntime::Stdio
|
||||
}
|
||||
AppServerTransport::WebSocket { bind_address } => {
|
||||
let shutdown_token = CancellationToken::new();
|
||||
let accept_handle = start_websocket_acceptor(
|
||||
bind_address,
|
||||
transport_event_tx.clone(),
|
||||
shutdown_token.clone(),
|
||||
)
|
||||
.await?;
|
||||
TransportRuntime::WebSocket {
|
||||
accept_handle,
|
||||
shutdown_token,
|
||||
}
|
||||
}
|
||||
};
|
||||
let single_client_mode = matches!(&transport_runtime, TransportRuntime::Stdio);
|
||||
let shutdown_when_no_connections = single_client_mode;
|
||||
let graceful_signal_restart_enabled = !single_client_mode;
|
||||
// Parse CLI overrides once and derive the base Config eagerly so later
|
||||
// components do not need to work with raw TOML values.
|
||||
let cli_kv_overrides = cli_config_overrides.parse_overrides().map_err(|e| {
|
||||
@@ -539,6 +542,65 @@ pub async fn run_main_with_transport(
|
||||
}
|
||||
}
|
||||
|
||||
let transport_shutdown_token = CancellationToken::new();
|
||||
let mut transport_accept_handles = Vec::<JoinHandle<()>>::new();
|
||||
let runtime_mode = transport_runtime_mode(transport);
|
||||
|
||||
match transport {
|
||||
AppServerTransport::Stdio => {
|
||||
start_stdio_connection(transport_event_tx.clone(), &mut transport_accept_handles)
|
||||
.await?;
|
||||
}
|
||||
AppServerTransport::WebSocket { bind_address } => {
|
||||
let accept_handle = start_websocket_acceptor(
|
||||
bind_address,
|
||||
transport_event_tx.clone(),
|
||||
transport_shutdown_token.clone(),
|
||||
)
|
||||
.await?;
|
||||
transport_accept_handles.push(accept_handle);
|
||||
}
|
||||
AppServerTransport::Headless => {}
|
||||
}
|
||||
let shutdown_when_no_connections = runtime_mode.shutdown_when_no_connections;
|
||||
let graceful_ctrl_c_restart_enabled = runtime_mode.graceful_ctrl_c_restart_enabled;
|
||||
let graceful_signal_restart_enabled = runtime_mode.graceful_ctrl_c_restart_enabled;
|
||||
|
||||
let auth_manager = AuthManager::shared(
|
||||
config.codex_home.clone(),
|
||||
false,
|
||||
config.cli_auth_credentials_store_mode,
|
||||
);
|
||||
auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone());
|
||||
|
||||
let remote_control_url = config.experimental_app_server_remote_control_url.clone();
|
||||
if matches!(transport, AppServerTransport::Headless) {
|
||||
let remote_control_url = remote_control_url.as_deref().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"headless app-server transport requires a remote control URL",
|
||||
)
|
||||
})?;
|
||||
validate_remote_control_auth(auth_manager.as_ref()).await?;
|
||||
info!("starting codexd remote-control daemon using `{remote_control_url}`");
|
||||
let mut stderr = std::io::stderr().lock();
|
||||
let _ = writeln!(stderr, "codexd remote-control daemon");
|
||||
let _ = writeln!(stderr, " control server: {remote_control_url}");
|
||||
let _ = writeln!(stderr, " auth: ChatGPT");
|
||||
}
|
||||
if let Some(remote_control_url) = remote_control_url {
|
||||
let accept_handle = start_remote_control(
|
||||
remote_control_url,
|
||||
config.codex_home.clone(),
|
||||
auth_manager.clone(),
|
||||
transport_event_tx.clone(),
|
||||
matches!(transport, AppServerTransport::Headless),
|
||||
transport_shutdown_token.clone(),
|
||||
)
|
||||
.await?;
|
||||
transport_accept_handles.push(accept_handle);
|
||||
}
|
||||
|
||||
let outbound_handle = tokio::spawn(async move {
|
||||
let mut outbound_connections = HashMap::<ConnectionId, OutboundConnectionState>::new();
|
||||
loop {
|
||||
@@ -619,10 +681,7 @@ pub async fn run_main_with_transport(
|
||||
let mut thread_created_rx = processor.thread_created_receiver();
|
||||
let mut running_turn_count_rx = processor.subscribe_running_assistant_turn_count();
|
||||
let mut connections = HashMap::<ConnectionId, ConnectionState>::new();
|
||||
let websocket_accept_shutdown = match &transport_runtime {
|
||||
TransportRuntime::WebSocket { shutdown_token, .. } => Some(shutdown_token.clone()),
|
||||
TransportRuntime::Stdio => None,
|
||||
};
|
||||
let transport_shutdown_token = transport_shutdown_token.clone();
|
||||
async move {
|
||||
let mut listen_for_threads = true;
|
||||
let mut shutdown_state = ShutdownState::default();
|
||||
@@ -635,9 +694,7 @@ pub async fn run_main_with_transport(
|
||||
shutdown_state.update(running_turn_count, connections.len()),
|
||||
ShutdownAction::Finish
|
||||
) {
|
||||
if let Some(shutdown_token) = &websocket_accept_shutdown {
|
||||
shutdown_token.cancel();
|
||||
}
|
||||
transport_shutdown_token.cancel();
|
||||
let _ = outbound_control_tx
|
||||
.send(OutboundControlEvent::DisconnectAll)
|
||||
.await;
|
||||
@@ -652,6 +709,24 @@ pub async fn run_main_with_transport(
|
||||
let running_turn_count = *running_turn_count_rx.borrow();
|
||||
shutdown_state.on_signal(connections.len(), running_turn_count);
|
||||
}
|
||||
ctrl_c_result = tokio::signal::ctrl_c(), if runtime_mode.ctrl_c_shutdown_enabled => {
|
||||
if let Err(err) = ctrl_c_result {
|
||||
warn!("failed to listen for Ctrl-C during daemon shutdown: {err}");
|
||||
}
|
||||
info!("received Ctrl-C; shutting down codexd remote-control daemon");
|
||||
transport_shutdown_token.cancel();
|
||||
let _ = outbound_control_tx
|
||||
.send(OutboundControlEvent::DisconnectAll)
|
||||
.await;
|
||||
break;
|
||||
}
|
||||
ctrl_c_result = tokio::signal::ctrl_c(), if graceful_ctrl_c_restart_enabled && !shutdown_state.forced() => {
|
||||
if let Err(err) = ctrl_c_result {
|
||||
warn!("failed to listen for Ctrl-C during graceful restart drain: {err}");
|
||||
}
|
||||
let running_turn_count = *running_turn_count_rx.borrow();
|
||||
shutdown_state.on_signal(connections.len(), running_turn_count);
|
||||
}
|
||||
changed = running_turn_count_rx.changed(), if graceful_signal_restart_enabled && shutdown_state.requested() => {
|
||||
if changed.is_err() {
|
||||
warn!("running-turn watcher closed during graceful restart drain");
|
||||
@@ -833,16 +908,8 @@ pub async fn run_main_with_transport(
|
||||
let _ = processor_handle.await;
|
||||
let _ = outbound_handle.await;
|
||||
|
||||
if let TransportRuntime::WebSocket {
|
||||
accept_handle,
|
||||
shutdown_token,
|
||||
} = transport_runtime
|
||||
{
|
||||
shutdown_token.cancel();
|
||||
let _ = accept_handle.await;
|
||||
}
|
||||
|
||||
for handle in stdio_handles {
|
||||
transport_shutdown_token.cancel();
|
||||
for handle in transport_accept_handles {
|
||||
let _ = handle.await;
|
||||
}
|
||||
|
||||
@@ -856,6 +923,9 @@ pub async fn run_main_with_transport(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::LogFormat;
|
||||
use super::TransportRuntimeMode;
|
||||
use super::transport_runtime_mode;
|
||||
use crate::AppServerTransport;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
@@ -872,4 +942,17 @@ mod tests {
|
||||
assert_eq!(LogFormat::from_env_value(Some("text")), LogFormat::Default);
|
||||
assert_eq!(LogFormat::from_env_value(Some("jsonl")), LogFormat::Default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_transport_runtime_mode_uses_daemon_shutdown_behavior() {
|
||||
assert_eq!(
|
||||
transport_runtime_mode(AppServerTransport::Headless),
|
||||
TransportRuntimeMode {
|
||||
single_client_mode: false,
|
||||
shutdown_when_no_connections: false,
|
||||
graceful_ctrl_c_restart_enabled: false,
|
||||
ctrl_c_shutdown_enabled: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -88,6 +88,58 @@ sandbox_mode = "workspace-write"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn config_read_includes_experimental_app_server_remote_control_url() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
write_config(
|
||||
&codex_home,
|
||||
r#"
|
||||
experimental_app_server_remote_control_url = "https://example.com/remote-control"
|
||||
"#,
|
||||
)?;
|
||||
let codex_home_path = codex_home.path().canonicalize()?;
|
||||
let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_config_read_request(ConfigReadParams {
|
||||
include_layers: true,
|
||||
cwd: None,
|
||||
})
|
||||
.await?;
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let ConfigReadResponse {
|
||||
config,
|
||||
origins,
|
||||
layers,
|
||||
} = to_response(resp)?;
|
||||
|
||||
assert_eq!(
|
||||
config.experimental_app_server_remote_control_url.as_deref(),
|
||||
Some("https://example.com/remote-control")
|
||||
);
|
||||
assert_eq!(
|
||||
origins
|
||||
.get("experimental_app_server_remote_control_url")
|
||||
.expect("origin")
|
||||
.name,
|
||||
ConfigLayerSource::User {
|
||||
file: user_file.clone(),
|
||||
}
|
||||
);
|
||||
|
||||
let layers = layers.expect("layers present");
|
||||
assert_layers_user_then_optional_system(&layers, user_file)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn config_read_includes_tools() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -8,6 +8,10 @@ license.workspace = true
|
||||
name = "codex"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "codexd"
|
||||
path = "src/bin/codexd.rs"
|
||||
|
||||
[lib]
|
||||
name = "codex_cli"
|
||||
path = "src/lib.rs"
|
||||
|
||||
129
codex-rs/cli/src/bin/codexd.rs
Normal file
129
codex-rs/cli/src/bin/codexd.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
use clap::Parser;
|
||||
use codex_app_server::AppServerTransport;
|
||||
use codex_app_server::run_main_with_transport;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_arg0::arg0_dispatch_or_else;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config_loader::LoaderOverrides;
|
||||
use codex_utils_cli::CliConfigOverrides;
|
||||
use std::io::ErrorKind;
|
||||
use toml::Value;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[clap(
|
||||
author,
|
||||
version,
|
||||
bin_name = "codexd",
|
||||
override_usage = "codexd [OPTIONS]"
|
||||
)]
|
||||
struct CodexdCli {
|
||||
#[clap(flatten)]
|
||||
config_overrides: CliConfigOverrides,
|
||||
}
|
||||
|
||||
fn default_remote_control_url(chatgpt_base_url: &str) -> String {
|
||||
let chatgpt_base_url = chatgpt_base_url.trim_end_matches('/');
|
||||
if chatgpt_base_url.contains("/backend-api") {
|
||||
format!("{chatgpt_base_url}/wham")
|
||||
} else {
|
||||
format!("{chatgpt_base_url}/backend-api/wham")
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move {
|
||||
let cli = CodexdCli::parse();
|
||||
let cli_kv_overrides = cli.config_overrides.parse_overrides().map_err(|err| {
|
||||
std::io::Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
format!("error parsing -c overrides: {err}"),
|
||||
)
|
||||
})?;
|
||||
let config = match ConfigBuilder::default()
|
||||
.cli_overrides(cli_kv_overrides.clone())
|
||||
.loader_overrides(LoaderOverrides::default())
|
||||
.build()
|
||||
.await
|
||||
{
|
||||
Ok(config) => config,
|
||||
Err(_err) => Config::load_default_with_cli_overrides(cli_kv_overrides).map_err(
|
||||
|fallback_err| {
|
||||
std::io::Error::new(
|
||||
ErrorKind::InvalidData,
|
||||
format!("error loading default config after config error: {fallback_err}"),
|
||||
)
|
||||
},
|
||||
)?,
|
||||
};
|
||||
let mut config_overrides = cli.config_overrides.clone();
|
||||
if config.experimental_app_server_remote_control_url.is_none() {
|
||||
config_overrides.raw_overrides.push(format!(
|
||||
"experimental_app_server_remote_control_url={}",
|
||||
Value::String(default_remote_control_url(&config.chatgpt_base_url))
|
||||
));
|
||||
}
|
||||
|
||||
run_main_with_transport(
|
||||
arg0_paths,
|
||||
config_overrides,
|
||||
LoaderOverrides::default(),
|
||||
false,
|
||||
AppServerTransport::Headless,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::CodexdCli;
|
||||
use super::default_remote_control_url;
|
||||
use clap::Parser;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn codexd_parses_root_config_overrides() {
|
||||
let cli = CodexdCli::try_parse_from([
|
||||
"codexd",
|
||||
"-c",
|
||||
"chatgpt_base_url=\"http://localhost:10000\"",
|
||||
"-c",
|
||||
"model=\"gpt-5.1\"",
|
||||
])
|
||||
.expect("codexd args should parse");
|
||||
|
||||
assert_eq!(
|
||||
cli.config_overrides.raw_overrides,
|
||||
vec![
|
||||
"chatgpt_base_url=\"http://localhost:10000\"".to_string(),
|
||||
"model=\"gpt-5.1\"".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_remote_control_url_adds_backend_api_wham_for_chatgpt_roots() {
|
||||
assert_eq!(
|
||||
default_remote_control_url("https://chatgpt.com"),
|
||||
"https://chatgpt.com/backend-api/wham"
|
||||
);
|
||||
assert_eq!(
|
||||
default_remote_control_url("http://localhost:10000"),
|
||||
"http://localhost:10000/backend-api/wham"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_remote_control_url_keeps_existing_backend_api_prefixes() {
|
||||
assert_eq!(
|
||||
default_remote_control_url("https://chatgpt.com/backend-api"),
|
||||
"https://chatgpt.com/backend-api/wham"
|
||||
);
|
||||
assert_eq!(
|
||||
default_remote_control_url("https://chatgpt.com/backend-api/"),
|
||||
"https://chatgpt.com/backend-api/wham"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -328,6 +328,7 @@ impl CloudRequirementsService {
|
||||
return Ok(None);
|
||||
};
|
||||
if !auth.is_chatgpt_auth()
|
||||
|| auth.is_external_chatgpt_tokens()
|
||||
|| !matches!(
|
||||
auth.account_plan_type(),
|
||||
Some(PlanType::Business | PlanType::Enterprise)
|
||||
@@ -967,6 +968,42 @@ mod tests {
|
||||
auth_manager_with_plan_and_identity(plan_type, Some("user-12345"), Some("account-12345"))
|
||||
}
|
||||
|
||||
fn auth_manager_with_external_chatgpt_tokens(plan_type: &str) -> Arc<AuthManager> {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let header = json!({ "alg": "none", "typ": "JWT" });
|
||||
let auth_payload = json!({
|
||||
"chatgpt_plan_type": plan_type,
|
||||
"chatgpt_user_id": "user-12345",
|
||||
"user_id": "user-12345",
|
||||
});
|
||||
let payload = json!({
|
||||
"email": "user@example.com",
|
||||
"https://api.openai.com/auth": auth_payload,
|
||||
});
|
||||
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header"));
|
||||
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload"));
|
||||
let signature_b64 = URL_SAFE_NO_PAD.encode(b"sig");
|
||||
let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
|
||||
|
||||
let auth_json = json!({
|
||||
"auth_mode": "chatgptAuthTokens",
|
||||
"OPENAI_API_KEY": null,
|
||||
"tokens": {
|
||||
"id_token": fake_jwt,
|
||||
"access_token": "test-access-token",
|
||||
"refresh_token": "",
|
||||
"account_id": "account-12345",
|
||||
},
|
||||
"last_refresh": "2025-01-01T00:00:00Z",
|
||||
});
|
||||
write_auth_json(tmp.path(), auth_json).expect("write auth");
|
||||
Arc::new(AuthManager::new(
|
||||
tmp.path().to_path_buf(),
|
||||
false,
|
||||
AuthCredentialsStoreMode::File,
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_for_fetch(contents: Option<&str>) -> Option<ConfigRequirementsToml> {
|
||||
contents.and_then(|contents| parse_cloud_requirements(contents).ok().flatten())
|
||||
}
|
||||
@@ -1088,6 +1125,21 @@ mod tests {
|
||||
assert_eq!(result, Ok(None));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_cloud_requirements_skips_external_chatgpt_tokens_auth() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let service = CloudRequirementsService::new(
|
||||
auth_manager_with_external_chatgpt_tokens("enterprise"),
|
||||
Arc::new(StaticFetcher {
|
||||
contents: Some("allowed_approval_policies = [\"never\"]".to_string()),
|
||||
}),
|
||||
codex_home.path().to_path_buf(),
|
||||
CLOUD_REQUIREMENTS_TIMEOUT,
|
||||
);
|
||||
let result = service.fetch().await;
|
||||
assert_eq!(result, Ok(None));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_cloud_requirements_skips_non_business_or_enterprise_plan() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
|
||||
@@ -1850,6 +1850,10 @@
|
||||
"description": "When true, disables burst-paste detection for typed input entirely. All characters are inserted as they are received, and no buffering or placeholder replacement will occur for fast keypress bursts.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"experimental_app_server_remote_control_url": {
|
||||
"description": "Experimental / do not use. Provides the app-server remote control URL. The app-server uses the presence of this field to opt into remote mode.",
|
||||
"type": "string"
|
||||
},
|
||||
"experimental_compact_prompt_file": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
|
||||
@@ -4252,6 +4252,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
|
||||
realtime: RealtimeConfig::default(),
|
||||
experimental_realtime_ws_backend_prompt: None,
|
||||
experimental_realtime_ws_startup_context: None,
|
||||
experimental_app_server_remote_control_url: None,
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
compact_prompt: None,
|
||||
@@ -4391,6 +4392,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
|
||||
realtime: RealtimeConfig::default(),
|
||||
experimental_realtime_ws_backend_prompt: None,
|
||||
experimental_realtime_ws_startup_context: None,
|
||||
experimental_app_server_remote_control_url: None,
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
compact_prompt: None,
|
||||
@@ -4528,6 +4530,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
|
||||
realtime: RealtimeConfig::default(),
|
||||
experimental_realtime_ws_backend_prompt: None,
|
||||
experimental_realtime_ws_startup_context: None,
|
||||
experimental_app_server_remote_control_url: None,
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
compact_prompt: None,
|
||||
@@ -4651,6 +4654,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
|
||||
realtime: RealtimeConfig::default(),
|
||||
experimental_realtime_ws_backend_prompt: None,
|
||||
experimental_realtime_ws_startup_context: None,
|
||||
experimental_app_server_remote_control_url: None,
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
compact_prompt: None,
|
||||
@@ -5912,6 +5916,34 @@ experimental_realtime_ws_startup_context = "startup context from config"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn experimental_app_server_remote_control_url_loads_from_config_toml() -> std::io::Result<()> {
|
||||
let cfg: ConfigToml = toml::from_str(
|
||||
r#"
|
||||
experimental_app_server_remote_control_url = "https://example.com/remote-control"
|
||||
"#,
|
||||
)
|
||||
.expect("TOML deserialization should succeed");
|
||||
|
||||
assert_eq!(
|
||||
cfg.experimental_app_server_remote_control_url.as_deref(),
|
||||
Some("https://example.com/remote-control")
|
||||
);
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
cfg,
|
||||
ConfigOverrides::default(),
|
||||
codex_home.path().to_path_buf(),
|
||||
)?;
|
||||
|
||||
assert_eq!(
|
||||
config.experimental_app_server_remote_control_url.as_deref(),
|
||||
Some("https://example.com/remote-control")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn experimental_realtime_ws_model_loads_from_config_toml() -> std::io::Result<()> {
|
||||
let cfg: ConfigToml = toml::from_str(
|
||||
|
||||
@@ -495,6 +495,9 @@ pub struct Config {
|
||||
/// instructions inserted into developer messages when realtime becomes
|
||||
/// active.
|
||||
pub experimental_realtime_start_instructions: Option<String>,
|
||||
/// Experimental / do not use. Provides the app-server remote control URL.
|
||||
/// The app-server uses the presence of this field to opt into remote mode.
|
||||
pub experimental_app_server_remote_control_url: Option<String>,
|
||||
/// When set, restricts ChatGPT login to a specific workspace identifier.
|
||||
pub forced_chatgpt_workspace_id: Option<String>,
|
||||
|
||||
@@ -1394,6 +1397,9 @@ pub struct ConfigToml {
|
||||
/// instructions inserted into developer messages when realtime becomes
|
||||
/// active.
|
||||
pub experimental_realtime_start_instructions: Option<String>,
|
||||
/// Experimental / do not use. Provides the app-server remote control URL.
|
||||
/// The app-server uses the presence of this field to opt into remote mode.
|
||||
pub experimental_app_server_remote_control_url: Option<String>,
|
||||
pub projects: Option<HashMap<String, ProjectConfig>>,
|
||||
|
||||
/// Controls the web search tool mode: disabled, cached, or live.
|
||||
@@ -2711,6 +2717,8 @@ impl Config {
|
||||
experimental_realtime_ws_backend_prompt: cfg.experimental_realtime_ws_backend_prompt,
|
||||
experimental_realtime_ws_startup_context: cfg.experimental_realtime_ws_startup_context,
|
||||
experimental_realtime_start_instructions: cfg.experimental_realtime_start_instructions,
|
||||
experimental_app_server_remote_control_url: cfg
|
||||
.experimental_app_server_remote_control_url,
|
||||
forced_chatgpt_workspace_id,
|
||||
forced_login_method,
|
||||
include_apply_patch_tool: include_apply_patch_tool_flag,
|
||||
|
||||
Reference in New Issue
Block a user