Compare commits

...

6 Commits

Author SHA1 Message Date
Anton Panasenko
5f354d6bb8 more info 2026-03-16 16:48:05 -07:00
Anton Panasenko
01d0307375 no dealy or reenroll 2026-03-16 16:48:05 -07:00
Anton Panasenko
a4fadaee80 reconnect 2026-03-16 16:45:52 -07:00
Anton Panasenko
f62fbddb64 codexd 2026-03-16 16:44:24 -07:00
Anton Panasenko
6853ce9136 enroll 2026-03-16 16:41:26 -07:00
Ruslan Nigmatullin
c8e2b46acd first step 2026-03-16 16:37:58 -07:00
18 changed files with 2698 additions and 50 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -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",

View File

@@ -6213,6 +6213,12 @@
"null"
]
},
"experimental_app_server_remote_control_url": {
"type": [
"string",
"null"
]
},
"forced_chatgpt_workspace_id": {
"type": [
"string",

View File

@@ -2857,6 +2857,12 @@
"null"
]
},
"experimental_app_server_remote_control_url": {
"type": [
"string",
"null"
]
},
"forced_chatgpt_workspace_id": {
"type": [
"string",

View File

@@ -233,6 +233,12 @@
"null"
]
},
"experimental_app_server_remote_control_url": {
"type": [
"string",
"null"
]
},
"forced_chatgpt_workspace_id": {
"type": [
"string",

View File

@@ -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 });

View File

@@ -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(),
});

View File

@@ -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 }

View File

@@ -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)

View File

@@ -86,6 +86,7 @@ fn transport_name(transport: AppServerTransport) -> &'static str {
match transport {
AppServerTransport::Stdio => "stdio",
AppServerTransport::WebSocket { .. } => "websocket",
AppServerTransport::Headless => "headless",
}
}

View File

@@ -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

View File

@@ -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()?;

View File

@@ -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"

View 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"
);
}
}

View File

@@ -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");

View File

@@ -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"
},

View File

@@ -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(

View File

@@ -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,