Merge origin/main into jif/add-session-id

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
jif-oai
2026-05-01 09:18:58 +01:00
co-authored by Codex
377 changed files with 24865 additions and 16204 deletions
+1
View File
@@ -44,6 +44,7 @@ codex-features = { workspace = true }
codex-git-utils = { workspace = true }
codex-hooks = { workspace = true }
codex-otel = { workspace = true }
codex-plugin = { workspace = true }
codex-shell-command = { workspace = true }
codex-utils-cli = { workspace = true }
codex-utils-pty = { workspace = true }
+10 -9
View File
@@ -144,12 +144,12 @@ Example with notification opt-out:
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. For permissions, prefer experimental `permissions` profile selection; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`.
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`.
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Pass `excludeTurns: true` when the client plans to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`.
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`.
- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read response `permissionProfile` for the exact active runtime permissions and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known.
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
- `thread/loaded/list` — list the thread ids currently loaded in memory.
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
- `thread/turns/list` — page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `nextCursor`, and `backwardsCursor`.
- `thread/turns/list` — experimental; page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `nextCursor`, and `backwardsCursor`.
- `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`.
- `thread/memoryMode/set` — experimental; set a thread’s persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success.
- `memory/reset` — experimental; clear the current `CODEX_HOME/memories` directory and reset persisted memory stage data in sqlite while preserving existing thread memory modes; returns `{}` on success.
@@ -201,8 +201,9 @@ Example with notification opt-out:
- `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present.
- `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists.
- `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors.
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**).
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**).
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**).
- `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle.
- `skills/changed` — notification emitted when watched local skill files change.
- `app/list` — list available apps.
- `device/key/create` — create or load a controller-local device signing key for an account/client binding. This local-key API is available only over local transports such as stdio and in-process; remote transports reject it. Hardware-backed providers are the target protection class; an OS-protected non-extractable fallback is allowed only with `protectionPolicy: "allow_os_protected_nonextractable"` and returns the reported `protectionClass`.
@@ -222,7 +223,7 @@ Example with notification opt-out:
- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id.
- `config/read` — fetch the effective config on disk after resolving config layering.
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and plugin/session migration items may additionally include structured `details` grouping plugin ids or session metadata.
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes plugin or session imports, the server emits `externalAgentConfig/import/completed` after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish).
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish).
- `config/value/write` — write a single config key/value to the user's config.toml on disk.
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
@@ -276,7 +277,7 @@ Valid `personality` values are `"friendly"`, `"pragmatic"`, and `"none"`. When `
To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`. When the stored session includes persisted token usage, the server emits `thread/tokenUsage/updated` immediately after the response so clients can render restored usage before the next turn starts. You can also pass the same configuration overrides supported by `thread/start`, including `approvalsReviewer`.
By default, `thread/resume` includes the reconstructed turn history in `thread.turns`. Pass `excludeTurns: true` to return only thread metadata and live resume state, then call `thread/turns/list` separately if you want to page the turn history over the network. In that mode the server also skips replaying restored `thread/tokenUsage/updated`, which avoids rebuilding turns just to attribute historical usage.
By default, `thread/resume` includes the reconstructed turn history in `thread.turns`. Experimental clients can pass `excludeTurns: true` to return only thread metadata and live resume state, then call `thread/turns/list` separately if they want to page the turn history over the network. In that mode the server also skips replaying restored `thread/tokenUsage/updated`, which avoids rebuilding turns just to attribute historical usage.
By default, resume uses the latest persisted `model` and `reasoningEffort` values associated with the thread. Supplying any of `model`, `modelProvider`, `config.model`, or `config.model_reasoning_effort` disables that persisted fallback and uses the explicit overrides plus normal config resolution instead.
@@ -304,7 +305,7 @@ To branch from a stored session, call `thread/fork` with the `thread.id`. This c
{ "method": "thread/started", "params": { "thread": { … } } }
```
Like `thread/resume`, `thread/fork` also accepts `excludeTurns: true` to return only thread metadata in `thread.turns` and let the client page history with `thread/turns/list`. In that mode the server skips replaying restored `thread/tokenUsage/updated`, which keeps the fork path from rebuilding turns just to attribute historical usage.
Like `thread/resume`, experimental clients can pass `excludeTurns: true` to `thread/fork` to return only thread metadata in `thread.turns` and page history with `thread/turns/list`. In that mode the server skips replaying restored `thread/tokenUsage/updated`, which keeps the fork path from rebuilding turns just to attribute historical usage.
Experimental API: `thread/start`, `thread/resume`, and `thread/fork` accept `persistExtendedHistory: true` to persist a richer subset of ThreadItems for non-lossy history when calling `thread/read`, `thread/resume`, and `thread/fork` later. This does not backfill events that were not persisted previously.
@@ -418,9 +419,9 @@ Use `thread/read` to fetch a stored thread by id without resuming it. Pass `incl
} }
```
### Example: List thread turns
### Example: List thread turns (experimental)
Use `thread/turns/list` to page a stored thread’s turn history without resuming it. By default, results are sorted descending so clients can start at the present and fetch older turns with `nextCursor`. The response also includes `backwardsCursor`; pass it as `cursor` on a later request with `sortDirection: "asc"` to fetch turns newer than the first item from the earlier page.
Use `thread/turns/list` with `capabilities.experimentalApi = true` to page a stored thread’s turn history without resuming it. By default, results are sorted descending so clients can start at the present and fetch older turns with `nextCursor`. The response also includes `backwardsCursor`; pass it as `cursor` on a later request with `sortDirection: "asc"` to fetch turns newer than the first item from the earlier page.
```json
{ "method": "thread/turns/list", "id": 24, "params": {
@@ -1183,7 +1184,7 @@ There are additional item-specific events:
#### fileChange
- `item/fileChange/patchUpdated` - when `features.apply_patch_streaming_events` is enabled, streams structured file-change snapshots parsed from the model-generated patch before it is executed.
- `item/fileChange/outputDelta` - contains the tool call response of the underlying `apply_patch` tool call.
- `item/fileChange/outputDelta` - deprecated legacy protocol entry for `apply_patch` text output; retained for compatibility but no longer emitted by the server.
### Errors
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -4,8 +4,8 @@ use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::AppSummary;
use codex_chatgpt::connectors;
use codex_core::config::Config;
use codex_core::plugins::AppConnectorId;
use codex_exec_server::EnvironmentManager;
use codex_plugin::AppConnectorId;
use tracing::warn;
pub(super) async fn load_plugin_app_summaries(
@@ -113,7 +113,7 @@ pub(super) fn plugin_apps_needing_auth(
#[cfg(test)]
mod tests {
use codex_app_server_protocol::AppInfo;
use codex_core::plugins::AppConnectorId;
use codex_plugin::AppConnectorId;
use pretty_assertions::assert_eq;
use super::plugin_apps_needing_auth;
@@ -1,7 +1,10 @@
use super::*;
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use codex_app_server_protocol::PluginAvailability;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_core_plugins::remote::is_valid_remote_plugin_id;
use codex_core_plugins::remote::validate_remote_plugin_id;
impl CodexMessageProcessor {
pub(super) async fn plugin_list(
@@ -37,14 +40,15 @@ impl CodexMessageProcessor {
{
return Ok(empty_response());
}
let plugins_input = config.plugins_config_input();
plugins_manager.maybe_start_plugin_list_background_tasks_for_config(
&config,
&plugins_input,
auth.clone(),
&roots,
Some(self.effective_plugins_changed_callback(config.clone())),
);
let config_for_marketplace_listing = config.clone();
let config_for_marketplace_listing = plugins_input.clone();
let plugins_manager_for_marketplace_listing = plugins_manager.clone();
let (mut data, marketplace_load_errors) = match tokio::task::spawn_blocking(move || {
let outcome = plugins_manager_for_marketplace_listing
@@ -76,6 +80,7 @@ impl CodexMessageProcessor {
source: marketplace_plugin_source_to_info(plugin.source),
install_policy: plugin.policy.installation.into(),
auth_policy: plugin.policy.authentication.into(),
availability: PluginAvailability::Available,
interface: plugin.interface.map(local_plugin_interface_to_info),
})
.collect(),
@@ -145,7 +150,7 @@ impl CodexMessageProcessor {
.any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
{
match plugins_manager
.featured_plugin_ids_for_config(&config, auth.as_ref())
.featured_plugin_ids_for_config(&plugins_input, auth.as_ref())
.await
{
Ok(featured_plugin_ids) => featured_plugin_ids,
@@ -201,6 +206,7 @@ impl CodexMessageProcessor {
});
let config = self.load_latest_config(config_cwd).await?;
let plugins_input = config.plugins_config_input();
let plugin = match read_source {
Ok(marketplace_path) => {
@@ -209,7 +215,7 @@ impl CodexMessageProcessor {
marketplace_path,
};
let outcome = plugins_manager
.read_plugin_for_config(&config, &request)
.read_plugin_for_config(&plugins_input, &request)
.await
.map_err(|err| Self::marketplace_error(err, "read plugin details"))?;
let environment_manager = self.thread_manager.environment_manager();
@@ -241,6 +247,7 @@ impl CodexMessageProcessor {
enabled: outcome.plugin.enabled,
install_policy: outcome.plugin.policy.installation.into(),
auth_policy: outcome.plugin.policy.authentication.into(),
availability: PluginAvailability::Available,
interface: outcome.plugin.interface.map(local_plugin_interface_to_info),
},
description: outcome.plugin.description,
@@ -256,15 +263,15 @@ impl CodexMessageProcessor {
if !config.features.enabled(Feature::Plugins)
|| !config.features.enabled(Feature::RemotePlugin)
{
return Err(invalid_request("remote plugin read is not enabled"));
return Err(invalid_request(format!(
"remote plugin read is not enabled for marketplace {remote_marketplace_name}"
)));
}
let auth = self.auth_manager.auth().await;
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
if plugin_name.is_empty() || !is_valid_remote_plugin_id(&plugin_name) {
return Err(invalid_request("invalid remote plugin id"));
}
validate_remote_plugin_id(&plugin_name)?;
let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail(
&remote_plugin_service_config,
auth.as_ref(),
@@ -279,7 +286,7 @@ impl CodexMessageProcessor {
.app_ids
.iter()
.cloned()
.map(codex_core::plugins::AppConnectorId)
.map(codex_plugin::AppConnectorId)
.collect::<Vec<_>>();
let environment_manager = self.thread_manager.environment_manager();
let app_summaries = plugin_app_helpers::load_plugin_app_summaries(
@@ -295,6 +302,61 @@ impl CodexMessageProcessor {
Ok(PluginReadResponse { plugin })
}
pub(super) async fn plugin_skill_read(
&self,
request_id: ConnectionRequestId,
params: PluginSkillReadParams,
) {
let result = self.plugin_skill_read_response(params).await;
self.outgoing.send_result(request_id, result).await;
}
async fn plugin_skill_read_response(
&self,
params: PluginSkillReadParams,
) -> Result<PluginSkillReadResponse, JSONRPCErrorError> {
let PluginSkillReadParams {
remote_marketplace_name,
remote_plugin_id,
skill_name,
} = params;
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
if !config.features.enabled(Feature::Plugins)
|| !config.features.enabled(Feature::RemotePlugin)
{
return Err(invalid_request(format!(
"remote plugin skill read is not enabled for marketplace {remote_marketplace_name}"
)));
}
validate_remote_plugin_id(&remote_plugin_id)?;
if skill_name.is_empty() {
return Err(invalid_request(
"invalid remote plugin skill name: cannot be empty",
));
}
let auth = self.auth_manager.auth().await;
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
let remote_skill_detail = codex_core_plugins::remote::fetch_remote_plugin_skill_detail(
&remote_plugin_service_config,
auth.as_ref(),
&remote_marketplace_name,
&remote_plugin_id,
&skill_name,
)
.await
.map_err(|err| {
remote_plugin_catalog_error_to_jsonrpc(err, "read remote plugin skill details")
})?;
Ok(PluginSkillReadResponse {
contents: remote_skill_detail.contents,
})
}
pub(super) async fn plugin_share_save(
&self,
request_id: ConnectionRequestId,
@@ -325,14 +387,16 @@ impl CodexMessageProcessor {
let result = codex_core_plugins::remote::save_remote_plugin_share(
&remote_plugin_service_config,
auth.as_ref(),
plugin_path.as_path(),
config.codex_home.as_path(),
&plugin_path,
remote_plugin_id.as_deref(),
)
.await
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "save remote plugin share"))?;
let remote_plugin_id = result.remote_plugin_id;
self.clear_plugin_related_caches();
Ok(PluginShareSaveResponse {
remote_plugin_id: result.remote_plugin_id,
remote_plugin_id,
share_url: result.share_url.unwrap_or_default(),
})
}
@@ -356,11 +420,24 @@ impl CodexMessageProcessor {
let data = codex_core_plugins::remote::list_remote_plugin_shares(
&remote_plugin_service_config,
auth.as_ref(),
config.codex_home.as_path(),
)
.await
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "list remote plugin shares"))?
.into_iter()
.map(remote_plugin_summary_to_info)
.map(|summary| {
let RemoteCatalogPluginShareSummary {
summary,
share_url,
local_plugin_path,
} = summary;
let plugin = remote_plugin_summary_to_info(summary);
PluginShareListItem {
plugin,
share_url: share_url.unwrap_or_default(),
local_plugin_path,
}
})
.collect();
Ok(PluginShareListResponse { data })
}
@@ -390,6 +467,7 @@ impl CodexMessageProcessor {
codex_core_plugins::remote::delete_remote_plugin_share(
&remote_plugin_service_config,
auth.as_ref(),
config.codex_home.as_path(),
&remote_plugin_id,
)
.await
@@ -503,17 +581,17 @@ impl CodexMessageProcessor {
async fn remote_plugin_install_response(
&self,
remote_marketplace_name: String,
plugin_name: String,
remote_plugin_id: String,
) -> Result<PluginInstallResponse, JSONRPCErrorError> {
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
if !config.features.enabled(Feature::Plugins)
|| !config.features.enabled(Feature::RemotePlugin)
{
return Err(invalid_request("remote plugin install is not enabled"));
}
if plugin_name.is_empty() || !is_valid_remote_plugin_id(&plugin_name) {
return Err(invalid_request("invalid remote plugin id"));
return Err(invalid_request(format!(
"remote plugin install is not enabled for marketplace {remote_marketplace_name}"
)));
}
validate_remote_plugin_id(&remote_plugin_id)?;
let auth = self.auth_manager.auth().await;
let remote_plugin_service_config = RemotePluginServiceConfig {
@@ -524,7 +602,7 @@ impl CodexMessageProcessor {
&remote_plugin_service_config,
auth.as_ref(),
&remote_marketplace_name,
&plugin_name,
&remote_plugin_id,
)
.await
.map_err(|err| {
@@ -533,14 +611,28 @@ impl CodexMessageProcessor {
"read remote plugin details before install",
)
})?;
if remote_detail.summary.availability == PluginAvailability::DisabledByAdmin {
let remote_plugin_id = &remote_detail.summary.id;
return Err(invalid_request(format!(
"remote plugin {remote_plugin_id} is disabled by admin"
)));
}
if remote_detail.summary.install_policy == PluginInstallPolicy::NotAvailable {
return Err(invalid_request(format!(
"remote plugin {plugin_name} is not available for install"
"remote plugin {remote_plugin_id} is not available for install"
)));
}
let actual_remote_marketplace_name = remote_detail.marketplace_name.clone();
// Direct install writes the same cache tree that installed-plugin sync
// prunes before the backend installed snapshot can include this plugin.
let _remote_plugin_cache_mutation =
codex_core_plugins::remote::mark_remote_plugin_cache_mutation_in_flight(
config.codex_home.as_path(),
&actual_remote_marketplace_name,
&remote_detail.summary.name,
);
let validated_bundle = codex_core_plugins::remote_bundle::validate_remote_plugin_bundle(
&plugin_name,
&remote_plugin_id,
&actual_remote_marketplace_name,
&remote_detail.summary.name,
remote_detail.release_version.as_deref(),
@@ -562,7 +654,7 @@ impl CodexMessageProcessor {
&remote_plugin_service_config,
auth.as_ref(),
&actual_remote_marketplace_name,
&plugin_name,
&remote_plugin_id,
)
.await
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "install remote plugin"))?;
@@ -570,11 +662,17 @@ impl CodexMessageProcessor {
self.thread_manager
.plugins_manager()
.maybe_start_remote_installed_plugins_cache_refresh_after_mutation(
&config,
&config.plugins_config_input(),
auth.clone(),
Some(self.effective_plugins_changed_callback(config.clone())),
);
let mut plugin_metadata =
plugin_telemetry_metadata_from_root(&result.plugin_id, &result.installed_path).await;
plugin_metadata.remote_plugin_id = Some(remote_plugin_id);
self.analytics_events_client
.track_plugin_installed(plugin_metadata);
let plugin_mcp_servers = load_plugin_mcp_servers(result.installed_path.as_path()).await;
if !plugin_mcp_servers.is_empty() {
self.start_plugin_mcp_oauth_logins(&config, plugin_mcp_servers)
@@ -602,7 +700,7 @@ impl CodexMessageProcessor {
config: &Config,
is_chatgpt_auth: bool,
plugin_id: &str,
plugin_apps: &[codex_core::plugins::AppConnectorId],
plugin_apps: &[codex_plugin::AppConnectorId],
) -> Vec<AppSummary> {
if plugin_apps.is_empty() || !config.features.apps_enabled_for_auth(is_chatgpt_auth) {
return Vec::new();
@@ -675,14 +773,14 @@ impl CodexMessageProcessor {
params: PluginUninstallParams,
) -> Result<PluginUninstallResponse, JSONRPCErrorError> {
let PluginUninstallParams { plugin_id } = params;
if codex_core::plugins::PluginId::parse(&plugin_id).is_err()
&& (plugin_id.is_empty() || !is_valid_remote_plugin_id(&plugin_id))
if codex_plugin::PluginId::parse(&plugin_id).is_err()
&& !is_valid_remote_uninstall_plugin_id(&plugin_id)
{
return Err(invalid_request(
"invalid plugin id: expected a local plugin id or remote plugin id",
"invalid plugin id: expected a local plugin id in the form `plugin@marketplace` or a remote plugin id starting with `plugins~`, `plugins_`, `app_`, `asdk_app_`, or `connector_`",
));
}
if !plugin_id.is_empty() && is_valid_remote_plugin_id(&plugin_id) {
if is_valid_remote_uninstall_plugin_id(&plugin_id) {
return self.remote_plugin_uninstall_response(plugin_id).await;
}
let plugins_manager = self.thread_manager.plugins_manager();
@@ -773,9 +871,7 @@ impl CodexMessageProcessor {
{
return Err(invalid_request("remote plugin uninstall is not enabled"));
}
if plugin_id.is_empty() || !is_valid_remote_plugin_id(&plugin_id) {
return Err(invalid_request("invalid remote plugin id"));
}
validate_remote_plugin_id(&plugin_id)?;
let auth = self.auth_manager.auth().await;
let remote_plugin_service_config = RemotePluginServiceConfig {
@@ -798,7 +894,7 @@ impl CodexMessageProcessor {
self.on_effective_plugins_changed(config.clone());
}
plugins_manager.maybe_start_remote_installed_plugins_cache_refresh_after_mutation(
&config,
&config.plugins_config_input(),
auth.clone(),
Some(self.effective_plugins_changed_callback(config.clone())),
);
@@ -811,10 +907,13 @@ impl CodexMessageProcessor {
}
}
fn is_valid_remote_plugin_id(plugin_name: &str) -> bool {
plugin_name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '~')
fn is_valid_remote_uninstall_plugin_id(plugin_name: &str) -> bool {
is_valid_remote_plugin_id(plugin_name)
&& (plugin_name.starts_with("plugins~")
|| plugin_name.starts_with("plugins_")
|| plugin_name.starts_with("app_")
|| plugin_name.starts_with("asdk_app_")
|| plugin_name.starts_with("connector_"))
}
fn remote_marketplace_to_info(marketplace: RemoteMarketplace) -> PluginMarketplaceEntry {
@@ -841,6 +940,7 @@ fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginS
enabled: summary.enabled,
install_policy: summary.install_policy,
auth_policy: summary.auth_policy,
availability: summary.availability,
interface: summary.interface,
}
}
@@ -891,7 +991,8 @@ fn remote_plugin_catalog_error_to_jsonrpc(
}
}
RemotePluginCatalogError::InvalidPluginPath { .. }
| RemotePluginCatalogError::ArchiveTooLarge { .. } => JSONRPCErrorError {
| RemotePluginCatalogError::ArchiveTooLarge { .. }
| RemotePluginCatalogError::UnknownMarketplace { .. } => JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("{context}: {err}"),
data: None,
@@ -900,7 +1001,10 @@ fn remote_plugin_catalog_error_to_jsonrpc(
| RemotePluginCatalogError::Request { .. }
| RemotePluginCatalogError::UnexpectedStatus { .. }
| RemotePluginCatalogError::Decode { .. }
| RemotePluginCatalogError::InvalidBaseUrl(_)
| RemotePluginCatalogError::InvalidBaseUrlPath
| RemotePluginCatalogError::UnexpectedPluginId { .. }
| RemotePluginCatalogError::UnexpectedSkillName { .. }
| RemotePluginCatalogError::UnexpectedEnabledState { .. }
| RemotePluginCatalogError::Archive { .. }
| RemotePluginCatalogError::ArchiveJoin(_)
@@ -1,9 +1,8 @@
use codex_config::types::PluginConfig;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::plugins::PluginId;
use codex_core::plugins::PluginInstallRequest;
use codex_core::plugins::PluginsManager;
use codex_core_plugins::PluginInstallRequest;
use codex_core_plugins::PluginsManager;
use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;
use codex_core_plugins::marketplace::find_marketplace_manifest_path;
use codex_core_plugins::marketplace_add::MarketplaceAddRequest;
@@ -20,6 +19,7 @@ use codex_external_agent_migration::missing_command_names;
use codex_external_agent_migration::missing_subagent_names;
use codex_external_agent_sessions::ExternalAgentSessionMigration;
use codex_external_agent_sessions::detect_recent_sessions;
use codex_plugin::PluginId;
use codex_protocol::protocol::Product;
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;
@@ -1146,8 +1146,9 @@ fn configured_marketplace_plugins(
config: &Config,
plugins_manager: &PluginsManager,
) -> io::Result<BTreeMap<String, HashSet<String>>> {
let plugins_input = config.plugins_config_input();
let marketplaces = plugins_manager
.list_marketplaces_for_config(config, &[])
.list_marketplaces_for_config(&plugins_input, &[])
.map_err(|err| {
invalid_data_error(format!("failed to list configured marketplaces: {err}"))
})?;
+1 -1
View File
@@ -32,11 +32,11 @@ use codex_config::ResidencyRequirement as CoreResidencyRequirement;
use codex_config::SandboxModeRequirement as CoreSandboxModeRequirement;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::plugins::PluginId;
use codex_core_plugins::loader::installed_plugin_telemetry_metadata;
use codex_core_plugins::toggles::collect_plugin_enabled_candidates;
use codex_features::canonical_feature_for_key;
use codex_features::feature_for_key;
use codex_plugin::PluginId;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::protocol::Op;
use serde_json::json;
+13 -9
View File
@@ -65,6 +65,7 @@ use codex_arg0::Arg0DispatchPaths;
use codex_chatgpt::connectors;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::thread_store_from_config;
use codex_exec_server::EnvironmentManager;
use codex_features::Feature;
use codex_feedback::CodexFeedback;
@@ -285,12 +286,17 @@ impl MessageProcessor {
auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge {
outgoing: outgoing.clone(),
}));
// The thread store is intentionally process-scoped. Config reloads can
// affect per-thread behavior, but they must not move newly started,
// resumed, or forked threads to a different persistence backend/root.
let thread_store = thread_store_from_config(config.as_ref());
let thread_manager = Arc::new(ThreadManager::new(
config.as_ref(),
auth_manager.clone(),
session_source,
environment_manager,
Some(analytics_events_client.clone()),
Arc::clone(&thread_store),
));
thread_manager
.plugins_manager()
@@ -304,6 +310,7 @@ impl MessageProcessor {
arg0_paths,
config: Arc::clone(&config),
config_manager: config_manager.clone(),
thread_store,
feedback,
log_db,
});
@@ -314,7 +321,7 @@ impl MessageProcessor {
thread_manager
.plugins_manager()
.maybe_start_plugin_startup_tasks_for_config(
&config,
&config.plugins_config_input(),
auth_manager.clone(),
Some(on_effective_plugins_changed),
);
@@ -1212,18 +1219,13 @@ impl MessageProcessor {
params: ExternalAgentConfigImportParams,
) -> Result<(), JSONRPCErrorError> {
let needs_runtime_refresh = migration_items_need_runtime_refresh(&params.migration_items);
let has_migration_items = !params.migration_items.is_empty();
let has_plugin_imports = params.migration_items.iter().any(|item| {
matches!(
item.item_type,
ExternalAgentConfigMigrationItemType::Plugins
)
});
let has_session_imports = params.migration_items.iter().any(|item| {
matches!(
item.item_type,
ExternalAgentConfigMigrationItemType::Sessions
)
});
let pending_session_imports = self
.external_agent_config_api
.validate_pending_session_imports(&params)?;
@@ -1235,11 +1237,13 @@ impl MessageProcessor {
.send_response(request_id, ExternalAgentConfigImportResponse {})
.await;
if !has_plugin_imports && !has_session_imports {
if !has_migration_items {
return Ok(());
}
if pending_plugin_imports.is_empty() && pending_session_imports.is_empty() {
let has_background_imports =
!pending_plugin_imports.is_empty() || !pending_session_imports.is_empty();
if !has_background_imports {
self.outgoing
.send_server_notification(ServerNotification::ExternalAgentConfigImportCompleted(
ExternalAgentConfigImportCompletedNotification {},
@@ -195,7 +195,7 @@ impl ClientTracker {
})
.await
}
ClientEvent::Ack => Ok(()),
ClientEvent::ClientMessageChunk { .. } | ClientEvent::Ack { .. } => Ok(()),
ClientEvent::Ping => {
if let Some(client) = self.clients.get_mut(&client_key) {
client.last_activity_at = Instant::now();
@@ -1,6 +1,7 @@
mod client_tracker;
mod enroll;
mod protocol;
mod segment;
mod websocket;
use crate::transport::remote_control::websocket::RemoteControlChannels;
@@ -121,5 +122,7 @@ pub(crate) async fn start_remote_control(
))
}
#[cfg(test)]
mod segment_tests;
#[cfg(test)]
mod tests;
@@ -47,10 +47,20 @@ pub enum ClientEvent {
ClientMessage {
message: JSONRPCMessage,
},
ClientMessageChunk {
segment_id: usize,
segment_count: usize,
message_size_bytes: usize,
message_chunk_base64: String,
},
/// Backend-generated acknowledgement for all server envelopes addressed to
/// `client_id` and `stream_id` whose envelope `seq_id` is less than or equal
/// to this ack's `seq_id`. This cursor is stream-scoped.
Ack,
/// to this ack's `seq_id`. Chunk acknowledgements carry `segment_id` so the
/// sender can retain only the still-unacked wire chunks on reconnect.
Ack {
#[serde(skip_serializing_if = "Option::is_none")]
segment_id: Option<usize>,
},
Ping,
ClientClosed,
}
@@ -85,6 +95,12 @@ pub enum ServerEvent {
ServerMessage {
message: Box<OutgoingMessage>,
},
ServerMessageChunk {
segment_id: usize,
segment_count: usize,
message_size_bytes: usize,
message_chunk_base64: String,
},
#[allow(dead_code)]
Ack,
Pong {
@@ -92,6 +108,15 @@ pub enum ServerEvent {
},
}
impl ServerEvent {
pub(crate) fn segment_id(&self) -> Option<usize> {
match self {
Self::ServerMessageChunk { segment_id, .. } => Some(*segment_id),
Self::ServerMessage { .. } | Self::Ack | Self::Pong { .. } => None,
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) struct ServerEnvelope {
@@ -0,0 +1,449 @@
use super::protocol::ClientEnvelope;
use super::protocol::ClientEvent;
use super::protocol::ClientId;
use super::protocol::ServerEnvelope;
use super::protocol::ServerEvent;
use super::protocol::StreamId;
use base64::DecodeSliceError;
use base64::Engine;
use codex_app_server_protocol::JSONRPCMessage;
use std::collections::HashMap;
use std::io;
use std::io::ErrorKind;
use std::io::Write;
use tokio::time::Instant;
use tracing::warn;
pub(super) const REMOTE_CONTROL_SEGMENT_TARGET_BYTES: usize = 100 * 1024;
pub(super) const REMOTE_CONTROL_SEGMENT_MAX_BYTES: usize = 150 * 1024;
pub(super) const REMOTE_CONTROL_REASSEMBLED_MAX_BYTES: usize = 100 * 1024 * 1024;
pub(super) const REMOTE_CONTROL_SEGMENT_COUNT_MAX: usize = 1024;
const REMOTE_CONTROL_SEGMENT_ASSEMBLY_MAX_COUNT: usize = 128;
#[derive(Debug)]
struct ClientSegmentAssembly {
stream_id: StreamId,
metadata: ClientSegmentMetadata,
raw: Vec<u8>,
next_segment_id: usize,
last_chunk_seen_at: Instant,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ClientSegmentMetadata {
seq_id: u64,
segment_count: usize,
message_size_bytes: usize,
}
#[derive(Default)]
pub(super) struct ClientSegmentReassembler {
assemblies: HashMap<ClientId, ClientSegmentAssembly>,
}
pub(super) enum ClientSegmentObservation {
Forward(Box<ClientEnvelope>),
Pending,
Dropped,
}
impl ClientSegmentReassembler {
pub(super) fn observe(&mut self, envelope: ClientEnvelope) -> ClientSegmentObservation {
let ClientEvent::ClientMessageChunk {
segment_id,
segment_count,
message_size_bytes,
message_chunk_base64,
} = &envelope.event
else {
return ClientSegmentObservation::Forward(Box::new(envelope));
};
let segment_id = *segment_id;
let segment_count = *segment_count;
let message_size_bytes = *message_size_bytes;
let Some(metadata) = ClientSegmentMetadata::from_envelope(&envelope) else {
warn!(
client_id = envelope.client_id.0.as_str(),
"dropping segmented remote-control client envelope without seq_id"
);
return ClientSegmentObservation::Dropped;
};
let Some(stream_id) = envelope.stream_id.clone() else {
warn!(
client_id = envelope.client_id.0.as_str(),
"dropping segmented remote-control client envelope without stream_id"
);
return ClientSegmentObservation::Dropped;
};
if self.should_ignore_chunk(&envelope.client_id, &stream_id, metadata.seq_id, segment_id) {
return ClientSegmentObservation::Dropped;
}
if segment_count == 0
|| segment_count > REMOTE_CONTROL_SEGMENT_COUNT_MAX
|| segment_id >= segment_count
|| message_size_bytes == 0
|| message_size_bytes > REMOTE_CONTROL_REASSEMBLED_MAX_BYTES
|| message_chunk_base64.is_empty()
{
warn!(
client_id = envelope.client_id.0.as_str(),
"dropping invalid segmented remote-control client envelope"
);
self.remove_assembly(&envelope.client_id, &stream_id);
return ClientSegmentObservation::Dropped;
}
let now = Instant::now();
match self.assemblies.get(&envelope.client_id) {
Some(assembly) if assembly.stream_id != stream_id => {
warn!(
client_id = envelope.client_id.0.as_str(),
"resetting segmented remote-control client envelope after stream change"
);
self.assemblies.insert(
envelope.client_id.clone(),
ClientSegmentAssembly {
stream_id: stream_id.clone(),
metadata: metadata.clone(),
raw: Vec::new(),
next_segment_id: 0,
last_chunk_seen_at: now,
},
);
}
Some(_) => {}
None => {
self.evict_assemblies_if_full();
self.assemblies.insert(
envelope.client_id.clone(),
ClientSegmentAssembly {
stream_id: stream_id.clone(),
metadata: metadata.clone(),
raw: Vec::new(),
next_segment_id: 0,
last_chunk_seen_at: now,
},
);
}
}
let result = {
let Some(assembly) = self.assemblies.get_mut(&envelope.client_id) else {
warn!(
client_id = envelope.client_id.0.as_str(),
"dropping segmented remote-control client envelope without assembly"
);
return ClientSegmentObservation::Dropped;
};
if metadata.seq_id < assembly.metadata.seq_id {
AssemblyUpdate::Ignore
} else if assembly.metadata != metadata {
warn!(
client_id = envelope.client_id.0.as_str(),
"resetting segmented remote-control client envelope after metadata mismatch"
);
AssemblyUpdate::Drop
} else if segment_id < assembly.next_segment_id {
AssemblyUpdate::Pending
} else if segment_id != assembly.next_segment_id {
warn!(
client_id = envelope.client_id.0.as_str(),
"dropping out-of-order segmented remote-control client envelope"
);
AssemblyUpdate::Drop
} else {
assembly.last_chunk_seen_at = now;
let chunk_start = assembly.raw.len();
let decoded_chunk_len = base64::decoded_len_estimate(message_chunk_base64.len());
let chunk_end = usize::min(
message_size_bytes,
chunk_start.saturating_add(decoded_chunk_len),
);
assembly.raw.resize(chunk_end, 0);
match base64::engine::general_purpose::STANDARD.decode_slice(
message_chunk_base64.as_bytes(),
&mut assembly.raw[chunk_start..],
) {
Ok(decoded_chunk_len) => {
assembly.raw.truncate(chunk_start + decoded_chunk_len);
assembly.next_segment_id += 1;
if assembly.next_segment_id < segment_count {
AssemblyUpdate::Pending
} else if assembly.raw.len() != message_size_bytes {
warn!(
client_id = envelope.client_id.0.as_str(),
"dropping reassembled remote-control client envelope with mismatched size"
);
AssemblyUpdate::Drop
} else {
match serde_json::from_slice::<JSONRPCMessage>(&assembly.raw) {
Ok(message) => AssemblyUpdate::Complete(message),
Err(err) => {
warn!(
client_id = envelope.client_id.0.as_str(),
"dropping invalid reassembled remote-control client envelope: {err}"
);
AssemblyUpdate::Drop
}
}
}
}
Err(DecodeSliceError::OutputSliceTooSmall) => {
warn!(
client_id = envelope.client_id.0.as_str(),
"dropping segmented remote-control client envelope after size overflow"
);
AssemblyUpdate::Drop
}
Err(err) => {
warn!(
client_id = envelope.client_id.0.as_str(),
"dropping segmented remote-control client envelope with invalid base64: {err}"
);
AssemblyUpdate::Drop
}
}
}
};
match result {
AssemblyUpdate::Pending => ClientSegmentObservation::Pending,
AssemblyUpdate::Ignore => ClientSegmentObservation::Dropped,
AssemblyUpdate::Drop => {
self.remove_assembly(&envelope.client_id, &stream_id);
ClientSegmentObservation::Dropped
}
AssemblyUpdate::Complete(message) => {
self.remove_assembly(&envelope.client_id, &stream_id);
ClientSegmentObservation::Forward(Box::new(ClientEnvelope {
event: ClientEvent::ClientMessage { message },
..envelope
}))
}
}
}
pub(super) fn invalidate_stream(&mut self, client_id: &ClientId, stream_id: &StreamId) {
self.remove_assembly(client_id, stream_id);
}
pub(super) fn invalidate_client(&mut self, client_id: &ClientId) {
self.assemblies.remove(client_id);
}
pub(super) fn should_ignore_chunk(
&self,
client_id: &ClientId,
stream_id: &StreamId,
seq_id: u64,
segment_id: usize,
) -> bool {
self.assemblies.get(client_id).is_some_and(|assembly| {
assembly.stream_id == *stream_id
&& (seq_id < assembly.metadata.seq_id
|| (seq_id == assembly.metadata.seq_id
&& segment_id < assembly.next_segment_id))
})
}
fn remove_assembly(&mut self, client_id: &ClientId, stream_id: &StreamId) {
if self
.assemblies
.get(client_id)
.is_some_and(|assembly| &assembly.stream_id == stream_id)
{
self.assemblies.remove(client_id);
}
}
fn evict_assemblies_if_full(&mut self) {
while self.assemblies.len() >= REMOTE_CONTROL_SEGMENT_ASSEMBLY_MAX_COUNT {
let Some(client_id) = self
.assemblies
.iter()
.min_by_key(|(_, assembly)| assembly.last_chunk_seen_at)
.map(|(client_id, _)| client_id.clone())
else {
return;
};
self.assemblies.remove(&client_id);
}
}
}
enum AssemblyUpdate {
Pending,
Ignore,
Drop,
Complete(JSONRPCMessage),
}
impl ClientSegmentMetadata {
fn from_envelope(envelope: &ClientEnvelope) -> Option<Self> {
let ClientEvent::ClientMessageChunk {
segment_count,
message_size_bytes,
..
} = &envelope.event
else {
return None;
};
Some(Self {
seq_id: envelope.seq_id?,
segment_count: *segment_count,
message_size_bytes: *message_size_bytes,
})
}
}
pub(super) fn split_server_envelope_for_transport(
envelope: ServerEnvelope,
) -> io::Result<Vec<ServerEnvelope>> {
if !matches!(envelope.event, ServerEvent::ServerMessage { .. }) {
return Ok(vec![envelope]);
}
let envelope_size_bytes = serialized_len(&envelope)?;
if envelope_size_bytes <= REMOTE_CONTROL_SEGMENT_MAX_BYTES {
return Ok(vec![envelope]);
}
let ServerEvent::ServerMessage { message } = envelope.event.clone() else {
unreachable!("server message variant checked above");
};
let raw = serde_json::to_vec(message.as_ref()).map_err(io::Error::other)?;
let message_size_bytes = raw.len();
if message_size_bytes > REMOTE_CONTROL_REASSEMBLED_MAX_BYTES {
warn!("dropping remote-control server envelope that exceeds reassembled size limit");
return Ok(Vec::new());
}
let minimal_segment_count =
usize::min(message_size_bytes.max(1), REMOTE_CONTROL_SEGMENT_COUNT_MAX);
let minimal_chunk = &raw[..usize::min(raw.len(), 1)];
if serialized_chunk_len(
&envelope,
/*segment_id*/ 0,
minimal_segment_count,
message_size_bytes,
minimal_chunk,
)? > REMOTE_CONTROL_SEGMENT_MAX_BYTES
{
warn!("dropping remote-control server envelope that cannot fit within segment size limit");
return Ok(Vec::new());
}
let mut segment_count = usize::max(
2,
message_size_bytes.div_ceil(REMOTE_CONTROL_SEGMENT_TARGET_BYTES),
);
loop {
let chunk_size = usize::max(1, message_size_bytes.div_ceil(segment_count));
segment_count = message_size_bytes.div_ceil(chunk_size);
let segments_fit = raw
.chunks(chunk_size)
.enumerate()
.all(|(segment_id, chunk)| {
serialized_chunk_len(
&envelope,
segment_id,
segment_count,
message_size_bytes,
chunk,
)
.is_ok_and(|size| size <= REMOTE_CONTROL_SEGMENT_MAX_BYTES)
});
if segments_fit {
return raw
.chunks(chunk_size)
.enumerate()
.map(|(segment_id, chunk)| {
build_chunk_envelope(
&envelope,
segment_id,
segment_count,
message_size_bytes,
chunk,
)
})
.collect();
}
if chunk_size == 1 {
warn!(
"dropping remote-control server envelope that cannot fit within segment size limit"
);
return Ok(Vec::new());
}
let next_segment_count = segment_count + 1;
let next_chunk_size = usize::max(1, message_size_bytes.div_ceil(next_segment_count));
segment_count = if next_chunk_size == chunk_size {
message_size_bytes
} else {
next_segment_count
};
}
}
fn serialized_chunk_len(
envelope: &ServerEnvelope,
segment_id: usize,
segment_count: usize,
message_size_bytes: usize,
chunk: &[u8],
) -> io::Result<usize> {
serialized_len(&build_chunk_envelope(
envelope,
segment_id,
segment_count,
message_size_bytes,
chunk,
)?)
}
#[derive(Default)]
struct CountingWriter {
len: usize,
}
impl Write for CountingWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.len += buf.len();
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn serialized_len(value: &impl serde::Serialize) -> io::Result<usize> {
let mut writer = CountingWriter::default();
serde_json::to_writer(&mut writer, value).map_err(io::Error::other)?;
Ok(writer.len)
}
fn build_chunk_envelope(
envelope: &ServerEnvelope,
segment_id: usize,
segment_count: usize,
message_size_bytes: usize,
chunk: &[u8],
) -> io::Result<ServerEnvelope> {
if segment_count > REMOTE_CONTROL_SEGMENT_COUNT_MAX {
return Err(io::Error::new(
ErrorKind::InvalidData,
"remote-control segment count exceeds maximum",
));
}
Ok(ServerEnvelope {
event: ServerEvent::ServerMessageChunk {
segment_id,
segment_count,
message_size_bytes,
message_chunk_base64: base64::engine::general_purpose::STANDARD.encode(chunk),
},
client_id: envelope.client_id.clone(),
stream_id: envelope.stream_id.clone(),
seq_id: envelope.seq_id,
})
}
@@ -0,0 +1,386 @@
use super::protocol::ClientEnvelope;
use super::protocol::ClientEvent;
use super::protocol::ClientId;
use super::protocol::ServerEnvelope;
use super::protocol::ServerEvent;
use super::protocol::StreamId;
use super::segment::ClientSegmentObservation;
use super::segment::ClientSegmentReassembler;
use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES;
use super::segment::split_server_envelope_for_transport;
use crate::outgoing_message::OutgoingMessage;
use base64::Engine;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::ServerNotification;
use pretty_assertions::assert_eq;
#[test]
fn reassembles_client_message_chunks() {
let message = JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
});
let raw = serde_json::to_vec(&message).expect("message should serialize");
let split = raw.len() / 2;
let client_id = ClientId("client-1".to_string());
let stream_id = Some(StreamId("stream-1".to_string()));
let mut reassembler = ClientSegmentReassembler::default();
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id.clone(),
stream_id.clone(),
/*seq_id*/ 7,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
)),
ClientSegmentObservation::Pending
));
let reassembled = match reassembler.observe(chunk_envelope(
client_id.clone(),
stream_id,
/*seq_id*/ 7,
/*segment_id*/ 1,
/*segment_count*/ 2,
raw.len(),
&raw[split..],
)) {
ClientSegmentObservation::Forward(reassembled) => *reassembled,
ClientSegmentObservation::Pending | ClientSegmentObservation::Dropped => {
panic!("message should reassemble")
}
};
assert_eq!(reassembled.client_id, client_id);
assert_eq!(
reassembled.stream_id,
Some(StreamId("stream-1".to_string()))
);
assert_eq!(reassembled.seq_id, Some(7));
assert_eq!(reassembled.cursor, None);
match reassembled.event {
ClientEvent::ClientMessage {
message: reassembled_message,
} => assert_eq!(reassembled_message, message),
other => panic!("expected client message, got {other:?}"),
}
}
#[test]
fn splits_large_server_messages_into_wire_chunks() {
let envelope = ServerEnvelope {
event: ServerEvent::ServerMessage {
message: Box::new(OutgoingMessage::AppServerNotification(
ServerNotification::ConfigWarning(ConfigWarningNotification {
summary: "x".repeat(REMOTE_CONTROL_SEGMENT_MAX_BYTES),
details: None,
path: None,
range: None,
}),
)),
},
client_id: ClientId("client-1".to_string()),
stream_id: StreamId("stream-1".to_string()),
seq_id: 9,
};
let segments = split_server_envelope_for_transport(envelope).expect("split should succeed");
assert!(segments.len() > 1);
assert!(
segments
.iter()
.all(|segment| matches!(segment.event, ServerEvent::ServerMessageChunk { .. }))
);
assert!(segments.iter().all(|segment| segment.seq_id == 9));
assert!(segments.iter().all(|segment| {
serde_json::to_vec(segment)
.expect("segment should serialize")
.len()
<= REMOTE_CONTROL_SEGMENT_MAX_BYTES
}));
}
#[test]
fn invalidates_incomplete_stream_assemblies() {
let message = JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
});
let raw = serde_json::to_vec(&message).expect("message should serialize");
let split = raw.len() / 2;
let client_id = ClientId("client-1".to_string());
let stream_id = StreamId("stream-1".to_string());
let mut reassembler = ClientSegmentReassembler::default();
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id.clone(),
Some(stream_id.clone()),
/*seq_id*/ 7,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
)),
ClientSegmentObservation::Pending
));
reassembler.invalidate_stream(&client_id, &stream_id);
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id,
Some(stream_id),
/*seq_id*/ 7,
/*segment_id*/ 1,
/*segment_count*/ 2,
raw.len(),
&raw[split..],
)),
ClientSegmentObservation::Dropped
));
}
#[test]
fn resets_incomplete_client_assembly_when_stream_changes() {
let message = JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
});
let raw = serde_json::to_vec(&message).expect("message should serialize");
let split = raw.len() / 2;
let client_id = ClientId("client-1".to_string());
let first_stream_id = StreamId("stream-1".to_string());
let second_stream_id = StreamId("stream-2".to_string());
let mut reassembler = ClientSegmentReassembler::default();
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id.clone(),
Some(first_stream_id.clone()),
/*seq_id*/ 7,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
)),
ClientSegmentObservation::Pending
));
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id.clone(),
Some(second_stream_id.clone()),
/*seq_id*/ 8,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
)),
ClientSegmentObservation::Pending
));
let reassembled = match reassembler.observe(chunk_envelope(
client_id.clone(),
Some(second_stream_id),
/*seq_id*/ 8,
/*segment_id*/ 1,
/*segment_count*/ 2,
raw.len(),
&raw[split..],
)) {
ClientSegmentObservation::Forward(reassembled) => *reassembled,
ClientSegmentObservation::Pending | ClientSegmentObservation::Dropped => {
panic!("replacement stream should reassemble")
}
};
assert_eq!(
reassembled.stream_id,
Some(StreamId("stream-2".to_string()))
);
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id,
Some(first_stream_id),
/*seq_id*/ 7,
/*segment_id*/ 1,
/*segment_count*/ 2,
raw.len(),
&raw[split..],
)),
ClientSegmentObservation::Dropped
));
}
#[test]
fn ignores_stale_chunks_without_dropping_newer_assembly() {
let message = JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
});
let raw = serde_json::to_vec(&message).expect("message should serialize");
let split = raw.len() / 2;
let client_id = ClientId("client-1".to_string());
let stream_id = Some(StreamId("stream-1".to_string()));
let mut reassembler = ClientSegmentReassembler::default();
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id.clone(),
stream_id.clone(),
/*seq_id*/ 8,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
)),
ClientSegmentObservation::Pending
));
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id.clone(),
stream_id.clone(),
/*seq_id*/ 7,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
)),
ClientSegmentObservation::Dropped
));
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id,
stream_id,
/*seq_id*/ 8,
/*segment_id*/ 1,
/*segment_count*/ 2,
raw.len(),
&raw[split..],
)),
ClientSegmentObservation::Forward(_)
));
}
#[test]
fn ignores_invalid_stale_chunks_without_dropping_newer_assembly() {
let message = JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
});
let raw = serde_json::to_vec(&message).expect("message should serialize");
let split = raw.len() / 2;
let client_id = ClientId("client-1".to_string());
let stream_id = Some(StreamId("stream-1".to_string()));
let mut reassembler = ClientSegmentReassembler::default();
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id.clone(),
stream_id.clone(),
/*seq_id*/ 8,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
)),
ClientSegmentObservation::Pending
));
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id.clone(),
stream_id.clone(),
/*seq_id*/ 7,
/*segment_id*/ 1,
/*segment_count*/ 2,
raw.len(),
b"",
)),
ClientSegmentObservation::Dropped
));
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id,
stream_id,
/*seq_id*/ 8,
/*segment_id*/ 1,
/*segment_count*/ 2,
raw.len(),
&raw[split..],
)),
ClientSegmentObservation::Forward(_)
));
}
#[test]
fn ignores_invalid_duplicate_chunks_without_dropping_current_assembly() {
let message = JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
});
let raw = serde_json::to_vec(&message).expect("message should serialize");
let split = raw.len() / 2;
let client_id = ClientId("client-1".to_string());
let stream_id = Some(StreamId("stream-1".to_string()));
let mut reassembler = ClientSegmentReassembler::default();
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id.clone(),
stream_id.clone(),
/*seq_id*/ 8,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
)),
ClientSegmentObservation::Pending
));
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id.clone(),
stream_id.clone(),
/*seq_id*/ 8,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
b"",
)),
ClientSegmentObservation::Dropped
));
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id,
stream_id,
/*seq_id*/ 8,
/*segment_id*/ 1,
/*segment_count*/ 2,
raw.len(),
&raw[split..],
)),
ClientSegmentObservation::Forward(_)
));
}
fn chunk_envelope(
client_id: ClientId,
stream_id: Option<StreamId>,
seq_id: u64,
segment_id: usize,
segment_count: usize,
message_size_bytes: usize,
chunk: &[u8],
) -> ClientEnvelope {
ClientEnvelope {
event: ClientEvent::ClientMessageChunk {
segment_id,
segment_count,
message_size_bytes,
message_chunk_base64: base64::engine::general_purpose::STANDARD.encode(chunk),
},
client_id,
stream_id,
seq_id: Some(seq_id),
cursor: None,
}
}
@@ -831,7 +831,7 @@ async fn remote_control_transport_clears_outgoing_buffer_when_backend_acks() {
send_client_event(
&mut first_websocket,
ClientEnvelope {
event: ClientEvent::Ack,
event: ClientEvent::Ack { segment_id: None },
client_id: client_id.clone(),
stream_id: Some(stream_id),
seq_id: Some(1),
@@ -15,6 +15,10 @@ use super::protocol::ClientId;
use super::protocol::RemoteControlTarget;
use super::protocol::ServerEnvelope;
use super::protocol::StreamId;
use super::segment::ClientSegmentObservation;
use super::segment::ClientSegmentReassembler;
use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES;
use super::segment::split_server_envelope_for_transport;
use axum::http::HeaderValue;
use base64::Engine;
use codex_app_server_protocol::RemoteControlConnectionStatus;
@@ -49,7 +53,7 @@ use tracing::error;
use tracing::info;
use tracing::warn;
pub(super) const REMOTE_CONTROL_PROTOCOL_VERSION: &str = "2";
pub(super) const REMOTE_CONTROL_PROTOCOL_VERSION: &str = "3";
pub(super) const REMOTE_CONTROL_ACCOUNT_ID_HEADER: &str = "chatgpt-account-id";
const REMOTE_CONTROL_SUBSCRIBE_CURSOR_HEADER: &str = "x-codex-subscribe-cursor";
const REMOTE_CONTROL_WEBSOCKET_PING_INTERVAL: std::time::Duration =
@@ -85,17 +89,29 @@ impl BoundedOutboundBuffer {
self.used_tx.send_modify(|used| *used += 1);
}
fn ack(&mut self, client_id: &ClientId, stream_id: &StreamId, acked_seq_id: u64) {
fn ack(
&mut self,
client_id: &ClientId,
stream_id: &StreamId,
acked_seq_id: u64,
acked_segment_id: Option<usize>,
) {
let key = (client_id.clone(), stream_id.clone());
let Some(buffer) = self.buffer_by_stream.get_mut(&key) else {
return;
};
while let Some(server_envelope) = buffer.front()
&& server_envelope.seq_id <= acked_seq_id
{
buffer.pop_front();
self.used_tx.send_modify(|used| *used -= 1);
}
let acked_cursor = (acked_seq_id, acked_segment_id.unwrap_or(usize::MAX));
buffer.retain(|server_envelope| {
let envelope_cursor = (
server_envelope.seq_id,
server_envelope.event.segment_id().unwrap_or_default(),
);
let is_acked = envelope_cursor <= acked_cursor;
if is_acked {
self.used_tx.send_modify(|used| *used -= 1);
}
!is_acked
});
if buffer.is_empty() {
self.buffer_by_stream.remove(&key);
}
@@ -112,6 +128,88 @@ struct WebsocketState {
outbound_buffer: BoundedOutboundBuffer,
subscribe_cursor: Option<String>,
next_seq_id_by_stream: HashMap<(ClientId, StreamId), u64>,
last_completed_client_chunk_seq_id_by_stream: HashMap<(ClientId, Option<StreamId>), u64>,
client_segment_reassembler: ClientSegmentReassembler,
}
impl WebsocketState {
fn observe_client_message(
&mut self,
client_envelope: ClientEnvelope,
wire_size_bytes: usize,
) -> ClientSegmentObservation {
let client_message_key = Self::client_message_key(&client_envelope);
if let Some((key, seq_id)) = client_message_key.as_ref()
&& self
.last_completed_client_chunk_seq_id_by_stream
.get(key)
.is_some_and(|last_seq_id| last_seq_id >= seq_id)
{
return ClientSegmentObservation::Dropped;
}
if let (
Some((_, seq_id)),
Some(stream_id),
ClientEvent::ClientMessageChunk { segment_id, .. },
) = (
client_message_key.as_ref(),
client_envelope.stream_id.as_ref(),
&client_envelope.event,
) && self.client_segment_reassembler.should_ignore_chunk(
&client_envelope.client_id,
stream_id,
*seq_id,
*segment_id,
) {
return ClientSegmentObservation::Dropped;
}
if client_message_key.is_some() && wire_size_bytes > REMOTE_CONTROL_SEGMENT_MAX_BYTES {
warn!(
client_id = client_envelope.client_id.0.as_str(),
"dropping oversized segmented remote-control client envelope"
);
if let Some(stream_id) = client_envelope.stream_id.as_ref() {
self.client_segment_reassembler
.invalidate_stream(&client_envelope.client_id, stream_id);
}
return ClientSegmentObservation::Dropped;
}
let observation = self.client_segment_reassembler.observe(client_envelope);
if matches!(observation, ClientSegmentObservation::Forward(_))
&& let Some((key, seq_id)) = client_message_key
{
self.last_completed_client_chunk_seq_id_by_stream
.insert(key, seq_id);
}
observation
}
fn invalidate_client_message_stream(&mut self, client_id: &ClientId, stream_id: &StreamId) {
self.last_completed_client_chunk_seq_id_by_stream
.remove(&(client_id.clone(), Some(stream_id.clone())));
}
fn invalidate_client_message_client(&mut self, client_id: &ClientId) {
self.last_completed_client_chunk_seq_id_by_stream
.retain(|(cursor_client_id, _), _| cursor_client_id != client_id);
}
fn client_message_key(
client_envelope: &ClientEnvelope,
) -> Option<((ClientId, Option<StreamId>), u64)> {
let seq_id = match (&client_envelope.event, client_envelope.seq_id) {
(ClientEvent::ClientMessageChunk { .. }, Some(seq_id)) => seq_id,
_ => return None,
};
Some((
(
client_envelope.client_id.clone(),
client_envelope.stream_id.clone(),
),
seq_id,
))
}
}
pub(crate) struct RemoteControlWebsocket {
@@ -231,6 +329,8 @@ impl RemoteControlWebsocket {
outbound_buffer,
subscribe_cursor: None,
next_seq_id_by_stream: HashMap::new(),
last_completed_client_chunk_seq_id_by_stream: HashMap::new(),
client_segment_reassembler: ClientSegmentReassembler::default(),
})),
server_event_rx: Arc::new(Mutex::new(server_event_rx)),
used_rx,
@@ -556,7 +656,7 @@ impl RemoteControlWebsocket {
}
}
};
let (payload, write_complete_tx) = {
let (payloads, write_complete_tx) = {
let mut state = state.lock().await;
let seq_key = (
queued_server_envelope.client_id.clone(),
@@ -573,29 +673,42 @@ impl RemoteControlWebsocket {
seq_id,
stream_id: queued_server_envelope.stream_id,
};
let payload = match serde_json::to_string(&server_envelope) {
Ok(payload) => payload,
let server_envelopes = match split_server_envelope_for_transport(server_envelope) {
Ok(server_envelopes) => server_envelopes,
Err(err) => {
error!("failed to serialize remote-control server event: {err}");
error!("failed to split remote-control server event: {err}");
continue;
}
};
let mut payloads = Vec::with_capacity(server_envelopes.len());
for server_envelope in server_envelopes {
let payload = match serde_json::to_string(&server_envelope) {
Ok(payload) => payload,
Err(err) => {
error!("failed to serialize remote-control server event: {err}");
continue;
}
};
state.outbound_buffer.insert(&server_envelope);
payloads.push(payload);
}
state
.next_seq_id_by_stream
.insert(seq_key, seq_id.saturating_add(1));
state.outbound_buffer.insert(&server_envelope);
(payload, queued_server_envelope.write_complete_tx)
(payloads, queued_server_envelope.write_complete_tx)
};
tokio::select! {
_ = shutdown_token.cancelled() => return Ok(()),
send_result = websocket_writer.send(tungstenite::Message::Text(payload.into())) => {
if let Err(err) = send_result {
return Err(io::Error::other(err));
for payload in payloads {
tokio::select! {
_ = shutdown_token.cancelled() => return Ok(()),
send_result = websocket_writer.send(tungstenite::Message::Text(payload.into())) => {
if let Err(err) = send_result {
return Err(io::Error::other(err));
}
}
}
};
}
if let Some(write_complete_tx) = write_complete_tx {
let _ = write_complete_tx.send(());
}
@@ -657,11 +770,30 @@ impl RemoteControlWebsocket {
if client_tracker.close_client(&client_key).await.is_err() {
return Ok(());
}
state
.lock()
.await
.client_segment_reassembler
.invalidate_stream(&client_key.0, &client_key.1);
state
.lock()
.await
.invalidate_client_message_stream(&client_key.0, &client_key.1);
continue;
}
_ = idle_sweep_interval.tick() => {
if client_tracker.close_expired_clients().await.is_err() {
return Ok(());
match client_tracker.close_expired_clients().await {
Ok(client_keys) => {
let mut websocket_state = state.lock().await;
for (client_id, stream_id) in client_keys {
websocket_state
.client_segment_reassembler
.invalidate_stream(&client_id, &stream_id);
websocket_state
.invalidate_client_message_stream(&client_id, &stream_id);
}
}
Err(_) => return Ok(()),
}
continue;
}
@@ -672,10 +804,11 @@ impl RemoteControlWebsocket {
}
}
};
let client_envelope = match incoming_message {
let (client_envelope, wire_size_bytes) = match incoming_message {
Ok(tungstenite::Message::Text(text)) => {
let wire_size_bytes = text.len();
match serde_json::from_str::<ClientEnvelope>(&text) {
Ok(client_envelope) => client_envelope,
Ok(client_envelope) => (client_envelope, wire_size_bytes),
Err(err) => {
warn!("failed to deserialize remote-control client event: {err}");
continue;
@@ -707,12 +840,21 @@ impl RemoteControlWebsocket {
}
};
let observation = {
let mut websocket_state = state.lock().await;
websocket_state.observe_client_message(client_envelope, wire_size_bytes)
};
let client_envelope = match observation {
ClientSegmentObservation::Forward(client_envelope) => *client_envelope,
ClientSegmentObservation::Pending | ClientSegmentObservation::Dropped => continue,
};
{
let mut websocket_state = state.lock().await;
if let Some(cursor) = client_envelope.cursor.as_deref() {
websocket_state.subscribe_cursor = Some(cursor.to_string());
}
if let ClientEvent::Ack = &client_envelope.event
if let ClientEvent::Ack { segment_id } = &client_envelope.event
&& let Some(acked_seq_id) = client_envelope.seq_id
&& let Some(stream_id) = client_envelope.stream_id.as_ref()
{
@@ -720,10 +862,18 @@ impl RemoteControlWebsocket {
&client_envelope.client_id,
stream_id,
acked_seq_id,
*segment_id,
);
}
}
let closed_client =
matches!(&client_envelope.event, ClientEvent::ClientClosed).then(|| {
(
client_envelope.client_id.clone(),
client_envelope.stream_id.clone(),
)
});
if client_tracker
.handle_message(client_envelope)
.await
@@ -731,6 +881,20 @@ impl RemoteControlWebsocket {
{
return Ok(());
}
if let Some((client_id, stream_id)) = closed_client {
let mut websocket_state = state.lock().await;
if let Some(stream_id) = stream_id {
websocket_state
.client_segment_reassembler
.invalidate_stream(&client_id, &stream_id);
websocket_state.invalidate_client_message_stream(&client_id, &stream_id);
} else {
websocket_state
.client_segment_reassembler
.invalidate_client(&client_id);
websocket_state.invalidate_client_message_client(&client_id);
}
}
}
}
}
@@ -1052,6 +1216,8 @@ mod tests {
use chrono::Utc;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::ServerNotification;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::test_support::auth_manager_from_auth;
@@ -1603,6 +1769,8 @@ mod tests {
outbound_buffer,
subscribe_cursor: None,
next_seq_id_by_stream: HashMap::new(),
last_completed_client_chunk_seq_id_by_stream: HashMap::new(),
client_segment_reassembler: ClientSegmentReassembler::default(),
}));
let (_server_event_tx, server_event_rx) = mpsc::channel(super::super::CHANNEL_CAPACITY);
let server_event_rx = Arc::new(Mutex::new(server_event_rx));
@@ -1639,6 +1807,8 @@ mod tests {
outbound_buffer,
subscribe_cursor: None,
next_seq_id_by_stream: HashMap::new(),
last_completed_client_chunk_seq_id_by_stream: HashMap::new(),
client_segment_reassembler: ClientSegmentReassembler::default(),
}));
let (server_event_tx, server_event_rx) = mpsc::channel(super::super::CHANNEL_CAPACITY);
let server_event_rx = Arc::new(Mutex::new(server_event_rx));
@@ -1716,6 +1886,8 @@ mod tests {
outbound_buffer,
subscribe_cursor: None,
next_seq_id_by_stream: HashMap::new(),
last_completed_client_chunk_seq_id_by_stream: HashMap::new(),
client_segment_reassembler: ClientSegmentReassembler::default(),
}));
let (server_event_tx, _server_event_rx) = mpsc::channel(super::super::CHANNEL_CAPACITY);
let (transport_event_tx, _transport_event_rx) =
@@ -1771,7 +1943,9 @@ mod tests {
"first-client-new-stream",
));
outbound_buffer.ack(&client_1, &stream_1, /*acked_seq_id*/ 3);
outbound_buffer.ack(
&client_1, &stream_1, /*acked_seq_id*/ 3, /*acked_segment_id*/ None,
);
let mut retained = outbound_buffer
.server_envelopes()
@@ -1814,7 +1988,9 @@ mod tests {
&client_2, "stream-1", /*seq_id*/ 3, "second",
));
outbound_buffer.ack(&client_1, &stream_1, /*acked_seq_id*/ 1);
outbound_buffer.ack(
&client_1, &stream_1, /*acked_seq_id*/ 1, /*acked_segment_id*/ None,
);
let mut retained = outbound_buffer
.server_envelopes()
@@ -1834,6 +2010,390 @@ mod tests {
assert_eq!(*used_rx.borrow(), 2);
}
#[test]
fn outbound_buffer_advances_segmented_acks_by_wire_cursor() {
let (mut outbound_buffer, used_rx) = BoundedOutboundBuffer::new();
let client_id = ClientId("client-1".to_string());
let stream_id = StreamId("stream-1".to_string());
outbound_buffer.insert(&server_chunk_envelope(
&client_id, "stream-1", /*seq_id*/ 4, /*segment_id*/ 0,
));
outbound_buffer.insert(&server_chunk_envelope(
&client_id, "stream-1", /*seq_id*/ 4, /*segment_id*/ 1,
));
outbound_buffer.ack(
&client_id,
&stream_id,
/*acked_seq_id*/ 4,
/*acked_segment_id*/ Some(1),
);
let retained = outbound_buffer
.server_envelopes()
.map(|server_envelope| server_envelope.event.segment_id())
.collect::<Vec<_>>();
assert_eq!(retained, Vec::<Option<usize>>::new());
assert_eq!(*used_rx.borrow(), 0);
}
#[test]
fn outbound_buffer_treats_segmentless_acks_as_seq_level_acks() {
let (mut outbound_buffer, used_rx) = BoundedOutboundBuffer::new();
let client_id = ClientId("client-1".to_string());
let stream_id = StreamId("stream-1".to_string());
outbound_buffer.insert(&server_chunk_envelope(
&client_id, "stream-1", /*seq_id*/ 4, /*segment_id*/ 0,
));
outbound_buffer.insert(&server_chunk_envelope(
&client_id, "stream-1", /*seq_id*/ 4, /*segment_id*/ 1,
));
outbound_buffer.ack(
&client_id, &stream_id, /*acked_seq_id*/ 4, /*acked_segment_id*/ None,
);
let retained = outbound_buffer
.server_envelopes()
.map(|server_envelope| server_envelope.event.segment_id())
.collect::<Vec<_>>();
assert_eq!(retained, Vec::<Option<usize>>::new());
assert_eq!(*used_rx.borrow(), 0);
}
#[test]
fn websocket_state_drops_duplicate_client_chunks_while_pending() {
let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new();
let mut state = WebsocketState {
outbound_buffer,
subscribe_cursor: None,
next_seq_id_by_stream: HashMap::new(),
last_completed_client_chunk_seq_id_by_stream: HashMap::new(),
client_segment_reassembler: ClientSegmentReassembler::default(),
};
let first_chunk = client_chunk_envelope(
"client-1", "stream-1", /*seq_id*/ 4, /*segment_id*/ 0,
/*segment_count*/ 2, /*message_size_bytes*/ 2, b"x",
);
let second_chunk = client_chunk_envelope(
"client-1", "stream-1", /*seq_id*/ 4, /*segment_id*/ 1,
/*segment_count*/ 2, /*message_size_bytes*/ 2, b"y",
);
assert!(matches!(
observe_client_message(&mut state, first_chunk.clone()),
ClientSegmentObservation::Pending
));
assert!(matches!(
observe_client_message(&mut state, first_chunk.clone()),
ClientSegmentObservation::Dropped
));
assert!(matches!(
observe_client_message(&mut state, second_chunk),
ClientSegmentObservation::Dropped
));
assert!(matches!(
observe_client_message(&mut state, first_chunk),
ClientSegmentObservation::Pending
));
}
#[test]
fn websocket_state_drops_replayed_client_chunks_after_completion() {
let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new();
let mut state = WebsocketState {
outbound_buffer,
subscribe_cursor: None,
next_seq_id_by_stream: HashMap::new(),
last_completed_client_chunk_seq_id_by_stream: HashMap::new(),
client_segment_reassembler: ClientSegmentReassembler::default(),
};
let message = JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
});
let raw = serde_json::to_vec(&message).expect("message should serialize");
let split = raw.len() / 2;
let first_chunk = client_chunk_envelope(
"client-1",
"stream-1",
/*seq_id*/ 4,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
);
let second_chunk = client_chunk_envelope(
"client-1",
"stream-1",
/*seq_id*/ 4,
/*segment_id*/ 1,
/*segment_count*/ 2,
raw.len(),
&raw[split..],
);
assert!(matches!(
observe_client_message(&mut state, first_chunk.clone()),
ClientSegmentObservation::Pending
));
assert!(matches!(
observe_client_message(&mut state, second_chunk),
ClientSegmentObservation::Forward(_)
));
assert!(matches!(
observe_client_message(&mut state, first_chunk),
ClientSegmentObservation::Dropped
));
}
#[test]
fn websocket_state_allows_replay_after_rejected_out_of_order_chunk() {
let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new();
let mut state = WebsocketState {
outbound_buffer,
subscribe_cursor: None,
next_seq_id_by_stream: HashMap::new(),
last_completed_client_chunk_seq_id_by_stream: HashMap::new(),
client_segment_reassembler: ClientSegmentReassembler::default(),
};
let first_chunk = client_chunk_envelope(
"client-1", "stream-1", /*seq_id*/ 4, /*segment_id*/ 0,
/*segment_count*/ 2, /*message_size_bytes*/ 2, b"x",
);
let second_chunk = client_chunk_envelope(
"client-1", "stream-1", /*seq_id*/ 4, /*segment_id*/ 1,
/*segment_count*/ 2, /*message_size_bytes*/ 2, b"y",
);
assert!(matches!(
observe_client_message(&mut state, second_chunk),
ClientSegmentObservation::Dropped
));
assert!(matches!(
observe_client_message(&mut state, first_chunk),
ClientSegmentObservation::Pending
));
}
#[test]
fn websocket_state_allows_replay_after_later_chunk_drops() {
let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new();
let mut state = WebsocketState {
outbound_buffer,
subscribe_cursor: None,
next_seq_id_by_stream: HashMap::new(),
last_completed_client_chunk_seq_id_by_stream: HashMap::new(),
client_segment_reassembler: ClientSegmentReassembler::default(),
};
let first_chunk = client_chunk_envelope(
"client-1", "stream-1", /*seq_id*/ 4, /*segment_id*/ 0,
/*segment_count*/ 2, /*message_size_bytes*/ 2, b"x",
);
let invalid_second_chunk = client_chunk_envelope(
"client-1", "stream-1", /*seq_id*/ 4, /*segment_id*/ 1,
/*segment_count*/ 2, /*message_size_bytes*/ 2, b"",
);
assert!(matches!(
observe_client_message(&mut state, first_chunk.clone()),
ClientSegmentObservation::Pending
));
assert!(matches!(
observe_client_message(&mut state, invalid_second_chunk),
ClientSegmentObservation::Dropped
));
assert!(matches!(
observe_client_message(&mut state, first_chunk),
ClientSegmentObservation::Pending
));
}
#[test]
fn websocket_state_drops_oversized_client_chunk_frames() {
let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new();
let mut state = WebsocketState {
outbound_buffer,
subscribe_cursor: None,
next_seq_id_by_stream: HashMap::new(),
last_completed_client_chunk_seq_id_by_stream: HashMap::new(),
client_segment_reassembler: ClientSegmentReassembler::default(),
};
let chunk = client_chunk_envelope(
"client-1", "stream-1", /*seq_id*/ 4, /*segment_id*/ 0,
/*segment_count*/ 1, /*message_size_bytes*/ 1, b"x",
);
assert!(matches!(
state.observe_client_message(chunk, REMOTE_CONTROL_SEGMENT_MAX_BYTES + 1),
ClientSegmentObservation::Dropped
));
}
#[test]
fn websocket_state_ignores_oversized_stale_chunks_without_dropping_newer_assembly() {
let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new();
let mut state = WebsocketState {
outbound_buffer,
subscribe_cursor: None,
next_seq_id_by_stream: HashMap::new(),
last_completed_client_chunk_seq_id_by_stream: HashMap::new(),
client_segment_reassembler: ClientSegmentReassembler::default(),
};
let message = JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
});
let raw = serde_json::to_vec(&message).expect("message should serialize");
let split = raw.len() / 2;
let first_newer_chunk = client_chunk_envelope(
"client-1",
"stream-1",
/*seq_id*/ 8,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
);
let oversized_stale_chunk = client_chunk_envelope(
"client-1",
"stream-1",
/*seq_id*/ 7,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
);
let second_newer_chunk = client_chunk_envelope(
"client-1",
"stream-1",
/*seq_id*/ 8,
/*segment_id*/ 1,
/*segment_count*/ 2,
raw.len(),
&raw[split..],
);
assert!(matches!(
observe_client_message(&mut state, first_newer_chunk),
ClientSegmentObservation::Pending
));
assert!(matches!(
state.observe_client_message(
oversized_stale_chunk,
REMOTE_CONTROL_SEGMENT_MAX_BYTES + 1,
),
ClientSegmentObservation::Dropped
));
assert!(matches!(
observe_client_message(&mut state, second_newer_chunk),
ClientSegmentObservation::Forward(_)
));
}
#[test]
fn websocket_state_ignores_oversized_duplicate_chunks_without_dropping_current_assembly() {
let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new();
let mut state = WebsocketState {
outbound_buffer,
subscribe_cursor: None,
next_seq_id_by_stream: HashMap::new(),
last_completed_client_chunk_seq_id_by_stream: HashMap::new(),
client_segment_reassembler: ClientSegmentReassembler::default(),
};
let message = JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
});
let raw = serde_json::to_vec(&message).expect("message should serialize");
let split = raw.len() / 2;
let first_chunk = client_chunk_envelope(
"client-1",
"stream-1",
/*seq_id*/ 8,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
);
let oversized_duplicate_chunk = client_chunk_envelope(
"client-1",
"stream-1",
/*seq_id*/ 8,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
);
let second_chunk = client_chunk_envelope(
"client-1",
"stream-1",
/*seq_id*/ 8,
/*segment_id*/ 1,
/*segment_count*/ 2,
raw.len(),
&raw[split..],
);
assert!(matches!(
observe_client_message(&mut state, first_chunk),
ClientSegmentObservation::Pending
));
assert!(matches!(
state.observe_client_message(
oversized_duplicate_chunk,
REMOTE_CONTROL_SEGMENT_MAX_BYTES + 1,
),
ClientSegmentObservation::Dropped
));
assert!(matches!(
observe_client_message(&mut state, second_chunk),
ClientSegmentObservation::Forward(_)
));
}
#[test]
fn websocket_state_clears_chunk_cursor_when_stream_is_invalidated() {
let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new();
let mut state = WebsocketState {
outbound_buffer,
subscribe_cursor: None,
next_seq_id_by_stream: HashMap::new(),
last_completed_client_chunk_seq_id_by_stream: HashMap::new(),
client_segment_reassembler: ClientSegmentReassembler::default(),
};
let client_id = ClientId("client-1".to_string());
let stream_id = StreamId("stream-1".to_string());
assert!(matches!(
observe_client_message(
&mut state,
client_chunk_envelope(
"client-1", "stream-1", /*seq_id*/ 4, /*segment_id*/ 0,
/*segment_count*/ 2, /*message_size_bytes*/ 2, b"x",
)
),
ClientSegmentObservation::Pending
));
state.invalidate_client_message_stream(&client_id, &stream_id);
state
.client_segment_reassembler
.invalidate_stream(&client_id, &stream_id);
assert!(matches!(
observe_client_message(
&mut state,
client_chunk_envelope(
"client-1", "stream-1", /*seq_id*/ 1, /*segment_id*/ 0,
/*segment_count*/ 2, /*message_size_bytes*/ 2, b"x",
)
),
ClientSegmentObservation::Pending
));
}
fn server_envelope(
client_id: &ClientId,
stream_id: &str,
@@ -1857,6 +2417,58 @@ mod tests {
}
}
fn server_chunk_envelope(
client_id: &ClientId,
stream_id: &str,
seq_id: u64,
segment_id: usize,
) -> ServerEnvelope {
ServerEnvelope {
event: ServerEvent::ServerMessageChunk {
segment_id,
segment_count: 2,
message_size_bytes: 2,
message_chunk_base64: String::new(),
},
client_id: client_id.clone(),
stream_id: StreamId(stream_id.to_string()),
seq_id,
}
}
fn client_chunk_envelope(
client_id: &str,
stream_id: &str,
seq_id: u64,
segment_id: usize,
segment_count: usize,
message_size_bytes: usize,
chunk: &[u8],
) -> ClientEnvelope {
ClientEnvelope {
event: ClientEvent::ClientMessageChunk {
segment_id,
segment_count,
message_size_bytes,
message_chunk_base64: base64::engine::general_purpose::STANDARD.encode(chunk),
},
client_id: ClientId(client_id.to_string()),
stream_id: Some(StreamId(stream_id.to_string())),
seq_id: Some(seq_id),
cursor: None,
}
}
fn observe_client_message(
state: &mut WebsocketState,
envelope: ClientEnvelope,
) -> ClientSegmentObservation {
let wire_size_bytes = serde_json::to_vec(&envelope)
.expect("client envelope should serialize")
.len();
state.observe_client_message(envelope, wire_size_bytes)
}
async fn accept_http_request(listener: &TcpListener) -> (TcpStream, String) {
let (stream, _) = timeout(TEST_HTTP_ACCEPT_TIMEOUT, listener.accept())
.await
@@ -59,6 +59,7 @@ use codex_app_server_protocol::ModelProviderCapabilitiesReadParams;
use codex_app_server_protocol::PluginInstallParams;
use codex_app_server_protocol::PluginListParams;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginSkillReadParams;
use codex_app_server_protocol::PluginUninstallParams;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewStartParams;
@@ -133,6 +134,13 @@ impl McpProcess {
Self::new_with_env_and_args(codex_home, &[], &[]).await
}
pub async fn new_with_env_and_plugin_startup_tasks(
codex_home: &Path,
env_overrides: &[(&str, Option<&str>)],
) -> anyhow::Result<Self> {
Self::new_with_env_and_args(codex_home, env_overrides, &[]).await
}
pub async fn new_with_args(codex_home: &Path, args: &[&str]) -> anyhow::Result<Self> {
let mut all_args = vec![DISABLE_PLUGIN_STARTUP_TASKS_ARG];
all_args.extend_from_slice(args);
@@ -653,6 +661,15 @@ impl McpProcess {
self.send_request("plugin/read", params).await
}
/// Send a `plugin/skill/read` JSON-RPC request.
pub async fn send_plugin_skill_read_request(
&mut self,
params: PluginSkillReadParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("plugin/skill/read", params).await
}
/// Send an `mcpServerStatus/list` JSON-RPC request.
pub async fn send_list_mcp_server_status_request(
&mut self,
@@ -32,6 +32,45 @@ use tokio::time::timeout;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
#[tokio::test]
async fn external_agent_config_import_sends_completion_notification_for_sync_only_import()
-> Result<()> {
let codex_home = TempDir::new()?;
let home_dir = codex_home.path().display().to_string();
let mut mcp =
McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_raw_request(
"externalAgentConfig/import",
Some(serde_json::json!({
"migrationItems": [{
"itemType": "CONFIG",
"description": "Import config",
"cwd": null
}]
})),
)
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: ExternalAgentConfigImportResponse = to_response(response)?;
assert_eq!(response, ExternalAgentConfigImportResponse {});
let notification = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"),
)
.await??;
assert_eq!(notification.method, "externalAgentConfig/import/completed");
Ok(())
}
#[tokio::test]
async fn external_agent_config_import_sends_completion_notification_for_local_plugins() -> Result<()>
{
@@ -63,7 +63,7 @@ fn write_plugin_hook_config(codex_home: &std::path::Path, hooks_json: &str) -> R
r#"[features]
plugins = true
plugin_hooks = true
codex_hooks = true
hooks = true
[plugins."demo@test"]
enabled = true
@@ -230,7 +230,7 @@ async fn hooks_list_uses_each_cwds_effective_feature_enablement() -> Result<()>
std::fs::write(
codex_home.path().join("config.toml"),
r#"[features]
codex_hooks = false
hooks = false
"#,
)?;
std::fs::create_dir_all(workspace.path().join(".git"))?;
@@ -238,7 +238,7 @@ codex_hooks = false
std::fs::write(
workspace.path().join(".codex/config.toml"),
r#"[features]
codex_hooks = true
hooks = true
[hooks]
@@ -5,16 +5,25 @@ use std::time::Duration;
use anyhow::Result;
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::to_response;
use app_test_support::write_mock_responses_config_toml;
use axum::Router;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::McpServerToolCallParams;
use codex_app_server_protocol::McpServerToolCallResponse;
use codex_app_server_protocol::McpToolCallStatus;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use rmcp::handler::server::ServerHandler;
@@ -42,6 +51,7 @@ use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
const TEST_SERVER_NAME: &str = "tool_server";
const TEST_TOOL_NAME: &str = "echo_tool";
const LARGE_RESPONSE_MESSAGE: &str = "large";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mcp_server_tool_call_returns_tool_result() -> Result<()> {
@@ -161,6 +171,137 @@ async fn mcp_server_tool_call_returns_error_for_unknown_thread() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mcp_tool_call_completion_notification_contains_truncated_large_result() -> Result<()> {
let call_id = "call-large-mcp";
let namespace = format!("mcp__{TEST_SERVER_NAME}__");
let responses = vec![
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call_with_namespace(
call_id,
&namespace,
TEST_TOOL_NAME,
&serde_json::to_string(&json!({
"message": LARGE_RESPONSE_MESSAGE,
}))?,
),
responses::ev_completed("resp-1"),
]),
create_final_assistant_message_sse_response("done")?,
];
let responses_server = create_mock_responses_server_sequence(responses).await;
let (mcp_server_url, mcp_server_handle) = start_mcp_server().await?;
let codex_home = TempDir::new()?;
write_mock_responses_config_toml(
codex_home.path(),
&responses_server.uri(),
&BTreeMap::new(),
/*auto_compact_limit*/ 1_000_000,
/*requires_openai_auth*/ None,
"mock_provider",
"compact",
)?;
let config_path = codex_home.path().join("config.toml");
let mut config_toml = std::fs::read_to_string(&config_path)?;
config_toml.push_str(&format!(
r#"
[mcp_servers.{TEST_SERVER_NAME}]
url = "{mcp_server_url}/mcp"
"#
));
std::fs::write(config_path, config_toml)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_start_id = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let thread_start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?;
let turn_start_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
input: vec![V2UserInput::Text {
text: "Call the large MCP tool".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let turn_start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)),
)
.await??;
let TurnStartResponse { turn, .. } = to_response(turn_start_resp)?;
let completed = wait_for_mcp_tool_call_completed(&mut mcp, call_id).await?;
assert_eq!(completed.turn_id, turn.id);
let ThreadItem::McpToolCall {
id,
server,
tool,
status,
result: Some(result),
error,
..
} = completed.item
else {
panic!("expected completed MCP tool call item");
};
assert_eq!(id, call_id);
assert_eq!(server, TEST_SERVER_NAME);
assert_eq!(tool, TEST_TOOL_NAME);
assert_eq!(status, McpToolCallStatus::Completed);
assert_eq!(error, None);
assert_eq!(result.structured_content, None);
assert_eq!(result.meta, None);
assert_eq!(result.content.len(), 1);
let text = result.content[0]
.get("text")
.and_then(serde_json::Value::as_str)
.expect("truncated MCP event result should be represented as text content");
assert!(text.contains("truncated"));
assert!(text.len() < DEFAULT_OUTPUT_BYTES_CAP + 1024);
let serialized_item = serde_json::to_string(&ThreadItem::McpToolCall {
id,
server,
tool,
status,
arguments: json!({ "message": LARGE_RESPONSE_MESSAGE }),
mcp_app_resource_uri: None,
result: Some(result),
error: None,
duration_ms: None,
})?;
assert!(serialized_item.len() < DEFAULT_OUTPUT_BYTES_CAP * 2 + 2048);
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
mcp_server_handle.abort();
let _ = mcp_server_handle.await;
Ok(())
}
#[derive(Clone, Default)]
struct ToolAppsMcpServer;
@@ -224,6 +365,16 @@ impl ServerHandler for ToolAppsMcpServer {
let mut meta = Meta::new();
meta.0.insert("calledBy".to_string(), json!("mcp-app"));
if message == LARGE_RESPONSE_MESSAGE {
let large_text = "large-mcp-content-".repeat(DEFAULT_OUTPUT_BYTES_CAP / 8);
let mut result = CallToolResult::structured(json!({
"large": "structured-value-".repeat(DEFAULT_OUTPUT_BYTES_CAP / 8),
}));
result.content = vec![Content::text(large_text)];
result.meta = Some(meta);
return Ok(result);
}
let mut result = CallToolResult::structured(json!({
"echoed": message,
"threadId": thread_id,
@@ -250,3 +401,23 @@ async fn start_mcp_server() -> Result<(String, JoinHandle<()>)> {
Ok((format!("http://{addr}"), handle))
}
async fn wait_for_mcp_tool_call_completed(
mcp: &mut McpProcess,
call_id: &str,
) -> Result<ItemCompletedNotification> {
loop {
let notification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("item/completed"),
)
.await??;
let Some(params) = notification.params else {
continue;
};
let completed: ItemCompletedNotification = serde_json::from_value(params)?;
if matches!(&completed.item, ThreadItem::McpToolCall { id, .. } if id == call_id) {
return Ok(completed);
}
}
}
@@ -23,6 +23,7 @@ use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::AppSummary;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginAvailability;
use codex_app_server_protocol::PluginInstallParams;
use codex_app_server_protocol::PluginInstallResponse;
use codex_app_server_protocol::RequestId;
@@ -407,6 +408,66 @@ async fn plugin_install_rejects_invalid_remote_plugin_name() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn plugin_install_rejects_remote_plugin_disabled_by_admin_before_download() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
let bundle_url = mount_remote_plugin_bundle(
&server,
/*status_code*/ 200,
remote_plugin_bundle_tar_gz_bytes("linear")?,
)
.await;
configure_remote_plugin_test(codex_home.path(), &server)?;
mount_remote_plugin_detail_with_status(
&server,
REMOTE_PLUGIN_ID,
"1.2.3",
Some(&bundle_url),
PluginAvailability::DisabledByAdmin,
)
.await;
mount_empty_remote_installed_plugins(&server).await;
let mut mcp = McpProcess::new_with_env(
codex_home.path(),
&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))],
)
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?;
let err = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(err.error.code, -32600);
assert!(err.error.message.contains("disabled by admin"));
wait_for_remote_plugin_request_count(
&server,
"GET",
"/bundles/linear.tar.gz",
/*expected_count*/ 0,
)
.await?;
wait_for_remote_plugin_request_count(
&server,
"POST",
&format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"),
/*expected_count*/ 0,
)
.await?;
assert!(
!codex_home
.path()
.join("plugins/cache/chatgpt-global/linear")
.exists()
);
Ok(())
}
#[tokio::test]
async fn plugin_install_rejects_when_workspace_codex_plugins_disabled() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -635,22 +696,7 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> {
let response: PluginInstallResponse = to_response(response)?;
assert_eq!(response.apps_needing_auth, Vec::<AppSummary>::new());
let payload = timeout(DEFAULT_TIMEOUT, async {
loop {
let Some(requests) = analytics_server.received_requests().await else {
tokio::time::sleep(Duration::from_millis(25)).await;
continue;
};
if let Some(request) = requests.iter().find(|request| {
request.method == "POST" && request.url.path() == "/codex/analytics-events/events"
}) {
break request.body.clone();
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
})
.await?;
let payload: serde_json::Value = serde_json::from_slice(&payload).expect("analytics payload");
let payload = wait_for_plugin_analytics_payload(&analytics_server).await?;
assert_eq!(
payload,
json!({
@@ -671,6 +717,59 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
let bundle_url = mount_remote_plugin_bundle(
&server,
/*status_code*/ 200,
remote_plugin_bundle_tar_gz_bytes("linear")?,
)
.await;
configure_remote_plugin_test(codex_home.path(), &server)?;
mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await;
mount_empty_remote_installed_plugins(&server).await;
mount_remote_plugin_install(&server, REMOTE_PLUGIN_ID).await;
mount_backend_analytics_events(&server).await;
let mut mcp = McpProcess::new_with_env(
codex_home.path(),
&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))],
)
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginInstallResponse = to_response(response)?;
assert_eq!(response.apps_needing_auth, Vec::<AppSummary>::new());
let payload = wait_for_plugin_analytics_payload(&server).await?;
assert_eq!(
payload,
json!({
"events": [{
"event_type": "codex_plugin_installed",
"event_params": {
"plugin_id": REMOTE_PLUGIN_ID,
"plugin_name": "linear",
"marketplace_name": "chatgpt-global",
"has_skills": true,
"mcp_server_count": 0,
"connector_ids": [],
"product_client_id": DEFAULT_CLIENT_NAME,
}
}]
})
);
Ok(())
}
#[tokio::test]
async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -1150,6 +1249,37 @@ fn write_analytics_config(codex_home: &std::path::Path, base_url: &str) -> std::
)
}
async fn mount_backend_analytics_events(server: &MockServer) {
Mock::given(method("POST"))
.and(path("/backend-api/codex/analytics-events/events"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status":"ok"}"#))
.mount(server)
.await;
}
async fn wait_for_plugin_analytics_payload(server: &MockServer) -> Result<serde_json::Value> {
timeout(DEFAULT_TIMEOUT, async {
loop {
let Some(requests) = server.received_requests().await else {
tokio::time::sleep(Duration::from_millis(25)).await;
continue;
};
if let Some(request) = requests.iter().find(|request| {
request.method == "POST"
&& request
.url
.path()
.ends_with("/codex/analytics-events/events")
}) {
return serde_json::from_slice(&request.body)
.map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"));
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
})
.await?
}
fn write_remote_plugin_catalog_config(
codex_home: &std::path::Path,
base_url: &str,
@@ -1203,6 +1333,27 @@ async fn mount_remote_plugin_detail(
release_version: &str,
bundle_download_url: Option<&str>,
) {
mount_remote_plugin_detail_with_status(
server,
remote_plugin_id,
release_version,
bundle_download_url,
PluginAvailability::Available,
)
.await;
}
async fn mount_remote_plugin_detail_with_status(
server: &MockServer,
remote_plugin_id: &str,
release_version: &str,
bundle_download_url: Option<&str>,
status: PluginAvailability,
) {
let status = match status {
PluginAvailability::Available => "ENABLED",
PluginAvailability::DisabledByAdmin => "DISABLED_BY_ADMIN",
};
let bundle_download_url_field = bundle_download_url
.map(|url| format!(r#" "bundle_download_url": "{url}","#))
.unwrap_or_default();
@@ -1213,6 +1364,7 @@ async fn mount_remote_plugin_detail(
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"status": "{status}",
"release": {{
"version": "{release_version}",
{bundle_download_url_field}
@@ -19,6 +19,8 @@ use codex_config::types::AuthCredentialsStoreMode;
use codex_core::config::set_project_trust_level;
use codex_protocol::config_types::TrustLevel;
use codex_utils_absolute_path::AbsolutePathBuf;
use flate2::Compression;
use flate2::write::GzEncoder;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -33,6 +35,8 @@ use wiremock::matchers::query_param;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1";
const TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS: &str =
"CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS";
const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json";
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
@@ -240,6 +244,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_
enabled: false,
install_policy: PluginInstallPolicy::Available,
auth_policy: PluginAuthPolicy::OnInstall,
availability: codex_app_server_protocol::PluginAvailability::Available,
interface: None,
}],
}]
@@ -523,6 +528,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab
enabled: false,
install_policy: PluginInstallPolicy::Available,
auth_policy: PluginAuthPolicy::OnInstall,
availability: codex_app_server_protocol::PluginAvailability::Available,
interface: Some(codex_app_server_protocol::PluginInterface {
display_name: Some("Valid Plugin".to_string()),
short_description: None,
@@ -555,6 +561,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab
enabled: false,
install_policy: PluginInstallPolicy::Available,
auth_policy: PluginAuthPolicy::OnInstall,
availability: codex_app_server_protocol::PluginAvailability::Available,
interface: None,
},
],
@@ -1129,6 +1136,135 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_catalog_config(
codex_home.path(),
&format!("{}/backend-api/", server.uri()),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let bundle_url = mount_remote_plugin_bundle(
&server,
"linear",
remote_plugin_bundle_tar_gz_bytes("linear")?,
)
.await;
let global_installed_body =
remote_installed_plugin_body(&bundle_url, "1.2.3", /*enabled*/ true);
mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await;
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
.await;
let installed_path = codex_home
.path()
.join("plugins/cache/chatgpt-global/linear/1.2.3");
let mut mcp = McpProcess::new_with_env_and_plugin_startup_tasks(
codex_home.path(),
&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))],
)
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
wait_for_path_exists(&installed_path.join(".codex-plugin/plugin.json")).await?;
assert!(installed_path.join("skills/plan-work/SKILL.md").is_file());
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
assert!(!config.contains("linear@chatgpt-global"));
Ok(())
}
#[tokio::test]
async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_catalog_config(
codex_home.path(),
&format!("{}/backend-api/", server.uri()),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
write_installed_plugin_with_version(&codex_home, "chatgpt-global", "linear", "1.0.0")?;
write_installed_plugin_with_version(&codex_home, "chatgpt-global", "stale", "1.0.0")?;
let bundle_url = mount_remote_plugin_bundle(
&server,
"linear",
remote_plugin_bundle_tar_gz_bytes("linear")?,
)
.await;
let global_installed_body =
remote_installed_plugin_body(&bundle_url, "1.2.3", /*enabled*/ true);
mount_remote_plugin_list(&server, "GLOBAL", &global_installed_body).await;
mount_remote_plugin_list(&server, "WORKSPACE", empty_remote_installed_plugins_body()).await;
mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await;
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
.await;
let old_path = codex_home
.path()
.join("plugins/cache/chatgpt-global/linear/1.0.0");
let new_path = codex_home
.path()
.join("plugins/cache/chatgpt-global/linear/1.2.3");
let stale_path = codex_home.path().join("plugins/cache/chatgpt-global/stale");
let mut mcp = McpProcess::new_with_env(
codex_home.path(),
&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))],
)
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams { cwds: None })
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
let remote_marketplace = response
.marketplaces
.into_iter()
.find(|marketplace| marketplace.name == "chatgpt-global")
.expect("expected chatgpt-global marketplace entry");
assert_eq!(
remote_marketplace
.plugins
.into_iter()
.map(|plugin| (plugin.id, plugin.installed, plugin.enabled))
.collect::<Vec<_>>(),
vec![(
"plugins~Plugin_00000000000000000000000000000000".to_string(),
true,
true
)]
);
wait_for_path_exists(&new_path.join(".codex-plugin/plugin.json")).await?;
wait_for_path_missing(&old_path).await?;
wait_for_path_missing(&stale_path).await?;
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
assert!(!config.contains("linear@chatgpt-global"));
Ok(())
}
#[tokio::test]
async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -1154,6 +1290,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"status": "ENABLED",
"release": {
"display_name": "Linear",
"description": "Track work in Linear",
@@ -1188,6 +1325,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"status": "ENABLED",
"release": {
"display_name": "Linear",
"description": "Track work in Linear",
@@ -1281,6 +1419,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
assert_eq!(remote_marketplace.plugins[0].source, PluginSource::Remote);
assert_eq!(remote_marketplace.plugins[0].installed, true);
assert_eq!(remote_marketplace.plugins[0].enabled, true);
assert_eq!(
remote_marketplace.plugins[0].availability,
codex_app_server_protocol::PluginAvailability::Available
);
assert_eq!(
remote_marketplace.plugins[0]
.interface
@@ -1292,6 +1434,138 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
Ok(())
}
#[tokio::test]
async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_catalog_config(
codex_home.path(),
&format!("{}/backend-api/", server.uri()),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let global_directory_body = r#"{
"plugins": [
{
"id": "plugins~Plugin_00000000000000000000000000000000",
"name": "linear",
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"status": "DISABLED_BY_ADMIN",
"release": {
"display_name": "Linear",
"description": "Track work in Linear",
"app_ids": [],
"interface": {},
"skills": []
}
}
],
"pagination": {
"limit": 50,
"next_page_token": null
}
}"#;
let global_installed_body = r#"{
"plugins": [
{
"id": "plugins~Plugin_00000000000000000000000000000000",
"name": "linear",
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"status": "DISABLED_BY_ADMIN",
"release": {
"display_name": "Linear",
"description": "Track work in Linear",
"app_ids": [],
"interface": {},
"skills": []
},
"enabled": true,
"disabled_skill_names": []
}
],
"pagination": {
"limit": 50,
"next_page_token": null
}
}"#;
let empty_page_body = r#"{
"plugins": [],
"pagination": {
"limit": 50,
"next_page_token": null
}
}"#;
for (scope, body) in [
("GLOBAL", global_directory_body),
("WORKSPACE", empty_page_body),
] {
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/list"))
.and(query_param("scope", scope))
.and(query_param("limit", "200"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(body))
.mount(&server)
.await;
}
for (scope, body) in [
("GLOBAL", global_installed_body),
("WORKSPACE", empty_page_body),
] {
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.and(query_param("scope", scope))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(body))
.mount(&server)
.await;
}
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams { cwds: None })
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
let remote_marketplace = response
.marketplaces
.into_iter()
.find(|marketplace| marketplace.name == "chatgpt-global")
.expect("expected ChatGPT remote marketplace");
let plugin = remote_marketplace
.plugins
.first()
.expect("expected remote plugin");
assert_eq!(plugin.installed, true);
assert_eq!(plugin.enabled, true);
assert_eq!(
plugin.availability,
codex_app_server_protocol::PluginAvailability::DisabledByAdmin
);
Ok(())
}
#[tokio::test]
async fn plugin_list_remote_marketplace_replaces_local_marketplace_with_same_name() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -1592,17 +1866,152 @@ async fn wait_for_path_exists(path: &std::path::Path) -> Result<()> {
Ok(())
}
async fn wait_for_path_missing(path: &std::path::Path) -> Result<()> {
timeout(DEFAULT_TIMEOUT, async {
loop {
if !path.exists() {
return Ok::<(), anyhow::Error>(());
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await??;
Ok(())
}
async fn mount_remote_plugin_list(server: &MockServer, scope: &str, body: &str) {
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/list"))
.and(query_param("scope", scope))
.and(query_param("limit", "200"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(body))
.mount(server)
.await;
}
async fn mount_remote_installed_plugins(server: &MockServer, scope: &str, body: &str) {
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.and(query_param("scope", scope))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(body))
.mount(server)
.await;
}
fn empty_remote_installed_plugins_body() -> &'static str {
r#"{
"plugins": [],
"pagination": {
"limit": 50,
"next_page_token": null
}
}"#
}
fn remote_installed_plugin_body(
bundle_download_url: &str,
release_version: &str,
enabled: bool,
) -> String {
format!(
r#"{{
"plugins": [
{{
"id": "plugins~Plugin_00000000000000000000000000000000",
"name": "linear",
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {{
"version": "{release_version}",
"display_name": "Linear",
"description": "Track work in Linear",
"bundle_download_url": "{bundle_download_url}",
"app_ids": [],
"interface": {{}},
"skills": []
}},
"enabled": {enabled},
"disabled_skill_names": []
}}
],
"pagination": {{
"limit": 50,
"next_page_token": null
}}
}}"#
)
}
async fn mount_remote_plugin_bundle(
server: &MockServer,
plugin_name: &str,
body: Vec<u8>,
) -> String {
let bundle_path = format!("/bundles/{plugin_name}.tar.gz");
Mock::given(method("GET"))
.and(path(bundle_path.as_str()))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/gzip")
.set_body_bytes(body),
)
.mount(server)
.await;
format!("{}{bundle_path}", server.uri())
}
fn remote_plugin_bundle_tar_gz_bytes(plugin_name: &str) -> Result<Vec<u8>> {
let manifest = format!(r#"{{"name":"{plugin_name}"}}"#);
let skill = "---\nname: plan-work\ndescription: Track work in Linear.\n---\n\n# Plan Work\n";
let encoder = GzEncoder::new(Vec::new(), Compression::default());
let mut tar = tar::Builder::new(encoder);
for (path, contents, mode) in [
(
".codex-plugin/plugin.json",
manifest.as_bytes(),
/*mode*/ 0o644,
),
(
"skills/plan-work/SKILL.md",
skill.as_bytes(),
/*mode*/ 0o644,
),
] {
let mut header = tar::Header::new_gnu();
header.set_size(contents.len() as u64);
header.set_mode(mode);
header.set_cksum();
tar.append_data(&mut header, path, contents)?;
}
Ok(tar.into_inner()?.finish()?)
}
fn write_installed_plugin(
codex_home: &TempDir,
marketplace_name: &str,
plugin_name: &str,
) -> Result<()> {
write_installed_plugin_with_version(codex_home, marketplace_name, plugin_name, "local")
}
fn write_installed_plugin_with_version(
codex_home: &TempDir,
marketplace_name: &str,
plugin_name: &str,
plugin_version: &str,
) -> Result<()> {
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join(marketplace_name)
.join(plugin_name)
.join("local/.codex-plugin");
.join(plugin_version)
.join(".codex-plugin");
std::fs::create_dir_all(&plugin_root)?;
std::fs::write(
plugin_root.join("plugin.json"),
@@ -22,6 +22,8 @@ use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginSkillReadParams;
use codex_app_server_protocol::PluginSkillReadResponse;
use codex_app_server_protocol::PluginSource;
use codex_app_server_protocol::RequestId;
use codex_config::types::AuthCredentialsStoreMode;
@@ -139,6 +141,7 @@ async fn plugin_read_rejects_remote_marketplace_when_remote_plugin_is_disabled()
.message
.contains("remote plugin read is not enabled")
);
assert!(err.error.message.contains("chatgpt-global"));
Ok(())
}
@@ -252,7 +255,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: None,
remote_marketplace_name: Some("caller-marketplace-is-ignored".to_string()),
remote_marketplace_name: Some("chatgpt-global".to_string()),
plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(),
})
.await?;
@@ -286,6 +289,70 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
Ok(())
}
#[tokio::test]
async fn plugin_skill_read_reads_remote_skill_contents_when_remote_plugin_enabled() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_catalog_config(
codex_home.path(),
&format!("{}/backend-api/", server.uri()),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let skill_body = r##"{
"plugin_id": "plugins~Plugin_00000000000000000000000000000000",
"status": "ENABLED",
"plugin_release_id": "release-1",
"name": "plan-work",
"description": "Plan work from Linear issues",
"plugin_release_skill_id": "skill-1",
"skill_md_contents": "# Plan Work\n\nUse Linear issues to create a plan."
}"##;
Mock::given(method("GET"))
.and(path(
"/backend-api/ps/plugins/plugins~Plugin_00000000000000000000000000000000/skills/plan-work",
))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(skill_body))
.mount(&server)
.await;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_skill_read_request(PluginSkillReadParams {
remote_marketplace_name: "chatgpt-global".to_string(),
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
skill_name: "plan-work".to_string(),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginSkillReadResponse = to_response(response)?;
assert_eq!(
response,
PluginSkillReadResponse {
contents: Some("# Plan Work\n\nUse Linear issues to create a plan.".to_string()),
}
);
Ok(())
}
#[tokio::test]
async fn plugin_read_maps_missing_remote_plugin_to_invalid_request() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -412,6 +479,11 @@ async fn plugin_read_rejects_invalid_remote_plugin_name() -> Result<()> {
assert_eq!(err.error.code, -32600);
assert!(err.error.message.contains("invalid remote plugin id"));
assert!(
err.error
.message
.contains("only ASCII letters, digits, `_`, `-`, and `~` are allowed")
);
Ok(())
}
@@ -12,6 +12,7 @@ use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginInterface;
use codex_app_server_protocol::PluginShareDeleteResponse;
use codex_app_server_protocol::PluginShareListItem;
use codex_app_server_protocol::PluginShareListResponse;
use codex_app_server_protocol::PluginShareSaveResponse;
use codex_app_server_protocol::PluginSource;
@@ -49,6 +50,7 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
write_corrupt_plugin_share_local_path_mapping(codex_home.path())?;
Mock::given(method("POST"))
.and(path("/backend-api/public/plugins/workspace/upload-url"))
@@ -88,11 +90,12 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let expected_plugin_path = AbsolutePathBuf::try_from(plugin_path.clone())?;
let request_id = mcp
.send_raw_request(
"plugin/share/save",
Some(json!({
"pluginPath": AbsolutePathBuf::try_from(plugin_path)?,
"pluginPath": expected_plugin_path.clone(),
})),
)
.await?;
@@ -111,6 +114,62 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
}
);
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/workspace/created"))
.and(query_param("limit", "200"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [remote_plugin_json("plugins_123")],
"pagination": empty_pagination_json(),
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.and(query_param("scope", "WORKSPACE"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [installed_remote_plugin_json("plugins_123")],
"pagination": empty_pagination_json(),
})))
.expect(1)
.mount(&server)
.await;
let request_id = mcp
.send_raw_request("plugin/share/list", Some(json!({})))
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginShareListResponse = to_response(response)?;
assert_eq!(
response,
PluginShareListResponse {
data: vec![PluginShareListItem {
plugin: PluginSummary {
id: "plugins_123".to_string(),
name: "demo-plugin".to_string(),
source: PluginSource::Remote,
installed: true,
enabled: true,
install_policy: PluginInstallPolicy::Available,
auth_policy: PluginAuthPolicy::OnUse,
availability: codex_app_server_protocol::PluginAvailability::Available,
interface: Some(expected_plugin_interface()),
},
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
local_plugin_path: Some(expected_plugin_path),
}],
}
);
Ok(())
}
@@ -169,15 +228,20 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> {
assert_eq!(
response,
PluginShareListResponse {
data: vec![PluginSummary {
id: "plugins_123".to_string(),
name: "demo-plugin".to_string(),
source: PluginSource::Remote,
installed: true,
enabled: true,
install_policy: PluginInstallPolicy::Available,
auth_policy: PluginAuthPolicy::OnUse,
interface: Some(expected_plugin_interface()),
data: vec![PluginShareListItem {
plugin: PluginSummary {
id: "plugins_123".to_string(),
name: "demo-plugin".to_string(),
source: PluginSource::Remote,
installed: true,
enabled: true,
install_policy: PluginInstallPolicy::Available,
auth_policy: PluginAuthPolicy::OnUse,
availability: codex_app_server_protocol::PluginAvailability::Available,
interface: Some(expected_plugin_interface()),
},
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
local_plugin_path: None,
}],
}
);
@@ -197,6 +261,8 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> {
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let local_plugin_path = AbsolutePathBuf::try_from(codex_home.path().join("local-plugin"))?;
write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &local_plugin_path)?;
Mock::given(method("DELETE"))
.and(path("/backend-api/public/plugins/workspace/plugins_123"))
@@ -226,6 +292,62 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> {
let response: PluginShareDeleteResponse = to_response(response)?;
assert_eq!(response, PluginShareDeleteResponse {});
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/workspace/created"))
.and(query_param("limit", "200"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [remote_plugin_json("plugins_123")],
"pagination": empty_pagination_json(),
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.and(query_param("scope", "WORKSPACE"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [installed_remote_plugin_json("plugins_123")],
"pagination": empty_pagination_json(),
})))
.expect(1)
.mount(&server)
.await;
let request_id = mcp
.send_raw_request("plugin/share/list", Some(json!({})))
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginShareListResponse = to_response(response)?;
assert_eq!(
response,
PluginShareListResponse {
data: vec![PluginShareListItem {
plugin: PluginSummary {
id: "plugins_123".to_string(),
name: "demo-plugin".to_string(),
source: PluginSource::Remote,
installed: true,
enabled: true,
install_policy: PluginInstallPolicy::Available,
auth_policy: PluginAuthPolicy::OnUse,
availability: codex_app_server_protocol::PluginAvailability::Available,
interface: Some(expected_plugin_interface()),
},
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
local_plugin_path: None,
}],
}
);
Ok(())
}
@@ -249,6 +371,7 @@ fn remote_plugin_json(plugin_id: &str) -> serde_json::Value {
"id": plugin_id,
"name": "demo-plugin",
"scope": "WORKSPACE",
"share_url": "https://chatgpt.example/plugins/share/share-key-1",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
@@ -314,6 +437,33 @@ fn write_test_plugin(root: &Path, plugin_name: &str) -> std::io::Result<PathBuf>
Ok(plugin_path)
}
fn write_corrupt_plugin_share_local_path_mapping(codex_home: &Path) -> std::io::Result<()> {
write_file(
&codex_home.join(".tmp/plugin-share-local-paths-v1.json"),
"not-json",
)
}
fn write_plugin_share_local_path_mapping(
codex_home: &Path,
remote_plugin_id: &str,
plugin_path: &AbsolutePathBuf,
) -> std::io::Result<()> {
let mut local_plugin_paths_by_remote_plugin_id = serde_json::Map::new();
local_plugin_paths_by_remote_plugin_id.insert(
remote_plugin_id.to_string(),
serde_json::to_value(plugin_path).map_err(std::io::Error::other)?,
);
let contents = serde_json::to_string_pretty(&json!({
"localPluginPathsByRemotePluginId": local_plugin_paths_by_remote_plugin_id,
}))
.map_err(std::io::Error::other)?;
write_file(
&codex_home.join(".tmp/plugin-share-local-paths-v1.json"),
&format!("{contents}\n"),
)
}
fn write_file(path: &Path, contents: &str) -> std::io::Result<()> {
let Some(parent) = path.parent() else {
return Err(std::io::Error::other(format!(
@@ -28,6 +28,8 @@ use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
@@ -136,10 +138,35 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste
})
.await??;
let response = client
.request(ClientRequest::ThreadList {
request_id: RequestId::Integer(3),
params: ThreadListParams {
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
model_providers: Some(Vec::new()),
source_kinds: None,
archived: None,
cwd: None,
use_state_db_only: false,
search_term: None,
},
})
.await?
.expect("thread/list should succeed");
let ThreadListResponse { data, .. } =
serde_json::from_value(response).expect("thread/list response should parse");
assert_eq!(data.len(), 1);
assert_eq!(data[0].id, thread.id);
assert_eq!(data[0].path, None);
client.shutdown().await?;
let calls = thread_store.calls().await;
assert_eq!(calls.create_thread, 1);
assert_eq!(calls.list_threads, 1);
assert!(
calls.append_items > 0,
"turn/start should append rollout items through the injected store"
@@ -5,6 +5,12 @@ use app_test_support::create_mock_responses_server_repeating_assistant;
use app_test_support::rollout_path;
use app_test_support::test_absolute_path;
use app_test_support::to_response;
use codex_app_server::in_process;
use codex_app_server::in_process::InProcessStartArgs;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::InitializeCapabilities;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
@@ -31,17 +37,39 @@ use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudRequirementsLoader;
use codex_config::LoaderOverrides;
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
use codex_core::config::ConfigBuilder;
use codex_exec_server::EnvironmentManager;
use codex_feedback::CodexFeedback;
use codex_protocol::models::BaseInstructions;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionSource as ProtocolSessionSource;
use codex_protocol::protocol::ThreadMemoryMode;
use codex_protocol::protocol::UserMessageEvent;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement;
use codex_thread_store::AppendThreadItemsParams;
use codex_thread_store::CreateThreadParams;
use codex_thread_store::InMemoryThreadStore;
use codex_thread_store::ThreadEventPersistenceMode;
use codex_thread_store::ThreadMetadataPatch;
use codex_thread_store::ThreadPersistenceMetadata;
use codex_thread_store::ThreadStore;
use codex_thread_store::UpdateThreadMetadataParams;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::io::Write;
use std::path::Path;
use std::sync::Arc;
use tempfile::TempDir;
use tokio::time::timeout;
use uuid::Uuid;
#[cfg(windows)]
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25);
@@ -246,6 +274,147 @@ async fn thread_turns_list_can_page_backward_and_forward() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result<()> {
let codex_home = TempDir::new()?;
let thread_id = codex_protocol::ThreadId::from_string("00000000-0000-4000-8000-000000000123")?;
let store_id = Uuid::new_v4().to_string();
create_config_toml_with_thread_store(codex_home.path(), &store_id)?;
let store = InMemoryThreadStore::for_id(store_id.clone());
let _in_memory_store = InMemoryThreadStoreId { store_id };
seed_pathless_store_thread(&store, thread_id).await?;
let loader_overrides = LoaderOverrides::without_managed_config_for_tests();
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.loader_overrides(loader_overrides.clone())
.build()
.await?;
let client = in_process::start(InProcessStartArgs {
arg0_paths: Arg0DispatchPaths::default(),
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
log_db: None,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
config_warnings: Vec::new(),
session_source: SessionSource::Cli.into(),
enable_codex_api_key_env: false,
initialize: InitializeParams {
client_info: ClientInfo {
name: "codex-app-server-tests".to_string(),
title: None,
version: "0.1.0".to_string(),
},
capabilities: Some(InitializeCapabilities {
experimental_api: true,
..Default::default()
}),
},
channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
})
.await?;
let result = client
.request(ClientRequest::ThreadTurnsList {
request_id: RequestId::Integer(1),
params: ThreadTurnsListParams {
thread_id: thread_id.to_string(),
cursor: None,
limit: Some(10),
sort_direction: Some(SortDirection::Asc),
},
})
.await?
.expect("thread/turns/list should succeed");
let ThreadTurnsListResponse { data, .. } = serde_json::from_value(result)?;
assert_eq!(turn_user_texts(&data), vec!["history from store"]);
client.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()> {
let codex_home = TempDir::new()?;
let thread_id = codex_protocol::ThreadId::from_string("00000000-0000-4000-8000-000000000124")?;
let store_id = Uuid::new_v4().to_string();
create_config_toml_with_thread_store(codex_home.path(), &store_id)?;
let store = InMemoryThreadStore::for_id(store_id.clone());
let _in_memory_store = InMemoryThreadStoreId { store_id };
seed_pathless_store_thread(&store, thread_id).await?;
let loader_overrides = LoaderOverrides::without_managed_config_for_tests();
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.loader_overrides(loader_overrides.clone())
.build()
.await?;
let client = in_process::start(InProcessStartArgs {
arg0_paths: Arg0DispatchPaths::default(),
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
log_db: None,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
config_warnings: Vec::new(),
session_source: SessionSource::Cli.into(),
enable_codex_api_key_env: false,
initialize: InitializeParams {
client_info: ClientInfo {
name: "codex-app-server-tests".to_string(),
title: None,
version: "0.1.0".to_string(),
},
capabilities: Some(InitializeCapabilities {
experimental_api: true,
..Default::default()
}),
},
channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
})
.await?;
let result = client
.request(ClientRequest::ThreadList {
request_id: RequestId::Integer(1),
params: ThreadListParams {
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
model_providers: Some(Vec::new()),
source_kinds: None,
archived: None,
cwd: None,
use_state_db_only: false,
search_term: None,
},
})
.await?
.expect("thread/list should succeed");
let ThreadListResponse { data, .. } = serde_json::from_value(result)?;
assert_eq!(data.len(), 1);
let thread = &data[0];
assert_eq!(thread.id, thread_id.to_string());
assert_eq!(thread.path, None);
assert_eq!(thread.preview, "");
assert_eq!(thread.name.as_deref(), Some("named pathless thread"));
client.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn thread_read_can_return_archived_threads_by_id() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
@@ -670,6 +839,59 @@ async fn thread_read_include_turns_rejects_unmaterialized_loaded_thread() -> Res
Ok(())
}
#[tokio::test]
async fn thread_turns_list_rejects_unmaterialized_loaded_thread() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let start_id = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
..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 thread_path = thread.path.clone().expect("thread path");
assert!(
!thread_path.exists(),
"fresh thread rollout should not be materialized yet"
);
let read_id = mcp
.send_thread_turns_list_request(ThreadTurnsListParams {
thread_id: thread.id,
cursor: None,
limit: None,
sort_direction: None,
})
.await?;
let read_err: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(read_id)),
)
.await??;
assert!(
read_err
.error
.message
.contains("thread/turns/list is unavailable before first user message"),
"unexpected error: {}",
read_err.error.message
);
Ok(())
}
#[tokio::test]
async fn thread_read_reports_system_error_idle_flag_after_failed_turn() -> Result<()> {
let server = responses::start_mock_server().await;
@@ -787,6 +1009,89 @@ fn turn_user_texts(turns: &[codex_app_server_protocol::Turn]) -> Vec<&str> {
.collect()
}
struct InMemoryThreadStoreId {
store_id: String,
}
impl Drop for InMemoryThreadStoreId {
fn drop(&mut self) {
InMemoryThreadStore::remove_id(&self.store_id);
}
}
async fn seed_pathless_store_thread(
store: &InMemoryThreadStore,
thread_id: codex_protocol::ThreadId,
) -> Result<()> {
store
.create_thread(CreateThreadParams {
thread_id,
forked_from_id: None,
source: ProtocolSessionSource::Cli,
base_instructions: BaseInstructions::default(),
dynamic_tools: Vec::new(),
metadata: ThreadPersistenceMetadata {
cwd: None,
model_provider: "test-provider".to_string(),
memory_mode: ThreadMemoryMode::Disabled,
},
event_persistence_mode: ThreadEventPersistenceMode::default(),
})
.await?;
store
.append_items(AppendThreadItemsParams {
thread_id,
items: store_history_items(),
})
.await?;
store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
name: Some("named pathless thread".to_string()),
..Default::default()
},
include_archived: true,
})
.await?;
Ok(())
}
fn store_history_items() -> Vec<RolloutItem> {
vec![RolloutItem::EventMsg(EventMsg::UserMessage(
UserMessageEvent {
message: "history from store".to_string(),
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
},
))]
}
fn create_config_toml_with_thread_store(codex_home: &Path, store_id: &str) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(
config_toml,
format!(
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "read-only"
experimental_thread_store = {{ type = "in_memory", id = "{store_id}" }}
model_provider = "mock_provider"
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "http://127.0.0.1:1/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
"#
),
)
}
// Helper to create a config.toml pointing at the mock model server.
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
@@ -71,6 +71,7 @@ use core_test_support::skip_if_no_network;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::fs::FileTimes;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
@@ -1669,7 +1670,7 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> {
}
#[tokio::test]
async fn thread_resume_rejects_mismatched_path_when_thread_is_running() -> Result<()> {
async fn thread_resume_uses_path_over_thread_id_when_thread_is_running() -> Result<()> {
let server = responses::start_mock_server().await;
let first_body = responses::sse(vec![
responses::ev_response_created("resp-1"),
@@ -1749,24 +1750,71 @@ async fn thread_resume_rejects_mismatched_path_when_thread_is_running() -> Resul
)
.await??;
let resume_id = primary
let other_thread_id = ThreadId::new().to_string();
let stale_path = rollout_path(codex_home.path(), "2025-01-01T00-00-00", &thread_id);
std::fs::create_dir_all(stale_path.parent().expect("stale path parent"))?;
let thread_uuid = Uuid::parse_str(&thread_id)?;
let mut stale_file = std::fs::File::create(&stale_path)?;
let stale_meta = json!({
"timestamp": "2025-01-01T00:00:00Z",
"type": "session_meta",
"payload": {
"id": thread_uuid,
"timestamp": "2025-01-01T00:00:00Z",
"cwd": codex_home.path(),
"originator": "test_originator",
"cli_version": "test_version",
"source": "cli",
"model_provider": "test-provider",
},
});
writeln!(stale_file, "{stale_meta}")?;
let stale_user_event = json!({
"timestamp": "2025-01-01T00:00:00Z",
"type": "event_msg",
"payload": {
"type": "user_message",
"message": "stale history",
"kind": "plain",
},
});
writeln!(stale_file, "{stale_user_event}")?;
let stale_resume_id = primary
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread_id.clone(),
path: Some(PathBuf::from("/tmp/does-not-match-running-rollout.jsonl")),
thread_id: other_thread_id.clone(),
path: Some(stale_path),
..Default::default()
})
.await?;
let resume_err: JSONRPCError = timeout(
let stale_resume_err: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_error_message(RequestId::Integer(resume_id)),
primary.read_stream_until_error_message(RequestId::Integer(stale_resume_id)),
)
.await??;
assert!(
resume_err.error.message.contains("mismatched path"),
stale_resume_err.error.message.contains("stale path"),
"unexpected resume error: {}",
resume_err.error.message
stale_resume_err.error.message
);
let resume_by_path_id = primary
.send_thread_resume_request(ThreadResumeParams {
thread_id: other_thread_id.clone(),
path: thread.path,
..Default::default()
})
.await?;
let resume_by_path_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(resume_by_path_id)),
)
.await??;
let ThreadResumeResponse {
thread: resumed, ..
} = to_response::<ThreadResumeResponse>(resume_by_path_resp)?;
assert_eq!(resumed.id, thread_id);
primary
.interrupt_turn_and_wait_for_aborted(thread_id, running_turn.id, DEFAULT_READ_TIMEOUT)
.await?;
@@ -2463,7 +2511,7 @@ async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> {
}
#[tokio::test]
async fn thread_resume_prefers_path_over_thread_id() -> Result<()> {
async fn thread_resume_uses_path_over_invalid_thread_id() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
@@ -2523,13 +2571,6 @@ async fn thread_resume_prefers_path_over_thread_id() -> Result<()> {
thread: resumed, ..
} = to_response::<ThreadResumeResponse>(resume_resp)?;
assert_eq!(resumed.id, thread.id);
let resumed_path = resumed.path.as_ref().expect("resumed thread path");
let original_path = thread.path.as_ref().expect("original thread path");
assert_eq!(
normalized_existing_path(resumed_path)?,
normalized_existing_path(original_path)?
);
assert_eq!(resumed.status, ThreadStatus::Idle);
Ok(())
}
@@ -24,7 +24,6 @@ use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
use codex_app_server_protocol::CommandExecutionStatus;
use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::FileChangeOutputDeltaNotification;
use codex_app_server_protocol::FileChangePatchUpdatedNotification;
use codex_app_server_protocol::FileChangeRequestApprovalResponse;
use codex_app_server_protocol::ItemCompletedNotification;
@@ -2198,9 +2197,8 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
)
.await?;
let mut saw_resolved = false;
let mut output_delta: Option<FileChangeOutputDeltaNotification> = None;
let mut completed_file_change: Option<ThreadItem> = None;
while !(output_delta.is_some() && completed_file_change.is_some()) {
while completed_file_change.is_none() {
let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??;
let JSONRPCMessage::Notification(notification) = message else {
continue;
@@ -2217,16 +2215,6 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
assert_eq!(resolved.request_id, resolved_request_id);
saw_resolved = true;
}
"item/fileChange/outputDelta" => {
assert!(saw_resolved, "serverRequest/resolved should arrive first");
let notification: FileChangeOutputDeltaNotification = serde_json::from_value(
notification
.params
.clone()
.expect("item/fileChange/outputDelta params"),
)?;
output_delta = Some(notification);
}
"item/completed" => {
let completed: ItemCompletedNotification = serde_json::from_value(
notification.params.clone().expect("item/completed params"),
@@ -2239,16 +2227,6 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
_ => {}
}
}
let output_delta = output_delta.expect("file change output delta should be observed");
assert_eq!(output_delta.thread_id, thread.id);
assert_eq!(output_delta.turn_id, turn.id);
assert_eq!(output_delta.item_id, "patch-call");
assert!(
!output_delta.delta.is_empty(),
"expected delta to be non-empty, got: {}",
output_delta.delta
);
let completed_file_change =
completed_file_change.expect("file change completion should be observed");
let ThreadItem::FileChange { ref id, status, .. } = completed_file_change else {
@@ -3000,11 +2978,6 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res
)
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("item/fileChange/outputDelta"),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("item/completed"),
@@ -3058,11 +3031,6 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res
// If the server incorrectly emits FileChangeRequestApproval, the helper below will error
// (it bails on unexpected JSONRPCMessage::Request), causing the test to fail.
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("item/fileChange/outputDelta"),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("item/completed"),