From 2d385e166c2dff131ca21294216ce61eb62fd3c2 Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 3 Jun 2026 01:10:26 +0200 Subject: [PATCH 01/16] feat: add skills extension scaffold (#25953) ## Disclaimer This is only here for iteration purpose! Do not make any code rely on this ## Why Skills still live behind `codex-core` discovery and injection paths, but the extension system needs an authority-aware home before that logic can move. This adds that boundary without changing current skills behavior, and keeps host, executor, and remote skills distinct so future list/read/search flows do not collapse back to ambient local paths. ## What changed - Add the `codex-skills-extension` workspace/Bazel crate under `ext/skills`. - Define the initial catalog, authority, provider, and turn-state types for authority-bound skill packages and resources. - Register placeholder thread/config/prompt/turn lifecycle contributors plus host, executor, and remote provider aggregation points. - Capture the remaining extraction work as TODOs, including the missing extension API hooks needed for per-turn catalog construction and typed skill injection. - Keep plugins outside the runtime skills model: plugin-installed skills are treated as materialized host-owned skill sources once available. ## Verification - Not run locally. --- codex-rs/Cargo.lock | 9 ++ codex-rs/Cargo.toml | 1 + codex-rs/ext/skills/BUILD.bazel | 6 + codex-rs/ext/skills/Cargo.toml | 19 +++ codex-rs/ext/skills/src/catalog.rs | 86 +++++++++++ codex-rs/ext/skills/src/extension.rs | 140 ++++++++++++++++++ codex-rs/ext/skills/src/lib.rs | 7 + codex-rs/ext/skills/src/provider.rs | 63 ++++++++ codex-rs/ext/skills/src/providers/executor.rs | 56 +++++++ codex-rs/ext/skills/src/providers/host.rs | 65 ++++++++ codex-rs/ext/skills/src/providers/mod.rs | 50 +++++++ codex-rs/ext/skills/src/providers/remote.rs | 58 ++++++++ codex-rs/ext/skills/src/state.rs | 24 +++ 13 files changed, 584 insertions(+) create mode 100644 codex-rs/ext/skills/BUILD.bazel create mode 100644 codex-rs/ext/skills/Cargo.toml create mode 100644 codex-rs/ext/skills/src/catalog.rs create mode 100644 codex-rs/ext/skills/src/extension.rs create mode 100644 codex-rs/ext/skills/src/lib.rs create mode 100644 codex-rs/ext/skills/src/provider.rs create mode 100644 codex-rs/ext/skills/src/providers/executor.rs create mode 100644 codex-rs/ext/skills/src/providers/host.rs create mode 100644 codex-rs/ext/skills/src/providers/mod.rs create mode 100644 codex-rs/ext/skills/src/providers/remote.rs create mode 100644 codex-rs/ext/skills/src/state.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 33392db931..94dac45699 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3715,6 +3715,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "codex-skills-extension" +version = "0.0.0" +dependencies = [ + "async-trait", + "codex-core", + "codex-extension-api", +] + [[package]] name = "codex-state" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index cc20f8a3a8..482951fb92 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -48,6 +48,7 @@ members = [ "ext/guardian", "ext/image-generation", "ext/memories", + "ext/skills", "ext/web-search", "external-agent-migration", "external-agent-sessions", diff --git a/codex-rs/ext/skills/BUILD.bazel b/codex-rs/ext/skills/BUILD.bazel new file mode 100644 index 0000000000..81410ca1f6 --- /dev/null +++ b/codex-rs/ext/skills/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "skills", + crate_name = "codex_skills_extension", +) diff --git a/codex-rs/ext/skills/Cargo.toml b/codex-rs/ext/skills/Cargo.toml new file mode 100644 index 0000000000..55671df6c9 --- /dev/null +++ b/codex-rs/ext/skills/Cargo.toml @@ -0,0 +1,19 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-skills-extension" +version.workspace = true + +[lib] +name = "codex_skills_extension" +path = "src/lib.rs" +test = false +doctest = false + +[lints] +workspace = true + +[dependencies] +async-trait = { workspace = true } +codex-core = { workspace = true } +codex-extension-api = { workspace = true } diff --git a/codex-rs/ext/skills/src/catalog.rs b/codex-rs/ext/skills/src/catalog.rs new file mode 100644 index 0000000000..86ae327d45 --- /dev/null +++ b/codex-rs/ext/skills/src/catalog.rs @@ -0,0 +1,86 @@ +/// Source authority that owns a skill package and must be used to read it. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum SkillSourceKind { + Host, + Executor, + Remote, +} + +/// Opaque authority identity for list/read routing. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct SkillAuthority { + pub kind: SkillSourceKind, + pub id: String, +} + +impl SkillAuthority { + pub fn new(kind: SkillSourceKind, id: impl Into) -> Self { + Self { + kind, + id: id.into(), + } + } +} + +/// Opaque package id. Callers should not parse local paths out of this value. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct SkillPackageId(pub String); + +/// Opaque resource id inside a skill package. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct SkillResourceId(pub String); + +/// Metadata shown in the always-visible skills catalog. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillCatalogEntry { + pub id: SkillPackageId, + pub authority: SkillAuthority, + pub name: String, + pub description: String, + pub entrypoint: SkillResourceId, +} + +/// Merged catalog for one turn. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SkillCatalog { + pub entries: Vec, + pub warnings: Vec, +} + +impl SkillCatalog { + pub fn extend(&mut self, other: SkillCatalog) { + // TODO(skills-extension): dedupe by authority-bound id first, then + // apply name precedence/conflict rules for user-facing mention + // resolution. Names are not stable identities. + self.entries.extend(other.entries); + self.warnings.extend(other.warnings); + } +} + +/// Contents returned after resolving a skill resource through its owner. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillReadResult { + pub resource: SkillResourceId, + pub contents: String, +} + +/// Search results for a package whose files are not readable through ordinary +/// executor filesystem access. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SkillSearchResult { + pub matches: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillSearchMatch { + pub resource: SkillResourceId, + pub title: String, + pub snippet: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillProviderError { + pub message: String, +} + +pub type SkillProviderResult = Result; diff --git a/codex-rs/ext/skills/src/extension.rs b/codex-rs/ext/skills/src/extension.rs new file mode 100644 index 0000000000..bf91b60de1 --- /dev/null +++ b/codex-rs/ext/skills/src/extension.rs @@ -0,0 +1,140 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use codex_core::config::Config; +use codex_extension_api::ConfigContributor; +use codex_extension_api::ContextContributor; +use codex_extension_api::ExtensionData; +use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::PromptFragment; +use codex_extension_api::ThreadLifecycleContributor; +use codex_extension_api::ThreadStartInput; +use codex_extension_api::TurnLifecycleContributor; +use codex_extension_api::TurnStartInput; + +use crate::provider::SkillListQuery; +use crate::providers::SkillProviders; +use crate::state::SkillsExtensionConfig; +use crate::state::SkillsTurnState; + +#[derive(Clone, Debug, Default)] +struct SkillsExtension { + providers: SkillProviders, +} + +#[async_trait::async_trait] +impl ThreadLifecycleContributor for SkillsExtension { + async fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) { + // TODO(skills-extension): this is only the thread-level config snapshot. + // Skills are loaded per turn today because cwd, plugin roots, config + // layers, and the primary environment filesystem can change between + // turns. The real migration needs a turn-preparation hook before model + // input construction, not just thread startup. + input + .thread_store + .insert(SkillsExtensionConfig::from_config(input.config)); + } +} + +impl ConfigContributor for SkillsExtension { + fn on_config_changed( + &self, + _session_store: &ExtensionData, + thread_store: &ExtensionData, + _previous_config: &Config, + new_config: &Config, + ) { + // TODO(skills-extension): update any cached/listing state that depends + // on skill config overrides, bundled skills, or include_instructions. + thread_store.insert(SkillsExtensionConfig::from_config(new_config)); + } +} + +impl ContextContributor for SkillsExtension { + fn contribute<'a>( + &'a self, + _session_store: &'a ExtensionData, + thread_store: &'a ExtensionData, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let Some(config) = thread_store.get::() else { + return Vec::new(); + }; + if !config.include_instructions { + return Vec::new(); + } + + // TODO(skills-extension): render the available-skills developer + // block from the merged per-turn SkillCatalog. This should + // preserve the existing bounded metadata budget, root aliasing, + // warning behavior, and telemetry side effects. + // + // TODO(skills-extension): avoid using raw PromptFragment strings + // for final skills context if the extension API grows typed + // contextual fragments. Existing skill blocks are typed so resume + // and history filtering can recognize them reliably. + // + // TODO(skills-extension): ContextContributor currently cannot see + // the turn_store, so it cannot read the per-turn catalog seeded by + // the turn provider path below. This is the main extension-api gap + // to close before skills can move out of codex-core. + Vec::new() + }) + } +} + +#[async_trait::async_trait] +impl TurnLifecycleContributor for SkillsExtension { + async fn on_turn_start(&self, input: TurnStartInput<'_>) { + // TODO(skills-extension): replace this lifecycle callback with a real + // turn-input contributor in codex-extension-api. This placeholder only + // demonstrates where provider aggregation belongs; it cannot resolve + // real skills because this hook does not receive cwd, executor + // selections, effective plugins/materialized plugin skill roots, + // connector slug counts, user input, cancellation, analytics, or a + // response-item output channel. + let query = SkillListQuery::placeholder_for_turn(input.turn_id); + let catalog = self + .providers + .list_for_turn(query) + .await + .unwrap_or_default(); + + input.turn_store.insert(SkillsTurnState { + catalog, + entrypoints_injected: false, + }); + + // TODO(skills-extension): after catalog resolution, collect explicit + // skill mentions from structured UserInput and text mentions. + // + // TODO(skills-extension): inject selected entrypoints as typed + // contextual user fragments, preserving ... history + // recognition and bounded body size limits. + // + // TODO(skills-extension): move explicit $skill mention resolution, + // SKILL.md reads, skill body injection, and MCP dependency prompting + // out of codex-core's turn assembly once that hook exists. + } +} + +/// Installs the skills extension contributor sketch. +/// +/// TODO(skills-extension): pass host capabilities here rather than letting the +/// extension depend on Session. The final extension needs capability objects for +/// loading skill roots, emitting warnings, tracking analytics, prompting for MCP +/// dependency install, refreshing MCP servers, and serving app-server catalog +/// requests. +/// +/// TODO(skills-extension): plugin handling should stay outside the runtime +/// skills model. Plugins are bundle/install units; once installed or refreshed, +/// their skill descriptors/roots should be handed to this extension just like +/// any other host-owned skill source. +pub fn install(registry: &mut ExtensionRegistryBuilder) { + let extension = Arc::new(SkillsExtension::default()); + registry.thread_lifecycle_contributor(extension.clone()); + registry.config_contributor(extension.clone()); + registry.prompt_contributor(extension.clone()); + registry.turn_lifecycle_contributor(extension); +} diff --git a/codex-rs/ext/skills/src/lib.rs b/codex-rs/ext/skills/src/lib.rs new file mode 100644 index 0000000000..6e8a0700a1 --- /dev/null +++ b/codex-rs/ext/skills/src/lib.rs @@ -0,0 +1,7 @@ +pub mod catalog; +mod extension; +pub mod provider; +mod providers; +mod state; + +pub use extension::install; diff --git a/codex-rs/ext/skills/src/provider.rs b/codex-rs/ext/skills/src/provider.rs new file mode 100644 index 0000000000..730adb51ba --- /dev/null +++ b/codex-rs/ext/skills/src/provider.rs @@ -0,0 +1,63 @@ +use std::future::Future; + +use crate::catalog::SkillAuthority; +use crate::catalog::SkillCatalog; +use crate::catalog::SkillPackageId; +use crate::catalog::SkillProviderResult; +use crate::catalog::SkillReadResult; +use crate::catalog::SkillResourceId; +use crate::catalog::SkillSearchResult; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillListQuery { + pub turn_id: String, + pub executor_authorities: Vec, + pub include_host_skills: bool, + pub include_remote_skills: bool, +} + +impl SkillListQuery { + pub(crate) fn placeholder_for_turn(turn_id: &str) -> Self { + Self { + turn_id: turn_id.to_string(), + executor_authorities: Vec::new(), + include_host_skills: true, + include_remote_skills: true, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillReadRequest { + pub authority: SkillAuthority, + pub resource: SkillResourceId, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillSearchRequest { + pub authority: SkillAuthority, + pub package: SkillPackageId, + pub query: String, +} + +/// Source-specific skill catalog and resource access. +/// +/// Implementations must preserve authority boundaries: a resource listed by a +/// provider must be read or searched through the same provider/authority rather +/// than converted into an ambient local path. +pub trait SkillProvider: Send + Sync { + fn list( + &self, + query: SkillListQuery, + ) -> impl Future> + Send; + + fn read( + &self, + request: SkillReadRequest, + ) -> impl Future> + Send; + + fn search( + &self, + request: SkillSearchRequest, + ) -> impl Future> + Send; +} diff --git a/codex-rs/ext/skills/src/providers/executor.rs b/codex-rs/ext/skills/src/providers/executor.rs new file mode 100644 index 0000000000..f9f399eebc --- /dev/null +++ b/codex-rs/ext/skills/src/providers/executor.rs @@ -0,0 +1,56 @@ +use std::future; + +use crate::catalog::SkillCatalog; +use crate::catalog::SkillProviderResult; +use crate::catalog::SkillReadResult; +use crate::catalog::SkillSearchResult; +use crate::provider::SkillListQuery; +use crate::provider::SkillProvider; +use crate::provider::SkillReadRequest; +use crate::provider::SkillSearchRequest; + +#[derive(Clone, Debug, Default)] +pub(crate) struct ExecutorSkillProvider; + +impl SkillProvider for ExecutorSkillProvider { + fn list( + &self, + _query: SkillListQuery, + ) -> impl Future> + Send { + future::ready(Ok(SkillCatalog::default())) + // TODO(skills-extension): list repo/workspace skills from each + // executor authority selected for the turn. + // + // TODO(skills-extension): if the executor exposes filesystem reads, + // preserve the existing SKILL.md discovery semantics. If CCA/no-FS + // applies, query an executor catalog/read capability instead. + // + // TODO(skills-extension): include the executor/environment id in skill + // identity so two executors with the same path/name do not collide. + } + + fn read( + &self, + request: SkillReadRequest, + ) -> impl Future> + Send { + future::ready(Err(crate::catalog::SkillProviderError { + message: format!( + "executor skill resource `{}` is not implemented", + request.resource.0 + ), + })) + // TODO(skills-extension): route reads back to the executor authority + // that listed the resource. Do not mint local paths from remote or + // non-filesystem executor resources. + } + + fn search( + &self, + _request: SkillSearchRequest, + ) -> impl Future> + Send { + future::ready(Ok(SkillSearchResult::default())) + // TODO(skills-extension): support search for executor skills only when + // the executor offers a catalog/search API. For ordinary filesystem + // executors, the model can keep using regular file reads/search tools. + } +} diff --git a/codex-rs/ext/skills/src/providers/host.rs b/codex-rs/ext/skills/src/providers/host.rs new file mode 100644 index 0000000000..fc4317f859 --- /dev/null +++ b/codex-rs/ext/skills/src/providers/host.rs @@ -0,0 +1,65 @@ +use std::future; + +use crate::catalog::SkillCatalog; +use crate::catalog::SkillProviderResult; +use crate::catalog::SkillReadResult; +use crate::catalog::SkillSearchResult; +use crate::provider::SkillListQuery; +use crate::provider::SkillProvider; +use crate::provider::SkillReadRequest; +use crate::provider::SkillSearchRequest; + +#[derive(Clone, Debug, Default)] +pub(crate) struct HostSkillProvider; + +impl SkillProvider for HostSkillProvider { + fn list( + &self, + _query: SkillListQuery, + ) -> impl Future> + Send { + future::ready(Ok(SkillCatalog::default())) + // TODO(skills-extension): list bundled/system/user/plugin-installed + // skills owned by the Codex host. This is the source for skills that + // are not tied to a particular executor authority. + // + // TODO(skills-extension): plugins should be treated as packaging and + // installation only. After a plugin is downloaded, cached, refreshed, + // or installed, its skill roots/descriptors should enter this provider + // and then use the normal skills catalog/read/injection code. + // + // TODO(skills-extension): remote skills that are materialized locally + // by plugin install or explicit download should also hand off here + // rather than remain remote-provider entries at runtime. + // + // TODO(skills-extension): keep current bundled system skill install or + // replace it with embedded host assets so CCA/no-FS hosts do not depend + // on local writable skill cache directories. + } + + fn read( + &self, + request: SkillReadRequest, + ) -> impl Future> + Send { + future::ready(Err(crate::catalog::SkillProviderError { + message: format!( + "host skill resource `{}` is not implemented", + request.resource.0 + ), + })) + // TODO(skills-extension): read host-owned entrypoints and supporting + // resources by opaque id, not by assuming a local filesystem path. + // + // TODO(skills-extension): for plugin-installed skills, route reads + // through the materialized plugin cache/root that produced the catalog + // entry, while keeping the public id opaque and authority-bound. + } + + fn search( + &self, + _request: SkillSearchRequest, + ) -> impl Future> + Send { + future::ready(Ok(SkillSearchResult::default())) + // TODO(skills-extension): decide whether host skills need search, or + // whether direct read by opaque resource id is enough. + } +} diff --git a/codex-rs/ext/skills/src/providers/mod.rs b/codex-rs/ext/skills/src/providers/mod.rs new file mode 100644 index 0000000000..fcf2788480 --- /dev/null +++ b/codex-rs/ext/skills/src/providers/mod.rs @@ -0,0 +1,50 @@ +mod executor; +mod host; +mod remote; + +use crate::catalog::SkillCatalog; +use crate::catalog::SkillProviderResult; +use crate::provider::SkillListQuery; +use crate::provider::SkillProvider; + +use executor::ExecutorSkillProvider; +use host::HostSkillProvider; +use remote::RemoteSkillProvider; + +#[derive(Clone, Debug, Default)] +pub(crate) struct SkillProviders { + host: HostSkillProvider, + executor: ExecutorSkillProvider, + remote: RemoteSkillProvider, +} + +impl SkillProviders { + pub(crate) async fn list_for_turn( + &self, + query: SkillListQuery, + ) -> SkillProviderResult { + let mut catalog = SkillCatalog::default(); + + if query.include_host_skills { + catalog.extend(self.host.list(query.clone()).await?); + } + + if !query.executor_authorities.is_empty() { + catalog.extend(self.executor.list(query.clone()).await?); + } + + if query.include_remote_skills { + catalog.extend(self.remote.list(query).await?); + } + + // TODO(skills-extension): apply final merged-catalog policy here: + // source precedence, duplicate name handling, disabled-skill rules, + // product/session-source filtering, and telemetry for omitted entries. + // + // TODO(skills-extension): treat plugin-installed skills as ordinary + // host catalog entries by this point. Plugin identity may remain useful + // for display, auth, and uninstall flows, but mention resolution and + // entrypoint injection should not special-case plugin packaging. + Ok(catalog) + } +} diff --git a/codex-rs/ext/skills/src/providers/remote.rs b/codex-rs/ext/skills/src/providers/remote.rs new file mode 100644 index 0000000000..22dd3368a5 --- /dev/null +++ b/codex-rs/ext/skills/src/providers/remote.rs @@ -0,0 +1,58 @@ +use std::future; + +use crate::catalog::SkillCatalog; +use crate::catalog::SkillProviderResult; +use crate::catalog::SkillReadResult; +use crate::catalog::SkillSearchResult; +use crate::provider::SkillListQuery; +use crate::provider::SkillProvider; +use crate::provider::SkillReadRequest; +use crate::provider::SkillSearchRequest; + +#[derive(Clone, Debug, Default)] +pub(crate) struct RemoteSkillProvider; + +impl SkillProvider for RemoteSkillProvider { + fn list( + &self, + _query: SkillListQuery, + ) -> impl Future> + Send { + future::ready(Ok(SkillCatalog::default())) + // TODO(skills-extension): list org/account/backend skills from a + // remote catalog only when they are not installed/materialized into the + // host. These skills should use opaque ids and backend authority, not + // paths. + // + // TODO(skills-extension): if a remote skill is downloaded or installed + // as part of a plugin-like bundle, hand it to HostSkillProvider for + // runtime listing/read instead of keeping a separate remote runtime + // path. + // + // TODO(skills-extension): decide how org policy and local enable/disable + // rules combine when the backend supplies a managed skill catalog. + } + + fn read( + &self, + request: SkillReadRequest, + ) -> impl Future> + Send { + future::ready(Err(crate::catalog::SkillProviderError { + message: format!( + "remote skill resource `{}` is not implemented", + request.resource.0 + ), + })) + // TODO(skills-extension): read remote skill entrypoints and supporting + // files through authenticated backend APIs. + } + + fn search( + &self, + _request: SkillSearchRequest, + ) -> impl Future> + Send { + future::ready(Ok(SkillSearchResult::default())) + // TODO(skills-extension): expose model-facing skills/search or resource + // APIs for large remote packages so the model can progressively + // disclose supporting files without filesystem access. + } +} diff --git a/codex-rs/ext/skills/src/state.rs b/codex-rs/ext/skills/src/state.rs new file mode 100644 index 0000000000..d6c355ed34 --- /dev/null +++ b/codex-rs/ext/skills/src/state.rs @@ -0,0 +1,24 @@ +use codex_core::config::Config; + +use crate::catalog::SkillCatalog; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SkillsExtensionConfig { + pub(crate) include_instructions: bool, + pub(crate) bundled_skills_enabled: bool, +} + +impl SkillsExtensionConfig { + pub(crate) fn from_config(config: &Config) -> Self { + Self { + include_instructions: config.include_skill_instructions, + bundled_skills_enabled: config.bundled_skills_enabled(), + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct SkillsTurnState { + pub(crate) catalog: SkillCatalog, + pub(crate) entrypoints_injected: bool, +} From 6471f8b31aba50c87d5616cc0a203160a80b5322 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 2 Jun 2026 16:22:32 -0700 Subject: [PATCH 02/16] [codex] Fix Windows BuildBuddy Bazel wrapper execution (#25915) ## Why #25156 moved Bazel CI launches into a shared Python wrapper. On Windows, launching Bazel with `os.execvp` can split the spaced `--test_env=PATH=...` argument and fail to propagate the eventual Bazel exit status, allowing jobs to pass without running tests. This reapplies the wrapper after #25909 with a Windows-safe launch path. ## What changed Use a waited `subprocess.run` launch on Windows while preserving `os.execvp` on Unix. Add a process-level regression test for spaced arguments and child exit status, and run it on Windows Bazel shard 1. ## Experiment To confirm Bazel was actually invoking tests, patch `87b61d0be6` temporarily added an intentionally failing `codex-core` unit test. Bazel failed on that sentinel on all three major platforms: - [Linux Bazel test](https://github.com/openai/codex/actions/runs/26841132773/job/79151062486) - [macOS Bazel test](https://github.com/openai/codex/actions/runs/26841132773/job/79151062362) - [Windows Bazel test shard 1/4](https://github.com/openai/codex/actions/runs/26841132773/job/79151062155) The sentinel was removed after collecting this evidence. Windows Bazel [clippy](https://github.com/openai/codex/actions/runs/26841132773/job/79151062914) and [release verification](https://github.com/openai/codex/actions/runs/26841132773/job/79151062739) also passed. ## Validation After removing the sentinel, `just test -p codex-core` no longer reported it. The local run retained two unrelated environment-specific failures. --- .bazelrc | 46 ++-- .github/scripts/run-bazel-ci.sh | 126 ++++------- .github/scripts/run-bazel-query-ci.sh | 56 +---- .github/scripts/run_bazel_with_buildbuddy.py | 147 ++++++++++++ .github/scripts/rusty_v8_bazel.py | 63 +++--- .../scripts/test_run_bazel_with_buildbuddy.py | 214 ++++++++++++++++++ .github/scripts/test_rusty_v8_bazel.py | 49 ++-- .github/workflows/bazel.yml | 13 +- .github/workflows/rusty-v8-release.yml | 5 +- .github/workflows/v8-canary.yml | 7 +- codex-rs/docs/bazel.md | 114 +++++++++- justfile | 11 +- scripts/list-bazel-clippy-targets.sh | 24 +- 13 files changed, 657 insertions(+), 218 deletions(-) create mode 100755 .github/scripts/run_bazel_with_buildbuddy.py create mode 100644 .github/scripts/test_run_bazel_with_buildbuddy.py diff --git a/.bazelrc b/.bazelrc index 6357eb83e4..e39a3aff22 100644 --- a/.bazelrc +++ b/.bazelrc @@ -38,24 +38,50 @@ common:windows --test_env=WINDIR common --test_env=RUST_MIN_STACK=8388608 # 8 MiB common --test_output=errors -common --bes_results_url=https://app.buildbuddy.io/invocation/ -common --bes_backend=grpcs://remote.buildbuddy.io -common --remote_cache=grpcs://remote.buildbuddy.io -common --remote_download_toplevel common --nobuild_runfile_links +# These settings tune BuildBuddy/RBE behavior but do not contact a remote +# service unless a `buildbuddy-*` configuration below supplies an endpoint. +common --remote_download_toplevel common --remote_timeout=3600 common --noexperimental_throttle_remote_action_building common --experimental_remote_execution_keepalive common --grpc_keepalive_time=30s -common --experimental_remote_downloader=grpcs://remote.buildbuddy.io + +# Opt-in remote configurations selected by +# `.github/scripts/run_bazel_with_buildbuddy.py`. Plain Bazel commands do not +# contact BuildBuddy unless a user selects one of these configurations. +# Use the generic host for cache, BES, and downloads without remote execution. +common:buildbuddy-generic --bes_backend=grpcs://remote.buildbuddy.io +common:buildbuddy-generic --bes_results_url=https://app.buildbuddy.io/invocation/ +common:buildbuddy-generic --remote_cache=grpcs://remote.buildbuddy.io +common:buildbuddy-generic --experimental_remote_downloader=grpcs://remote.buildbuddy.io + +# Add remote execution on the generic host. +common:buildbuddy-generic-rbe --config=buildbuddy-generic +common:buildbuddy-generic-rbe --config=remote +common:buildbuddy-generic-rbe --remote_executor=grpcs://remote.buildbuddy.io + +# Use the OpenAI tenant for cache, BES, and downloads without remote execution. +common:buildbuddy-openai --bes_backend=grpcs://openai.buildbuddy.io +common:buildbuddy-openai --bes_results_url=https://openai.buildbuddy.io/invocation/ +common:buildbuddy-openai --remote_cache=grpcs://openai.buildbuddy.io +common:buildbuddy-openai --experimental_remote_downloader=grpcs://openai.buildbuddy.io + +# Add remote execution on the OpenAI tenant. +common:buildbuddy-openai-rbe --config=buildbuddy-openai +common:buildbuddy-openai-rbe --config=remote +common:buildbuddy-openai-rbe --remote_executor=grpcs://openai.buildbuddy.io # This limits both in-flight executions and concurrent downloads. Even with high number # of jobs execution will still be limited by CPU cores, so this just pays a bit of # memory in exchange for higher download concurrency. common --jobs=30 +# Shared remote execution policy. The endpoint-bearing `buildbuddy-*-rbe` +# configurations include this group; CI configs override TestRunner below +# when tests must remain local on their runner. +common:remote --strategy=remote common:remote --extra_execution_platforms=//:rbe -common:remote --remote_executor=grpcs://remote.buildbuddy.io common:remote --jobs=800 # TODO(team): Evaluate if this actually helps, zbarsky is not sure, everything seems bottlenecked on `core` either way. # Enable pipelined compilation since we are not bound by local CPU count. @@ -146,15 +172,11 @@ common:ci-windows --repo_contents_cache=D:/a/.cache/bazel-repo-contents-cache # Linux crossbuilds don't work until we untangle the libc constraint mess. common:ci-linux --config=ci-bazel common:ci-linux --build_metadata=TAG_os=linux -common:ci-linux --config=remote -common:ci-linux --strategy=remote common:ci-linux --platforms=//:rbe # On mac, we can run all the build actions remotely but test actions locally. common:ci-macos --config=ci-bazel common:ci-macos --build_metadata=TAG_os=macos -common:ci-macos --config=remote -common:ci-macos --strategy=remote common:ci-macos --strategy=TestRunner=darwin-sandbox,local # On Windows, use Linux remote execution for build actions but keep test actions @@ -162,9 +184,7 @@ common:ci-macos --strategy=TestRunner=darwin-sandbox,local # still run against Windows binaries. common:ci-windows-cross --config=ci-windows common:ci-windows-cross --build_metadata=TAG_windows_cross_compile=true -common:ci-windows-cross --config=remote common:ci-windows-cross --host_platform=//:rbe -common:ci-windows-cross --strategy=remote common:ci-windows-cross --strategy=TestRunner=local common:ci-windows-cross --local_test_jobs=4 common:ci-windows-cross --test_env=RUST_TEST_THREADS=1 @@ -180,8 +200,6 @@ common:ci-windows-cross --extra_toolchains=//:windows_gnullvm_tests_on_msvc_host common:ci-v8 --config=ci common:ci-v8 --build_metadata=TAG_workflow=v8 common:ci-v8 --build_metadata=TAG_os=linux -common:ci-v8 --config=remote -common:ci-v8 --strategy=remote # Source-built Bazel V8 artifacts use the in-process sandbox by default. This # does not affect Cargo's default prebuilt rusty_v8 path. diff --git a/.github/scripts/run-bazel-ci.sh b/.github/scripts/run-bazel-ci.sh index f98e4d8cb9..89f937a998 100755 --- a/.github/scripts/run-bazel-ci.sh +++ b/.github/scripts/run-bazel-ci.sh @@ -53,11 +53,20 @@ fi run_bazel() { if [[ "${RUNNER_OS:-}" == "Windows" ]]; then - MSYS2_ARG_CONV_EXCL='*' bazel "$@" + MSYS2_ARG_CONV_EXCL='*' "$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@" return fi - bazel "$@" + "$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@" +} + +run_bazel_with_startup_args() { + if (( ${#bazel_startup_args[@]} > 0 )); then + run_bazel "${bazel_startup_args[@]}" "$@" + return + fi + + run_bazel "$@" } ci_config=ci-linux @@ -77,23 +86,16 @@ esac print_bazel_test_log_tails() { local console_log="$1" local testlogs_dir - local -a bazel_info_cmd=(bazel) + local -a bazel_info_args=(info) - - if (( ${#bazel_startup_args[@]} > 0 )); then - bazel_info_cmd+=("${bazel_startup_args[@]}") - fi - - # `bazel info` needs the same CI config as the failed test invocation so - # platform-specific output roots match. On Windows, omitting `ci-windows` - # would point at `local_windows-fastbuild` even when the test ran with the - # MSVC host platform under `local_windows_msvc-fastbuild`. if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then - bazel_info_args+=( - "--config=${ci_config}" - "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - ) + # `bazel info` needs the same CI config as the failed test invocation so + # platform-specific output roots match. On Windows, omitting `ci-windows` + # would point at `local_windows-fastbuild` even when the test ran with the + # MSVC host platform under `local_windows_msvc-fastbuild`. + bazel_info_args+=("--config=${ci_config}") fi + # Only pass flags that affect Bazel's output-root selection or repository # lookup. Test/build-only flags such as execution logs or remote download # mode can make `bazel info` fail, which would hide the real test log path. @@ -105,7 +107,7 @@ print_bazel_test_log_tails() { esac done - testlogs_dir="$(run_bazel "${bazel_info_cmd[@]:1}" \ + testlogs_dir="$(run_bazel_with_startup_args \ --noexperimental_remote_repo_contents_cache \ "${bazel_info_args[@]}" \ bazel-testlogs 2>/dev/null || echo bazel-testlogs)" @@ -254,8 +256,9 @@ if [[ ${#bazel_args[@]} -eq 0 || ${#bazel_targets[@]} -eq 0 ]]; then fi if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -z "${BUILDBUDDY_API_KEY:-}" ]]; then - # Fork PRs do not receive the BuildBuddy secret needed for the remote - # cross-compile config. Preserve the previous local Windows build shape. + # Windows cross-compilation depends on authenticated RBE. Preserve the local + # Windows build shape when credentials are unavailable. + ci_config=ci-windows windows_msvc_host_platform=1 fi @@ -297,9 +300,9 @@ if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -n "${BUI fi if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -z "${BUILDBUDDY_API_KEY:-}" ]]; then - # The Windows cross-compile config depends on remote execution. Fork PRs do - # not receive the BuildBuddy secret, so fall back to the existing local build - # shape and keep its lower concurrency cap. + # The Windows cross-compile config depends on authenticated remote + # execution. When credentials are unavailable, keep the local build shape + # and its lower concurrency cap. post_config_bazel_args+=(--jobs=8) fi @@ -377,70 +380,31 @@ fi bazel_console_log="$(mktemp)" trap 'rm -f "$bazel_console_log"' EXIT -bazel_cmd=(bazel) -if (( ${#bazel_startup_args[@]} > 0 )); then - bazel_cmd+=("${bazel_startup_args[@]}") -fi - +bazel_run_args=( + "${bazel_args[@]}" +) if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then echo "BuildBuddy API key is available; using remote Bazel configuration." - # Work around Bazel 9 remote repo contents cache / overlay materialization failures - # seen in CI (for example "is not a symlink" or permission errors while - # materializing external repos such as rules_perl). We still use BuildBuddy for - # remote execution/cache; this only disables the startup-level repo contents cache. - bazel_run_args=( - "${bazel_args[@]}" - "--config=${ci_config}" - "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - ) - if (( ${#post_config_bazel_args[@]} > 0 )); then - bazel_run_args+=("${post_config_bazel_args[@]}") - fi - set +e - run_bazel "${bazel_cmd[@]:1}" \ - --noexperimental_remote_repo_contents_cache \ - "${bazel_run_args[@]}" \ - -- \ - "${bazel_targets[@]}" \ - 2>&1 | tee "$bazel_console_log" - bazel_status=${PIPESTATUS[0]} - set -e + bazel_run_args+=("--config=${ci_config}") else echo "BuildBuddy API key is not available; using local Bazel configuration." - # Keep fork/community PRs on Bazel but disable remote services that are - # configured in .bazelrc and require auth. - # - # Flag docs: - # - Command-line reference: https://bazel.build/reference/command-line-reference - # - Remote caching overview: https://bazel.build/remote/caching - # - Remote execution overview: https://bazel.build/remote/rbe - # - Build Event Protocol overview: https://bazel.build/remote/bep - # - # --noexperimental_remote_repo_contents_cache: - # disable remote repo contents cache enabled in .bazelrc startup options. - # https://bazel.build/reference/command-line-reference#startup_options-flag--experimental_remote_repo_contents_cache - # --remote_cache= and --remote_executor=: - # clear remote cache/execution endpoints configured in .bazelrc. - # https://bazel.build/reference/command-line-reference#common_options-flag--remote_cache - # https://bazel.build/reference/command-line-reference#common_options-flag--remote_executor - bazel_run_args=( - "${bazel_args[@]}" - --remote_cache= - --remote_executor= - ) - if (( ${#post_config_bazel_args[@]} > 0 )); then - bazel_run_args+=("${post_config_bazel_args[@]}") - fi - set +e - run_bazel "${bazel_cmd[@]:1}" \ - --noexperimental_remote_repo_contents_cache \ - "${bazel_run_args[@]}" \ - -- \ - "${bazel_targets[@]}" \ - 2>&1 | tee "$bazel_console_log" - bazel_status=${PIPESTATUS[0]} - set -e fi +if (( ${#post_config_bazel_args[@]} > 0 )); then + bazel_run_args+=("${post_config_bazel_args[@]}") +fi +set +e +# Work around Bazel 9 remote repo contents cache / overlay materialization +# failures seen in CI (for example "is not a symlink" or permission errors +# while materializing external repos such as rules_perl). This only disables +# the startup-level repo contents cache; keyed runs still use BuildBuddy. +run_bazel_with_startup_args \ + --noexperimental_remote_repo_contents_cache \ + "${bazel_run_args[@]}" \ + -- \ + "${bazel_targets[@]}" \ + 2>&1 | tee "$bazel_console_log" +bazel_status=${PIPESTATUS[0]} +set -e if [[ ${bazel_status:-0} -ne 0 ]]; then if [[ $print_failed_bazel_action_summary -eq 1 ]]; then diff --git a/.github/scripts/run-bazel-query-ci.sh b/.github/scripts/run-bazel-query-ci.sh index dd03b67169..f5d4f56f49 100755 --- a/.github/scripts/run-bazel-query-ci.sh +++ b/.github/scripts/run-bazel-query-ci.sh @@ -2,48 +2,17 @@ set -euo pipefail -# Run Bazel queries with the same CI startup settings as the main build/test -# invocation so target-discovery queries can reuse the same Bazel server. +# Run target-discovery queries with the same startup settings as the main +# build/test invocation so they can reuse the same Bazel server. Queries only +# enumerate labels, so they intentionally do not select CI or remote configs. -query_args=() -windows_cross_compile=0 -while [[ $# -gt 0 ]]; do - case "$1" in - --windows-cross-compile) - windows_cross_compile=1 - shift - ;; - --) - shift - break - ;; - *) - query_args+=("$1") - shift - ;; - esac -done - -if [[ $# -ne 1 ]]; then - echo "Usage: $0 [--windows-cross-compile] [...] -- " >&2 +if [[ $# -lt 2 || "${@: -2:1}" != "--" ]]; then + echo "Usage: $0 [...] -- " >&2 exit 1 fi -query_expression="$1" - -ci_config=ci-linux -case "${RUNNER_OS:-}" in - macOS) - ci_config=ci-macos - ;; - Windows) - if [[ $windows_cross_compile -eq 1 ]]; then - ci_config=ci-windows-cross - else - ci_config=ci-windows - fi - ;; -esac +query_args=("${@:1:$#-2}") +query_expression="${@: -1}" bazel_startup_args=() if [[ -n "${BAZEL_OUTPUT_USER_ROOT:-}" ]]; then @@ -60,12 +29,6 @@ run_bazel() { } bazel_query_args=(--noexperimental_remote_repo_contents_cache query) -if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then - bazel_query_args+=( - "--config=${ci_config}" - "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - ) -fi if [[ -n "${BAZEL_REPO_CONTENTS_CACHE:-}" ]]; then bazel_query_args+=("--repo_contents_cache=${BAZEL_REPO_CONTENTS_CACHE}") @@ -75,7 +38,10 @@ if [[ -n "${BAZEL_REPOSITORY_CACHE:-}" ]]; then bazel_query_args+=("--repository_cache=${BAZEL_REPOSITORY_CACHE}") fi -bazel_query_args+=("${query_args[@]}" "$query_expression") +if (( ${#query_args[@]} > 0 )); then + bazel_query_args+=("${query_args[@]}") +fi +bazel_query_args+=("$query_expression") if (( ${#bazel_startup_args[@]} > 0 )); then run_bazel "${bazel_startup_args[@]}" "${bazel_query_args[@]}" diff --git a/.github/scripts/run_bazel_with_buildbuddy.py b/.github/scripts/run_bazel_with_buildbuddy.py new file mode 100755 index 0000000000..f9f329d686 --- /dev/null +++ b/.github/scripts/run_bazel_with_buildbuddy.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 + +import json +import os +import subprocess +import sys +from collections.abc import Mapping +from collections.abc import Sequence +from pathlib import Path + + +OPENAI_REPOSITORY = "openai/codex" +# Remote configurations select cache/BES/download endpoints. Their -rbe forms +# also select the matching remote executor endpoint. +GENERIC_REMOTE_CONFIG = "buildbuddy-generic" +OPENAI_REMOTE_CONFIG = "buildbuddy-openai" +# These CI configurations require remote build execution. The wrapper supplies +# an RBE configuration, which also includes the common `remote` settings. +REMOTE_EXECUTION_CONFIGS = { + "--config=ci-linux", + "--config=ci-macos", + "--config=ci-v8", + "--config=ci-windows-cross", +} +# Only authenticated workflow runs executing trusted upstream code may use the +# OpenAI BuildBuddy host. A pull request event without proof that its head is +# in the upstream repository fails closed to the generic host. +def is_trusted_upstream_run(env: Mapping[str, str]) -> bool: + # `GITHUB_REPOSITORY` is easy to set locally. Requiring GitHub's workflow + # marker prevents a local command from opting itself into the OpenAI host. + if ( + env.get("GITHUB_ACTIONS") != "true" + or env.get("GITHUB_REPOSITORY") != OPENAI_REPOSITORY + ): + return False + # Non-PR workflow runs in `openai/codex` execute upstream refs, so they are + # trusted. Fork code reaches these workflows only through pull requests. + if env.get("GITHUB_EVENT_NAME") != "pull_request": + return True + + event_path = env.get("GITHUB_EVENT_PATH") + if not event_path: + return False + try: + event = json.loads(Path(event_path).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return False + + try: + return event["pull_request"]["head"]["repo"]["fork"] is False + except (KeyError, TypeError): + return False + + +def uses_openai_host(env: Mapping[str, str]) -> bool: + return bool(env.get("BUILDBUDDY_API_KEY")) and is_trusted_upstream_run(env) + + +def uses_remote_execution(args: Sequence[str]) -> bool: + try: + separator_idx = args.index("--") + except ValueError: + separator_idx = len(args) + return any(arg in REMOTE_EXECUTION_CONFIGS for arg in args[:separator_idx]) + + +def remote_config(args: Sequence[str], env: Mapping[str, str]) -> str | None: + if not env.get("BUILDBUDDY_API_KEY"): + return None + + config = OPENAI_REMOTE_CONFIG if uses_openai_host(env) else GENERIC_REMOTE_CONFIG + if uses_remote_execution(args): + config += "-rbe" + return config + + +def bazel_args_without_remote_execution(args: Sequence[str]) -> list[str]: + # Remote CI configs require BuildBuddy credentials. Removing them preserves + # the local fallback used for fork pull requests. + try: + separator_idx = args.index("--") + except ValueError: + separator_idx = len(args) + return [ + *(arg for arg in args[:separator_idx] if arg not in REMOTE_EXECUTION_CONFIGS), + *args[separator_idx:], + ] + + +def bazel_args_with_remote_config( + args: Sequence[str], env: Mapping[str, str] +) -> list[str]: + config = remote_config(args, env) + if config is None: + return bazel_args_without_remote_execution(args) + + # `remote_config()` returns a configuration only when this key is present. + api_key = env["BUILDBUDDY_API_KEY"] + remote_args = [ + f"--config={config}", + f"--remote_header=x-buildbuddy-api-key={api_key}", + ] + + # Insert immediately after the Bazel command. This keeps wrapper-added + # options out of positional payloads and lets later CI configs override + # shared RBE defaults such as the Windows cross-compilation exec platforms. + insertion_idx = next( + (idx + 1 for idx, arg in enumerate(args) if not arg.startswith("-")), + len(args), + ) + return [*args[:insertion_idx], *remote_args, *args[insertion_idx:]] + + +def bazel_command(*args: str, env: Mapping[str, str] | None = None) -> list[str]: + env = os.environ if env is None else env + bazel = env.get("CODEX_BAZEL_BIN", "bazel") + return [bazel, *bazel_args_with_remote_config(args, env)] + + +def main() -> None: + config = remote_config(sys.argv[1:], os.environ) + if config is None: + print( + "BuildBuddy key unavailable; using local Bazel configuration.", + file=sys.stderr, + ) + else: + host_description = ( + "OpenAI tenant" if uses_openai_host(os.environ) else "generic" + ) + print( + f"Using {host_description} BuildBuddy configuration: {config}.", + file=sys.stderr, + ) + + command = bazel_command(*sys.argv[1:]) + if os.name == "nt": + # Windows CRT exec can split arguments containing spaces and lose the + # eventual child exit status. Wait for Bazel and propagate its status. + result = subprocess.run(command, check=False) + raise SystemExit(result.returncode) + + os.execvp(command[0], command) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/rusty_v8_bazel.py b/.github/scripts/rusty_v8_bazel.py index 2f46daf45e..329d3f6c54 100644 --- a/.github/scripts/rusty_v8_bazel.py +++ b/.github/scripts/rusty_v8_bazel.py @@ -5,7 +5,6 @@ from __future__ import annotations import argparse import gzip import hashlib -import os import re import shutil import subprocess @@ -13,6 +12,7 @@ import sys import tomllib from pathlib import Path +from run_bazel_with_buildbuddy import bazel_command from rusty_v8_module_bazel import ( RustyV8ChecksumError, check_module_bazel, @@ -29,33 +29,22 @@ SANDBOX_ARTIFACT_PROFILE = "ptrcomp_sandbox_release" ARTIFACT_BAZEL_CONFIGS = ["rusty-v8-upstream-libcxx"] -def bazel_remote_args() -> list[str]: - buildbuddy_api_key = os.environ.get("BUILDBUDDY_API_KEY") - if not buildbuddy_api_key: - return [] - return [f"--remote_header=x-buildbuddy-api-key={buildbuddy_api_key}"] - - def bazel_execroot() -> Path: - result = subprocess.run( - ["bazel", "info", "execution_root"], + output = subprocess.check_output( + bazel_command("info", "execution_root"), cwd=ROOT, - check=True, - capture_output=True, text=True, ) - return Path(result.stdout.strip()) + return Path(output.strip()) def bazel_output_base() -> Path: - result = subprocess.run( - ["bazel", "info", "output_base"], + output = subprocess.check_output( + bazel_command("info", "output_base"), cwd=ROOT, - check=True, - capture_output=True, text=True, ) - return Path(result.stdout.strip()) + return Path(output.strip()) def bazel_output_path(path: str) -> Path: @@ -72,24 +61,22 @@ def bazel_output_files( ) -> list[Path]: expression = "set(" + " ".join(labels) + ")" bazel_configs = bazel_configs or [] - result = subprocess.run( - [ - "bazel", + output = subprocess.check_output( + bazel_command( "cquery", "-c", compilation_mode, f"--platforms=@llvm//platforms:{platform}", *[f"--config={config}" for config in bazel_configs], - *bazel_remote_args(), "--output=files", expression, - ], + ), cwd=ROOT, - check=True, - capture_output=True, text=True, ) - return [bazel_output_path(line.strip()) for line in result.stdout.splitlines() if line.strip()] + return [ + bazel_output_path(line.strip()) for line in output.splitlines() if line.strip() + ] def bazel_build( @@ -102,17 +89,15 @@ def bazel_build( bazel_configs = bazel_configs or [] download_args = ["--remote_download_toplevel"] if download_toplevel else [] subprocess.run( - [ - "bazel", + bazel_command( "build", "-c", compilation_mode, f"--platforms=@llvm//platforms:{platform}", *[f"--config={config}" for config in bazel_configs], - *bazel_remote_args(), *download_args, *labels, - ], + ), cwd=ROOT, check=True, ) @@ -172,7 +157,7 @@ def resolved_v8_crate_version() -> str: matches = sorted( set( re.findall( - r'https://static\.crates\.io/crates/v8/v8-([0-9]+\.[0-9]+\.[0-9]+)\.crate', + r"https://static\.crates\.io/crates/v8/v8-([0-9]+\.[0-9]+\.[0-9]+)\.crate", module_bazel, ) ) @@ -234,13 +219,17 @@ def stage_artifacts( output_dir: Path, sandbox: bool, ) -> None: - missing_paths = [str(path) for path in [lib_path, binding_path] if not path.exists()] + missing_paths = [ + str(path) for path in [lib_path, binding_path] if not path.exists() + ] if missing_paths: raise SystemExit(f"missing release outputs for {target}: {missing_paths}") output_dir.mkdir(parents=True, exist_ok=True) artifact_profile = SANDBOX_ARTIFACT_PROFILE if sandbox else RELEASE_ARTIFACT_PROFILE - staged_library = output_dir / staged_archive_name(target, lib_path, artifact_profile) + staged_library = output_dir / staged_archive_name( + target, lib_path, artifact_profile + ) staged_binding = output_dir / staged_binding_name(target, artifact_profile) with lib_path.open("rb") as src, staged_library.open("wb") as dst: @@ -270,7 +259,9 @@ def stage_artifacts( def upstream_release_pair_paths(source_root: Path, target: str) -> tuple[Path, Path]: - lib_name = "rusty_v8.lib" if target.endswith("-pc-windows-msvc") else "librusty_v8.a" + lib_name = ( + "rusty_v8.lib" if target.endswith("-pc-windows-msvc") else "librusty_v8.a" + ) gn_out = source_root / "target" / target / "release" / "gn_out" return gn_out / "obj" / lib_name, gn_out / "src_binding.rs" @@ -338,7 +329,9 @@ def parse_args() -> argparse.Namespace: stage_upstream_release_pair_parser = subparsers.add_parser( "stage-upstream-release-pair" ) - stage_upstream_release_pair_parser.add_argument("--source-root", type=Path, required=True) + stage_upstream_release_pair_parser.add_argument( + "--source-root", type=Path, required=True + ) stage_upstream_release_pair_parser.add_argument("--target", required=True) stage_upstream_release_pair_parser.add_argument("--output-dir", required=True) stage_upstream_release_pair_parser.add_argument("--sandbox", action="store_true") diff --git a/.github/scripts/test_run_bazel_with_buildbuddy.py b/.github/scripts/test_run_bazel_with_buildbuddy.py new file mode 100644 index 0000000000..0f594b7947 --- /dev/null +++ b/.github/scripts/test_run_bazel_with_buildbuddy.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 + +import json +import os +import subprocess +import sys +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +import run_bazel_with_buildbuddy + + +class RunBazelWithBuildBuddyTest(unittest.TestCase): + def github_env( + self, + temp_dir: str, + *, + repository: str = "openai/codex", + fork: bool = False, + event_name: str = "pull_request", + ) -> dict[str, str]: + event_path = Path(temp_dir) / "event.json" + event_path.write_text( + json.dumps({"pull_request": {"head": {"repo": {"fork": fork}}}}), + encoding="utf-8", + ) + return { + "BUILDBUDDY_API_KEY": "token", + "GITHUB_ACTIONS": "true", + "GITHUB_EVENT_NAME": event_name, + "GITHUB_EVENT_PATH": str(event_path), + "GITHUB_REPOSITORY": repository, + } + + def test_keyless_invocation_drops_remote_ci_configuration(self) -> None: + self.assertIsNone( + run_bazel_with_buildbuddy.remote_config( + ["build", "--config=ci-linux", "//codex-rs/cli:codex"], + {}, + ) + ) + self.assertEqual( + run_bazel_with_buildbuddy.bazel_args_with_remote_config( + ["build", "--config=ci-linux", "--", "//codex-rs/cli:codex"], + {}, + ), + ["build", "--", "//codex-rs/cli:codex"], + ) + + def test_program_arguments_after_separator_do_not_select_or_lose_rbe(self) -> None: + args = ["run", "//codex-rs/cli:codex", "--", "--config=remote"] + + self.assertEqual( + run_bazel_with_buildbuddy.bazel_args_with_remote_config(args, {}), + args, + ) + self.assertEqual( + run_bazel_with_buildbuddy.remote_config( + args, {"BUILDBUDDY_API_KEY": "fork-token"} + ), + "buildbuddy-generic", + ) + + def test_upstream_push_selects_openai_rbe_before_target_separator(self) -> None: + with TemporaryDirectory() as temp_dir: + env = self.github_env(temp_dir, event_name="push") + + self.assertEqual( + run_bazel_with_buildbuddy.bazel_args_with_remote_config( + ["build", "--config=ci-linux", "--", "//codex-rs/cli:codex"], + env, + ), + [ + "build", + "--config=buildbuddy-openai-rbe", + "--remote_header=x-buildbuddy-api-key=token", + "--config=ci-linux", + "--", + "//codex-rs/cli:codex", + ], + ) + + def test_windows_cross_ci_configuration_follows_remote_configuration(self) -> None: + env = {"BUILDBUDDY_API_KEY": "fork-token"} + + self.assertEqual( + run_bazel_with_buildbuddy.bazel_args_with_remote_config( + ["build", "--config=ci-windows-cross", "//codex-rs/cli:codex"], + env, + ), + [ + "build", + "--config=buildbuddy-generic-rbe", + "--remote_header=x-buildbuddy-api-key=fork-token", + "--config=ci-windows-cross", + "//codex-rs/cli:codex", + ], + ) + + def test_query_remote_configuration_is_inserted_before_expression(self) -> None: + expression = 'kind("rust_library rule", //codex-rs/...)' + env = {"BUILDBUDDY_API_KEY": "fork-token"} + + for command in ("query", "cquery", "aquery"): + with self.subTest(command=command): + self.assertEqual( + run_bazel_with_buildbuddy.bazel_args_with_remote_config( + [ + command, + "--config=ci-windows-cross", + "--output=label", + expression, + ], + env, + ), + [ + command, + "--config=buildbuddy-generic-rbe", + "--remote_header=x-buildbuddy-api-key=fork-token", + "--config=ci-windows-cross", + "--output=label", + expression, + ], + ) + + def test_same_repository_pull_request_selects_openai_host(self) -> None: + with TemporaryDirectory() as temp_dir: + self.assertEqual( + run_bazel_with_buildbuddy.remote_config( + ["build", "--config=ci-v8"], self.github_env(temp_dir) + ), + "buildbuddy-openai-rbe", + ) + + def test_fork_pull_request_cannot_select_openai_host(self) -> None: + with TemporaryDirectory() as temp_dir: + env = self.github_env(temp_dir, fork=True) + + self.assertEqual( + run_bazel_with_buildbuddy.remote_config( + ["build", "--config=ci-v8"], env + ), + "buildbuddy-generic-rbe", + ) + + def test_run_in_fork_repository_cannot_select_openai_host(self) -> None: + with TemporaryDirectory() as temp_dir: + env = self.github_env(temp_dir, repository="contributor/codex") + + self.assertEqual( + run_bazel_with_buildbuddy.remote_config( + ["build", "--config=ci-v8"], env + ), + "buildbuddy-generic-rbe", + ) + + def test_pull_request_without_readable_event_payload_fails_closed(self) -> None: + for event_path in (None, "missing-event.json"): + env = { + "BUILDBUDDY_API_KEY": "token", + "GITHUB_ACTIONS": "true", + "GITHUB_EVENT_NAME": "pull_request", + "GITHUB_REPOSITORY": "openai/codex", + } + if event_path is not None: + env["GITHUB_EVENT_PATH"] = event_path + + with self.subTest(event_path=event_path): + self.assertEqual( + run_bazel_with_buildbuddy.remote_config(["build"], env), + "buildbuddy-generic", + ) + + def test_bazel_command_uses_configured_binary_locally(self) -> None: + self.assertEqual( + run_bazel_with_buildbuddy.bazel_command( + "info", + "execution_root", + env={"CODEX_BAZEL_BIN": "fake-bazel"}, + ), + ["fake-bazel", "info", "execution_root"], + ) + + def test_main_preserves_spaced_argument_and_child_exit_status(self) -> None: + spaced_arg = ( + r"--test_env=PATH=C:\Program Files\PowerShell\7;C:\Program Files\Git\bin" + ) + child_code = ( + f"import sys; sys.exit(37 if sys.argv[1] == {spaced_arg!r} else 91)" + ) + env = os.environ.copy() + env["CODEX_BAZEL_BIN"] = sys.executable + env.pop("BUILDBUDDY_API_KEY", None) + + result = subprocess.run( + [ + sys.executable, + str(Path(run_bazel_with_buildbuddy.__file__)), + "-c", + child_code, + spaced_arg, + ], + env=env, + check=False, + capture_output=True, + text=True, + ) + + self.assertEqual(result.returncode, 37, result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test_rusty_v8_bazel.py b/.github/scripts/test_rusty_v8_bazel.py index 19690dbece..0b5c03f436 100644 --- a/.github/scripts/test_rusty_v8_bazel.py +++ b/.github/scripts/test_rusty_v8_bazel.py @@ -88,24 +88,49 @@ class RustyV8BazelTest(unittest.TestCase): ), ) - def test_bazel_remote_args_include_buildbuddy_header_when_present(self) -> None: - with patch.dict(environ, {"BUILDBUDDY_API_KEY": "token"}, clear=False): + def test_bazel_commands_use_shared_buildbuddy_remote_config_library(self) -> None: + with patch.dict(environ, {}, clear=True): self.assertEqual( - ["--remote_header=x-buildbuddy-api-key=token"], - rusty_v8_bazel.bazel_remote_args(), + [ + "bazel", + "build", + "//third_party/v8:release", + ], + rusty_v8_bazel.bazel_command( + "build", + "--config=ci-v8", + "//third_party/v8:release", + ), + ) + with patch.dict(environ, {"BUILDBUDDY_API_KEY": "token"}, clear=True): + self.assertEqual( + [ + "bazel", + "build", + "--config=buildbuddy-generic-rbe", + "--remote_header=x-buildbuddy-api-key=token", + "--config=ci-v8", + "//third_party/v8:release", + ], + rusty_v8_bazel.bazel_command( + "build", + "--config=ci-v8", + "//third_party/v8:release", + ), ) - with patch.dict(environ, {}, clear=True): - self.assertEqual([], rusty_v8_bazel.bazel_remote_args()) - - def test_release_pair_labels_and_staged_names_distinguish_sandbox_artifacts(self) -> None: + def test_release_pair_labels_and_staged_names_distinguish_sandbox_artifacts( + self, + ) -> None: self.assertEqual( "//third_party/v8:rusty_v8_release_pair_x86_64_unknown_linux_musl", rusty_v8_bazel.release_pair_label("x86_64-unknown-linux-musl"), ) self.assertEqual( "//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_unknown_linux_musl", - rusty_v8_bazel.release_pair_label("x86_64-unknown-linux-musl", sandbox=True), + rusty_v8_bazel.release_pair_label( + "x86_64-unknown-linux-musl", sandbox=True + ), ) self.assertEqual( "//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_apple_darwin", @@ -205,11 +230,7 @@ class RustyV8BazelTest(unittest.TestCase): with TemporaryDirectory() as source_dir, TemporaryDirectory() as output_dir: source_root = Path(source_dir) gn_out = ( - source_root - / "target" - / "x86_64-pc-windows-msvc" - / "release" - / "gn_out" + source_root / "target" / "x86_64-pc-windows-msvc" / "release" / "gn_out" ) (gn_out / "obj").mkdir(parents=True) (gn_out / "obj" / "rusty_v8.lib").write_bytes(b"archive") diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 153ace0fc9..c6dfd60231 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -15,6 +15,7 @@ concurrency: # See https://docs.github.com/en/actions/using-jobs/using-concurrency and https://docs.github.com/en/actions/learn-github-actions/contexts for more info. group: concurrency-group::${{ github.workflow }}::${{ github.event.pull_request.number > 0 && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}${{ github.ref_name == 'main' && format('::{0}', github.run_id) || ''}} cancel-in-progress: ${{ github.ref_name != 'main' }} + jobs: test: # PRs use the sharded Windows cross-compiled test jobs below. Post-merge @@ -55,12 +56,17 @@ jobs: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false + - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 + if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu' + with: + tool: just + - name: Check rusty_v8 MODULE.bazel checksums if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu' shell: bash run: | python3 .github/scripts/rusty_v8_bazel.py check-module-bazel - python3 -m unittest discover -s .github/scripts -p test_rusty_v8_bazel.py + just test-github-scripts - name: Prepare Bazel CI id: prepare_bazel @@ -152,6 +158,11 @@ jobs: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false + - name: Test BuildBuddy Bazel wrapper + if: matrix.shard == 1 + shell: pwsh + run: python .github/scripts/test_run_bazel_with_buildbuddy.py + - name: Prepare Bazel CI id: prepare_bazel uses: ./.github/actions/prepare-bazel-ci diff --git a/.github/workflows/rusty-v8-release.yml b/.github/workflows/rusty-v8-release.yml index 4b5320dcb9..d6fb73e96d 100644 --- a/.github/workflows/rusty-v8-release.yml +++ b/.github/workflows/rusty-v8-release.yml @@ -191,11 +191,10 @@ jobs: bazel_args+=(--config=v8-release-compat) fi - bazel \ + ./.github/scripts/run_bazel_with_buildbuddy.py \ --noexperimental_remote_repo_contents_cache \ "${bazel_args[@]}" \ - "--config=${{ matrix.bazel_config }}" \ - "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" + "--config=${{ matrix.bazel_config }}" - name: Stage release pair env: diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index 71ce5d7865..0ad9f850d1 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -5,6 +5,7 @@ on: paths: - ".bazelrc" - ".github/actions/setup-bazel-ci/**" + - ".github/scripts/run_bazel_with_buildbuddy.py" - ".github/scripts/rusty_v8_bazel.py" - ".github/scripts/rusty_v8_module_bazel.py" - ".github/workflows/rusty-v8-release.yml" @@ -23,6 +24,7 @@ on: paths: - ".bazelrc" - ".github/actions/setup-bazel-ci/**" + - ".github/scripts/run_bazel_with_buildbuddy.py" - ".github/scripts/rusty_v8_bazel.py" - ".github/scripts/rusty_v8_module_bazel.py" - ".github/workflows/rusty-v8-release.yml" @@ -203,11 +205,10 @@ jobs: bazel_args+=(--config=v8-release-compat) fi - bazel \ + ./.github/scripts/run_bazel_with_buildbuddy.py \ --noexperimental_remote_repo_contents_cache \ "${bazel_args[@]}" \ - "--config=${{ matrix.bazel_config }}" \ - "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" + "--config=${{ matrix.bazel_config }}" - name: Stage release pair env: diff --git a/codex-rs/docs/bazel.md b/codex-rs/docs/bazel.md index a124688a24..085c15992f 100644 --- a/codex-rs/docs/bazel.md +++ b/codex-rs/docs/bazel.md @@ -4,7 +4,7 @@ This repository uses Bazel to build the Rust workspace under `codex-rs`. Cargo remains the source of truth for crates and features, while Bazel provides hermetic builds, toolchains, and cross-platform artifacts. -As of 1/9/2026, this setup is still experimental as we stabilize it. +As of 6/1/2026, this setup is still experimental as we stabilize it. ## High-level layout @@ -20,6 +20,118 @@ As of 1/9/2026, this setup is still experimental as we stabilize it. makes some adjustments if the crate needs additional compile-time or runtime data, or other customizations. +## Running Bazel locally + +The repository root `justfile` exposes the common Bazel entry points: + +```bash +just bazel-test +just bazel-clippy +``` + +Ordinary local `bazel` and `just` invocations run locally. BuildBuddy cache, +build event upload, downloads, and remote execution are opt-in configurations. + +## BuildBuddy + +Codex uses BuildBuddy for a shared Bazel cache and remoted builds and tests. To use it +to speed up your builds and tests you'll need to provide an API key and select a +configuration. + +### BuildBuddy API key + +If you're an OpenAI employee, log in to https://openai.buildbuddy.io and use Google sign-in. + +Create a BuildBuddy API key as described in BuildBuddy's [Authentication Guide][bb-auth-guide], +then add it to `~/.bazelrc`: + +```bazelrc +# Local machine only; this file contains a BuildBuddy credential. +common --remote_header=x-buildbuddy-api-key= +``` + +Keeping the credential outside the workspace reduces the risk of accidentally +committing it. + +If you need different API keys for different projects, put the API key in +`%workspace%/user.bazelrc` instead. The checked-in `.bazelrc` optionally imports +that file, and `.gitignore` excludes it. Do not commit or share a file containing +the credential. + +[bb-auth-guide]: https://www.buildbuddy.io/docs/guide-auth/#managing-keys + +### Selecting a remote build configuration + +OpenAI employees should default to the OpenAI host with remote execution unless +they have a reason to choose another configuration. Add the following configuration +to `%workspace%/user.bazelrc`: + +```bazelrc +common --config=buildbuddy-openai-rbe +``` + +OpenAI employees who don't want remote execution can use `buildbuddy-openai`. External users +should use `buildbuddy-generic-rbe` or `buildbuddy-generic`. See below for details on these +configurations. + +### All remote configurations + +GitHub Actions routes Bazel build and output-resolution commands through +`.github/scripts/run_bazel_with_buildbuddy.py`. Higher-level helpers such as +`.github/scripts/run-bazel-ci.sh` and `.github/scripts/rusty_v8_bazel.py` +delegate remote configuration selection to that wrapper. The wrapper reads the +GitHub Actions repository and event payload rather than relying on workflow +files to duplicate tenant-selection logic. + +Loading-phase target-discovery `bazel query` commands run locally because they +only enumerate labels and do not need remote caches or execution. + +The `Cache/BES` host is also used for remote downloads. + +| Invocation/config | Key Required | Cache/BES | Build exec | Test exec | +| --- | --- | --- | --- | --- | +| `bazel ...` | No | None | Local | Local | +| `bazel ... --config=buildbuddy-generic` | Yes | `remote.buildbuddy.io` | Local | Local | +| `bazel ... --config=buildbuddy-generic-rbe` | Yes | `remote.buildbuddy.io` | Remote | Remote | +| `bazel ... --config=buildbuddy-openai` | Yes | `openai.buildbuddy.io` | Local | Local | +| `bazel ... --config=buildbuddy-openai-rbe` | Yes | `openai.buildbuddy.io` | Remote | Remote | + +Without an API key, the wrapper removes remote CI configurations and runs +locally. With a key, workflows choose the host as follows: + +| Run | Key | Uses OpenAI BuildBuddy Host | +| --- | --- | --- | +| Push to `main` in `openai/codex` | Yes | Yes | +| `workflow_dispatch` in `openai/codex` | Yes | Yes | +| Same-repository pull request in `openai/codex` | Yes | Yes | +| Fork pull request into `openai/codex` | No | No; local | +| Push or `workflow_dispatch` in a fork with a key | Yes | No; generic host | +| Pull request run in a fork repository with a key | Yes | No; generic host | + +CI configurations determine whether builds and tests execute remotely: + +| CI config | Remote config | Build exec | Test exec | +| --- | --- | --- | --- | +| `ci-linux` | `*-rbe` | Remote host | Remote host | +| `ci-v8` | `*-rbe` | Remote host | Remote host | +| `ci-macos` | `*-rbe` | Remote host | Local | +| `ci-windows-cross` | `*-rbe` | Remote host | Local | +| `ci-windows` | non-RBE | Local | Local | +| Keyless CI fallback | none | Local | Local | + +To exercise the generic remote configuration with your key: + +```bash +BUILDBUDDY_API_KEY=... GITHUB_REPOSITORY=my-fork/codex \ + ./.github/scripts/run_bazel_with_buildbuddy.py \ + build --config=ci-linux //codex-rs/cli:codex +``` + +The wrapper selects the OpenAI host only inside GitHub Actions for a trusted +run in `openai/codex`. A missing or malformed pull request event +payload fails closed to the generic host. For local OpenAI host access, use +the `user.bazelrc` configuration above. + ## Evolving the setup When you add or change Rust dependencies, update the Cargo.toml/Cargo.lock as normal. diff --git a/justfile b/justfile index 34b5115ee0..fe7e7349b3 100644 --- a/justfile +++ b/justfile @@ -83,6 +83,12 @@ test *args: $env:RUST_MIN_STACK = "{{ rust_min_stack }}"; cargo nextest run --no-fail-fast @($args | Select-Object -Skip 1) just bench-smoke +# Run from the repository root so scripts that resolve paths from `cwd` see +# the same layout they use in GitHub Actions. +[no-cd] +test-github-scripts: + {{ python }} -m unittest discover -s {{ justfile_directory() }}/.github/scripts -p 'test_*.py' + # Run explicit workspace benchmark targets. bench *args: cargo bench --workspace --bench '*' {args} @@ -129,11 +135,8 @@ bazel-clippy: bazel-argument-comment-lint: bazel build --config=argument-comment-lint -- $({{ justfile_directory() }}/tools/argument-comment-lint/list-bazel-targets.sh) -bazel-remote-test: - bazel test --test_tag_filters=-argument-comment-lint //... --config=remote --platforms=//:rbe --keep_going - build-for-release: - bazel build //codex-rs/cli:release_binaries --config=remote + bazel build //codex-rs/cli:release_binaries # Run the MCP server mcp-server-run *args: diff --git a/scripts/list-bazel-clippy-targets.sh b/scripts/list-bazel-clippy-targets.sh index b76fc2a830..d12a256d00 100755 --- a/scripts/list-bazel-clippy-targets.sh +++ b/scripts/list-bazel-clippy-targets.sh @@ -20,23 +20,13 @@ while [[ $# -gt 0 ]]; do done # Resolve the dynamic targets before printing anything so callers do not -# continue with a partial list if `bazel query` fails. Reuse the same CI Bazel -# server settings as the subsequent build so Windows jobs do not cold-start a -# second Bazel server just for target discovery. -if [[ $windows_cross_compile -eq 1 ]]; then - manual_rust_test_targets="$( - ./.github/scripts/run-bazel-query-ci.sh \ - --windows-cross-compile \ - --output=label \ - -- 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))' - )" -else - manual_rust_test_targets="$( - ./.github/scripts/run-bazel-query-ci.sh \ - --output=label \ - -- 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))' - )" -fi +# continue with a partial list if `bazel query` fails. Target discovery is +# local on all platforms. +manual_rust_test_targets="$( + ./.github/scripts/run-bazel-query-ci.sh \ + --output=label \ + -- 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))' +)" if [[ "${RUNNER_OS:-}" != "Windows" ]]; then # Non-Windows clippy jobs lint the native test binaries; the # Windows-cross binaries exist only for the fast Windows test leg. From a28b32a8357b4bd67aa3ade5bb9a4e2ddb5cae1a Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 2 Jun 2026 16:26:36 -0700 Subject: [PATCH 03/16] config: express implicit sandbox defaults as permission profiles (#25926) ## Why `PermissionProfile` is becoming the default way to represent Codex permissions, but the implicit default behavior should stay the same for now: - trusted projects use `:workspace` - untrusted projects also use `:workspace` - roots without a trust decision use `:read-only` - unsandboxed Windows falls back to `:read-only` This keeps the existing sandbox semantics while making silent config defaults observable as built-in permission profiles instead of treating the legacy `SandboxPolicy` projection as the primary shape. ## What Changed - Refactored legacy sandbox derivation to resolve the configured sandbox mode once, then apply the implicit project fallback only when no sandbox mode was configured. - Preserved the existing trust-decision fallback: trusted and untrusted projects default to workspace-write where supported. - Added empty-config coverage asserting that an untrusted project resolves to the built-in active permission profile (`:workspace` outside unsandboxed Windows). ## Verification - `just fmt` - `just test -p codex-core 'config::'` - `just test -p codex-config` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/25926). * __->__ #25926 --- codex-rs/config/src/config_toml.rs | 26 ++++------- codex-rs/core/src/config/config_tests.rs | 59 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 16 deletions(-) diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 0311482cf2..7c060cadb7 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -728,29 +728,23 @@ impl ConfigToml { active_project: Option<&ProjectConfig>, permission_profile_constraint: Option<&crate::Constrained>, ) -> PermissionProfile { - let sandbox_mode_was_explicit = - sandbox_mode_override.is_some() || self.sandbox_mode.is_some(); - let resolved_sandbox_mode = sandbox_mode_override - .or(self.sandbox_mode) - .or(if sandbox_mode_was_explicit { - None - } else { + let configured_sandbox_mode = sandbox_mode_override.or(self.sandbox_mode); + let resolved_sandbox_mode = configured_sandbox_mode + .or_else(|| { // If no sandbox_mode is set but this directory has a trust decision, // default to workspace-write except on unsandboxed Windows where we // default to read-only. - active_project.and_then(|p| { - if p.is_trusted() || p.is_untrusted() { + active_project + .filter(|project| project.is_trusted() || project.is_untrusted()) + .map(|_| { if cfg!(target_os = "windows") && windows_sandbox_level == WindowsSandboxLevel::Disabled { - Some(SandboxMode::ReadOnly) + SandboxMode::ReadOnly } else { - Some(SandboxMode::WorkspaceWrite) + SandboxMode::WorkspaceWrite } - } else { - None - } - }) + }) }) .unwrap_or_default(); let effective_sandbox_mode = if cfg!(target_os = "windows") @@ -788,7 +782,7 @@ impl ConfigToml { }, SandboxMode::DangerFullAccess => PermissionProfile::Disabled, }; - if !sandbox_mode_was_explicit + if configured_sandbox_mode.is_none() && let Some(constraint) = permission_profile_constraint && let Err(err) = constraint.can_set(&permission_profile) { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 192e449ff8..8bc33bbb45 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -2548,6 +2548,65 @@ async fn empty_config_defaults_to_builtin_profile_for_trusted_project() -> std:: Ok(()) } +#[tokio::test] +async fn empty_config_defaults_to_builtin_profile_for_untrusted_project() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let project_key = cwd.path().to_string_lossy().to_string(); + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + projects: Some(HashMap::from([( + project_key, + ProjectConfig { + trust_level: Some(TrustLevel::Untrusted), + }, + )])), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + let policy = config.permissions.file_system_sandbox_policy(); + assert_eq!( + config + .permissions + .active_permission_profile() + .as_ref() + .map(|active| active.id.as_str()), + Some(if cfg!(target_os = "windows") { + BUILT_IN_PERMISSION_PROFILE_READ_ONLY + } else { + BUILT_IN_PERMISSION_PROFILE_WORKSPACE + }) + ); + assert!( + policy.can_read_path_with_cwd(cwd.path(), cwd.path()), + "expected untrusted project fallback to allow reads, policy: {policy:?}" + ); + if cfg!(target_os = "windows") { + assert!( + !policy.can_write_path_with_cwd(cwd.path(), cwd.path()), + "expected untrusted project fallback to stay read-only without Windows sandbox support, policy: {policy:?}" + ); + } else { + assert!( + policy.can_write_path_with_cwd(cwd.path(), cwd.path()), + "expected untrusted project fallback to use :workspace, policy: {policy:?}" + ); + assert!( + !policy.can_write_path_with_cwd(&cwd.path().join(".codex"), cwd.path()), + "expected :workspace metadata carveouts, policy: {policy:?}" + ); + } + Ok(()) +} + #[tokio::test] async fn implicit_builtin_workspace_profile_preserves_sandbox_workspace_write_settings() -> std::io::Result<()> { From 271d5cecf2e71f9ea171653306cc9ce7c6ec3f19 Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 3 Jun 2026 01:33:31 +0200 Subject: [PATCH 04/16] feat: add extension turn-input contributors (#25959) ## Disclaimer Do not use for now ## Why Extensions can already contribute prompt fragments and request same-turn item injection, but there was no host-owned hook for contributing structured `ResponseItem`s while Codex is assembling a new turn's initial model input. This change adds that seam so extensions can attach turn-local input that depends on the submitted user input and resolved turn environments without routing through prompt text or late injection. ## What changed - add `TurnInputContributor` to `codex_extension_api` and export the new `TurnInputContext` / `TurnInputEnvironment` types it receives - teach `ExtensionRegistry` to register and expose turn-input contributors alongside the existing extension hooks - call registered turn-input contributors from `core/src/session/turn.rs` while building the initial injected input for a turn, then append their returned `ResponseItem`s after the skill and plugin injections --- codex-rs/core/src/session/turn.rs | 53 +++++++++++++++++++ .../ext/extension-api/src/contributors.rs | 23 ++++++++ .../src/contributors/turn_input.rs | 25 +++++++++ codex-rs/ext/extension-api/src/lib.rs | 4 ++ codex-rs/ext/extension-api/src/registry.rs | 15 ++++++ 5 files changed, 120 insertions(+) create mode 100644 codex-rs/ext/extension-api/src/contributors/turn_input.rs diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 498e65c7b9..c6a3155b74 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -69,6 +69,8 @@ use codex_analytics::InvocationType; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_async_utils::OrCancelExt; +use codex_extension_api::TurnInputContext; +use codex_extension_api::TurnInputEnvironment; use codex_features::Feature; use codex_git_utils::get_git_repo_root; use codex_git_utils::get_git_repo_root_with_fs; @@ -513,6 +515,9 @@ async fn build_skills_and_plugins( }; let skills_outcome = turn_context.turn_skills.outcome.as_ref(); let connector_slug_counts = build_connector_slug_counts(&available_connectors); + let extension_injection_items = + build_extension_turn_input_items(sess, turn_context, &user_input, cancellation_token) + .await?; let skill_name_counts_lower = build_skill_name_counts(&skills_outcome.skills, &skills_outcome.disabled_paths).1; let mentioned_skills = collect_explicit_skill_mentions( @@ -588,9 +593,57 @@ async fn build_skills_and_plugins( let mut injection_items = skill_items; injection_items.extend(plugin_items); + injection_items.extend(extension_injection_items); Some((injection_items, explicitly_enabled_connectors)) } +async fn build_extension_turn_input_items( + sess: &Arc, + turn_context: &TurnContext, + user_input: &[UserInput], + cancellation_token: &CancellationToken, +) -> Option> { + let contributors = sess.services.extensions.turn_input_contributors().to_vec(); + if contributors.is_empty() { + return Some(Vec::new()); + } + + let environments = turn_context + .environments + .turn_environments + .iter() + .enumerate() + .map(|(index, environment)| TurnInputEnvironment { + environment_id: environment.environment_id.clone(), + cwd: environment.cwd.as_path().to_path_buf(), + is_primary: index == 0, + }) + .collect::>(); + + let input = TurnInputContext { + turn_id: turn_context.sub_id.to_string(), + user_input: user_input.to_vec(), + environments, + }; + + let mut items = Vec::new(); + for contributor in contributors { + let contributed_items = contributor + .contribute( + input.clone(), + &sess.services.session_extension_data, + &sess.services.thread_extension_data, + turn_context.extension_data.as_ref(), + ) + .or_cancel(cancellation_token) + .await + .ok()?; + items.extend(contributed_items); + } + + Some(items) +} + async fn track_turn_resolved_config_analytics( sess: &Session, turn_context: &TurnContext, diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index 71007e3817..98bc395a2f 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -2,6 +2,7 @@ use std::future::Future; use std::sync::Arc; use codex_protocol::items::TurnItem; +use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::TokenUsageInfo; use codex_tools::ToolCall; @@ -12,6 +13,7 @@ use crate::ExtensionData; mod prompt; mod thread_lifecycle; mod tool_lifecycle; +mod turn_input; mod turn_lifecycle; pub use prompt::PromptFragment; @@ -25,6 +27,8 @@ pub use tool_lifecycle::ToolCallSource; pub use tool_lifecycle::ToolFinishInput; pub use tool_lifecycle::ToolLifecycleFuture; pub use tool_lifecycle::ToolStartInput; +pub use turn_input::TurnInputContext; +pub use turn_input::TurnInputEnvironment; pub use turn_lifecycle::TurnAbortInput; pub use turn_lifecycle::TurnErrorInput; pub use turn_lifecycle::TurnStartInput; @@ -85,6 +89,25 @@ pub trait TurnLifecycleContributor: Send + Sync { async fn on_turn_error(&self, _input: TurnErrorInput<'_>) {} } +/// WARNING: DO NOT USE YET +/// Extension contribution that can add turn-local model input. +/// +/// Implementations should resolve only the model-visible input they own and +/// must preserve authority boundaries for external resources. Expensive or +/// host-specific dependencies belong on the extension value installed by the +/// host, not in this input. +#[async_trait::async_trait] +pub trait TurnInputContributor: Send + Sync { + /// Returns additional model input items for one submitted turn. + async fn contribute( + &self, + input: TurnInputContext, + session_store: &ExtensionData, + thread_store: &ExtensionData, + turn_store: &ExtensionData, + ) -> Vec; +} + /// Contributor for host-owned configuration changes. /// /// Implementations should treat the supplied values as immutable before/after diff --git a/codex-rs/ext/extension-api/src/contributors/turn_input.rs b/codex-rs/ext/extension-api/src/contributors/turn_input.rs new file mode 100644 index 0000000000..9e4723440d --- /dev/null +++ b/codex-rs/ext/extension-api/src/contributors/turn_input.rs @@ -0,0 +1,25 @@ +use std::path::PathBuf; + +use codex_protocol::user_input::UserInput; + +/// Host-owned turn environment summary visible to turn-input contributors. +#[derive(Debug, Clone)] +pub struct TurnInputEnvironment { + /// Stable host environment id used to route executor-scoped capabilities. + pub environment_id: String, + /// Effective working directory for this turn in the environment. + pub cwd: PathBuf, + /// Whether this is the primary environment for the turn. + pub is_primary: bool, +} + +/// Turn facts supplied before the host records turn-local model input items. +#[derive(Debug, Clone)] +pub struct TurnInputContext { + /// Stable host-owned turn identifier. + pub turn_id: String, + /// User input submitted for this turn. + pub user_input: Vec, + /// Resolved turn environments, in host priority order. + pub environments: Vec, +} diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index 4705581c8a..870e331fbb 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -10,6 +10,7 @@ pub use capabilities::NoopExtensionEventSink; pub use capabilities::NoopResponseItemInjector; pub use capabilities::ResponseItemInjectionFuture; pub use capabilities::ResponseItemInjector; +pub use codex_protocol::models::ResponseItem; pub use codex_tools::ConversationHistory; pub use codex_tools::ExtensionTurnItem; pub use codex_tools::FunctionCallError; @@ -46,6 +47,9 @@ pub use contributors::ToolLifecycleFuture; pub use contributors::ToolStartInput; pub use contributors::TurnAbortInput; pub use contributors::TurnErrorInput; +pub use contributors::TurnInputContext; +pub use contributors::TurnInputContributor; +pub use contributors::TurnInputEnvironment; pub use contributors::TurnItemContributor; pub use contributors::TurnLifecycleContributor; pub use contributors::TurnStartInput; diff --git a/codex-rs/ext/extension-api/src/registry.rs b/codex-rs/ext/extension-api/src/registry.rs index a359726257..0849387110 100644 --- a/codex-rs/ext/extension-api/src/registry.rs +++ b/codex-rs/ext/extension-api/src/registry.rs @@ -12,6 +12,7 @@ use crate::ThreadLifecycleContributor; use crate::TokenUsageContributor; use crate::ToolContributor; use crate::ToolLifecycleContributor; +use crate::TurnInputContributor; use crate::TurnItemContributor; use crate::TurnLifecycleContributor; @@ -23,6 +24,7 @@ pub struct ExtensionRegistryBuilder { config_contributors: Vec>>, token_usage_contributors: Vec>, context_contributors: Vec>, + turn_input_contributors: Vec>, tool_contributors: Vec>, tool_lifecycle_contributors: Vec>, turn_item_contributors: Vec>, @@ -39,6 +41,7 @@ impl Default for ExtensionRegistryBuilder { token_usage_contributors: Vec::new(), approval_review_contributors: Vec::new(), context_contributors: Vec::new(), + turn_input_contributors: Vec::new(), tool_contributors: Vec::new(), tool_lifecycle_contributors: Vec::new(), turn_item_contributors: Vec::new(), @@ -98,6 +101,11 @@ impl ExtensionRegistryBuilder { self.context_contributors.push(contributor); } + /// Registers one turn-input contributor. + pub fn turn_input_contributor(&mut self, contributor: Arc) { + self.turn_input_contributors.push(contributor); + } + /// Registers one native tool contributor. pub fn tool_contributor(&mut self, contributor: Arc) { self.tool_contributors.push(contributor); @@ -123,6 +131,7 @@ impl ExtensionRegistryBuilder { token_usage_contributors: self.token_usage_contributors, approval_review_contributors: self.approval_review_contributors, context_contributors: self.context_contributors, + turn_input_contributors: self.turn_input_contributors, tool_contributors: self.tool_contributors, tool_lifecycle_contributors: self.tool_lifecycle_contributors, turn_item_contributors: self.turn_item_contributors, @@ -138,6 +147,7 @@ pub struct ExtensionRegistry { config_contributors: Vec>>, token_usage_contributors: Vec>, context_contributors: Vec>, + turn_input_contributors: Vec>, tool_contributors: Vec>, tool_lifecycle_contributors: Vec>, turn_item_contributors: Vec>, @@ -195,6 +205,11 @@ impl ExtensionRegistry { &self.context_contributors } + /// Returns the registered turn-input contributors. + pub fn turn_input_contributors(&self) -> &[Arc] { + &self.turn_input_contributors + } + /// Returns the registered native tool contributors. pub fn tool_contributors(&self) -> &[Arc] { &self.tool_contributors From 1fd2a6d328017c58f4863b76f430d4000cbfd1b5 Mon Sep 17 00:00:00 2001 From: joeflorencio-openai Date: Tue, 2 Jun 2026 16:41:48 -0700 Subject: [PATCH 05/16] Allow EDU accounts to fetch cloud config bundles (#25963) ## Summary Allow EDU ChatGPT workspaces to fetch cloud config bundles. The existing cloud config eligibility gate only allowed business-like and enterprise plans, which meant EDU admins could configure managed policies in the UI but the Codex client would skip fetching them. This keeps individual/pro and team-like usage-based plans excluded, and adds service-level coverage for both `edu` and `education` plan aliases. ## Validation - `just fmt` - `just test -p codex-cloud-config` - Built the Codex app locally, created a new EDU ChatGPT workspace, and verified config bundles can be fetched and are properly applied. --- codex-rs/cloud-config/src/service.rs | 3 +- codex-rs/cloud-config/src/service_tests.rs | 97 +++++++++------------- 2 files changed, 43 insertions(+), 57 deletions(-) diff --git a/codex-rs/cloud-config/src/service.rs b/codex-rs/cloud-config/src/service.rs index b0e8d49e27..eed94c0a10 100644 --- a/codex-rs/cloud-config/src/service.rs +++ b/codex-rs/cloud-config/src/service.rs @@ -49,7 +49,8 @@ fn cloud_config_eligible_auth(auth: &CodexAuth) -> bool { return false; }; auth.uses_codex_backend() - && (plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise)) + && (plan_type.is_business_like() + || matches!(plan_type, PlanType::Enterprise | PlanType::Edu)) } fn optional_bundle(bundle: CloudConfigBundle) -> Option { diff --git a/codex-rs/cloud-config/src/service_tests.rs b/codex-rs/cloud-config/src/service_tests.rs index a07443ba0c..abedca4b07 100644 --- a/codex-rs/cloud-config/src/service_tests.rs +++ b/codex-rs/cloud-config/src/service_tests.rs @@ -351,7 +351,7 @@ async fn get_bundle_skips_non_chatgpt_auth() { } #[tokio::test] -async fn get_bundle_skips_non_business_or_enterprise_plan() { +async fn get_bundle_skips_individual_plan() { let fetcher = Arc::new(StaticBundleClient::new(test_bundle())); let codex_home = tempdir().expect("tempdir"); let service = CloudConfigBundleService::new( @@ -365,6 +365,46 @@ async fn get_bundle_skips_non_business_or_enterprise_plan() { assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); } +#[tokio::test] +async fn get_bundle_allows_eligible_workspace_plans_and_writes_cache() { + for plan_type in [ + "business", + "enterprise_cbp_usage_based", + "enterprise", + "hc", + "edu", + "education", + ] { + let bundle = test_bundle(); + let fetcher = Arc::new(StaticBundleClient::new(bundle.clone())); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_plan(plan_type).await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!( + service.load_startup_bundle().await, + Ok(Some(bundle)), + "plan_type: {plan_type}" + ); + assert_eq!( + fetcher.request_count.load(Ordering::SeqCst), + 1, + "plan_type: {plan_type}" + ); + assert!( + codex_home + .path() + .join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME) + .exists(), + "plan_type: {plan_type}" + ); + } +} + #[tokio::test] async fn get_bundle_skips_team_like_usage_based_plan() { let fetcher = Arc::new(StaticBundleClient::new(test_bundle())); @@ -380,31 +420,6 @@ async fn get_bundle_skips_team_like_usage_based_plan() { assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); } -#[tokio::test] -async fn get_bundle_allows_business_plan_and_writes_cache() { - let bundle = test_bundle(); - let codex_home = tempdir().expect("tempdir"); - let fetcher = Arc::new(StaticBundleClient::new(bundle.clone())); - let service = CloudConfigBundleService::new( - auth_manager_with_plan("business").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_CONFIG_BUNDLE_TIMEOUT, - ); - - assert_eq!( - service.load_startup_bundle().await, - Ok(Some(bundle.clone())) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - assert!( - codex_home - .path() - .join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME) - .exists() - ); -} - #[tokio::test] async fn get_bundle_rejects_invalid_remote_bundle_before_cache_write() { let codex_home = tempdir().expect("tempdir"); @@ -468,36 +483,6 @@ async fn get_bundle_ignores_invalid_cache_and_refetches() { ); } -#[tokio::test] -async fn get_bundle_allows_business_like_usage_based_plan() { - let fetcher = Arc::new(StaticBundleClient::new(test_bundle())); - let codex_home = tempdir().expect("tempdir"); - let service = CloudConfigBundleService::new( - auth_manager_with_plan("enterprise_cbp_usage_based").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_CONFIG_BUNDLE_TIMEOUT, - ); - - assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle()))); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); -} - -#[tokio::test] -async fn get_bundle_allows_hc_plan_as_enterprise() { - let fetcher = Arc::new(StaticBundleClient::new(test_bundle())); - let codex_home = tempdir().expect("tempdir"); - let service = CloudConfigBundleService::new( - auth_manager_with_plan("hc").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_CONFIG_BUNDLE_TIMEOUT, - ); - - assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle()))); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); -} - #[tokio::test] async fn get_bundle_empty_response_is_success_and_cached() { let codex_home = tempdir().expect("tempdir"); From 98a62a62ce7e1e6e6100c6e888e9d2bd794378e2 Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Tue, 2 Jun 2026 17:01:02 -0700 Subject: [PATCH 06/16] feat(app-server): add remote control client management RPCs (#25785) ## Why Remote-control clients need to list and revoke controller-device grants without enabling or enrolling the local relay. These are signed-in account-management operations, so coupling them to websocket, pairing, enrollment, or persisted relay state would prevent clients from managing stale grants from the picker. Related enhancement request: N/A. This adds the Codex app-server surface for the planned upstream environment-scoped revoke endpoint. ## What Changed - Added experimental app-server v2 RPCs: - `remoteControl/client/list` - `remoteControl/client/revoke` - Added picker-oriented protocol types and standard generated schema fixtures. The list response intentionally omits backend account id, enrollment status, and location fields. - Added `app-server-transport/src/transport/remote_control/clients.rs` for environment-scoped GET and DELETE requests. It builds escaped URL path segments, forwards optional pagination query fields, sends ChatGPT auth plus `chatgpt-account-id`, converts RFC3339 `last_seen_at` values to Unix seconds, accepts `204 No Content` revoke responses, and retries once after a `401`. - Extracted shared ChatGPT auth loading and recovery into `app-server-transport/src/transport/remote_control/auth.rs` so websocket, pairing, and client management use the same account-auth boundary. - Retained the configured remote-control base URL on `RemoteControlHandle` and resolve management URLs lazily, preserving deferred validation while relay startup is disabled. - Registered list as `global_shared_read("remote-control-clients")` and revoke as `global("remote-control-clients")`. ## Verification - Added transport coverage proving list and revoke work while relay state is disabled, IDs are escaped, picker-only fields are returned, timestamps are converted, revoke accepts `204`, auth headers are forwarded, `401` retries exactly once, `403` is not retried, and malformed list payloads retain decode context. - Added an app-server integration test proving both JSON-RPC methods work before relay enablement and successful revoke returns `{}`. - Regenerated and validated experimental and standard app-server schema fixtures. --- codex-rs/app-server-protocol/src/export.rs | 38 +- .../src/protocol/common.rs | 35 ++ .../src/protocol/v2/remote_control.rs | 60 +++ .../src/protocol/v2/remote_control_tests.rs | 50 +++ .../src/transport/remote_control/auth.rs | 105 +++++ .../src/transport/remote_control/clients.rs | 307 +++++++++++++++ .../src/transport/remote_control/enroll.rs | 7 +- .../src/transport/remote_control/mod.rs | 37 +- .../src/transport/remote_control/protocol.rs | 73 ++-- .../src/transport/remote_control/tests.rs | 2 + .../remote_control/tests/clients_tests.rs | 359 ++++++++++++++++++ .../src/transport/remote_control/websocket.rs | 96 +---- codex-rs/app-server/README.md | 2 + codex-rs/app-server/src/message_processor.rs | 10 + .../remote_control_processor.rs | 34 ++ .../remote_control_processor_tests.rs | 31 ++ .../tests/common/test_app_server.rs | 21 + .../tests/suite/v2/remote_control.rs | 144 +++++++ 18 files changed, 1281 insertions(+), 130 deletions(-) create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/remote_control_tests.rs create mode 100644 codex-rs/app-server-transport/src/transport/remote_control/auth.rs create mode 100644 codex-rs/app-server-transport/src/transport/remote_control/clients.rs create mode 100644 codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index b9c44fe9a6..20f923f327 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -39,6 +39,8 @@ use ts_rs::TS; pub(crate) const GENERATED_TS_HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n"; const IGNORED_DEFINITIONS: &[&str] = &["Option<()>"]; const JSON_V1_ALLOWLIST: &[&str] = &["InitializeParams", "InitializeResponse"]; +const EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES: &[&str] = + &["RemoteControlClient", "RemoteControlClientsListOrder"]; const SPECIAL_DEFINITIONS: &[&str] = &[ "ClientNotification", "ClientRequest", @@ -554,6 +556,7 @@ fn experimental_method_types() -> HashSet { let mut type_names = HashSet::new(); collect_experimental_type_names(EXPERIMENTAL_CLIENT_METHOD_PARAM_TYPES, &mut type_names); collect_experimental_type_names(EXPERIMENTAL_CLIENT_METHOD_RESPONSE_TYPES, &mut type_names); + collect_experimental_type_names(EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES, &mut type_names); type_names } @@ -2132,6 +2135,14 @@ mod tests { fixture_tree.contains_key(Path::new("v2/MockExperimentalMethodResponse.ts")), false ); + assert_eq!( + fixture_tree.contains_key(Path::new("v2/RemoteControlClient.ts")), + false + ); + assert_eq!( + fixture_tree.contains_key(Path::new("v2/RemoteControlClientsListOrder.ts")), + false + ); let mut undefined_offenders = Vec::new(); let mut optional_nullable_offenders = BTreeSet::new(); @@ -2847,6 +2858,11 @@ permissionProfile?: string | null}; flat_v2_bundle_json.contains("MockExperimentalMethodResponse"), false ); + assert_eq!(flat_v2_bundle_json.contains("RemoteControlClient"), false); + assert_eq!( + flat_v2_bundle_json.contains("RemoteControlClientsListOrder"), + false + ); assert_eq!(flat_v2_bundle_json.contains("#/definitions/v2/"), false); assert_eq!( flat_v2_bundle_json.contains("\"title\": \"CodexAppServerProtocolV2\""), @@ -2920,22 +2936,42 @@ permissionProfile?: string | null}; .exists(), false ); + assert_eq!( + output_dir + .join("v2") + .join("RemoteControlClient.json") + .exists(), + false + ); + assert_eq!( + output_dir + .join("v2") + .join("RemoteControlClientsListOrder.json") + .exists(), + false + ); let _cleanup = fs::remove_dir_all(&output_dir); Ok(()) } #[test] - fn generate_json_includes_remote_control_pairing_start_with_experimental_api() -> Result<()> { + fn generate_json_includes_remote_control_methods_with_experimental_api() -> Result<()> { let output_dir = std::env::temp_dir().join(format!("codex_schema_{}", Uuid::now_v7())); fs::create_dir(&output_dir)?; generate_json_with_experimental(&output_dir, /*experimental_api*/ true)?; let client_request_json = fs::read_to_string(output_dir.join("ClientRequest.json"))?; assert!(client_request_json.contains("remoteControl/pairing/start")); + assert!(client_request_json.contains("remoteControl/client/list")); + assert!(client_request_json.contains("remoteControl/client/revoke")); for schema in [ "RemoteControlPairingStartParams.json", "RemoteControlPairingStartResponse.json", + "RemoteControlClientsListParams.json", + "RemoteControlClientsListResponse.json", + "RemoteControlClientsRevokeParams.json", + "RemoteControlClientsRevokeResponse.json", ] { assert!(output_dir.join("v2").join(schema).exists()); } diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 961a4a227c..5a60d8d002 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -849,6 +849,18 @@ client_request_definitions! { serialization: global("remote-control-pairing"), response: v2::RemoteControlPairingStartResponse, }, + #[experimental("remoteControl/client/list")] + RemoteControlClientsList => "remoteControl/client/list" { + params: v2::RemoteControlClientsListParams, + serialization: global_shared_read("remote-control-clients"), + response: v2::RemoteControlClientsListResponse, + }, + #[experimental("remoteControl/client/revoke")] + RemoteControlClientsRevoke => "remoteControl/client/revoke" { + params: v2::RemoteControlClientsRevokeParams, + serialization: global("remote-control-clients"), + response: v2::RemoteControlClientsRevokeResponse, + }, #[experimental("collaborationMode/list")] /// Lists collaboration mode presets. CollaborationModeList => "collaborationMode/list" { @@ -1994,6 +2006,29 @@ mod tests { "remote-control-pairing" )) ); + let remote_control_clients_list = ClientRequest::RemoteControlClientsList { + request_id: request_id(), + params: v2::RemoteControlClientsListParams::default(), + }; + assert_eq!( + remote_control_clients_list.serialization_scope(), + Some(ClientRequestSerializationScope::GlobalSharedRead( + "remote-control-clients" + )) + ); + let remote_control_clients_revoke = ClientRequest::RemoteControlClientsRevoke { + request_id: request_id(), + params: v2::RemoteControlClientsRevokeParams { + environment_id: "environment-id".to_string(), + client_id: "client-id".to_string(), + }, + }; + assert_eq!( + remote_control_clients_revoke.serialization_scope(), + Some(ClientRequestSerializationScope::Global( + "remote-control-clients" + )) + ); } #[test] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs index e5c39a7773..f5f004f784 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs @@ -62,6 +62,62 @@ pub struct RemoteControlPairingStartResponse { pub expires_at: i64, } +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlClientsListParams { + pub environment_id: String, + #[ts(optional = nullable)] + pub cursor: Option, + #[ts(optional = nullable)] + pub limit: Option, + #[ts(optional = nullable)] + pub order: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum RemoteControlClientsListOrder { + Asc, + Desc, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlClientsListResponse { + pub data: Vec, + pub next_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlClient { + pub client_id: String, + pub display_name: Option, + pub device_type: Option, + pub platform: Option, + pub os_version: Option, + pub device_model: Option, + pub app_version: Option, + pub last_seen_at: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlClientsRevokeParams { + pub environment_id: String, + pub client_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlClientsRevokeResponse {} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase", export_to = "v2/")] @@ -105,3 +161,7 @@ impl From for RemoteControlDisableRespon } } } + +#[cfg(test)] +#[path = "remote_control_tests.rs"] +mod tests; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/remote_control_tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/remote_control_tests.rs new file mode 100644 index 0000000000..ff6b26d236 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/remote_control_tests.rs @@ -0,0 +1,50 @@ +use super::*; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn remote_control_clients_list_params_serialize_nullable_optional_fields() { + assert_eq!( + serde_json::to_value(RemoteControlClientsListParams { + environment_id: "env-123".to_string(), + cursor: None, + limit: None, + order: None, + }) + .expect("params should serialize"), + json!({ + "environmentId": "env-123", + "cursor": null, + "limit": null, + "order": null, + }) + ); +} + +#[test] +fn remote_control_clients_list_params_deserialize_camel_case_fields() { + assert_eq!( + serde_json::from_value::(json!({ + "environmentId": "env-123", + "cursor": "cursor-123", + "limit": 10, + "order": "asc", + })) + .expect("params should deserialize"), + RemoteControlClientsListParams { + environment_id: "env-123".to_string(), + cursor: Some("cursor-123".to_string()), + limit: Some(10), + order: Some(RemoteControlClientsListOrder::Asc), + } + ); +} + +#[test] +fn remote_control_clients_revoke_response_serializes_as_empty_object() { + assert_eq!( + serde_json::to_value(RemoteControlClientsRevokeResponse {}) + .expect("response should serialize"), + json!({}) + ); +} diff --git a/codex-rs/app-server-transport/src/transport/remote_control/auth.rs b/codex-rs/app-server-transport/src/transport/remote_control/auth.rs new file mode 100644 index 0000000000..dbd231027e --- /dev/null +++ b/codex-rs/app-server-transport/src/transport/remote_control/auth.rs @@ -0,0 +1,105 @@ +use codex_api::SharedAuthProvider; +use codex_login::AuthManager; +use codex_login::UnauthorizedRecovery; +use std::io; +use std::io::ErrorKind; +use std::sync::Arc; +use tokio::sync::watch; +use tracing::info; +use tracing::warn; + +pub(super) struct RemoteControlConnectionAuth { + pub(super) auth_provider: SharedAuthProvider, + pub(super) account_id: String, +} + +pub(super) async fn load_remote_control_auth( + auth_manager: &Arc, +) -> io::Result { + let mut reloaded = false; + let auth = loop { + let Some(auth) = auth_manager.auth().await else { + if reloaded { + return Err(io::Error::new( + ErrorKind::PermissionDenied, + "remote control requires ChatGPT authentication", + )); + } + auth_manager.reload().await; + reloaded = true; + continue; + }; + if !auth.uses_codex_backend() { + break auth; + } + if auth.get_account_id().is_none() && !reloaded { + auth_manager.reload().await; + reloaded = true; + continue; + } + break auth; + }; + + if !auth.uses_codex_backend() { + return Err(io::Error::new( + ErrorKind::PermissionDenied, + "remote control requires ChatGPT authentication; API key auth is not supported", + )); + } + + Ok(RemoteControlConnectionAuth { + auth_provider: codex_model_provider::auth_provider_from_auth(&auth), + account_id: auth.get_account_id().ok_or_else(|| { + io::Error::new( + ErrorKind::WouldBlock, + "remote control enrollment is waiting for a ChatGPT account id", + ) + })?, + }) +} + +pub(super) async fn recover_remote_control_auth( + auth_recovery: &mut UnauthorizedRecovery, + auth_change_rx: &mut watch::Receiver, +) -> bool { + if !auth_recovery.has_next() { + return false; + } + + let mode = auth_recovery.mode_name(); + let step = auth_recovery.step_name(); + let auth_change_revision_before_recovery = *auth_change_rx.borrow(); + match auth_recovery.next().await { + Ok(step_result) => { + if step_result.auth_state_changed() == Some(true) { + mark_recovery_auth_change_seen( + auth_change_rx, + auth_change_revision_before_recovery, + ); + } + info!( + "remote control auth recovery succeeded: mode={mode}, step={step}, auth_state_changed={:?}", + step_result.auth_state_changed() + ); + true + } + Err(err) => { + warn!("remote control auth recovery failed: mode={mode}, step={step}: {err}"); + false + } + } +} + +pub(super) fn mark_recovery_auth_change_seen( + auth_change_rx: &mut watch::Receiver, + auth_change_revision_before_recovery: u64, +) { + let auth_change_revision_after_recovery = *auth_change_rx.borrow(); + if auth_change_revision_after_recovery == auth_change_revision_before_recovery.wrapping_add(1) { + // Recovery updated the same watch that wakes the outer reconnect + // loop. Mark only that single revision seen; if more revisions + // arrived while recovery was in flight, leave them pending so the + // reconnect loop still reacts to the later external auth change. + auth_change_rx.borrow_and_update(); + } +} diff --git a/codex-rs/app-server-transport/src/transport/remote_control/clients.rs b/codex-rs/app-server-transport/src/transport/remote_control/clients.rs new file mode 100644 index 0000000000..25316c6090 --- /dev/null +++ b/codex-rs/app-server-transport/src/transport/remote_control/clients.rs @@ -0,0 +1,307 @@ +use super::auth::RemoteControlConnectionAuth; +use super::auth::load_remote_control_auth; +use super::auth::recover_remote_control_auth; +use super::enroll::REMOTE_CONTROL_ACCOUNT_ID_HEADER; +use super::enroll::format_headers; +use super::enroll::preview_remote_control_response_body; +use super::protocol::normalize_remote_control_base_url; +use axum::http::HeaderMap; +use codex_app_server_protocol::RemoteControlClient; +use codex_app_server_protocol::RemoteControlClientsListOrder; +use codex_app_server_protocol::RemoteControlClientsListParams; +use codex_app_server_protocol::RemoteControlClientsListResponse; +use codex_app_server_protocol::RemoteControlClientsRevokeParams; +use codex_app_server_protocol::RemoteControlClientsRevokeResponse; +use codex_login::AuthManager; +use codex_login::default_client::build_reqwest_client; +use serde::Deserialize; +use std::io; +use std::io::ErrorKind; +use std::sync::Arc; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; +use url::Url; + +const REMOTE_CONTROL_CLIENT_MANAGEMENT_TIMEOUT: std::time::Duration = + std::time::Duration::from_secs(30); + +#[derive(Debug, Deserialize)] +struct ListRemoteControlClientsResponse { + items: Vec, + #[serde(default)] + cursor: Option, +} + +#[derive(Debug, Deserialize)] +struct RemoteControlClientResponse { + client_id: String, + #[serde(default)] + display_name: Option, + #[serde(default)] + device_type: Option, + #[serde(default)] + platform: Option, + #[serde(default)] + os_version: Option, + #[serde(default)] + device_model: Option, + #[serde(default)] + app_version: Option, + #[serde(default)] + last_seen_at: Option, +} + +enum ClientManagementRequest<'a> { + List { + url: &'a Url, + params: &'a RemoteControlClientsListParams, + }, + Revoke { + url: &'a Url, + }, +} + +struct ClientManagementResponse { + status: axum::http::StatusCode, + headers: HeaderMap, + body: Vec, +} + +pub(super) async fn list_remote_control_clients( + remote_control_url: &str, + auth_manager: &Arc, + params: RemoteControlClientsListParams, +) -> io::Result { + if params.environment_id.is_empty() { + return Err(io::Error::new( + ErrorKind::InvalidInput, + "remote control client list requires environmentId", + )); + } + if params + .limit + .is_some_and(|limit| !(1..=100).contains(&limit)) + { + return Err(io::Error::new( + ErrorKind::InvalidInput, + "remote control client list limit must be between 1 and 100", + )); + } + let url = environment_clients_url(remote_control_url, ¶ms.environment_id)?; + let response = send_client_management_request( + auth_manager, + ClientManagementRequest::List { + url: &url, + params: ¶ms, + }, + "list remote control clients", + ) + .await?; + let ClientManagementResponse { + status, + headers, + body, + } = response; + let body_preview = preview_remote_control_response_body(&body); + ensure_success_response(status, &headers, &url, &body_preview, "client list")?; + let response = serde_json::from_slice::(&body).map_err( + |err| { + io::Error::other(format!( + "failed to parse remote control client list response from `{url}`: HTTP {status}, {}, body: {body_preview}, decode error: {err}", + format_headers(&headers) + )) + }, + )?; + Ok(RemoteControlClientsListResponse { + data: response + .items + .into_iter() + .map(RemoteControlClient::try_from) + .collect::>()?, + next_cursor: response.cursor, + }) +} + +pub(super) async fn revoke_remote_control_client( + remote_control_url: &str, + auth_manager: &Arc, + params: RemoteControlClientsRevokeParams, +) -> io::Result { + if params.environment_id.is_empty() { + return Err(io::Error::new( + ErrorKind::InvalidInput, + "remote control client revoke requires environmentId", + )); + } + if params.client_id.is_empty() { + return Err(io::Error::new( + ErrorKind::InvalidInput, + "remote control client revoke requires clientId", + )); + } + let mut url = environment_clients_url(remote_control_url, ¶ms.environment_id)?; + url.path_segments_mut() + .map_err(|()| { + io::Error::new( + ErrorKind::InvalidInput, + "remote control URL cannot be a base", + ) + })? + .push(¶ms.client_id); + let response = send_client_management_request( + auth_manager, + ClientManagementRequest::Revoke { url: &url }, + "revoke remote control client", + ) + .await?; + let ClientManagementResponse { + status, + headers, + body, + } = response; + let body_preview = preview_remote_control_response_body(&body); + ensure_success_response(status, &headers, &url, &body_preview, "client revoke")?; + Ok(RemoteControlClientsRevokeResponse {}) +} + +async fn send_client_management_request( + auth_manager: &Arc, + request: ClientManagementRequest<'_>, + action: &str, +) -> io::Result { + let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut auth_change_rx = auth_manager.auth_change_receiver(); + let auth = load_remote_control_auth(auth_manager).await?; + let response = send_client_management_request_once(&auth, &request, action).await?; + if response.status.as_u16() != 401 + || !recover_remote_control_auth(&mut auth_recovery, &mut auth_change_rx).await + { + return Ok(response); + } + let auth = load_remote_control_auth(auth_manager).await?; + send_client_management_request_once(&auth, &request, action).await +} + +async fn send_client_management_request_once( + auth: &RemoteControlConnectionAuth, + request: &ClientManagementRequest<'_>, + action: &str, +) -> io::Result { + let client = build_reqwest_client(); + let mut auth_headers = HeaderMap::new(); + auth.auth_provider.add_auth_headers(&mut auth_headers); + let request = match request { + ClientManagementRequest::List { url, params } => { + let mut query = Vec::new(); + if let Some(cursor) = ¶ms.cursor { + query.push(("cursor", cursor.clone())); + } + if let Some(limit) = params.limit { + query.push(("limit", limit.to_string())); + } + if let Some(order) = params.order { + query.push(( + "order", + match order { + RemoteControlClientsListOrder::Asc => "asc", + RemoteControlClientsListOrder::Desc => "desc", + } + .to_string(), + )); + } + client.get((*url).clone()).query(&query) + } + ClientManagementRequest::Revoke { url } => client.delete((*url).clone()), + }; + let response = request + .timeout(REMOTE_CONTROL_CLIENT_MANAGEMENT_TIMEOUT) + .headers(auth_headers) + .header(REMOTE_CONTROL_ACCOUNT_ID_HEADER, &auth.account_id) + .send() + .await + .map_err(|err| io::Error::other(format!("failed to {action}: {err}")))?; + let headers = response.headers().clone(); + let status = response.status(); + let body = response + .bytes() + .await + .map_err(|err| io::Error::other(format!("failed to read {action} response: {err}")))? + .to_vec(); + Ok(ClientManagementResponse { + status, + headers, + body, + }) +} + +fn ensure_success_response( + status: axum::http::StatusCode, + headers: &HeaderMap, + url: &Url, + body_preview: &str, + response_kind: &str, +) -> io::Result<()> { + if status.is_success() { + return Ok(()); + } + let error_kind = match status.as_u16() { + 400 => ErrorKind::InvalidInput, + 401 | 403 => ErrorKind::PermissionDenied, + 404 => ErrorKind::NotFound, + _ => ErrorKind::Other, + }; + Err(io::Error::new( + error_kind, + format!( + "remote control {response_kind} failed at `{url}`: HTTP {status}, {}, body: {body_preview}", + format_headers(headers) + ), + )) +} + +fn environment_clients_url(remote_control_url: &str, environment_id: &str) -> io::Result { + let mut url = normalize_remote_control_base_url(remote_control_url)? + .join("wham/remote/control/environments") + .map_err(io::Error::other)?; + url.path_segments_mut() + .map_err(|()| { + io::Error::new( + ErrorKind::InvalidInput, + "remote control URL cannot be a base", + ) + })? + .push(environment_id) + .push("clients"); + Ok(url) +} + +impl TryFrom for RemoteControlClient { + type Error = io::Error; + + fn try_from(client: RemoteControlClientResponse) -> Result { + Ok(Self { + client_id: client.client_id, + display_name: client.display_name, + device_type: client.device_type, + platform: client.platform, + os_version: client.os_version, + device_model: client.device_model, + app_version: client.app_version, + last_seen_at: client + .last_seen_at + .map(|last_seen_at| { + OffsetDateTime::parse(&last_seen_at, &Rfc3339) + .map(OffsetDateTime::unix_timestamp) + .map_err(|err| { + io::Error::new( + ErrorKind::InvalidData, + format!( + "failed to parse remote control client last_seen_at `{last_seen_at}`: {err}" + ), + ) + }) + }) + .transpose()?, + }) + } +} diff --git a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs index ac67726371..c491596e65 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs @@ -1,3 +1,4 @@ +use super::auth::RemoteControlConnectionAuth; use super::pairing_unavailable_error; use super::protocol::EnrollRemoteServerRequest; use super::protocol::EnrollRemoteServerResponse; @@ -6,7 +7,6 @@ use super::protocol::RemoteControlTarget; use super::protocol::StartRemoteControlPairingRequest; use super::protocol::StartRemoteControlPairingResponse; use axum::http::HeaderMap; -use codex_api::SharedAuthProvider; use codex_app_server_protocol::RemoteControlPairingStartResponse; use codex_login::default_client::build_reqwest_client; use codex_state::RemoteControlEnrollmentRecord; @@ -151,11 +151,6 @@ impl RemoteControlEnrollment { } } -pub(super) struct RemoteControlConnectionAuth { - pub(super) auth_provider: SharedAuthProvider, - pub(super) account_id: String, -} - pub(super) async fn load_persisted_remote_control_enrollment( state_db: Option<&StateRuntime>, remote_control_target: &RemoteControlTarget, diff --git a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs index a55667886f..38e34e6d43 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs @@ -1,9 +1,13 @@ +mod auth; mod client_tracker; +mod clients; mod enroll; mod protocol; mod segment; mod websocket; +use self::auth::load_remote_control_auth; +use self::auth::recover_remote_control_auth; use self::enroll::RemoteControlEnrollment; use self::enroll::refresh_remote_control_server; use crate::transport::remote_control::websocket::RemoteControlChannels; @@ -17,6 +21,10 @@ use self::protocol::normalize_remote_control_url; use super::CHANNEL_CAPACITY; use super::TransportEvent; use super::next_connection_id; +use codex_app_server_protocol::RemoteControlClientsListParams; +use codex_app_server_protocol::RemoteControlClientsListResponse; +use codex_app_server_protocol::RemoteControlClientsRevokeParams; +use codex_app_server_protocol::RemoteControlClientsRevokeResponse; use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_protocol::RemoteControlPairingStartParams; use codex_app_server_protocol::RemoteControlPairingStartResponse; @@ -57,6 +65,7 @@ pub struct RemoteControlHandle { enabled_tx: Arc>, status_tx: Arc>, state_db_available: bool, + remote_control_url: String, current_enrollment: CurrentRemoteControlEnrollment, auth_manager: Arc, } @@ -146,7 +155,7 @@ impl RemoteControlHandle { if !*self.enabled_tx.borrow() { return Err(Self::pairing_disabled_error()); } - let mut auth = websocket::load_remote_control_auth(&self.auth_manager) + let mut auth = load_remote_control_auth(&self.auth_manager) .await .map_err(|_| pairing_unavailable_error())?; let mut enrollment = { @@ -205,7 +214,7 @@ impl RemoteControlHandle { if !*self.enabled_tx.borrow() { return Err(Self::pairing_disabled_error()); } - let current_auth = websocket::load_remote_control_auth(&self.auth_manager) + let current_auth = load_remote_control_auth(&self.auth_manager) .await .map_err(|_| pairing_unavailable_error())?; if current_auth.account_id != auth.account_id { @@ -214,6 +223,22 @@ impl RemoteControlHandle { pairing_response } + pub async fn list_clients( + &self, + params: RemoteControlClientsListParams, + ) -> io::Result { + clients::list_remote_control_clients(&self.remote_control_url, &self.auth_manager, params) + .await + } + + pub async fn revoke_client( + &self, + params: RemoteControlClientsRevokeParams, + ) -> io::Result { + clients::revoke_remote_control_client(&self.remote_control_url, &self.auth_manager, params) + .await + } + fn pairing_disabled_error() -> io::Error { io::Error::new( io::ErrorKind::InvalidInput, @@ -255,7 +280,7 @@ impl RemoteControlHandle { async fn refresh_pairing_enrollment( current_enrollment: &CurrentRemoteControlEnrollment, auth_manager: &Arc, - auth: &mut enroll::RemoteControlConnectionAuth, + auth: &mut auth::RemoteControlConnectionAuth, installation_id: &str, enrollment: &mut RemoteControlEnrollment, ) -> io::Result<()> { @@ -265,10 +290,10 @@ async fn refresh_pairing_enrollment( } let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - if !websocket::recover_remote_control_auth(&mut auth_recovery, &mut auth_change_rx).await { + if !recover_remote_control_auth(&mut auth_recovery, &mut auth_change_rx).await { return Err(err); } - *auth = websocket::load_remote_control_auth(auth_manager) + *auth = load_remote_control_auth(auth_manager) .await .map_err(|_| pairing_unavailable_error())?; if auth.account_id != enrollment.account_id { @@ -440,6 +465,7 @@ pub async fn start_remote_control( "starting app-server remote control websocket task" ); let remote_control_url_for_log = remote_control_url.clone(); + let handle_remote_control_url = remote_control_url.clone(); let installation_id_for_log = installation_id.clone(); let server_name_for_log = server_name.clone(); let shutdown_token_for_log = shutdown_token.clone(); @@ -508,6 +534,7 @@ pub async fn start_remote_control( enabled_tx: Arc::new(enabled_tx), status_tx: Arc::new(status_tx), state_db_available, + remote_control_url: handle_remote_control_url, current_enrollment, auth_manager: handle_auth_manager, }, diff --git a/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs b/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs index 8eeb28e266..630fd862c8 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs @@ -177,6 +177,48 @@ fn is_localhost(host: &Option>) -> bool { pub(super) fn normalize_remote_control_url( remote_control_url: &str, ) -> io::Result { + let remote_control_url = normalize_remote_control_base_url(remote_control_url)?; + let map_url_parse_error = |err: url::ParseError| -> io::Error { + io::Error::new( + ErrorKind::InvalidInput, + format!("invalid remote control URL `{remote_control_url}`: {err}"), + ) + }; + + let enroll_url = remote_control_url + .join("wham/remote/control/server/enroll") + .map_err(map_url_parse_error)?; + let refresh_url = remote_control_url + .join("wham/remote/control/server/refresh") + .map_err(map_url_parse_error)?; + let pair_url = remote_control_url + .join("wham/remote/control/server/pair") + .map_err(map_url_parse_error)?; + let mut websocket_url = remote_control_url + .join("wham/remote/control/server") + .map_err(map_url_parse_error)?; + websocket_url + .set_scheme(if enroll_url.scheme() == "https" { + "wss" + } else { + "ws" + }) + .map_err(|()| { + io::Error::new( + ErrorKind::InvalidInput, + format!("invalid remote control URL `{remote_control_url}`"), + ) + })?; + + Ok(RemoteControlTarget { + websocket_url: websocket_url.to_string(), + enroll_url: enroll_url.to_string(), + refresh_url: refresh_url.to_string(), + pair_url: pair_url.to_string(), + }) +} + +pub(super) fn normalize_remote_control_base_url(remote_control_url: &str) -> io::Result { let map_url_parse_error = |err: url::ParseError| -> io::Error { io::Error::new( ErrorKind::InvalidInput, @@ -198,35 +240,14 @@ pub(super) fn normalize_remote_control_url( remote_control_url.set_path(&normalized_path); } - let enroll_url = remote_control_url - .join("wham/remote/control/server/enroll") - .map_err(map_url_parse_error)?; - let refresh_url = remote_control_url - .join("wham/remote/control/server/refresh") - .map_err(map_url_parse_error)?; - let pair_url = remote_control_url - .join("wham/remote/control/server/pair") - .map_err(map_url_parse_error)?; - let mut websocket_url = remote_control_url - .join("wham/remote/control/server") - .map_err(map_url_parse_error)?; - let host = enroll_url.host(); - match enroll_url.scheme() { - "https" if is_localhost(&host) || is_allowed_remote_control_chatgpt_host(&host) => { - websocket_url.set_scheme("wss").map_err(map_scheme_error)?; - } - "http" if is_localhost(&host) => { - websocket_url.set_scheme("ws").map_err(map_scheme_error)?; - } + let host = remote_control_url.host(); + match remote_control_url.scheme() { + "https" if is_localhost(&host) || is_allowed_remote_control_chatgpt_host(&host) => {} + "http" if is_localhost(&host) => {} _ => return Err(map_scheme_error(())), } - Ok(RemoteControlTarget { - websocket_url: websocket_url.to_string(), - enroll_url: enroll_url.to_string(), - refresh_url: refresh_url.to_string(), - pair_url: pair_url.to_string(), - }) + Ok(remote_control_url) } #[cfg(test)] diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index 5d96f89d89..a1a284da9b 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -60,6 +60,7 @@ use tokio_tungstenite::accept_hdr_async; use tokio_tungstenite::tungstenite; use tokio_util::sync::CancellationToken; +mod clients_tests; mod pairing_tests; const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111"; @@ -163,6 +164,7 @@ fn remote_control_handle_with_current_enrollment( enabled_tx: Arc::new(enabled_tx), status_tx: Arc::new(status_tx), state_db_available: true, + remote_control_url: remote_control_url.to_string(), current_enrollment, auth_manager, } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs new file mode 100644 index 0000000000..22aa290762 --- /dev/null +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs @@ -0,0 +1,359 @@ +use super::super::clients::list_remote_control_clients; +use super::super::clients::revoke_remote_control_client; +use super::*; +use codex_app_server_protocol::RemoteControlClient; +use codex_app_server_protocol::RemoteControlClientsListOrder; +use codex_app_server_protocol::RemoteControlClientsListParams; +use codex_app_server_protocol::RemoteControlClientsListResponse; +use codex_app_server_protocol::RemoteControlClientsRevokeParams; +use codex_app_server_protocol::RemoteControlClientsRevokeResponse; +use pretty_assertions::assert_eq; + +fn client_management_handle( + remote_control_url: String, + auth_manager: Arc, +) -> RemoteControlHandle { + let (enabled_tx, _enabled_rx) = watch::channel(/*init*/ false); + let (status_tx, _status_rx) = watch::channel(RemoteControlStatusChangedNotification { + status: RemoteControlConnectionStatus::Disabled, + server_name: test_server_name(), + installation_id: TEST_INSTALLATION_ID.to_string(), + environment_id: None, + }); + RemoteControlHandle { + enabled_tx: Arc::new(enabled_tx), + status_tx: Arc::new(status_tx), + state_db_available: false, + remote_control_url, + current_enrollment: Arc::new(StdMutex::new(None)), + auth_manager, + } +} + +fn empty_client_list() -> serde_json::Value { + json!({ + "items": [], + "cursor": null, + }) +} + +#[tokio::test] +async fn remote_control_handle_lists_clients_while_disabled() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let request = accept_http_request(&listener).await; + assert_eq!( + request.request_line, + "GET /backend-api/wham/remote/control/environments/env%20%2F%3F/clients?cursor=cursor+%2F%3F&limit=10&order=asc HTTP/1.1" + ); + assert_eq!( + request.headers.get("authorization"), + Some(&"Bearer Access Token".to_string()) + ); + assert_eq!( + request.headers.get(REMOTE_CONTROL_ACCOUNT_ID_HEADER), + Some(&"account_id".to_string()) + ); + respond_with_json( + request.stream, + json!({ + "items": [{ + "client_id": "client-123", + "account_user_id": "user-123", + "enrollment_status": "enrolled_device_key", + "display_name": "Anton Phone", + "device_type": "phone", + "platform": "ios", + "os_version": "19.0", + "device_model": "iPhone", + "app_version": "1.2.3", + "last_seen_at": "2026-03-05T07:00:00Z", + "last_seen_city": "San Francisco", + }], + "cursor": "next-cursor", + }), + ) + .await; + }); + let handle = client_management_handle(remote_control_url, remote_control_auth_manager()); + + let response = handle + .list_clients(RemoteControlClientsListParams { + environment_id: "env /?".to_string(), + cursor: Some("cursor /?".to_string()), + limit: Some(10), + order: Some(RemoteControlClientsListOrder::Asc), + }) + .await + .expect("client list should succeed while remote control is disabled"); + server_task.await.expect("server task should finish"); + + assert_eq!( + response, + RemoteControlClientsListResponse { + data: vec![RemoteControlClient { + client_id: "client-123".to_string(), + display_name: Some("Anton Phone".to_string()), + device_type: Some("phone".to_string()), + platform: Some("ios".to_string()), + os_version: Some("19.0".to_string()), + device_model: Some("iPhone".to_string()), + app_version: Some("1.2.3".to_string()), + last_seen_at: Some(1_772_694_000), + }], + next_cursor: Some("next-cursor".to_string()), + } + ); +} + +#[tokio::test] +async fn remote_control_handle_revokes_client_while_disabled() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let request = accept_http_request(&listener).await; + assert_eq!( + request.request_line, + "DELETE /backend-api/wham/remote/control/environments/env%20%2F%3F/clients/client%20%2F%3F HTTP/1.1" + ); + respond_with_status(request.stream, "204 No Content", "").await; + }); + let handle = client_management_handle(remote_control_url, remote_control_auth_manager()); + + let response = handle + .revoke_client(RemoteControlClientsRevokeParams { + environment_id: "env /?".to_string(), + client_id: "client /?".to_string(), + }) + .await + .expect("client revoke should succeed while remote control is disabled"); + server_task.await.expect("server task should finish"); + + assert_eq!(response, RemoteControlClientsRevokeResponse {}); +} + +#[tokio::test] +async fn list_remote_control_clients_recovers_auth_after_unauthorized() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let stale_request = accept_http_request(&listener).await; + assert_eq!( + stale_request.headers.get("authorization"), + Some(&"Bearer stale-token".to_string()) + ); + respond_with_status(stale_request.stream, "401 Unauthorized", "").await; + + let recovered_request = accept_http_request(&listener).await; + assert_eq!( + recovered_request.headers.get("authorization"), + Some(&"Bearer fresh-token".to_string()) + ); + respond_with_json(recovered_request.stream, empty_client_list()).await; + }); + let codex_home = TempDir::new().expect("temp dir should create"); + let mut stale_auth = remote_control_auth_dot_json(Some("account_id")); + stale_auth + .tokens + .as_mut() + .expect("stale auth should include tokens") + .access_token = "stale-token".to_string(); + save_auth( + codex_home.path(), + &stale_auth, + AuthCredentialsStoreMode::File, + ) + .expect("stale auth should save"); + let auth_manager = AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await; + let mut fresh_auth = remote_control_auth_dot_json(Some("account_id")); + fresh_auth + .tokens + .as_mut() + .expect("fresh auth should include tokens") + .access_token = "fresh-token".to_string(); + save_auth( + codex_home.path(), + &fresh_auth, + AuthCredentialsStoreMode::File, + ) + .expect("fresh auth should save"); + + let response = list_remote_control_clients( + &remote_control_url, + &auth_manager, + RemoteControlClientsListParams { + environment_id: "env-123".to_string(), + ..Default::default() + }, + ) + .await + .expect("client list should recover auth"); + server_task.await.expect("server task should finish"); + + assert_eq!( + response, + RemoteControlClientsListResponse { + data: Vec::new(), + next_cursor: None, + } + ); +} + +#[tokio::test] +async fn list_remote_control_clients_retries_unauthorized_only_once() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let stale_request = accept_http_request(&listener).await; + assert_eq!( + stale_request.headers.get("authorization"), + Some(&"Bearer stale-token".to_string()) + ); + respond_with_status(stale_request.stream, "401 Unauthorized", "").await; + + let recovered_request = accept_http_request(&listener).await; + assert_eq!( + recovered_request.headers.get("authorization"), + Some(&"Bearer fresh-token".to_string()) + ); + respond_with_status(recovered_request.stream, "401 Unauthorized", "").await; + + assert!( + timeout(Duration::from_millis(100), accept_http_request(&listener)) + .await + .is_err() + ); + }); + let codex_home = TempDir::new().expect("temp dir should create"); + let mut stale_auth = remote_control_auth_dot_json(Some("account_id")); + stale_auth + .tokens + .as_mut() + .expect("stale auth should include tokens") + .access_token = "stale-token".to_string(); + save_auth( + codex_home.path(), + &stale_auth, + AuthCredentialsStoreMode::File, + ) + .expect("stale auth should save"); + let auth_manager = AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await; + let mut fresh_auth = remote_control_auth_dot_json(Some("account_id")); + fresh_auth + .tokens + .as_mut() + .expect("fresh auth should include tokens") + .access_token = "fresh-token".to_string(); + save_auth( + codex_home.path(), + &fresh_auth, + AuthCredentialsStoreMode::File, + ) + .expect("fresh auth should save"); + + let err = list_remote_control_clients( + &remote_control_url, + &auth_manager, + RemoteControlClientsListParams { + environment_id: "env-123".to_string(), + ..Default::default() + }, + ) + .await + .expect_err("second unauthorized response should fail"); + server_task.await.expect("server task should finish"); + + assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); +} + +#[tokio::test] +async fn revoke_remote_control_client_does_not_retry_forbidden() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let request = accept_http_request(&listener).await; + respond_with_status_and_headers( + request.stream, + "403 Forbidden", + &[("x-request-id", "request-123"), ("cf-ray", "ray-123")], + "forbidden", + ) + .await; + }); + + let err = revoke_remote_control_client( + &remote_control_url, + &remote_control_auth_manager(), + RemoteControlClientsRevokeParams { + environment_id: "env-123".to_string(), + client_id: "client-123".to_string(), + }, + ) + .await + .expect_err("forbidden revoke should fail"); + server_task.await.expect("server task should finish"); + + assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); + assert_eq!( + err.to_string(), + format!( + "remote control client revoke failed at `{remote_control_url}wham/remote/control/environments/env-123/clients/client-123`: HTTP 403 Forbidden, request-id: request-123, cf-ray: ray-123, body: forbidden" + ) + ); +} + +#[tokio::test] +async fn list_remote_control_clients_preserves_decode_error_context() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let request = accept_http_request(&listener).await; + respond_with_status(request.stream, "200 OK", "{").await; + }); + + let err = list_remote_control_clients( + &remote_control_url, + &remote_control_auth_manager(), + RemoteControlClientsListParams { + environment_id: "env-123".to_string(), + ..Default::default() + }, + ) + .await + .expect_err("malformed client list should fail"); + server_task.await.expect("server task should finish"); + + assert!( + err.to_string().contains( + "failed to parse remote control client list response from `http://127.0.0.1:" + ) + ); + assert!(err.to_string().contains("HTTP 200 OK")); + assert!(err.to_string().contains("body: {")); + assert!(err.to_string().contains("decode error:")); +} diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 5ec4f3d04e..60792df54c 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -13,9 +13,11 @@ use super::segment::ClientSegmentReassembler; use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES; use super::segment::split_server_envelope_for_transport; use crate::transport::TransportEvent; +use crate::transport::remote_control::auth::RemoteControlConnectionAuth; +use crate::transport::remote_control::auth::load_remote_control_auth; +use crate::transport::remote_control::auth::recover_remote_control_auth; use crate::transport::remote_control::client_tracker::ClientTracker; use crate::transport::remote_control::client_tracker::REMOTE_CONTROL_IDLE_SWEEP_INTERVAL; -use crate::transport::remote_control::enroll::RemoteControlConnectionAuth; use crate::transport::remote_control::enroll::RemoteControlEnrollment; use crate::transport::remote_control::enroll::enroll_remote_control_server; use crate::transport::remote_control::enroll::format_headers; @@ -1172,51 +1174,6 @@ fn build_remote_control_websocket_request( Ok(request) } -pub(crate) async fn load_remote_control_auth( - auth_manager: &Arc, -) -> io::Result { - let mut reloaded = false; - let auth = loop { - let Some(auth) = auth_manager.auth().await else { - if reloaded { - return Err(io::Error::new( - ErrorKind::PermissionDenied, - "remote control requires ChatGPT authentication", - )); - } - auth_manager.reload().await; - reloaded = true; - continue; - }; - if !auth.uses_codex_backend() { - break auth; - } - if auth.get_account_id().is_none() && !reloaded { - auth_manager.reload().await; - reloaded = true; - continue; - } - break auth; - }; - - if !auth.uses_codex_backend() { - return Err(io::Error::new( - ErrorKind::PermissionDenied, - "remote control requires ChatGPT authentication; API key auth is not supported", - )); - } - - Ok(RemoteControlConnectionAuth { - auth_provider: codex_model_provider::auth_provider_from_auth(&auth), - account_id: auth.get_account_id().ok_or_else(|| { - io::Error::new( - ErrorKind::WouldBlock, - "remote control enrollment is waiting for a ChatGPT account id", - ) - })?, - }) -} - fn next_reconnect_delay(reconnect_attempt: &mut u64) -> (std::time::Duration, bool) { let reconnect_delay = backoff(*reconnect_attempt).min(REMOTE_CONTROL_RECONNECT_BACKOFF_CAP); let reconnect_backoff_reset = reconnect_delay == REMOTE_CONTROL_RECONNECT_BACKOFF_CAP; @@ -1548,52 +1505,6 @@ async fn enroll_remote_control_server_if_missing( Ok(()) } -pub(super) async fn recover_remote_control_auth( - auth_recovery: &mut UnauthorizedRecovery, - auth_change_rx: &mut watch::Receiver, -) -> bool { - if !auth_recovery.has_next() { - return false; - } - - let mode = auth_recovery.mode_name(); - let step = auth_recovery.step_name(); - let auth_change_revision_before_recovery = *auth_change_rx.borrow(); - match auth_recovery.next().await { - Ok(step_result) => { - if step_result.auth_state_changed() == Some(true) { - mark_recovery_auth_change_seen( - auth_change_rx, - auth_change_revision_before_recovery, - ); - } - info!( - "remote control websocket auth recovery succeeded: mode={mode}, step={step}, auth_state_changed={:?}", - step_result.auth_state_changed() - ); - true - } - Err(err) => { - warn!("remote control websocket auth recovery failed: mode={mode}, step={step}: {err}"); - false - } - } -} - -fn mark_recovery_auth_change_seen( - auth_change_rx: &mut watch::Receiver, - auth_change_revision_before_recovery: u64, -) { - let auth_change_revision_after_recovery = *auth_change_rx.borrow(); - if auth_change_revision_after_recovery == auth_change_revision_before_recovery.wrapping_add(1) { - // Recovery updated the same watch that wakes the outer reconnect - // loop. Mark only that single revision seen; if more revisions - // arrived while recovery was in flight, leave them pending so the - // reconnect loop still reacts to the later external auth change. - auth_change_rx.borrow_and_update(); - } -} - fn format_remote_control_websocket_connect_error( websocket_url: &str, err: &tungstenite::Error, @@ -1620,6 +1531,7 @@ mod tests { use super::*; use crate::outgoing_message::OutgoingMessage; use crate::transport::remote_control::ServerEvent; + use crate::transport::remote_control::auth::mark_recovery_auth_change_seen; use crate::transport::remote_control::protocol::StreamId; use crate::transport::remote_control::protocol::normalize_remote_control_url; use chrono::Utc; diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index fdc34e524c..dc02ff8c04 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -211,6 +211,8 @@ Example with notification opt-out: - `remoteControl/disable` — experimental; disable remote control for the current app-server process and return the current remote-control status snapshot. This does not revoke already enrolled controller devices. - `remoteControl/status/read` — experimental; read the current remote-control status snapshot. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. - `remoteControl/pairing/start` — experimental; start a short-lived remote-control pairing artifact for the current app-server process. Pass `manualCode: true` to also request a manual pairing code. Returns `pairingCode`, `manualPairingCode`, `environmentId`, and Unix-seconds `expiresAt`; app-server intentionally does not expose the backend `serverId`. +- `remoteControl/client/list` — experimental; list controller devices granted access to an environment. Pass `environmentId` and optional `cursor`, `limit`, and `order`; returns picker-oriented client metadata plus `nextCursor`. This signed-in account-management operation works while the local relay is disabled or unenrolled. +- `remoteControl/client/revoke` — experimental; revoke one controller device's grant for an environment. Pass `environmentId` and `clientId`; returns an empty object. This signed-in account-management operation works while the local relay is disabled or unenrolled. - `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot. - `skills/config/write` — write user-level skill config by name or absolute path. - `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**). diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 7aa93ec86e..84f22809ee 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -922,6 +922,16 @@ impl MessageProcessor { .pairing_start(params) .await .map(|response| Some(response.into())), + ClientRequest::RemoteControlClientsList { params, .. } => self + .remote_control_processor + .clients_list(params) + .await + .map(|response| Some(response.into())), + ClientRequest::RemoteControlClientsRevoke { params, .. } => self + .remote_control_processor + .clients_revoke(params) + .await + .map(|response| Some(response.into())), ClientRequest::ConfigRequirementsRead { params: _, .. } => self .config_processor .config_requirements_read() diff --git a/codex-rs/app-server/src/request_processors/remote_control_processor.rs b/codex-rs/app-server/src/request_processors/remote_control_processor.rs index 7c279585f2..804339c17a 100644 --- a/codex-rs/app-server/src/request_processors/remote_control_processor.rs +++ b/codex-rs/app-server/src/request_processors/remote_control_processor.rs @@ -3,6 +3,10 @@ use crate::error_code::invalid_request; use crate::transport::RemoteControlHandle; use crate::transport::RemoteControlUnavailable; use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::RemoteControlClientsListParams; +use codex_app_server_protocol::RemoteControlClientsListResponse; +use codex_app_server_protocol::RemoteControlClientsRevokeParams; +use codex_app_server_protocol::RemoteControlClientsRevokeResponse; use codex_app_server_protocol::RemoteControlDisableResponse; use codex_app_server_protocol::RemoteControlEnableResponse; use codex_app_server_protocol::RemoteControlPairingStartParams; @@ -55,6 +59,26 @@ impl RemoteControlRequestProcessor { .map_err(map_pairing_start_error) } + pub(crate) async fn clients_list( + &self, + params: RemoteControlClientsListParams, + ) -> Result { + self.handle()? + .list_clients(params) + .await + .map_err(map_client_management_error) + } + + pub(crate) async fn clients_revoke( + &self, + params: RemoteControlClientsRevokeParams, + ) -> Result { + self.handle()? + .revoke_client(params) + .await + .map_err(map_client_management_error) + } + fn handle(&self) -> Result<&RemoteControlHandle, JSONRPCErrorError> { self.remote_control_handle .as_ref() @@ -74,5 +98,15 @@ fn map_pairing_start_error(err: io::Error) -> JSONRPCErrorError { } } +fn map_client_management_error(err: io::Error) -> JSONRPCErrorError { + match err.kind() { + io::ErrorKind::InvalidInput + | io::ErrorKind::NotFound + | io::ErrorKind::PermissionDenied + | io::ErrorKind::WouldBlock => invalid_request(err.to_string()), + _ => internal_error(err.to_string()), + } +} + #[cfg(test)] mod remote_control_processor_tests; diff --git a/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs b/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs index 8a1db50a3a..4381e48959 100644 --- a/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs @@ -46,3 +46,34 @@ fn pairing_start_maps_backend_failures_to_internal_error() { } ); } + +#[test] +fn client_management_maps_user_actionable_errors_to_invalid_request() { + for kind in [ + io::ErrorKind::InvalidInput, + io::ErrorKind::NotFound, + io::ErrorKind::PermissionDenied, + io::ErrorKind::WouldBlock, + ] { + assert_eq!( + map_client_management_error(io::Error::new(kind, "client management unavailable")), + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + data: None, + message: "client management unavailable".to_string(), + } + ); + } +} + +#[test] +fn client_management_maps_backend_failures_to_internal_error() { + assert_eq!( + map_client_management_error(io::Error::other("client management failed")), + JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + data: None, + message: "client management failed".to_string(), + } + ); +} diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index 88378322a8..ec4b30d92f 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -67,6 +67,8 @@ use codex_app_server_protocol::ProcessKillParams; use codex_app_server_protocol::ProcessResizePtyParams; use codex_app_server_protocol::ProcessSpawnParams; use codex_app_server_protocol::ProcessWriteStdinParams; +use codex_app_server_protocol::RemoteControlClientsListParams; +use codex_app_server_protocol::RemoteControlClientsRevokeParams; use codex_app_server_protocol::RemoteControlPairingStartParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; @@ -654,6 +656,25 @@ impl TestAppServer { .await } + /// Send a `remoteControl/client/list` JSON-RPC request. + pub async fn send_remote_control_clients_list_request( + &mut self, + params: RemoteControlClientsListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("remoteControl/client/list", params).await + } + + /// Send a `remoteControl/client/revoke` JSON-RPC request. + pub async fn send_remote_control_clients_revoke_request( + &mut self, + params: RemoteControlClientsRevokeParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("remoteControl/client/revoke", params) + .await + } + /// Send an `app/list` JSON-RPC request. pub async fn send_apps_list_request(&mut self, params: AppsListParams) -> anyhow::Result { let params = Some(serde_json::to_value(params)?); diff --git a/codex-rs/app-server/tests/suite/v2/remote_control.rs b/codex-rs/app-server/tests/suite/v2/remote_control.rs index 86187cfc40..b5384d3bad 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_control.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_control.rs @@ -8,6 +8,12 @@ use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RemoteControlClient; +use codex_app_server_protocol::RemoteControlClientsListOrder; +use codex_app_server_protocol::RemoteControlClientsListParams; +use codex_app_server_protocol::RemoteControlClientsListResponse; +use codex_app_server_protocol::RemoteControlClientsRevokeParams; +use codex_app_server_protocol::RemoteControlClientsRevokeResponse; use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_protocol::RemoteControlDisableResponse; use codex_app_server_protocol::RemoteControlEnableResponse; @@ -187,11 +193,130 @@ async fn remote_control_pairing_start_returns_pairing_artifacts() -> Result<()> Ok(()) } +#[tokio::test] +async fn remote_control_client_management_works_while_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let mut backend = ClientManagementRemoteControlBackend::start(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_remote_control_clients_list_request(RemoteControlClientsListParams { + environment_id: "environment-id".to_string(), + cursor: Some("cursor-id".to_string()), + limit: Some(10), + order: Some(RemoteControlClientsListOrder::Desc), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: RemoteControlClientsListResponse = to_response(response)?; + assert_eq!( + received, + RemoteControlClientsListResponse { + data: vec![RemoteControlClient { + client_id: "client-id".to_string(), + display_name: Some("Anton Phone".to_string()), + device_type: Some("phone".to_string()), + platform: Some("ios".to_string()), + os_version: Some("19.0".to_string()), + device_model: Some("iPhone".to_string()), + app_version: Some("1.2.3".to_string()), + last_seen_at: Some(1_772_694_000), + }], + next_cursor: Some("next-cursor".to_string()), + } + ); + + let request_id = mcp + .send_remote_control_clients_revoke_request(RemoteControlClientsRevokeParams { + environment_id: "environment-id".to_string(), + client_id: "client-id".to_string(), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: RemoteControlClientsRevokeResponse = to_response(response)?; + assert_eq!(received, RemoteControlClientsRevokeResponse {}); + assert_eq!( + timeout(DEFAULT_TIMEOUT, backend.wait_for_requests()).await??, + vec![ + "GET /backend-api/wham/remote/control/environments/environment-id/clients?cursor=cursor-id&limit=10&order=desc HTTP/1.1".to_string(), + "DELETE /backend-api/wham/remote/control/environments/environment-id/clients/client-id HTTP/1.1".to_string(), + ] + ); + Ok(()) +} + struct BlockingRemoteControlBackend { enroll_request_rx: Option>>, server_task: JoinHandle<()>, } +struct ClientManagementRemoteControlBackend { + requests_rx: Option>>>, + server_task: JoinHandle<()>, +} + +impl ClientManagementRemoteControlBackend { + async fn start(codex_home: &std::path::Path) -> Result { + let listener = configured_remote_control_listener(codex_home).await?; + let (requests_tx, requests_rx) = oneshot::channel(); + let server_task = tokio::spawn(async move { + let result = async { + let list_request = read_http_request(&listener).await?; + let list_request_line = list_request.request_line; + respond_with_json( + list_request.reader.into_inner(), + serde_json::json!({ + "items": [{ + "client_id": "client-id", + "account_user_id": "user-id", + "enrollment_status": "enrolled_device_key", + "display_name": "Anton Phone", + "device_type": "phone", + "platform": "ios", + "os_version": "19.0", + "device_model": "iPhone", + "app_version": "1.2.3", + "last_seen_at": "2026-03-05T07:00:00Z", + "last_seen_city": "San Francisco", + }], + "cursor": "next-cursor", + }), + ) + .await?; + + let revoke_request = read_http_request(&listener).await?; + let revoke_request_line = revoke_request.request_line; + respond_with_status(revoke_request.reader.into_inner(), "204 No Content", "") + .await?; + + Ok(vec![list_request_line, revoke_request_line]) + } + .await; + let _ = requests_tx.send(result); + }); + Ok(Self { + requests_rx: Some(requests_rx), + server_task, + }) + } + + async fn wait_for_requests(&mut self) -> Result> { + self.requests_rx + .take() + .context("requests should only be awaited once")? + .await? + } +} + impl BlockingRemoteControlBackend { async fn start(codex_home: &std::path::Path) -> Result { let listener = configured_remote_control_listener(codex_home).await?; @@ -303,6 +428,12 @@ impl Drop for BlockingRemoteControlBackend { } } +impl Drop for ClientManagementRemoteControlBackend { + fn drop(&mut self) { + self.server_task.abort(); + } +} + struct HttpRequest { request_line: String, reader: BufReader, @@ -365,3 +496,16 @@ async fn respond_with_json(stream: TcpStream, body: serde_json::Value) -> Result .await?; Ok(()) } + +async fn respond_with_status(mut stream: TcpStream, status: &str, body: &str) -> Result<()> { + stream + .write_all( + format!( + "HTTP/1.1 {status}\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await?; + Ok(()) +} From d36a3ead3c896d0552207763ef483262bce9ac73 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 2 Jun 2026 17:34:04 -0700 Subject: [PATCH 07/16] revert: publish release symbol artifacts (#25988) revert https://github.com/openai/codex/pull/25916 and https://github.com/openai/codex/pull/25649. --- ...hive-release-symbols-and-strip-binaries.sh | 119 ------------------ .github/workflows/rust-release-windows.yml | 35 +----- .github/workflows/rust-release.yml | 41 +----- 3 files changed, 3 insertions(+), 192 deletions(-) delete mode 100755 .github/scripts/archive-release-symbols-and-strip-binaries.sh diff --git a/.github/scripts/archive-release-symbols-and-strip-binaries.sh b/.github/scripts/archive-release-symbols-and-strip-binaries.sh deleted file mode 100755 index 3e5894bb99..0000000000 --- a/.github/scripts/archive-release-symbols-and-strip-binaries.sh +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: archive-release-symbols-and-strip-binaries.sh \ - --target \ - --artifact-name \ - --release-dir \ - --archive-dir \ - --binaries "" -EOF -} - -target="" -artifact_name="" -release_dir="" -archive_dir="" -binaries="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --target) - target="${2:?--target requires a value}" - shift 2 - ;; - --artifact-name) - artifact_name="${2:?--artifact-name requires a value}" - shift 2 - ;; - --release-dir) - release_dir="${2:?--release-dir requires a value}" - shift 2 - ;; - --archive-dir) - archive_dir="${2:?--archive-dir requires a value}" - shift 2 - ;; - --binaries) - binaries="${2:?--binaries requires a value}" - shift 2 - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "Unexpected argument: $1" >&2 - usage >&2 - exit 1 - ;; - esac -done - -if [[ -z "$target" || -z "$artifact_name" || -z "$release_dir" || -z "$archive_dir" || -z "$binaries" ]]; then - usage >&2 - exit 1 -fi - -symbols_root="${RUNNER_TEMP:-/tmp}/codex-symbols-${artifact_name}" -symbols_dir="${symbols_root}/codex-symbols-${artifact_name}" -archive_path="${archive_dir%/}/codex-symbols-${artifact_name}.tar.gz" -rm -rf "$symbols_root" -mkdir -p "$symbols_dir" "$archive_dir" -read -r -a binary_names <<< "$binaries" - -case "$target" in - *apple-darwin) - for binary in "${binary_names[@]}"; do - binary_path="${release_dir%/}/${binary}" - dsym_path="${binary_path}.dSYM" - if [[ ! -f "$binary_path" ]]; then - echo "Binary $binary_path not found" >&2 - exit 1 - fi - if [[ ! -d "$dsym_path" ]]; then - echo "dSYM $dsym_path not found" >&2 - exit 1 - fi - - cp -RL "$dsym_path" "${symbols_dir}/${binary}.dSYM" - strip -S -x "$binary_path" - done - ;; - *linux*) - objcopy_bin="${OBJCOPY:-objcopy}" - strip_bin="${STRIP:-strip}" - for binary in "${binary_names[@]}"; do - binary_path="${release_dir%/}/${binary}" - debug_path="${symbols_dir}/${binary}.debug" - if [[ ! -f "$binary_path" ]]; then - echo "Binary $binary_path not found" >&2 - exit 1 - fi - - "$objcopy_bin" --only-keep-debug "$binary_path" "$debug_path" - "$strip_bin" --strip-debug --strip-unneeded "$binary_path" - "$objcopy_bin" --add-gnu-debuglink="$debug_path" "$binary_path" - done - ;; - *windows*) - for binary in "${binary_names[@]}"; do - pdb_path="${release_dir%/}/${binary}.pdb" - if [[ ! -f "$pdb_path" ]]; then - echo "PDB $pdb_path not found" >&2 - exit 1 - fi - - cp "$pdb_path" "${symbols_dir}/${binary}.pdb" - done - ;; - *) - echo "No symbols packaging support for target: $target" >&2 - exit 1 - ;; -esac - -rm -f "$archive_path" -tar -C "$symbols_root" -czf "$archive_path" "codex-symbols-${artifact_name}" diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index fc14088ef1..89bcd33ea1 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -39,8 +39,6 @@ jobs: working-directory: codex-rs env: CARGO_PROFILE_RELEASE_LTO: ${{ inputs.release-lto }} - CARGO_PROFILE_RELEASE_DEBUG: full - CARGO_PROFILE_RELEASE_STRIP: "false" strategy: fail-fast: false @@ -134,22 +132,10 @@ jobs: - name: Stage Windows binaries shell: bash run: | - release_dir="target/${{ matrix.target }}/release" - output_dir="$release_dir/staged-${{ matrix.bundle }}" + output_dir="target/${{ matrix.target }}/release/staged-${{ matrix.bundle }}" mkdir -p "$output_dir" for binary in ${{ matrix.binaries }}; do - pdb_name="${binary//-/_}" - pdb_path="$release_dir/${pdb_name}.pdb" - if [[ ! -f "$pdb_path" ]]; then - pdb_path="$release_dir/${binary}.pdb" - fi - if [[ ! -f "$pdb_path" ]]; then - echo "PDB for $binary not found at $release_dir/${pdb_name}.pdb or $release_dir/${binary}.pdb" >&2 - exit 1 - fi - - cp "$release_dir/${binary}.exe" "$output_dir/${binary}.exe" - cp "$pdb_path" "$output_dir/${binary}.pdb" + cp "target/${{ matrix.target }}/release/${binary}.exe" "$output_dir/${binary}.exe" done - name: Upload Windows binaries @@ -232,23 +218,6 @@ jobs: account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - - name: Build symbols archive - shell: bash - run: | - bash "${GITHUB_WORKSPACE}/.github/scripts/archive-release-symbols-and-strip-binaries.sh" \ - --target "${{ matrix.target }}" \ - --artifact-name "${{ matrix.target }}" \ - --release-dir "target/${{ matrix.target }}/release" \ - --archive-dir "symbols-dist/${{ matrix.target }}" \ - --binaries "${WINDOWS_BINARIES}" - - - name: Upload symbols archive - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.target }}-symbols - path: codex-rs/symbols-dist/${{ matrix.target }}/* - if-no-files-found: error - - name: Stage artifacts shell: bash run: | diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index ec1b902e2a..55f95fa270 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -149,9 +149,6 @@ jobs: # 2026-03-04: temporarily change releases to use thin LTO because # Ubuntu ARM is timing out at 60 minutes. CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} - CARGO_PROFILE_RELEASE_DEBUG: full - CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO: ${{ contains(matrix.target, 'apple-darwin') && 'packed' || 'off' }} - CARGO_PROFILE_RELEASE_STRIP: "false" # Use the git CLI instead of Cargo's libgit2 path for git dependencies. # macOS release runners have intermittently failed to fetch nested # submodules through SecureTransport/libgit2, especially libwebrtc's @@ -252,7 +249,7 @@ jobs: run: | set -euo pipefail sudo apt-get update -y - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends binutils pkg-config libcap-dev + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: targets: ${{ matrix.target }} @@ -311,10 +308,6 @@ jobs: exit 1 fi - # Codex embeds this digest at build time and verifies the bundled - # bwrap resource before use. Strip bwrap before hashing so the digest - # covers the exact bytes that the release packages. - strip --strip-debug --strip-unneeded "$bwrap_path" digest="$(sha256sum "$bwrap_path" | awk '{print $1}')" echo "CODEX_BWRAP_SHA256=${digest}" >> "$GITHUB_ENV" echo "Built bwrap ${bwrap_path} with sha256:${digest}" @@ -328,11 +321,6 @@ jobs: fi build_args=() for binary in ${{ matrix.binaries }}; do - # bwrap was built, finalized, and hashed before this build so - # Codex can embed the digest of the bytes that will be packaged. - if [[ "$binary" == "bwrap" ]]; then - continue - fi build_args+=(--bin "$binary") done echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}" @@ -345,32 +333,6 @@ jobs: path: codex-rs/target/**/cargo-timings/cargo-timing.html if-no-files-found: warn - - name: Build symbols archive and strip binaries - shell: bash - run: | - binaries=() - for binary in ${{ matrix.binaries }}; do - # bwrap is already stripped before hashing. Its symbols are not - # useful enough to justify a separate pre-Codex symbols pass. - if [[ "$binary" == "bwrap" ]]; then - continue - fi - binaries+=("$binary") - done - bash "${GITHUB_WORKSPACE}/.github/scripts/archive-release-symbols-and-strip-binaries.sh" \ - --target "${{ matrix.target }}" \ - --artifact-name "${{ matrix.artifact_name }}" \ - --release-dir "target/${{ matrix.target }}/release" \ - --archive-dir "symbols-dist/${{ matrix.artifact_name }}" \ - --binaries "${binaries[*]}" - - - name: Upload symbols archive - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }}-symbols - path: codex-rs/symbols-dist/${{ matrix.artifact_name }}/* - if-no-files-found: error - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} name: Stage unsigned macOS artifacts shell: bash @@ -1068,7 +1030,6 @@ jobs: run: | find dist -mindepth 1 -maxdepth 1 -type d \ ! -name '*-apple-darwin*-unsigned' \ - ! -name '*-symbols' \ ! -name 'aarch64-unknown-linux-musl' \ ! -name 'aarch64-unknown-linux-musl-app-server' \ ! -name 'x86_64-unknown-linux-musl' \ From 668703c23f8a6cde07c317f687d9b95606293752 Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 3 Jun 2026 12:22:23 +0200 Subject: [PATCH 08/16] feat: default hide_spawn_agent_metadata to true (#26114) For MAv2 CBv9 --- codex-rs/core/src/config/config_tests.rs | 1 + codex-rs/core/src/config/mod.rs | 2 +- .../core/src/tools/handlers/multi_agents_spec.rs | 14 ++++++++++++-- .../src/tools/handlers/multi_agents_spec_tests.rs | 9 ++++++++- .../core/src/tools/handlers/multi_agents_tests.rs | 6 +++--- .../core/tests/suite/spawn_agent_description.rs | 1 + .../core/tests/suite/subagent_notifications.rs | 1 + 7 files changed, 27 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 8bc33bbb45..87f00fed86 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -9914,6 +9914,7 @@ enabled = true .unwrap_or_default() .contains("maximum concurrency"), ); + assert!(config.multi_agent_v2.hide_spawn_agent_metadata); assert!(config.multi_agent_v2.non_code_mode_only); Ok(()) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index fd71cea987..43b2a2ed29 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1065,7 +1065,7 @@ impl Default for MultiAgentV2Config { DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT.to_string(), ), tool_namespace: None, - hide_spawn_agent_metadata: false, + hide_spawn_agent_metadata: true, non_code_mode_only: true, } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs index 4359445f97..baf46d0d0c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -48,6 +48,8 @@ impl Default for WaitAgentTimeoutOptions { pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions) -> ToolSpec { let available_models_description = (!options.hide_agent_type_model_reasoning) .then(|| spawn_agent_models_description(&options.available_models)); + let inherited_model_guidance = + (!options.hide_agent_type_model_reasoning).then_some(SPAWN_AGENT_INHERITED_MODEL_GUIDANCE); let return_value_description = "Returns the spawned agent id plus the user-facing nickname when available."; let mut properties = spawn_agent_common_properties_v1(&options.agent_type_description); @@ -62,6 +64,7 @@ pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions) -> ToolSpec { name: "spawn_agent".to_string(), description: spawn_agent_tool_description( available_models_description.as_deref(), + inherited_model_guidance, return_value_description, options.include_usage_hint, options.usage_hint_text, @@ -77,6 +80,8 @@ pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions) -> ToolSpec { pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions) -> ToolSpec { let available_models_description = (!options.hide_agent_type_model_reasoning) .then(|| spawn_agent_models_description(&options.available_models)); + let inherited_model_guidance = + (!options.hide_agent_type_model_reasoning).then_some(SPAWN_AGENT_INHERITED_MODEL_GUIDANCE); let mut properties = spawn_agent_common_properties_v2(&options.agent_type_description); if options.hide_agent_type_model_reasoning { hide_spawn_agent_metadata_options(&mut properties); @@ -93,6 +98,7 @@ pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions) -> ToolSpec { name: "spawn_agent".to_string(), description: spawn_agent_tool_description_v2( available_models_description.as_deref(), + inherited_model_guidance, options.include_usage_hint, options.usage_hint_text, options.max_concurrent_threads_per_session, @@ -632,16 +638,18 @@ fn hide_spawn_agent_metadata_options(properties: &mut BTreeMap, + inherited_model_guidance: Option<&str>, return_value_description: &str, include_usage_hint: bool, usage_hint_text: Option, ) -> String { let agent_role_guidance = available_models_description.unwrap_or_default(); + let inherited_model_guidance = inherited_model_guidance.unwrap_or_default(); let tool_description = format!( r#" {agent_role_guidance} - Spawn a sub-agent for a well-scoped task. {return_value_description} {SPAWN_AGENT_INHERITED_MODEL_GUIDANCE}"# + Spawn a sub-agent for a well-scoped task. {return_value_description} {inherited_model_guidance}"# ); if !include_usage_hint { @@ -701,11 +709,13 @@ Requests for depth, thoroughness, research, investigation, or detailed codebase fn spawn_agent_tool_description_v2( available_models_description: Option<&str>, + inherited_model_guidance: Option<&str>, include_usage_hint: bool, usage_hint_text: Option, max_concurrent_threads_per_session: Option, ) -> String { let agent_role_guidance = available_models_description.unwrap_or_default(); + let inherited_model_guidance = inherited_model_guidance.unwrap_or_default(); let concurrency_guidance = max_concurrent_threads_per_session .map(|limit| { format!( @@ -720,7 +730,7 @@ fn spawn_agent_tool_description_v2( Spawns an agent to work on the specified task. If your current task is `/root/task1` and you spawn_agent with task_name "task_3" the agent will have canonical task name `/root/task1/task_3`. You are then able to refer to this agent as `task_3` or `/root/task1/task_3` interchangeably. However an agent `/root/task2/task_3` would only be able to communicate with this agent via its canonical name `/root/task1/task_3`. The spawned agent will have the same tools as you and the ability to spawn its own subagents. -{SPAWN_AGENT_INHERITED_MODEL_GUIDANCE} +{inherited_model_guidance} It will be able to send you and other running agents messages, and its final answer will be provided to you when it finishes. The new agent's canonical task name will be provided to it along with the message. {concurrency_guidance}"# diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs index fc18c312df..5f4a66a75c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs @@ -197,7 +197,12 @@ fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() { max_concurrent_threads_per_session: Some(4), }); - let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else { + let ToolSpec::Function(ResponsesApiTool { + description, + parameters, + .. + }) = tool + else { panic!("spawn_agent should be a function tool"); }; let properties = parameters @@ -209,6 +214,8 @@ fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() { assert!(!properties.contains_key("model")); assert!(!properties.contains_key("reasoning_effort")); assert!(!properties.contains_key("service_tier")); + assert!(!description.contains(SPAWN_AGENT_INHERITED_MODEL_GUIDANCE)); + assert!(!description.contains("Available model overrides")); } #[test] diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index d4ce84ad55..e33d942343 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1161,7 +1161,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat let spawn_result: SpawnAgentResult = serde_json::from_str(&content).expect("spawn result should parse"); assert_eq!(spawn_result.task_name, "/root/test_process"); - assert!(spawn_result.nickname.is_some()); + assert_eq!(spawn_result.nickname, None); let child_thread_id = session .services @@ -2179,7 +2179,7 @@ async fn multi_agent_v2_spawn_omits_agent_id_when_named() { assert!(result.get("agent_id").is_none()); assert_eq!(result["task_name"], "/root/test_process"); - assert!(result.get("nickname").is_some()); + assert!(result.get("nickname").is_none()); assert_eq!(success, Some(true)); } @@ -2436,7 +2436,7 @@ async fn multi_agent_v2_spawn_agent_ignores_configured_max_depth() { let result: SpawnAgentResult = serde_json::from_str(&content).expect("spawn_agent result should be json"); assert_eq!(result.task_name, "/root/parent/child"); - assert!(result.nickname.is_some()); + assert_eq!(result.nickname, None); assert_eq!(success, Some(true)); } diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index c1f0d522c7..3b581c9051 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -165,6 +165,7 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() -> .features .enable(Feature::Collab) .expect("test config should allow feature update"); + config.multi_agent_v2.hide_spawn_agent_metadata = false; }); let test = builder.build(&server).await?; wait_for_model_available(&test.thread_manager.get_models_manager(), "visible-model").await; diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 52fd1a25e7..c39a6bb39d 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -1184,6 +1184,7 @@ async fn spawn_agent_tool_description_mentions_role_locked_settings() -> Result< .features .enable(Feature::Collab) .expect("test config should allow feature update"); + config.multi_agent_v2.hide_spawn_agent_metadata = false; let role_path = config.codex_home.join("custom-role.toml"); std::fs::write( &role_path, From ac67905fc44b2e4ebd1c295dfe73f87033c50826 Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 3 Jun 2026 12:25:21 +0200 Subject: [PATCH 09/16] chore: extract context fragments into dedicated crate (#26122) ## Why `codex-core` currently owns the generic contextual-fragment trait and several reusable fragment implementations. That makes it harder for other crates to share the same host-owned model-input abstraction without depending on all of `codex-core`. This change extracts the reusable fragment machinery into a small `codex-context-fragments` crate so future extension and skills work can depend on the fragment abstraction directly. ## What Changed - Added the `codex-context-fragments` crate with: - `ContextualUserFragment` - `FragmentRegistration` / `FragmentRegistrationProxy` - additional-context fragment types - Moved `SkillInstructions` into `codex-core-skills`, since skill-specific rendering belongs with skills rather than generic core context machinery. - Kept `codex-core` re-exporting the fragment types it still uses internally, so existing call sites keep the same shape. - Updated Cargo and Bazel workspace metadata for the new crate. ## Verification - `cargo metadata --locked --format-version 1 --no-deps` - `just bazel-lock-update` - `just bazel-lock-check` --- codex-rs/Cargo.lock | 11 ++++++++++ codex-rs/Cargo.toml | 2 ++ codex-rs/context-fragments/BUILD.bazel | 6 ++++++ codex-rs/context-fragments/Cargo.toml | 18 +++++++++++++++++ .../src/additional_context.rs} | 11 +++++----- .../src}/fragment.rs | 19 +++++++++++------- codex-rs/context-fragments/src/lib.rs | 8 ++++++++ codex-rs/core-skills/Cargo.toml | 1 + codex-rs/core-skills/src/lib.rs | 2 ++ .../src}/skill_instructions.rs | 12 +++++------ codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/context/mod.rs | 15 ++++++-------- .../src/context/permissions_instructions.rs | 20 ------------------- codex-rs/prompts/Cargo.toml | 1 + .../prompts/src/permissions_instructions.rs | 19 ++++++++++++++++++ 15 files changed, 99 insertions(+), 47 deletions(-) create mode 100644 codex-rs/context-fragments/BUILD.bazel create mode 100644 codex-rs/context-fragments/Cargo.toml rename codex-rs/{core/src/context/fragments.rs => context-fragments/src/additional_context.rs} (89%) rename codex-rs/{core/src/context => context-fragments/src}/fragment.rs (88%) create mode 100644 codex-rs/context-fragments/src/lib.rs rename codex-rs/{core/src/context => core-skills/src}/skill_instructions.rs (77%) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 94dac45699..f7e6ed5de3 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2506,6 +2506,14 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "codex-context-fragments" +version = "0.0.0" +dependencies = [ + "codex-protocol", + "codex-utils-string", +] + [[package]] name = "codex-core" version = "0.0.0" @@ -2528,6 +2536,7 @@ dependencies = [ "codex-code-mode", "codex-config", "codex-connectors", + "codex-context-fragments", "codex-core-plugins", "codex-core-skills", "codex-exec-server", @@ -2692,6 +2701,7 @@ dependencies = [ "codex-analytics", "codex-app-server-protocol", "codex-config", + "codex-context-fragments", "codex-exec-server", "codex-login", "codex-model-provider", @@ -3458,6 +3468,7 @@ name = "codex-prompts" version = "0.0.0" dependencies = [ "anyhow", + "codex-context-fragments", "codex-execpolicy", "codex-git-utils", "codex-protocol", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 482951fb92..59d161958d 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -29,6 +29,7 @@ members = [ "collaboration-mode-templates", "connectors", "config", + "context-fragments", "shell-command", "shell-escalation", "skills", @@ -156,6 +157,7 @@ codex-cloud-tasks-mock-client = { path = "cloud-tasks-mock-client" } codex-code-mode = { path = "code-mode" } codex-config = { path = "config" } codex-connectors = { path = "connectors" } +codex-context-fragments = { path = "context-fragments" } codex-core = { path = "core" } codex-core-api = { path = "core-api" } codex-core-plugins = { path = "core-plugins" } diff --git a/codex-rs/context-fragments/BUILD.bazel b/codex-rs/context-fragments/BUILD.bazel new file mode 100644 index 0000000000..b5920d0a23 --- /dev/null +++ b/codex-rs/context-fragments/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "context-fragments", + crate_name = "codex_context_fragments", +) diff --git a/codex-rs/context-fragments/Cargo.toml b/codex-rs/context-fragments/Cargo.toml new file mode 100644 index 0000000000..ca84916ce9 --- /dev/null +++ b/codex-rs/context-fragments/Cargo.toml @@ -0,0 +1,18 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-context-fragments" +version.workspace = true + +[lib] +name = "codex_context_fragments" +path = "src/lib.rs" +test = false +doctest = false + +[lints] +workspace = true + +[dependencies] +codex-protocol = { workspace = true } +codex-utils-string = { workspace = true } diff --git a/codex-rs/core/src/context/fragments.rs b/codex-rs/context-fragments/src/additional_context.rs similarity index 89% rename from codex-rs/core/src/context/fragments.rs rename to codex-rs/context-fragments/src/additional_context.rs index e23f96f59f..4d08b5178a 100644 --- a/codex-rs/core/src/context/fragments.rs +++ b/codex-rs/context-fragments/src/additional_context.rs @@ -1,18 +1,19 @@ -use super::ContextualUserFragment; use codex_utils_string::truncate_middle_with_token_budget; +use crate::ContextualUserFragment; + const MAX_ADDITIONAL_CONTEXT_VALUE_TOKENS: usize = 1_000; const ADDITIONAL_CONTEXT_END_MARKER_SUFFIX: &str = ">"; const ADDITIONAL_CONTEXT_START_MARKER_PREFIX: &str = " Self { + pub fn new(key: String, value: String) -> Self { Self { key, value } } } @@ -52,13 +53,13 @@ impl ContextualUserFragment for AdditionalContextUserFragment { } #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct AdditionalContextDeveloperFragment { +pub struct AdditionalContextDeveloperFragment { key: String, value: String, } impl AdditionalContextDeveloperFragment { - pub(crate) fn new(key: String, value: String) -> Self { + pub fn new(key: String, value: String) -> Self { Self { key, value } } } diff --git a/codex-rs/core/src/context/fragment.rs b/codex-rs/context-fragments/src/fragment.rs similarity index 88% rename from codex-rs/core/src/context/fragment.rs rename to codex-rs/context-fragments/src/fragment.rs index 5c500fe8ef..f41cbb63ee 100644 --- a/codex-rs/core/src/context/fragment.rs +++ b/codex-rs/context-fragments/src/fragment.rs @@ -1,28 +1,33 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; -use std::marker::PhantomData; /// Type-erased registration for a contextual user fragment. /// /// Implementations are used by context filtering code to recognize injected /// fragments without constructing the concrete context payload. -pub(crate) trait FragmentRegistration: Sync { +pub trait FragmentRegistration: Sync { fn matches_text(&self, text: &str) -> bool; } -pub(crate) struct FragmentRegistrationProxy { - _marker: PhantomData T>, +pub struct FragmentRegistrationProxy { + _marker: std::marker::PhantomData T>, } impl FragmentRegistrationProxy { - pub(crate) const fn new() -> Self { + pub const fn new() -> Self { Self { - _marker: PhantomData, + _marker: std::marker::PhantomData, } } } +impl Default for FragmentRegistrationProxy { + fn default() -> Self { + Self::new() + } +} + impl FragmentRegistration for FragmentRegistrationProxy { fn matches_text(&self, text: &str) -> bool { T::matches_text(text) @@ -97,7 +102,7 @@ pub trait ContextualUserFragment { } } -fn matches_marked_text(start_marker: &str, end_marker: &str, text: &str) -> bool { +pub(crate) fn matches_marked_text(start_marker: &str, end_marker: &str, text: &str) -> bool { if start_marker.is_empty() || end_marker.is_empty() { return false; } diff --git a/codex-rs/context-fragments/src/lib.rs b/codex-rs/context-fragments/src/lib.rs new file mode 100644 index 0000000000..c95f3001d3 --- /dev/null +++ b/codex-rs/context-fragments/src/lib.rs @@ -0,0 +1,8 @@ +mod additional_context; +mod fragment; + +pub use additional_context::AdditionalContextDeveloperFragment; +pub use additional_context::AdditionalContextUserFragment; +pub use fragment::ContextualUserFragment; +pub use fragment::FragmentRegistration; +pub use fragment::FragmentRegistrationProxy; diff --git a/codex-rs/core-skills/Cargo.toml b/codex-rs/core-skills/Cargo.toml index 4324d29dee..3c18bee603 100644 --- a/codex-rs/core-skills/Cargo.toml +++ b/codex-rs/core-skills/Cargo.toml @@ -17,6 +17,7 @@ anyhow = { workspace = true } codex-analytics = { workspace = true } codex-app-server-protocol = { workspace = true } codex-config = { workspace = true } +codex-context-fragments = { workspace = true } codex-exec-server = { workspace = true } codex-login = { workspace = true } codex-model-provider = { workspace = true } diff --git a/codex-rs/core-skills/src/lib.rs b/codex-rs/core-skills/src/lib.rs index 1577662bc5..e4a61697d1 100644 --- a/codex-rs/core-skills/src/lib.rs +++ b/codex-rs/core-skills/src/lib.rs @@ -7,6 +7,7 @@ mod mention_counts; pub mod model; pub mod remote; pub mod render; +mod skill_instructions; pub mod system; pub(crate) use invocation_utils::build_implicit_skill_path_indexes; @@ -29,3 +30,4 @@ pub use render::SkillRenderReport; pub use render::build_available_skills; pub use render::default_skill_metadata_budget; pub use render::render_available_skills_body; +pub use skill_instructions::SkillInstructions; diff --git a/codex-rs/core/src/context/skill_instructions.rs b/codex-rs/core-skills/src/skill_instructions.rs similarity index 77% rename from codex-rs/core/src/context/skill_instructions.rs rename to codex-rs/core-skills/src/skill_instructions.rs index 5c4b4b78a6..47dd0d0219 100644 --- a/codex-rs/core/src/context/skill_instructions.rs +++ b/codex-rs/core-skills/src/skill_instructions.rs @@ -1,12 +1,12 @@ -use codex_core_skills::injection::SkillInjection; +use codex_context_fragments::ContextualUserFragment; -use super::ContextualUserFragment; +use crate::injection::SkillInjection; #[derive(Debug, Clone, PartialEq)] -pub(crate) struct SkillInstructions { - pub(crate) name: String, - pub(crate) path: String, - pub(crate) contents: String, +pub struct SkillInstructions { + name: String, + path: String, + contents: String, } impl From<&SkillInjection> for SkillInstructions { diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 4f6ab2e777..97e104464f 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -31,6 +31,7 @@ codex-apply-patch = { workspace = true } codex-async-utils = { workspace = true } codex-code-mode = { workspace = true } codex-connectors = { workspace = true } +codex-context-fragments = { workspace = true } codex-config = { workspace = true } codex-core-plugins = { workspace = true } codex-core-skills = { workspace = true } diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index 3e01d5212e..5115bda6d4 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -7,8 +7,6 @@ mod available_skills_instructions; mod collaboration_mode_instructions; mod contextual_user_message; mod environment_context; -mod fragment; -mod fragments; mod guardian_followup_review_reminder; mod hook_additional_context; mod image_generation_instructions; @@ -24,7 +22,6 @@ mod plugin_instructions; mod realtime_end_instructions; mod realtime_start_instructions; mod realtime_start_with_instructions; -mod skill_instructions; mod subagent_notification; mod turn_aborted; mod user_instructions; @@ -34,15 +31,16 @@ pub(crate) use approved_command_prefix_saved::ApprovedCommandPrefixSaved; pub(crate) use apps_instructions::AppsInstructions; pub(crate) use available_plugins_instructions::AvailablePluginsInstructions; pub(crate) use available_skills_instructions::AvailableSkillsInstructions; +pub(crate) use codex_context_fragments::AdditionalContextDeveloperFragment; +pub(crate) use codex_context_fragments::AdditionalContextUserFragment; +pub use codex_context_fragments::ContextualUserFragment; +pub(crate) use codex_context_fragments::FragmentRegistration; +pub(crate) use codex_context_fragments::FragmentRegistrationProxy; +pub(crate) use codex_core_skills::SkillInstructions; pub(crate) use collaboration_mode_instructions::CollaborationModeInstructions; pub(crate) use contextual_user_message::is_contextual_user_fragment; pub(crate) use contextual_user_message::parse_visible_hook_prompt_message; pub(crate) use environment_context::EnvironmentContext; -pub use fragment::ContextualUserFragment; -pub(crate) use fragment::FragmentRegistration; -pub(crate) use fragment::FragmentRegistrationProxy; -pub(crate) use fragments::AdditionalContextDeveloperFragment; -pub(crate) use fragments::AdditionalContextUserFragment; pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder; pub(crate) use hook_additional_context::HookAdditionalContext; pub(crate) use image_generation_instructions::ImageGenerationInstructions; @@ -60,7 +58,6 @@ pub(crate) use plugin_instructions::PluginInstructions; pub(crate) use realtime_end_instructions::RealtimeEndInstructions; pub(crate) use realtime_start_instructions::RealtimeStartInstructions; pub(crate) use realtime_start_with_instructions::RealtimeStartWithInstructions; -pub(crate) use skill_instructions::SkillInstructions; pub(crate) use subagent_notification::SubagentNotification; pub(crate) use turn_aborted::TurnAborted; pub(crate) use user_instructions::UserInstructions; diff --git a/codex-rs/core/src/context/permissions_instructions.rs b/codex-rs/core/src/context/permissions_instructions.rs index 513390d592..73629a3d53 100644 --- a/codex-rs/core/src/context/permissions_instructions.rs +++ b/codex-rs/core/src/context/permissions_instructions.rs @@ -1,21 +1 @@ -use super::ContextualUserFragment; - pub use codex_prompts::PermissionsInstructions; - -impl ContextualUserFragment for PermissionsInstructions { - fn role() -> &'static str { - "developer" - } - - fn markers(&self) -> (&'static str, &'static str) { - Self::type_markers() - } - - fn type_markers() -> (&'static str, &'static str) { - ("", "") - } - - fn body(&self) -> String { - PermissionsInstructions::body(self) - } -} diff --git a/codex-rs/prompts/Cargo.toml b/codex-rs/prompts/Cargo.toml index 1fc217988c..6302d49e2f 100644 --- a/codex-rs/prompts/Cargo.toml +++ b/codex-rs/prompts/Cargo.toml @@ -14,6 +14,7 @@ workspace = true [dependencies] anyhow = { workspace = true } +codex-context-fragments = { workspace = true } codex-git-utils = { workspace = true } codex-execpolicy = { workspace = true } codex-protocol = { workspace = true } diff --git a/codex-rs/prompts/src/permissions_instructions.rs b/codex-rs/prompts/src/permissions_instructions.rs index 426fc85b70..46c3e865d2 100644 --- a/codex-rs/prompts/src/permissions_instructions.rs +++ b/codex-rs/prompts/src/permissions_instructions.rs @@ -1,3 +1,4 @@ +use codex_context_fragments::ContextualUserFragment; use codex_execpolicy::Policy; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::SandboxMode; @@ -141,6 +142,24 @@ impl PermissionsInstructions { } } +impl ContextualUserFragment for PermissionsInstructions { + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } + + fn body(&self) -> String { + PermissionsInstructions::body(self) + } +} + fn sandbox_prompt_from_policy( file_system_policy: &FileSystemSandboxPolicy, cwd: &Path, From 51493157cd1fda08343e521ef47ee002009b8a40 Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 3 Jun 2026 13:30:04 +0200 Subject: [PATCH 10/16] Reject MAv2 close_agent self-targets (#26144) ## Why `close_agent` is a parent-owned coordination tool: a worker should return its result, then let its parent decide when to close it. Before this change, if an MAv2 worker targeted itself, the resolved target could flow through the normal close path and ask the agent control layer to close the current conversation. ## What changed - Reject `close_agent` when the resolved target is the current session's `conversation_id`, returning a model-visible error that tells the worker to return its result instead. - Keep the guard after target resolution so it covers both thread-id targets and task-path targets. - Add coverage for self-targeting by thread id and by task name in `multi_agents_tests.rs`. Relevant code: - [`handle_close_agent`](https://github.com/openai/codex/blob/7c24e6641b693a3eed933dd376ce8f424ab6ea5f/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs#L39-L57) - [`multi_agent_v2_close_agent_rejects_self_target_by_id` / `multi_agent_v2_close_agent_rejects_self_target_by_task_name`](https://github.com/openai/codex/blob/7c24e6641b693a3eed933dd376ce8f424ab6ea5f/codex-rs/core/src/tools/handlers/multi_agents_tests.rs#L3936-L4070) ## Testing Not run locally. --- .../src/tools/handlers/multi_agents_tests.rs | 136 ++++++++++++++++++ .../handlers/multi_agents_v2/close_agent.rs | 6 + 2 files changed, 142 insertions(+) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index e33d942343..3d8e8401bf 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -3933,6 +3933,142 @@ async fn multi_agent_v2_close_agent_rejects_root_target_and_id() { ); } +#[tokio::test] +async fn multi_agent_v2_close_agent_rejects_self_target_by_id() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + set_turn_config(&mut turn, config); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + + let child_path = AgentPath::try_from("/root/worker").expect("agent path"); + let child_thread_id = session + .services + .agent_control + .spawn_agent_with_metadata( + (*turn.config).clone(), + vec![UserInput::Text { + text: "inspect this repo".to_string(), + text_elements: Vec::new(), + }] + .into(), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root.thread_id, + depth: 1, + agent_path: Some(child_path.clone()), + agent_nickname: None, + agent_role: None, + })), + crate::agent::control::SpawnAgentOptions::default(), + ) + .await + .expect("worker spawn should succeed") + .thread_id; + session.conversation_id = child_thread_id; + turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root.thread_id, + depth: 1, + agent_path: Some(child_path), + agent_nickname: None, + agent_role: None, + }); + + let err = CloseAgentHandlerV2 + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "close_agent", + function_payload(json!({"target": child_thread_id.to_string()})), + )) + .await + .err() + .expect("close_agent should reject self-target by id"); + assert_eq!( + err, + FunctionCallError::RespondToModel( + "an agent cannot close itself; return your result and let the parent close you if needed" + .to_string() + ) + ); +} + +#[tokio::test] +async fn multi_agent_v2_close_agent_rejects_self_target_by_task_name() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + set_turn_config(&mut turn, config); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + + let child_path = AgentPath::try_from("/root/worker").expect("agent path"); + let child_thread_id = session + .services + .agent_control + .spawn_agent_with_metadata( + (*turn.config).clone(), + vec![UserInput::Text { + text: "inspect this repo".to_string(), + text_elements: Vec::new(), + }] + .into(), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root.thread_id, + depth: 1, + agent_path: Some(child_path.clone()), + agent_nickname: None, + agent_role: None, + })), + crate::agent::control::SpawnAgentOptions::default(), + ) + .await + .expect("worker spawn should succeed") + .thread_id; + session.conversation_id = child_thread_id; + turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root.thread_id, + depth: 1, + agent_path: Some(child_path.clone()), + agent_nickname: None, + agent_role: None, + }); + + let err = CloseAgentHandlerV2 + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "close_agent", + function_payload(json!({"target": child_path.to_string()})), + )) + .await + .err() + .expect("close_agent should reject self-target by task name"); + assert_eq!( + err, + FunctionCallError::RespondToModel( + "an agent cannot close itself; return your result and let the parent close you if needed" + .to_string() + ) + ); +} + #[tokio::test] async fn close_agent_submits_shutdown_and_returns_previous_status() { let (mut session, turn) = make_session_and_context().await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs index 15502572a5..0f7238cdbc 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs @@ -49,6 +49,12 @@ async fn handle_close_agent( "root is not a spawned agent".to_string(), )); } + if agent_id == session.conversation_id { + return Err(FunctionCallError::RespondToModel( + "an agent cannot close itself; return your result and let the parent close you if needed" + .to_string(), + )); + } session .send_event( &turn, From 3389fa554e953d07a12a34f5681aae46f17958f8 Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 3 Jun 2026 13:32:55 +0200 Subject: [PATCH 11/16] skills: resolve per-turn catalogs from turn input context (#26106) ## Why The skills extension needs the resolved turn environments to build a real per-turn `SkillListQuery`. The previous `TurnLifecycleContributor` hook only had a turn id, so it could only seed a placeholder query and never carry the executor authorities that executor-scoped skill routing will need. Moving catalog resolution onto `TurnInputContributor` puts the skills extension on the same turn-preparation path that already has the environment ids and working directories for the submitted turn, while keeping the actual prompt injection work for follow-up changes. ## What changed - switch `ext/skills` from `TurnLifecycleContributor` to `TurnInputContributor` - build `executor_authorities` from `TurnInputContext.environments` and pass them through `SkillListQuery` - keep storing the resolved catalog in `SkillsTurnState`, but drop the placeholder query helper that no longer matches the real data flow - update the extension TODOs to reflect that per-turn catalog resolution now happens in the turn-input contributor, and that prompt/context injection still needs to move later ## Testing - Not run locally. --- codex-rs/Cargo.lock | 1 + .../src/additional_context.rs | 4 +- codex-rs/context-fragments/src/fragment.rs | 19 ++++-- .../core-skills/src/skill_instructions.rs | 2 +- .../context/approved_command_prefix_saved.rs | 2 +- .../core/src/context/apps_instructions.rs | 2 +- .../context/available_plugins_instructions.rs | 2 +- .../context/available_skills_instructions.rs | 2 +- .../collaboration_mode_instructions.rs | 2 +- .../core/src/context/environment_context.rs | 2 +- .../guardian_followup_review_reminder.rs | 2 +- .../src/context/hook_additional_context.rs | 2 +- .../context/image_generation_instructions.rs | 2 +- .../src/context/internal_model_context.rs | 2 +- ...legacy_apply_patch_exec_command_warning.rs | 2 +- .../context/legacy_model_mismatch_warning.rs | 2 +- ...gacy_unified_exec_process_limit_warning.rs | 2 +- .../src/context/model_switch_instructions.rs | 2 +- .../core/src/context/network_rule_saved.rs | 2 +- .../context/personality_spec_instructions.rs | 2 +- .../core/src/context/plugin_instructions.rs | 2 +- .../src/context/realtime_end_instructions.rs | 2 +- .../context/realtime_start_instructions.rs | 2 +- .../realtime_start_with_instructions.rs | 2 +- .../core/src/context/subagent_notification.rs | 2 +- codex-rs/core/src/context/turn_aborted.rs | 2 +- .../core/src/context/user_instructions.rs | 2 +- .../core/src/context/user_shell_command.rs | 2 +- codex-rs/core/src/session/turn.rs | 8 ++- codex-rs/ext/extension-api/Cargo.toml | 1 + .../ext/extension-api/src/contributors.rs | 6 +- codex-rs/ext/extension-api/src/lib.rs | 1 + codex-rs/ext/skills/src/extension.rs | 63 +++++++++++-------- codex-rs/ext/skills/src/provider.rs | 11 ---- .../prompts/src/permissions_instructions.rs | 2 +- 35 files changed, 92 insertions(+), 74 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f7e6ed5de3..0b599b4195 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2863,6 +2863,7 @@ name = "codex-extension-api" version = "0.0.0" dependencies = [ "async-trait", + "codex-context-fragments", "codex-protocol", "codex-tools", ] diff --git a/codex-rs/context-fragments/src/additional_context.rs b/codex-rs/context-fragments/src/additional_context.rs index 4d08b5178a..d1c5147d2f 100644 --- a/codex-rs/context-fragments/src/additional_context.rs +++ b/codex-rs/context-fragments/src/additional_context.rs @@ -19,7 +19,7 @@ impl AdditionalContextUserFragment { } impl ContextualUserFragment for AdditionalContextUserFragment { - fn role() -> &'static str { + fn role(&self) -> &'static str { "user" } @@ -65,7 +65,7 @@ impl AdditionalContextDeveloperFragment { } impl ContextualUserFragment for AdditionalContextDeveloperFragment { - fn role() -> &'static str { + fn role(&self) -> &'static str { "developer" } diff --git a/codex-rs/context-fragments/src/fragment.rs b/codex-rs/context-fragments/src/fragment.rs index f41cbb63ee..bd0e06772b 100644 --- a/codex-rs/context-fragments/src/fragment.rs +++ b/codex-rs/context-fragments/src/fragment.rs @@ -44,9 +44,7 @@ impl FragmentRegistration for FragmentRegistrationPro /// in which case the default helpers render only the body and never match /// arbitrary text. pub trait ContextualUserFragment { - fn role() -> &'static str - where - Self: Sized; + fn role(&self) -> &'static str; fn markers(&self) -> (&'static str, &'static str); @@ -80,7 +78,18 @@ pub trait ContextualUserFragment { { ResponseItem::Message { id: None, - role: Self::role().to_string(), + role: self.role().to_string(), + content: vec![ContentItem::InputText { + text: self.render(), + }], + phase: None, + } + } + + fn into_boxed_response_item(self: Box) -> ResponseItem { + ResponseItem::Message { + id: None, + role: self.role().to_string(), content: vec![ContentItem::InputText { text: self.render(), }], @@ -93,7 +102,7 @@ pub trait ContextualUserFragment { Self: Sized, { ResponseInputItem::Message { - role: Self::role().to_string(), + role: self.role().to_string(), content: vec![ContentItem::InputText { text: self.render(), }], diff --git a/codex-rs/core-skills/src/skill_instructions.rs b/codex-rs/core-skills/src/skill_instructions.rs index 47dd0d0219..b2a002d902 100644 --- a/codex-rs/core-skills/src/skill_instructions.rs +++ b/codex-rs/core-skills/src/skill_instructions.rs @@ -20,7 +20,7 @@ impl From<&SkillInjection> for SkillInstructions { } impl ContextualUserFragment for SkillInstructions { - fn role() -> &'static str { + fn role(&self) -> &'static str { "user" } diff --git a/codex-rs/core/src/context/approved_command_prefix_saved.rs b/codex-rs/core/src/context/approved_command_prefix_saved.rs index 532a2494c3..3686741d1c 100644 --- a/codex-rs/core/src/context/approved_command_prefix_saved.rs +++ b/codex-rs/core/src/context/approved_command_prefix_saved.rs @@ -14,7 +14,7 @@ impl ApprovedCommandPrefixSaved { } impl ContextualUserFragment for ApprovedCommandPrefixSaved { - fn role() -> &'static str { + fn role(&self) -> &'static str { "developer" } diff --git a/codex-rs/core/src/context/apps_instructions.rs b/codex-rs/core/src/context/apps_instructions.rs index 9a475dc311..f88ee49677 100644 --- a/codex-rs/core/src/context/apps_instructions.rs +++ b/codex-rs/core/src/context/apps_instructions.rs @@ -18,7 +18,7 @@ impl AppsInstructions { } impl ContextualUserFragment for AppsInstructions { - fn role() -> &'static str { + fn role(&self) -> &'static str { "developer" } diff --git a/codex-rs/core/src/context/available_plugins_instructions.rs b/codex-rs/core/src/context/available_plugins_instructions.rs index e7f560f6a1..b2c12c7591 100644 --- a/codex-rs/core/src/context/available_plugins_instructions.rs +++ b/codex-rs/core/src/context/available_plugins_instructions.rs @@ -22,7 +22,7 @@ impl AvailablePluginsInstructions { } impl ContextualUserFragment for AvailablePluginsInstructions { - fn role() -> &'static str { + fn role(&self) -> &'static str { "developer" } diff --git a/codex-rs/core/src/context/available_skills_instructions.rs b/codex-rs/core/src/context/available_skills_instructions.rs index e63d637677..f7921072b2 100644 --- a/codex-rs/core/src/context/available_skills_instructions.rs +++ b/codex-rs/core/src/context/available_skills_instructions.rs @@ -21,7 +21,7 @@ impl From for AvailableSkillsInstructions { } impl ContextualUserFragment for AvailableSkillsInstructions { - fn role() -> &'static str { + fn role(&self) -> &'static str { "developer" } diff --git a/codex-rs/core/src/context/collaboration_mode_instructions.rs b/codex-rs/core/src/context/collaboration_mode_instructions.rs index 16e3d38198..f7b0423d8a 100644 --- a/codex-rs/core/src/context/collaboration_mode_instructions.rs +++ b/codex-rs/core/src/context/collaboration_mode_instructions.rs @@ -22,7 +22,7 @@ impl CollaborationModeInstructions { } impl ContextualUserFragment for CollaborationModeInstructions { - fn role() -> &'static str { + fn role(&self) -> &'static str { "developer" } diff --git a/codex-rs/core/src/context/environment_context.rs b/codex-rs/core/src/context/environment_context.rs index 051147b7cd..aaaa2b62bb 100644 --- a/codex-rs/core/src/context/environment_context.rs +++ b/codex-rs/core/src/context/environment_context.rs @@ -523,7 +523,7 @@ fn workspace_roots_from_turn_context_item( } impl ContextualUserFragment for EnvironmentContext { - fn role() -> &'static str { + fn role(&self) -> &'static str { "user" } diff --git a/codex-rs/core/src/context/guardian_followup_review_reminder.rs b/codex-rs/core/src/context/guardian_followup_review_reminder.rs index 4de750629c..cb3569c67a 100644 --- a/codex-rs/core/src/context/guardian_followup_review_reminder.rs +++ b/codex-rs/core/src/context/guardian_followup_review_reminder.rs @@ -4,7 +4,7 @@ use super::ContextualUserFragment; pub(crate) struct GuardianFollowupReviewReminder; impl ContextualUserFragment for GuardianFollowupReviewReminder { - fn role() -> &'static str { + fn role(&self) -> &'static str { "developer" } diff --git a/codex-rs/core/src/context/hook_additional_context.rs b/codex-rs/core/src/context/hook_additional_context.rs index 1094261665..bd23468440 100644 --- a/codex-rs/core/src/context/hook_additional_context.rs +++ b/codex-rs/core/src/context/hook_additional_context.rs @@ -12,7 +12,7 @@ impl HookAdditionalContext { } impl ContextualUserFragment for HookAdditionalContext { - fn role() -> &'static str { + fn role(&self) -> &'static str { "developer" } diff --git a/codex-rs/core/src/context/image_generation_instructions.rs b/codex-rs/core/src/context/image_generation_instructions.rs index 7ddb231b8f..52ce1ca4e9 100644 --- a/codex-rs/core/src/context/image_generation_instructions.rs +++ b/codex-rs/core/src/context/image_generation_instructions.rs @@ -17,7 +17,7 @@ impl ImageGenerationInstructions { } impl ContextualUserFragment for ImageGenerationInstructions { - fn role() -> &'static str { + fn role(&self) -> &'static str { "developer" } diff --git a/codex-rs/core/src/context/internal_model_context.rs b/codex-rs/core/src/context/internal_model_context.rs index a25e77a82a..cbba3ecd75 100644 --- a/codex-rs/core/src/context/internal_model_context.rs +++ b/codex-rs/core/src/context/internal_model_context.rs @@ -76,7 +76,7 @@ impl InternalModelContextFragment { } impl ContextualUserFragment for InternalModelContextFragment { - fn role() -> &'static str { + fn role(&self) -> &'static str { "user" } diff --git a/codex-rs/core/src/context/legacy_apply_patch_exec_command_warning.rs b/codex-rs/core/src/context/legacy_apply_patch_exec_command_warning.rs index b9ecbf6170..c764a8838a 100644 --- a/codex-rs/core/src/context/legacy_apply_patch_exec_command_warning.rs +++ b/codex-rs/core/src/context/legacy_apply_patch_exec_command_warning.rs @@ -5,7 +5,7 @@ use super::ContextualUserFragment; pub(crate) struct LegacyApplyPatchExecCommandWarning; impl ContextualUserFragment for LegacyApplyPatchExecCommandWarning { - fn role() -> &'static str { + fn role(&self) -> &'static str { "user" } diff --git a/codex-rs/core/src/context/legacy_model_mismatch_warning.rs b/codex-rs/core/src/context/legacy_model_mismatch_warning.rs index 9b769e11e9..d713993c90 100644 --- a/codex-rs/core/src/context/legacy_model_mismatch_warning.rs +++ b/codex-rs/core/src/context/legacy_model_mismatch_warning.rs @@ -5,7 +5,7 @@ use super::ContextualUserFragment; pub(crate) struct LegacyModelMismatchWarning; impl ContextualUserFragment for LegacyModelMismatchWarning { - fn role() -> &'static str { + fn role(&self) -> &'static str { "user" } diff --git a/codex-rs/core/src/context/legacy_unified_exec_process_limit_warning.rs b/codex-rs/core/src/context/legacy_unified_exec_process_limit_warning.rs index 54f1a40e3e..59fe03a2d8 100644 --- a/codex-rs/core/src/context/legacy_unified_exec_process_limit_warning.rs +++ b/codex-rs/core/src/context/legacy_unified_exec_process_limit_warning.rs @@ -5,7 +5,7 @@ use super::ContextualUserFragment; pub(crate) struct LegacyUnifiedExecProcessLimitWarning; impl ContextualUserFragment for LegacyUnifiedExecProcessLimitWarning { - fn role() -> &'static str { + fn role(&self) -> &'static str { "user" } diff --git a/codex-rs/core/src/context/model_switch_instructions.rs b/codex-rs/core/src/context/model_switch_instructions.rs index f15dcd22e0..b1bdebbc9e 100644 --- a/codex-rs/core/src/context/model_switch_instructions.rs +++ b/codex-rs/core/src/context/model_switch_instructions.rs @@ -14,7 +14,7 @@ impl ModelSwitchInstructions { } impl ContextualUserFragment for ModelSwitchInstructions { - fn role() -> &'static str { + fn role(&self) -> &'static str { "developer" } diff --git a/codex-rs/core/src/context/network_rule_saved.rs b/codex-rs/core/src/context/network_rule_saved.rs index 5b45dbb560..4826027007 100644 --- a/codex-rs/core/src/context/network_rule_saved.rs +++ b/codex-rs/core/src/context/network_rule_saved.rs @@ -18,7 +18,7 @@ impl NetworkRuleSaved { } impl ContextualUserFragment for NetworkRuleSaved { - fn role() -> &'static str { + fn role(&self) -> &'static str { "developer" } diff --git a/codex-rs/core/src/context/personality_spec_instructions.rs b/codex-rs/core/src/context/personality_spec_instructions.rs index 590ebde80d..f584486d08 100644 --- a/codex-rs/core/src/context/personality_spec_instructions.rs +++ b/codex-rs/core/src/context/personality_spec_instructions.rs @@ -12,7 +12,7 @@ impl PersonalitySpecInstructions { } impl ContextualUserFragment for PersonalitySpecInstructions { - fn role() -> &'static str { + fn role(&self) -> &'static str { "developer" } diff --git a/codex-rs/core/src/context/plugin_instructions.rs b/codex-rs/core/src/context/plugin_instructions.rs index 8fbccbfa10..be2ac8ec09 100644 --- a/codex-rs/core/src/context/plugin_instructions.rs +++ b/codex-rs/core/src/context/plugin_instructions.rs @@ -12,7 +12,7 @@ impl PluginInstructions { } impl ContextualUserFragment for PluginInstructions { - fn role() -> &'static str { + fn role(&self) -> &'static str { "developer" } diff --git a/codex-rs/core/src/context/realtime_end_instructions.rs b/codex-rs/core/src/context/realtime_end_instructions.rs index 39d58c9aec..b4225dfb49 100644 --- a/codex-rs/core/src/context/realtime_end_instructions.rs +++ b/codex-rs/core/src/context/realtime_end_instructions.rs @@ -17,7 +17,7 @@ impl RealtimeEndInstructions { } impl ContextualUserFragment for RealtimeEndInstructions { - fn role() -> &'static str { + fn role(&self) -> &'static str { "developer" } diff --git a/codex-rs/core/src/context/realtime_start_instructions.rs b/codex-rs/core/src/context/realtime_start_instructions.rs index af01c184ff..074f1a9789 100644 --- a/codex-rs/core/src/context/realtime_start_instructions.rs +++ b/codex-rs/core/src/context/realtime_start_instructions.rs @@ -7,7 +7,7 @@ use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG; pub(crate) struct RealtimeStartInstructions; impl ContextualUserFragment for RealtimeStartInstructions { - fn role() -> &'static str { + fn role(&self) -> &'static str { "developer" } diff --git a/codex-rs/core/src/context/realtime_start_with_instructions.rs b/codex-rs/core/src/context/realtime_start_with_instructions.rs index ee242f9068..a61130969d 100644 --- a/codex-rs/core/src/context/realtime_start_with_instructions.rs +++ b/codex-rs/core/src/context/realtime_start_with_instructions.rs @@ -16,7 +16,7 @@ impl RealtimeStartWithInstructions { } impl ContextualUserFragment for RealtimeStartWithInstructions { - fn role() -> &'static str { + fn role(&self) -> &'static str { "developer" } diff --git a/codex-rs/core/src/context/subagent_notification.rs b/codex-rs/core/src/context/subagent_notification.rs index 0c1358f33c..6d92b976b9 100644 --- a/codex-rs/core/src/context/subagent_notification.rs +++ b/codex-rs/core/src/context/subagent_notification.rs @@ -18,7 +18,7 @@ impl SubagentNotification { } impl ContextualUserFragment for SubagentNotification { - fn role() -> &'static str { + fn role(&self) -> &'static str { "user" } diff --git a/codex-rs/core/src/context/turn_aborted.rs b/codex-rs/core/src/context/turn_aborted.rs index 6dc6697288..c2ef156b34 100644 --- a/codex-rs/core/src/context/turn_aborted.rs +++ b/codex-rs/core/src/context/turn_aborted.rs @@ -17,7 +17,7 @@ impl TurnAborted { } impl ContextualUserFragment for TurnAborted { - fn role() -> &'static str { + fn role(&self) -> &'static str { "user" } diff --git a/codex-rs/core/src/context/user_instructions.rs b/codex-rs/core/src/context/user_instructions.rs index 1b3fa40298..a387376f17 100644 --- a/codex-rs/core/src/context/user_instructions.rs +++ b/codex-rs/core/src/context/user_instructions.rs @@ -7,7 +7,7 @@ pub(crate) struct UserInstructions { } impl ContextualUserFragment for UserInstructions { - fn role() -> &'static str { + fn role(&self) -> &'static str { "user" } diff --git a/codex-rs/core/src/context/user_shell_command.rs b/codex-rs/core/src/context/user_shell_command.rs index cf5a3337d0..377342e502 100644 --- a/codex-rs/core/src/context/user_shell_command.rs +++ b/codex-rs/core/src/context/user_shell_command.rs @@ -27,7 +27,7 @@ impl UserShellCommand { } impl ContextualUserFragment for UserShellCommand { - fn role() -> &'static str { + fn role(&self) -> &'static str { "user" } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index c6a3155b74..e301a9c04f 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -628,7 +628,7 @@ async fn build_extension_turn_input_items( let mut items = Vec::new(); for contributor in contributors { - let contributed_items = contributor + let contributed_fragments = contributor .contribute( input.clone(), &sess.services.session_extension_data, @@ -638,7 +638,11 @@ async fn build_extension_turn_input_items( .or_cancel(cancellation_token) .await .ok()?; - items.extend(contributed_items); + items.extend( + contributed_fragments + .into_iter() + .map(ContextualUserFragment::into_boxed_response_item), + ); } Some(items) diff --git a/codex-rs/ext/extension-api/Cargo.toml b/codex-rs/ext/extension-api/Cargo.toml index 15f6e99fa0..85c7d8f98e 100644 --- a/codex-rs/ext/extension-api/Cargo.toml +++ b/codex-rs/ext/extension-api/Cargo.toml @@ -15,5 +15,6 @@ workspace = true [dependencies] async-trait = { workspace = true } +codex-context-fragments = { workspace = true } codex-protocol = { workspace = true } codex-tools = { workspace = true } diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index 98bc395a2f..14ccb68909 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -1,8 +1,8 @@ use std::future::Future; use std::sync::Arc; +use codex_context_fragments::ContextualUserFragment; use codex_protocol::items::TurnItem; -use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::TokenUsageInfo; use codex_tools::ToolCall; @@ -98,14 +98,14 @@ pub trait TurnLifecycleContributor: Send + Sync { /// host, not in this input. #[async_trait::async_trait] pub trait TurnInputContributor: Send + Sync { - /// Returns additional model input items for one submitted turn. + /// Returns additional contextual fragments for one submitted turn. async fn contribute( &self, input: TurnInputContext, session_store: &ExtensionData, thread_store: &ExtensionData, turn_store: &ExtensionData, - ) -> Vec; + ) -> Vec>; } /// Contributor for host-owned configuration changes. diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index 870e331fbb..7fa60c0fe7 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -10,6 +10,7 @@ pub use capabilities::NoopExtensionEventSink; pub use capabilities::NoopResponseItemInjector; pub use capabilities::ResponseItemInjectionFuture; pub use capabilities::ResponseItemInjector; +pub use codex_context_fragments::ContextualUserFragment; pub use codex_protocol::models::ResponseItem; pub use codex_tools::ConversationHistory; pub use codex_tools::ExtensionTurnItem; diff --git a/codex-rs/ext/skills/src/extension.rs b/codex-rs/ext/skills/src/extension.rs index bf91b60de1..adbaa8ffee 100644 --- a/codex-rs/ext/skills/src/extension.rs +++ b/codex-rs/ext/skills/src/extension.rs @@ -5,14 +5,17 @@ use std::sync::Arc; use codex_core::config::Config; use codex_extension_api::ConfigContributor; use codex_extension_api::ContextContributor; +use codex_extension_api::ContextualUserFragment; use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::PromptFragment; use codex_extension_api::ThreadLifecycleContributor; use codex_extension_api::ThreadStartInput; -use codex_extension_api::TurnLifecycleContributor; -use codex_extension_api::TurnStartInput; +use codex_extension_api::TurnInputContext; +use codex_extension_api::TurnInputContributor; +use crate::catalog::SkillAuthority; +use crate::catalog::SkillSourceKind; use crate::provider::SkillListQuery; use crate::providers::SkillProviders; use crate::state::SkillsExtensionConfig; @@ -29,8 +32,8 @@ impl ThreadLifecycleContributor for SkillsExtension { // TODO(skills-extension): this is only the thread-level config snapshot. // Skills are loaded per turn today because cwd, plugin roots, config // layers, and the primary environment filesystem can change between - // turns. The real migration needs a turn-preparation hook before model - // input construction, not just thread startup. + // turns. The TurnInputContributor below owns per-turn catalog + // resolution. input .thread_store .insert(SkillsExtensionConfig::from_config(input.config)); @@ -66,42 +69,50 @@ impl ContextContributor for SkillsExtension { } // TODO(skills-extension): render the available-skills developer - // block from the merged per-turn SkillCatalog. This should - // preserve the existing bounded metadata budget, root aliasing, - // warning behavior, and telemetry side effects. + // block only if the final model needs thread-level guidance that + // does not depend on the per-turn SkillCatalog. // // TODO(skills-extension): avoid using raw PromptFragment strings // for final skills context if the extension API grows typed // contextual fragments. Existing skill blocks are typed so resume // and history filtering can recognize them reliably. - // - // TODO(skills-extension): ContextContributor currently cannot see - // the turn_store, so it cannot read the per-turn catalog seeded by - // the turn provider path below. This is the main extension-api gap - // to close before skills can move out of codex-core. Vec::new() }) } } #[async_trait::async_trait] -impl TurnLifecycleContributor for SkillsExtension { - async fn on_turn_start(&self, input: TurnStartInput<'_>) { - // TODO(skills-extension): replace this lifecycle callback with a real - // turn-input contributor in codex-extension-api. This placeholder only - // demonstrates where provider aggregation belongs; it cannot resolve - // real skills because this hook does not receive cwd, executor - // selections, effective plugins/materialized plugin skill roots, - // connector slug counts, user input, cancellation, analytics, or a - // response-item output channel. - let query = SkillListQuery::placeholder_for_turn(input.turn_id); +impl TurnInputContributor for SkillsExtension { + async fn contribute( + &self, + input: TurnInputContext, + _session_store: &ExtensionData, + _thread_store: &ExtensionData, + turn_store: &ExtensionData, + ) -> Vec> { + let executor_authorities = input + .environments + .iter() + .map(|environment| { + SkillAuthority::new( + SkillSourceKind::Executor, + environment.environment_id.clone(), + ) + }) + .collect(); + let query = SkillListQuery { + turn_id: input.turn_id, + executor_authorities, + include_host_skills: true, + include_remote_skills: true, + }; let catalog = self .providers .list_for_turn(query) .await .unwrap_or_default(); - input.turn_store.insert(SkillsTurnState { + turn_store.insert(SkillsTurnState { catalog, entrypoints_injected: false, }); @@ -115,7 +126,9 @@ impl TurnLifecycleContributor for SkillsExtension { // // TODO(skills-extension): move explicit $skill mention resolution, // SKILL.md reads, skill body injection, and MCP dependency prompting - // out of codex-core's turn assembly once that hook exists. + // out of codex-core's turn assembly once provider implementations + // are ready to preserve behavior. + Vec::new() } } @@ -136,5 +149,5 @@ pub fn install(registry: &mut ExtensionRegistryBuilder) { registry.thread_lifecycle_contributor(extension.clone()); registry.config_contributor(extension.clone()); registry.prompt_contributor(extension.clone()); - registry.turn_lifecycle_contributor(extension); + registry.turn_input_contributor(extension); } diff --git a/codex-rs/ext/skills/src/provider.rs b/codex-rs/ext/skills/src/provider.rs index 730adb51ba..b6f2407526 100644 --- a/codex-rs/ext/skills/src/provider.rs +++ b/codex-rs/ext/skills/src/provider.rs @@ -16,17 +16,6 @@ pub struct SkillListQuery { pub include_remote_skills: bool, } -impl SkillListQuery { - pub(crate) fn placeholder_for_turn(turn_id: &str) -> Self { - Self { - turn_id: turn_id.to_string(), - executor_authorities: Vec::new(), - include_host_skills: true, - include_remote_skills: true, - } - } -} - #[derive(Clone, Debug, PartialEq, Eq)] pub struct SkillReadRequest { pub authority: SkillAuthority, diff --git a/codex-rs/prompts/src/permissions_instructions.rs b/codex-rs/prompts/src/permissions_instructions.rs index 46c3e865d2..f360a987db 100644 --- a/codex-rs/prompts/src/permissions_instructions.rs +++ b/codex-rs/prompts/src/permissions_instructions.rs @@ -143,7 +143,7 @@ impl PermissionsInstructions { } impl ContextualUserFragment for PermissionsInstructions { - fn role() -> &'static str { + fn role(&self) -> &'static str { "developer" } From 7db18c8241cfe40eee31539c086bb3f600d359c7 Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 3 Jun 2026 15:10:16 +0200 Subject: [PATCH 12/16] fix: serialize goal progress accounting (#26155) ## Why Goal progress accounting can be reached from multiple completion paths for the same thread. Each path takes a progress snapshot, writes the usage delta, and then marks that snapshot as accounted. When two tool-completion hooks run at the same time, they can both observe the same unaccounted delta and charge it twice. ## What changed - Added a per-thread progress-accounting permit to `GoalAccountingState`. - Held that permit across the snapshot/write/mark-accounted critical section for active-turn, idle, and tool-finish accounting. - Added regression coverage for parallel tool-finish hooks so a shared token delta is charged once and only one progress event is emitted. ## Testing - Not run locally. - Added `parallel_tool_finish_accounts_active_goal_progress_once`. --- codex-rs/ext/goal/Cargo.toml | 1 + codex-rs/ext/goal/src/accounting.rs | 25 +++++++- codex-rs/ext/goal/src/runtime.rs | 8 +++ codex-rs/ext/goal/src/tool.rs | 9 +++ .../ext/goal/tests/goal_extension_backend.rs | 63 +++++++++++++++++++ 5 files changed, 105 insertions(+), 1 deletion(-) diff --git a/codex-rs/ext/goal/Cargo.toml b/codex-rs/ext/goal/Cargo.toml index 667ed5d14d..6116383243 100644 --- a/codex-rs/ext/goal/Cargo.toml +++ b/codex-rs/ext/goal/Cargo.toml @@ -24,6 +24,7 @@ codex-tools = { workspace = true } codex-utils-template = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +tokio = { workspace = true, features = ["sync"] } tracing = { workspace = true } [dev-dependencies] diff --git a/codex-rs/ext/goal/src/accounting.rs b/codex-rs/ext/goal/src/accounting.rs index e0a84f5f8b..db7766177a 100644 --- a/codex-rs/ext/goal/src/accounting.rs +++ b/codex-rs/ext/goal/src/accounting.rs @@ -6,10 +6,13 @@ use std::sync::Mutex; use std::sync::PoisonError; use std::time::Duration; use std::time::Instant; +use tokio::sync::Semaphore; +use tokio::sync::SemaphorePermit; -#[derive(Debug, Default)] +#[derive(Debug)] pub(crate) struct GoalAccountingState { inner: Mutex, + progress_accounting_lock: Semaphore, } #[derive(Debug)] @@ -83,6 +86,17 @@ impl GoalAccountingState { self.inner().current_turn_id.clone() } + /// Acquires the per-thread progress-accounting permit. + /// + /// Hold the returned permit from before taking a progress snapshot until after the persistent + /// usage write has succeeded and the snapshot has been marked accounted. This serializes + /// concurrent tool-completion hooks so only one hook can charge a given token or time delta. + pub(crate) async fn progress_accounting_permit( + &self, + ) -> Result, tokio::sync::AcquireError> { + self.progress_accounting_lock.acquire().await + } + pub(crate) fn turn_is_current_active_goal(&self, turn_id: &str) -> bool { let inner = self.inner(); if inner.current_turn_id.as_deref() != Some(turn_id) { @@ -287,6 +301,15 @@ impl GoalAccountingState { } } +impl Default for GoalAccountingState { + fn default() -> Self { + Self { + inner: Mutex::new(GoalAccountingInner::default()), + progress_accounting_lock: Semaphore::new(/*permits*/ 1), + } + } +} + fn token_delta_since_last_accounting(last: &TokenUsage, current: &TokenUsage) -> i64 { let delta = TokenUsage { input_tokens: current.input_tokens.saturating_sub(last.input_tokens), diff --git a/codex-rs/ext/goal/src/runtime.rs b/codex-rs/ext/goal/src/runtime.rs index 8d79391c95..de818dabec 100644 --- a/codex-rs/ext/goal/src/runtime.rs +++ b/codex-rs/ext/goal/src/runtime.rs @@ -348,6 +348,10 @@ impl GoalRuntimeHandle { budget_limited_goal_disposition: BudgetLimitedGoalDisposition, ) -> Result, String> { let accounting = self.accounting_state(); + let _accounting_permit = accounting + .progress_accounting_permit() + .await + .map_err(|err| err.to_string())?; let Some(snapshot) = accounting.progress_snapshot(turn_id) else { return Ok(None); }; @@ -398,6 +402,10 @@ impl GoalRuntimeHandle { budget_limited_goal_disposition: BudgetLimitedGoalDisposition, ) -> Result, String> { let accounting = self.accounting_state(); + let _accounting_permit = accounting + .progress_accounting_permit() + .await + .map_err(|err| err.to_string())?; let Some(snapshot) = accounting.idle_progress_snapshot() else { return Ok(None); }; diff --git a/codex-rs/ext/goal/src/tool.rs b/codex-rs/ext/goal/src/tool.rs index 45ef3c54d0..17947aa343 100644 --- a/codex-rs/ext/goal/src/tool.rs +++ b/codex-rs/ext/goal/src/tool.rs @@ -291,6 +291,15 @@ impl GoalToolExecutor { let Some(turn_id) = self.accounting_state.current_turn_id() else { return Ok(None); }; + let _accounting_permit = self + .accounting_state + .progress_accounting_permit() + .await + .map_err(|err| { + FunctionCallError::Fatal(format!( + "goal progress accounting semaphore closed: {err}" + )) + })?; let Some(snapshot) = self.accounting_state.progress_snapshot(turn_id.as_str()) else { return Ok(None); }; diff --git a/codex-rs/ext/goal/tests/goal_extension_backend.rs b/codex-rs/ext/goal/tests/goal_extension_backend.rs index 16b8cf261b..3c437a0889 100644 --- a/codex-rs/ext/goal/tests/goal_extension_backend.rs +++ b/codex-rs/ext/goal/tests/goal_extension_backend.rs @@ -269,6 +269,69 @@ async fn tool_finish_accounts_active_goal_progress_and_emits_event() -> anyhow:: Ok(()) } +#[tokio::test] +async fn parallel_tool_finish_accounts_active_goal_progress_once() -> anyhow::Result<()> { + let runtime = test_runtime().await?; + let thread_id = test_thread_id()?; + seed_thread_metadata(runtime.as_ref(), thread_id).await?; + let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?; + harness + .start_turn( + "turn-1", + &token_usage( + /*input_tokens*/ 100, /*cached_input_tokens*/ 0, + /*output_tokens*/ 0, /*reasoning_output_tokens*/ 0, + /*total_tokens*/ 100, + ), + ) + .await; + + let tools = harness.tools(); + let create_tool = tool_by_name(&tools, "create_goal"); + create_tool + .handle(tool_call( + "create_goal", + "call-create-goal", + json!({ "objective": "ship goal extension backend" }), + )) + .await?; + harness.sink.clear(); + + harness + .record_token_usage( + "turn-1", + &token_usage( + /*input_tokens*/ 130, /*cached_input_tokens*/ 0, + /*output_tokens*/ 0, /*reasoning_output_tokens*/ 0, + /*total_tokens*/ 130, + ), + ) + .await; + + tokio::join!( + harness.notify_tool_finish("turn-1", "call-shell-1", "shell"), + harness.notify_tool_finish("turn-1", "call-shell-2", "shell"), + ); + + let goal = runtime + .thread_goals() + .get_thread_goal(thread_id) + .await? + .ok_or_else(|| anyhow::anyhow!("goal should exist"))?; + assert_eq!(30, goal.tokens_used); + + assert_eq!( + vec![CapturedGoalEvent { + event_id: "call-shell-1".to_string(), + turn_id: Some("turn-1".to_string()), + status: ThreadGoalStatus::Active, + tokens_used: 30, + }], + harness.sink.goal_events() + ); + Ok(()) +} + #[tokio::test] async fn budget_limited_goal_keeps_accruing_until_turn_stop() -> anyhow::Result<()> { let runtime = test_runtime().await?; From c9ae0f48a13022191285d998bf10234ccf20313a Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 3 Jun 2026 15:38:30 +0200 Subject: [PATCH 13/16] chore: mechanical rename (#26156) Rename `Session::conversation_id` to `Session::thread_id` with an auto refactor in RustRover --- codex-rs/core/src/agent/agent_resolver.rs | 4 +- codex-rs/core/src/codex_delegate.rs | 6 +- codex-rs/core/src/compact.rs | 2 +- codex-rs/core/src/goals.rs | 58 ++++---- codex-rs/core/src/guardian/prompt.rs | 2 +- codex-rs/core/src/guardian/review.rs | 2 +- codex-rs/core/src/guardian/review_session.rs | 2 +- codex-rs/core/src/guardian/tests.rs | 10 +- codex-rs/core/src/hook_runtime.rs | 4 +- codex-rs/core/src/mcp_tool_call.rs | 15 +- codex-rs/core/src/mcp_tool_call_tests.rs | 6 +- codex-rs/core/src/realtime_conversation.rs | 2 +- codex-rs/core/src/session/mod.rs | 12 +- codex-rs/core/src/session/session.rs | 6 +- codex-rs/core/src/session/tests.rs | 58 ++++---- codex-rs/core/src/session/turn.rs | 16 +-- codex-rs/core/src/skills.rs | 2 +- codex-rs/core/src/stream_events_utils.rs | 8 +- codex-rs/core/src/tasks/mod.rs | 4 +- codex-rs/core/src/tasks/user_shell.rs | 2 +- .../core/src/tools/handlers/agent_jobs.rs | 2 +- .../agent_jobs/report_agent_job_result.rs | 2 +- .../src/tools/handlers/extension_tools.rs | 2 +- .../handlers/multi_agents/close_agent.rs | 6 +- .../handlers/multi_agents/resume_agent.rs | 6 +- .../tools/handlers/multi_agents/send_input.rs | 4 +- .../src/tools/handlers/multi_agents/spawn.rs | 8 +- .../src/tools/handlers/multi_agents/wait.rs | 6 +- .../src/tools/handlers/multi_agents_tests.rs | 130 ++++++++---------- .../handlers/multi_agents_v2/close_agent.rs | 6 +- .../handlers/multi_agents_v2/list_agents.rs | 2 +- .../handlers/multi_agents_v2/message_tool.rs | 4 +- .../tools/handlers/multi_agents_v2/spawn.rs | 8 +- .../tools/handlers/multi_agents_v2/wait.rs | 4 +- .../tools/handlers/request_plugin_install.rs | 2 +- .../src/tools/handlers/shell/shell_command.rs | 2 +- .../core/src/tools/handlers/shell_tests.rs | 6 +- .../core/src/tools/tool_dispatch_trace.rs | 2 +- .../src/tools/tool_dispatch_trace_tests.rs | 2 +- .../core/src/unified_exec/process_manager.rs | 2 +- 40 files changed, 207 insertions(+), 220 deletions(-) diff --git a/codex-rs/core/src/agent/agent_resolver.rs b/codex-rs/core/src/agent/agent_resolver.rs index 115739c2fd..fff2d7afd6 100644 --- a/codex-rs/core/src/agent/agent_resolver.rs +++ b/codex-rs/core/src/agent/agent_resolver.rs @@ -18,7 +18,7 @@ pub(crate) async fn resolve_agent_target( session .services .agent_control - .resolve_agent_reference(session.conversation_id, &turn.session_source, target) + .resolve_agent_reference(session.thread_id, &turn.session_source, target) .await .map_err(|err| match err { codex_protocol::error::CodexErr::UnsupportedOperation(message) => { @@ -32,5 +32,5 @@ fn register_session_root(session: &Arc, turn: &Arc) { session .services .agent_control - .register_session_root(session.conversation_id, turn.parent_thread_id); + .register_session_root(session.thread_id, turn.parent_thread_id); } diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 678a6e5d10..5a1473b057 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -92,7 +92,7 @@ pub(crate) async fn run_codex_thread_interactive( conversation_history, session_source: SessionSource::SubAgent(subagent_source.clone()), forked_from_thread_id, - parent_thread_id: Some(parent_session.conversation_id), + parent_thread_id: Some(parent_session.thread_id), thread_source: Some(ThreadSource::Subagent), agent_control: parent_session.services.agent_control.clone(), dynamic_tools: Vec::new(), @@ -116,8 +116,8 @@ pub(crate) async fn run_codex_thread_interactive( &parent_session.services.analytics_events_client, client_metadata, codex.session.session_id(), - codex.session.conversation_id, - Some(parent_session.conversation_id), + codex.session.thread_id, + Some(parent_session.thread_id), thread_config, subagent_source, ); diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index a002ce20d6..4ff35c09e4 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -327,7 +327,7 @@ impl CompactionAnalyticsAttempt { ) -> Self { let active_context_tokens_before = sess.get_total_token_usage().await; Self { - thread_id: sess.conversation_id.to_string(), + thread_id: sess.thread_id.to_string(), turn_id: turn_context.sub_id.clone(), trigger, reason, diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index ca92f8bec7..6e0663655c 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -403,7 +403,7 @@ impl Session { let state_db = self.require_state_db_for_thread_goals().await?; state_db .thread_goals() - .get_thread_goal(self.conversation_id) + .get_thread_goal(self.thread_id) .await .map(|goal| goal.map(protocol_goal_from_state)) } @@ -442,14 +442,14 @@ impl Session { let goal = if let Some(objective) = objective.as_deref() { let existing_goal = state_db .thread_goals() - .get_thread_goal(self.conversation_id) + .get_thread_goal(self.thread_id) .await?; previous_status = existing_goal.as_ref().map(|goal| goal.status); if let Some(existing_goal) = existing_goal.as_ref() { state_db .thread_goals() .update_thread_goal( - self.conversation_id, + self.thread_id, codex_state::GoalUpdate { objective: Some(objective.to_string()), status: status.map(state_goal_status_from_protocol), @@ -461,7 +461,7 @@ impl Session { .ok_or_else(|| { anyhow::anyhow!( "cannot update goal for thread {}: no goal exists", - self.conversation_id + self.thread_id ) })? } else { @@ -469,7 +469,7 @@ impl Session { state_db .thread_goals() .replace_thread_goal( - self.conversation_id, + self.thread_id, objective, status .map(state_goal_status_from_protocol) @@ -481,7 +481,7 @@ impl Session { } else { let existing_goal = state_db .thread_goals() - .get_thread_goal(self.conversation_id) + .get_thread_goal(self.thread_id) .await?; previous_status = existing_goal.as_ref().map(|goal| goal.status); let expected_goal_id = existing_goal.map(|goal| goal.goal_id); @@ -489,7 +489,7 @@ impl Session { state_db .thread_goals() .update_thread_goal( - self.conversation_id, + self.thread_id, codex_state::GoalUpdate { objective: None, status, @@ -501,7 +501,7 @@ impl Session { .ok_or_else(|| { anyhow::anyhow!( "cannot update goal for thread {}: no goal exists", - self.conversation_id + self.thread_id ) })? }; @@ -509,7 +509,7 @@ impl Session { if objective.is_some() { set_thread_preview_from_goal_objective( &state_db, - self.conversation_id, + self.thread_id, goal.objective.as_str(), ) .await; @@ -546,7 +546,7 @@ impl Session { self.send_event( turn_context, EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { - thread_id: self.conversation_id, + thread_id: self.thread_id, turn_id: Some(turn_context.sub_id.clone()), goal: goal.clone(), }), @@ -582,7 +582,7 @@ impl Session { let goal = state_db .thread_goals() .insert_thread_goal( - self.conversation_id, + self.thread_id, objective, codex_state::ThreadGoalStatus::Active, token_budget, @@ -591,16 +591,12 @@ impl Session { .ok_or_else(|| { anyhow::anyhow!( "cannot create a new goal because thread {} already has a goal", - self.conversation_id + self.thread_id ) })?; - set_thread_preview_from_goal_objective( - &state_db, - self.conversation_id, - goal.objective.as_str(), - ) - .await; + set_thread_preview_from_goal_objective(&state_db, self.thread_id, goal.objective.as_str()) + .await; let goal_id = goal.goal_id.clone(); self.emit_goal_created_metric(); let goal = protocol_goal_from_state(goal); @@ -617,7 +613,7 @@ impl Session { self.send_event( turn_context, EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { - thread_id: self.conversation_id, + thread_id: self.thread_id, turn_id: Some(turn_context.sub_id.clone()), goal: goal.clone(), }), @@ -799,7 +795,7 @@ impl Session { ) -> anyhow::Result> { let goal = state_db .thread_goals() - .get_thread_goal(self.conversation_id) + .get_thread_goal(self.thread_id) .await?; Ok(goal.and_then(|goal| { expected_goal_id @@ -843,7 +839,7 @@ impl Session { }; match state_db .thread_goals() - .get_thread_goal(self.conversation_id) + .get_thread_goal(self.thread_id) .await { Ok(Some(goal)) @@ -981,7 +977,7 @@ impl Session { let outcome = state_db .thread_goals() .account_thread_goal_usage( - self.conversation_id, + self.thread_id, time_delta_seconds, token_delta, codex_state::GoalAccountingMode::ActiveOnly, @@ -1043,7 +1039,7 @@ impl Session { self.send_event( turn_context, EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { - thread_id: self.conversation_id, + thread_id: self.thread_id, turn_id: Some(turn_context.sub_id.clone()), goal: goal.clone(), }), @@ -1106,7 +1102,7 @@ impl Session { match state_db .thread_goals() .account_thread_goal_usage( - self.conversation_id, + self.thread_id, time_delta_seconds, /*token_delta*/ 0, mode, @@ -1174,7 +1170,7 @@ impl Session { .await?; let Some(goal) = state_db .thread_goals() - .usage_limit_active_thread_goal(self.conversation_id) + .usage_limit_active_thread_goal(self.thread_id) .await? else { return Ok(()); @@ -1186,7 +1182,7 @@ impl Session { self.send_event( turn_context, EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { - thread_id: self.conversation_id, + thread_id: self.thread_id, turn_id: Some(turn_context.sub_id.clone()), goal, }), @@ -1217,7 +1213,7 @@ impl Session { }; let Some(goal) = state_db .thread_goals() - .get_thread_goal(self.conversation_id) + .get_thread_goal(self.thread_id) .await? else { self.clear_stopped_thread_goal_runtime_state().await; @@ -1269,7 +1265,7 @@ impl Session { let goal_is_current = match self.state_db_for_thread_goals().await { Ok(Some(state_db)) => match state_db .thread_goals() - .get_thread_goal(self.conversation_id) + .get_thread_goal(self.thread_id) .await { Ok(Some(goal)) @@ -1367,7 +1363,7 @@ impl Session { }; let goal = match state_db .thread_goals() - .get_thread_goal(self.conversation_id) + .get_thread_goal(self.thread_id) .await { Ok(Some(goal)) => goal, @@ -1430,7 +1426,7 @@ impl Session { }; let thread_metadata_present = state_db - .get_thread(self.conversation_id) + .get_thread(self.thread_id) .await .context("failed to read thread metadata before reconciling thread goals")? .is_some(); @@ -1453,7 +1449,7 @@ impl Session { ) .await; let thread_metadata_present = state_db - .get_thread(self.conversation_id) + .get_thread(self.thread_id) .await .context("failed to read thread metadata after reconciling thread goals")? .is_some(); diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 062a21deef..008e979574 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -186,7 +186,7 @@ pub(crate) async fn build_guardian_prompt_items_with_parent_turn( push_text(headings.transcript_end.to_string()); push_text(format!( "Reviewed Codex session id: {}\n", - session.conversation_id + session.thread_id )); if let Some(note) = omission_note { push_text(format!("\n{note}\n")); diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 167b153e20..06d1a78931 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -265,7 +265,7 @@ async fn run_guardian_review( let action_summary = guardian_assessment_action(&request); let reviewed_action = guardian_reviewed_action(&request); let review_tracking = GuardianReviewTrackContext::new( - session.conversation_id.to_string(), + session.thread_id.to_string(), assessment_turn_id.clone(), review_id.clone(), target_item_id.clone(), diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index b8c5c98075..c139a910a7 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -662,7 +662,7 @@ async fn run_review_on_session( None }; let mut analytics_result = GuardianReviewAnalyticsResult::from_session( - review_session.codex.session.conversation_id.to_string(), + review_session.codex.session.thread_id.to_string(), guardian_session_kind, params.model.clone(), guardian_reasoning_effort.map(|effort| effort.to_string()), diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index aba67d8500..ffadd801ca 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -176,7 +176,7 @@ async fn guardian_test_session_and_turn_with_base_url( base_url: &str, ) -> (Arc, Arc) { let (mut session, mut turn) = crate::session::tests::make_session_and_context().await; - session.conversation_id = fixed_guardian_parent_session_id(); + session.thread_id = fixed_guardian_parent_session_id(); let mut config = (*turn.config).clone(); config.model_provider.base_url = Some(format!("{base_url}/v1")); config.user_instructions = None; @@ -365,7 +365,7 @@ async fn build_guardian_prompt_full_mode_preserves_initial_review_format() -> an #[tokio::test(flavor = "current_thread")] async fn build_guardian_prompt_includes_parent_turn_denied_reads() -> anyhow::Result<()> { let (mut session, mut turn) = crate::session::tests::make_session_and_context().await; - session.conversation_id = fixed_guardian_parent_session_id(); + session.thread_id = fixed_guardian_parent_session_id(); let denied_root = test_path_buf("/repo/private").abs(); let denied_glob = test_path_buf("/repo/private/**").display().to_string(); turn.permission_profile = PermissionProfile::from_runtime_permissions( @@ -1415,7 +1415,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() .await; let (mut session, mut turn) = crate::session::tests::make_session_and_context().await; - session.conversation_id = fixed_guardian_parent_session_id(); + session.thread_id = fixed_guardian_parent_session_id(); let temp_cwd = TempDir::new()?; let mut config = (*turn.config).clone(); config.cwd = temp_cwd.abs(); @@ -1464,7 +1464,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() .as_deref() .expect("guardian thread id"); assert_eq!(assessment.outcome, GuardianAssessmentOutcome::Allow); - assert_ne!(guardian_thread_id, session.conversation_id.to_string()); + assert_ne!(guardian_thread_id, session.thread_id.to_string()); ThreadId::from_string(guardian_thread_id).expect("guardian thread id should be a valid UUID"); assert!(matches!( metadata.guardian_session_kind, @@ -1567,7 +1567,7 @@ async fn build_guardian_prompt_items_includes_parent_session_id() -> anyhow::Res assert!( prompt_text.contains(&format!( ">>> TRANSCRIPT END\nReviewed Codex session id: {}\n", - session.conversation_id + session.thread_id )), "guardian prompt should expose the parent session id immediately after the transcript end" ); diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 336433fe16..6e2d4fe33c 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -451,7 +451,7 @@ pub(crate) async fn run_legacy_after_agent_hook( triggered_at: chrono::Utc::now(), hook_event: codex_hooks::HookEvent::AfterAgent { event: codex_hooks::HookEventAfterAgent { - thread_id: sess.conversation_id, + thread_id: sess.thread_id, turn_id: turn_context.sub_id.clone(), input_messages, last_assistant_message, @@ -655,7 +655,7 @@ fn track_hook_completed_analytics( completed: &HookCompletedEvent, ) { let (tracking, hook) = - hook_run_analytics_payload(sess.conversation_id.to_string(), turn_context, completed); + hook_run_analytics_payload(sess.thread_id.to_string(), turn_context, completed); sess.services .analytics_events_client .track_hook_run(tracking, hook); diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 9c00ba2be6..b9a8d4ff08 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -468,8 +468,8 @@ fn mcp_tool_call_span( mcp.connector.name = fields.connector_name.unwrap_or(""), tool.name = fields.tool_name, tool.call_id = fields.call_id, - conversation.id = %session.conversation_id, - session.id = %session.conversation_id, + conversation.id = %session.thread_id, + session.id = %session.thread_id, turn.id = turn_context.sub_id.as_str(), server.address = Empty, server.port = Empty, @@ -554,8 +554,7 @@ async fn execute_mcp_tool_call( metadata: Option<&McpToolApprovalMetadata>, request_meta: Option, ) -> Result { - let request_meta = - with_mcp_tool_call_thread_id_meta(request_meta, &sess.conversation_id.to_string()); + let request_meta = with_mcp_tool_call_thread_id_meta(request_meta, &sess.thread_id.to_string()); let request_meta = augment_mcp_tool_request_meta_with_sandbox_state( sess, turn_context, @@ -645,7 +644,7 @@ async fn maybe_request_codex_apps_auth_elicitation( let request_id = rmcp::model::RequestId::String(plan.elicitation.elicitation_id.clone().into()); let params = McpServerElicitationRequestParams { - thread_id: sess.conversation_id.to_string(), + thread_id: sess.thread_id.to_string(), turn_id: Some(turn_context.sub_id.clone()), server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), request: McpServerElicitationRequest::Url { @@ -766,7 +765,7 @@ async fn maybe_mark_thread_memory_mode_polluted( } state_db::mark_thread_memory_mode_polluted( sess.services.state_db.as_deref(), - sess.conversation_id, + sess.thread_id, "mcp_tool_call", ) .await; @@ -945,7 +944,7 @@ async fn maybe_track_codex_app_used( let tracking = build_track_events_context( turn_context.model_info.slug.clone(), - sess.conversation_id.to_string(), + sess.thread_id.to_string(), turn_context.sub_id.clone(), ); sess.services.analytics_events_client.track_app_used( @@ -1615,7 +1614,7 @@ fn build_mcp_tool_approval_elicitation_request( .unwrap_or_else(|| request.question.question.clone()); McpServerElicitationRequestParams { - thread_id: sess.conversation_id.to_string(), + thread_id: sess.thread_id.to_string(), turn_id: Some(turn_context.sub_id.clone()), server_name: request.server.to_string(), request: McpServerElicitationRequest::Form { diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index c9259b8f2d..bd71c3a054 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -138,7 +138,7 @@ async fn execute_mcp_tool_call_records_replayable_correlation() -> anyhow::Resul .rollout_thread_trace .start_tool_dispatch_trace(|| { Some(ToolDispatchInvocation { - thread_id: session.conversation_id.to_string(), + thread_id: session.thread_id.to_string(), codex_turn_id: turn_context.sub_id.clone(), tool_call_id: "mcp-call".to_string(), tool_name: "search".to_string(), @@ -280,7 +280,7 @@ fn attach_trace_bundle( codex_rollout_trace::ThreadTraceContext::start_root_in_root_for_test( root, ThreadStartedTraceMetadata { - thread_id: session.conversation_id.to_string(), + thread_id: session.thread_id.to_string(), agent_path: "/root".to_string(), task_name: None, nickname: None, @@ -646,7 +646,7 @@ async fn approval_elicitation_request_uses_message_override_and_preserves_tool_p assert_eq!( request, McpServerElicitationRequestParams { - thread_id: session.conversation_id.to_string(), + thread_id: session.thread_id.to_string(), turn_id: Some(turn_context.sub_id), server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), request: McpServerElicitationRequest::Form { diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 7f71142e19..9f35fb2fa4 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -716,7 +716,7 @@ pub(crate) async fn build_realtime_session_config( Ok(RealtimeSessionConfig { instructions: prompt, model, - session_id: Some(realtime_session_id.unwrap_or_else(|| sess.conversation_id.to_string())), + session_id: Some(realtime_session_id.unwrap_or_else(|| sess.thread_id.to_string())), event_parser, session_mode, output_modality, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 5a92c9c825..8ce16de740 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -654,7 +654,7 @@ impl Codex { error!("Failed to create session: {e:#}"); map_session_init_error(&e, &config.codex_home) })?; - let thread_id = session.conversation_id; + let thread_id = session.thread_id; // This task will run until Op::Shutdown is received. let session_for_loop = Arc::clone(&session); @@ -1369,7 +1369,7 @@ impl Session { ShellSnapshot::refresh_snapshot( codex_home.clone(), - self.conversation_id, + self.thread_id, next_cwd.clone(), self.services.user_shell.as_ref().clone(), self.services.shell_snapshot_tx.clone(), @@ -1640,7 +1640,7 @@ impl Session { self.services .analytics_events_client .track_turn_codex_error(TurnCodexErrorFact::from_codex_err( - self.conversation_id.to_string(), + self.thread_id.to_string(), turn_context.sub_id.clone(), error, )); @@ -1819,7 +1819,7 @@ impl Session { self.send_event( turn_context, EventMsg::ItemStarted(ItemStartedEvent { - thread_id: self.conversation_id, + thread_id: self.thread_id, turn_id: turn_context.sub_id.clone(), item: item.clone(), started_at_ms: now_unix_timestamp_ms(), @@ -1837,7 +1837,7 @@ impl Session { self.send_event( turn_context, EventMsg::ItemCompleted(ItemCompletedEvent { - thread_id: self.conversation_id, + thread_id: self.thread_id, turn_id: turn_context.sub_id.clone(), item, completed_at_ms: now_unix_timestamp_ms(), @@ -2900,7 +2900,7 @@ impl Session { let subagents = self .services .agent_control - .format_environment_context_subagents(self.conversation_id) + .format_environment_context_subagents(self.thread_id) .await; contextual_user_sections.push( crate::context::EnvironmentContext::from_turn_context(turn_context, shell.as_ref()) diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 05773abb8c..6ab7e86f32 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -19,7 +19,7 @@ use tokio::sync::Semaphore; /// /// A session has at most 1 running task at a time, and can be interrupted by user input. pub(crate) struct Session { - pub(crate) conversation_id: ThreadId, + pub(crate) thread_id: ThreadId, pub(crate) installation_id: String, pub(super) tx_event: Sender, pub(super) agent_status: watch::Sender, @@ -471,7 +471,7 @@ async fn warm_plugins_and_skills_for_session_init( impl Session { /// Returns the concrete identity for this thread. pub(crate) fn thread_id(&self) -> ThreadId { - self.conversation_id + self.thread_id } /// Returns the identity shared by the root thread and all descendant threads. @@ -1061,7 +1061,7 @@ impl Session { watch::channel(false); let sess = Arc::new(Session { - conversation_id: thread_id, + thread_id, installation_id, tx_event: tx_event.clone(), agent_status, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index a3e5558971..4d4709e65d 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -344,7 +344,7 @@ async fn request_mcp_server_elicitation_auto_accepts_when_auto_deny_is_enabled() turn_context.as_ref(), RequestId::String("request-1".into()), McpServerElicitationRequestParams { - thread_id: session.conversation_id.to_string(), + thread_id: session.thread_id.to_string(), turn_id: Some(turn_context.sub_id.clone()), server_name: "codex_apps".to_string(), request: McpServerElicitationRequest::Form { @@ -1333,7 +1333,7 @@ async fn reload_user_config_layer_refreshes_hooks() -> anyhow::Result<()> { }))?; let request = codex_hooks::SessionStartRequest { - session_id: session.conversation_id, + session_id: session.thread_id, cwd: session.get_config().await.cwd.clone(), transcript_path: None, model: "gpt-5.2".to_string(), @@ -1440,7 +1440,7 @@ async fn refresh_runtime_config_refreshes_hooks() -> anyhow::Result<()> { std::fs::write(&config_toml_path, toml::to_string(&trusted_user_config)?)?; let request = codex_hooks::SessionStartRequest { - session_id: session.conversation_id, + session_id: session.thread_id, cwd: session.get_config().await.cwd.clone(), transcript_path: None, model: "gpt-5.2".to_string(), @@ -1999,7 +1999,7 @@ async fn record_token_usage_info_notifies_extension_contributors() { let expected = vec![ RecordedTokenUsage { session_level_id: session.session_id().to_string(), - thread_level_id: session.conversation_id.to_string(), + thread_level_id: session.thread_id.to_string(), turn_level_id: turn_context.sub_id.clone(), token_usage: TokenUsageInfo { total_token_usage: first_usage.clone(), @@ -2011,7 +2011,7 @@ async fn record_token_usage_info_notifies_extension_contributors() { }, RecordedTokenUsage { session_level_id: session.session_id().to_string(), - thread_level_id: session.conversation_id.to_string(), + thread_level_id: session.thread_id.to_string(), turn_level_id: turn_context.sub_id.clone(), token_usage: TokenUsageInfo { total_token_usage: expected_total_usage, @@ -2100,7 +2100,7 @@ async fn turn_start_lifecycle_exposes_turn_metadata_and_token_baseline() { let expected = RecordedTurnStart { session_level_id: session.session_id().to_string(), - thread_level_id: session.conversation_id.to_string(), + thread_level_id: session.thread_id.to_string(), turn_level_id: turn_context.sub_id.clone(), turn_id: turn_context.sub_id.clone(), collaboration_mode: turn_context.collaboration_mode.clone(), @@ -2188,7 +2188,7 @@ async fn turn_error_lifecycle_exposes_error_and_stores() { let expected = RecordedTurnError { session_level_id: session.session_id().to_string(), - thread_level_id: session.conversation_id.to_string(), + thread_level_id: session.thread_id.to_string(), turn_level_id: turn_context.sub_id.clone(), turn_id: turn_context.sub_id.clone(), error: CodexErrorInfo::UsageLimitExceeded, @@ -3539,7 +3539,7 @@ async fn attach_thread_persistence(session: &mut Session) -> PathBuf { let live_thread = LiveThread::create( Arc::clone(&session.services.thread_store), CreateThreadParams { - thread_id: session.conversation_id, + thread_id: session.thread_id, forked_from_id: None, parent_thread_id: None, source: SessionSource::Exec, @@ -4820,7 +4820,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { ); let session = Session { - conversation_id: thread_id, + thread_id, installation_id: "11111111-1111-4111-8111-111111111111".to_string(), tx_event, agent_status: agent_status_tx, @@ -6282,7 +6282,7 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() { let live_thread = LiveThread::create( Arc::clone(&thread_store), CreateThreadParams { - thread_id: session.conversation_id, + thread_id: session.thread_id, forked_from_id: None, parent_thread_id: None, source: SessionSource::Exec, @@ -6347,7 +6347,7 @@ async fn submission_loop_channel_close_emits_thread_stop_lifecycle() { let mut builder = codex_extension_api::ExtensionRegistryBuilder::::new(); builder.thread_lifecycle_contributor(Arc::new(ThreadStopRecorder { calls: Arc::clone(&calls), - expected_thread_id: session.conversation_id, + expected_thread_id: session.thread_id, })); session.services.extensions = Arc::new(builder.build()); session @@ -6409,7 +6409,7 @@ async fn submission_loop_channel_close_aborts_active_turn_before_thread_stop_lif let calls = Arc::new(std::sync::Mutex::new(Vec::new())); let recorder = Arc::new(LifecycleRecorder { calls: Arc::clone(&calls), - expected_thread_id: session.conversation_id, + expected_thread_id: session.thread_id, expected_turn_id: turn_context.sub_id.clone(), }); let mut builder = codex_extension_api::ExtensionRegistryBuilder::::new(); @@ -6910,7 +6910,7 @@ where )); let session = Arc::new(Session { - conversation_id: thread_id, + thread_id, installation_id: "11111111-1111-4111-8111-111111111111".to_string(), tx_event, agent_status: agent_status_tx, @@ -6976,7 +6976,7 @@ async fn upsert_goal_test_thread(session: &Session) { .state_db() .expect("goal test session should have a state db"); let mut builder = codex_state::ThreadMetadataBuilder::new( - session.conversation_id, + session.thread_id, config .codex_home .join("goal-test-rollout.jsonl") @@ -7797,7 +7797,7 @@ async fn handle_output_item_done_records_image_save_history_message() { let call_id = "ig_history_records_message"; let expected_saved_path = crate::stream_events_utils::image_generation_artifact_path( &turn_context.config.codex_home, - &session.conversation_id.to_string(), + &session.thread_id.to_string(), call_id, ); let _ = std::fs::remove_file(&expected_saved_path); @@ -7824,7 +7824,7 @@ async fn handle_output_item_done_records_image_save_history_message() { let history = session.clone_history().await; let image_output_path = crate::stream_events_utils::image_generation_artifact_path( &turn_context.config.codex_home, - &session.conversation_id.to_string(), + &session.thread_id.to_string(), "", ); let image_output_dir = image_output_path @@ -7852,7 +7852,7 @@ async fn handle_output_item_done_skips_image_save_message_when_save_fails() { let call_id = "ig_history_no_message"; let expected_saved_path = crate::stream_events_utils::image_generation_artifact_path( &turn_context.config.codex_home, - &session.conversation_id.to_string(), + &session.thread_id.to_string(), call_id, ); let _ = std::fs::remove_file(&expected_saved_path); @@ -8698,7 +8698,7 @@ async fn task_finish_emits_thread_idle_lifecycle_after_active_turn_clears() { builder.thread_lifecycle_contributor(Arc::new(ThreadIdleRecorder { calls: Arc::clone(&calls), idle_tx, - expected_thread_id: session.conversation_id, + expected_thread_id: session.thread_id, })); session.services.extensions = Arc::new(builder.build()); @@ -9321,7 +9321,7 @@ async fn create_thread_goal_fills_empty_thread_preview() -> anyhow::Result<()> { .iter() .map(|thread| thread.id) .collect::>(); - assert_eq!(vec![sess.conversation_id], ids); + assert_eq!(vec![sess.thread_id], ids); assert_eq!( Some("Keep improving the benchmark"), page.items[0].preview.as_deref() @@ -9401,7 +9401,7 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh let state_db = goal_test_state_db(sess.as_ref()).await?; let goal = state_db .thread_goals() - .get_thread_goal(sess.conversation_id) + .get_thread_goal(sess.thread_id) .await? .expect("goal should remain persisted after accounting"); assert_eq!(codex_state::ThreadGoalStatus::BudgetLimited, goal.status); @@ -9425,7 +9425,7 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh let goal = state_db .thread_goals() - .get_thread_goal(sess.conversation_id) + .get_thread_goal(sess.thread_id) .await? .expect("goal should remain persisted after follow-up accounting"); assert_eq!(codex_state::ThreadGoalStatus::BudgetLimited, goal.status); @@ -9473,7 +9473,7 @@ async fn usage_limit_runtime_stops_active_goal_and_prevents_idle_continuation() let state_db = goal_test_state_db(sess.as_ref()).await?; let goal = state_db .thread_goals() - .get_thread_goal(sess.conversation_id) + .get_thread_goal(sess.thread_id) .await? .expect("goal should remain persisted after usage limiting"); assert_eq!(codex_state::ThreadGoalStatus::UsageLimited, goal.status); @@ -9516,7 +9516,7 @@ async fn external_goal_mutation_accounts_active_turn_before_status_change() -> a let state_db = goal_test_state_db(sess.as_ref()).await?; let goal = state_db .thread_goals() - .get_thread_goal(sess.conversation_id) + .get_thread_goal(sess.thread_id) .await? .expect("goal should remain persisted"); assert_eq!(70, goal.tokens_used); @@ -9526,7 +9526,7 @@ async fn external_goal_mutation_accounts_active_turn_before_status_change() -> a let updated_goal = state_db .thread_goals() .update_thread_goal( - sess.conversation_id, + sess.thread_id, codex_state::GoalUpdate { objective: None, status: Some(codex_state::ThreadGoalStatus::Complete), @@ -9547,7 +9547,7 @@ async fn external_goal_mutation_accounts_active_turn_before_status_change() -> a assert!(sess.active_turn.lock().await.is_some()); let goal = state_db .thread_goals() - .get_thread_goal(sess.conversation_id) + .get_thread_goal(sess.thread_id) .await? .expect("goal should remain persisted"); assert_eq!(codex_state::ThreadGoalStatus::Complete, goal.status); @@ -9575,7 +9575,7 @@ async fn external_objective_change_steers_active_turn() -> anyhow::Result<()> { let old_goal = state_db .thread_goals() .replace_thread_goal( - sess.conversation_id, + sess.thread_id, "Keep improving the benchmark", codex_state::ThreadGoalStatus::Active, /*token_budget*/ Some(10_000), @@ -9584,7 +9584,7 @@ async fn external_objective_change_steers_active_turn() -> anyhow::Result<()> { let new_goal = state_db .thread_goals() .replace_thread_goal( - sess.conversation_id, + sess.thread_id, "Write a concise benchmark summary", codex_state::ThreadGoalStatus::Active, /*token_budget*/ Some(10_000), @@ -9642,7 +9642,7 @@ async fn external_active_goal_set_marks_current_turn_for_accounting() -> anyhow: let goal = state_db .thread_goals() .replace_thread_goal( - sess.conversation_id, + sess.thread_id, "Keep improving the benchmark", codex_state::ThreadGoalStatus::Active, /*token_budget*/ None, @@ -9675,7 +9675,7 @@ async fn external_active_goal_set_marks_current_turn_for_accounting() -> anyhow: let goal = state_db .thread_goals() - .get_thread_goal(sess.conversation_id) + .get_thread_goal(sess.thread_id) .await? .expect("goal should remain persisted"); assert_eq!(codex_state::ThreadGoalStatus::Active, goal.status); diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index e301a9c04f..42d609561e 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -469,7 +469,7 @@ async fn build_skills_and_plugins( .collect::>(); let tracking = build_track_events_context( turn_context.model_info.slug.clone(), - sess.conversation_id.to_string(), + sess.thread_id.to_string(), turn_context.sub_id.clone(), ); let loaded_plugins = sess @@ -665,7 +665,7 @@ async fn track_turn_resolved_config_analytics( .analytics_events_client .track_turn_resolved_config(TurnResolvedConfigFact { turn_id: turn_context.sub_id.clone(), - thread_id: sess.conversation_id.to_string(), + thread_id: sess.thread_id.to_string(), num_input_images: input .iter() .filter_map(|item| match item { @@ -1306,7 +1306,7 @@ impl ProposedPlanItemState { return; } let event = PlanDeltaEvent { - thread_id: sess.conversation_id.to_string(), + thread_id: sess.thread_id.to_string(), turn_id: turn_context.sub_id.clone(), item_id: self.item_id.clone(), delta: delta.to_string(), @@ -1482,7 +1482,7 @@ async fn handle_plan_segments( maybe_emit_pending_agent_message_start(sess, turn_context, state, item_id).await; let event = AgentMessageContentDeltaEvent { - thread_id: sess.conversation_id.to_string(), + thread_id: sess.thread_id.to_string(), turn_id: turn_context.sub_id.clone(), item_id: item_id.to_string(), delta, @@ -1536,7 +1536,7 @@ async fn emit_streamed_assistant_text_delta( return; } let event = AgentMessageContentDeltaEvent { - thread_id: sess.conversation_id.to_string(), + thread_id: sess.thread_id.to_string(), turn_id: turn_context.sub_id.clone(), item_id: item_id.to_string(), delta: parsed.visible_text, @@ -2102,7 +2102,7 @@ async fn try_run_sampling_request( .await; } else { let event = AgentMessageContentDeltaEvent { - thread_id: sess.conversation_id.to_string(), + thread_id: sess.thread_id.to_string(), turn_id: turn_context.sub_id.clone(), item_id, delta, @@ -2141,7 +2141,7 @@ async fn try_run_sampling_request( continue; } let event = ReasoningContentDeltaEvent { - thread_id: sess.conversation_id.to_string(), + thread_id: sess.thread_id.to_string(), turn_id: turn_context.sub_id.clone(), item_id: active.id(), delta, @@ -2177,7 +2177,7 @@ async fn try_run_sampling_request( continue; } let event = ReasoningRawContentDeltaEvent { - thread_id: sess.conversation_id.to_string(), + thread_id: sess.thread_id.to_string(), turn_id: turn_context.sub_id.clone(), item_id: active.id(), delta, diff --git a/codex-rs/core/src/skills.rs b/codex-rs/core/src/skills.rs index d540cd6d43..b11f79260a 100644 --- a/codex-rs/core/src/skills.rs +++ b/codex-rs/core/src/skills.rs @@ -100,7 +100,7 @@ pub(crate) async fn maybe_emit_implicit_skill_invocation( .track_skill_invocations( build_track_events_context( turn_context.model_info.slug.clone(), - sess.conversation_id.to_string(), + sess.thread_id.to_string(), turn_context.sub_id.clone(), ), vec![invocation], diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 7914c8d482..20fc6787ac 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -132,7 +132,7 @@ pub(crate) async fn persist_image_generation_item( image_item: &mut ImageGenerationItem, ) -> Option { image_item.saved_path = None; - let session_id = sess.conversation_id.to_string(); + let session_id = sess.thread_id.to_string(); match save_image_generation_result( &turn_context.config.codex_home, &session_id, @@ -172,7 +172,7 @@ async fn record_image_generation_instructions( if image_item.saved_path.is_none() { return; } - let session_id = sess.conversation_id.to_string(); + let session_id = sess.thread_id.to_string(); let image_output_path = image_generation_artifact_path(&turn_context.config.codex_home, &session_id, ""); let image_output_dir = image_output_path @@ -263,7 +263,7 @@ pub(crate) async fn mark_thread_memory_mode_polluted_if_external_context( } state_db::mark_thread_memory_mode_polluted( sess.services.state_db.as_deref(), - sess.conversation_id, + sess.thread_id, "record_completed_response_item", ) .await; @@ -422,7 +422,7 @@ pub(crate) async fn handle_output_item_done( let payload_preview = call.payload.log_payload().into_owned(); tracing::info!( - thread_id = %ctx.sess.conversation_id, + thread_id = %ctx.sess.thread_id, "ToolCall: {} {}", call.tool_name, payload_preview diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 76d5e6478e..ef2ba0f16d 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -383,7 +383,7 @@ impl Session { let task_span = info_span!( "turn", otel.name = span_name, - thread.id = %self.conversation_id, + thread.id = %self.thread_id, turn.id = %turn_context.sub_id, model = %turn_context.model_info.slug, codex.turn.reasoning_effort = %reasoning_effort, @@ -708,7 +708,7 @@ impl Session { .analytics_events_client .track_turn_token_usage(TurnTokenUsageFact { turn_id: turn_context.sub_id.clone(), - thread_id: self.conversation_id.to_string(), + thread_id: self.thread_id.to_string(), token_usage: turn_token_usage.clone(), }); self.services.session_telemetry.histogram( diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 15778b64dc..e016a725c4 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -126,7 +126,7 @@ pub(crate) async fn execute_user_shell_command( let display_command = session_shell.derive_exec_args(&command, use_login_shell); let mut exec_env_map = create_env( &turn_context.shell_environment_policy, - Some(session.conversation_id), + Some(session.thread_id), ); if exec_env_map.contains_key(PROXY_ACTIVE_ENV_KEY) { strip_managed_proxy_env(&mut exec_env_map); diff --git a/codex-rs/core/src/tools/handlers/agent_jobs.rs b/codex-rs/core/src/tools/handlers/agent_jobs.rs index 48ba087434..25c8f9c4ca 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs.rs @@ -211,7 +211,7 @@ async fn run_agent_job_loop( "agent_job:{job_id}" )))), SpawnAgentOptions { - parent_thread_id: Some(session.conversation_id), + parent_thread_id: Some(session.thread_id), environments: Some(turn.environments.to_selections()), ..Default::default() }, diff --git a/codex-rs/core/src/tools/handlers/agent_jobs/report_agent_job_result.rs b/codex-rs/core/src/tools/handlers/agent_jobs/report_agent_job_result.rs index 6f4463c6ea..c7adb27610 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs/report_agent_job_result.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs/report_agent_job_result.rs @@ -61,7 +61,7 @@ pub async fn handle( )); } let db = required_state_db(&session)?; - let reporting_thread_id = session.conversation_id.to_string(); + let reporting_thread_id = session.thread_id.to_string(); let accepted = db .report_agent_job_item_result( args.job_id.as_str(), diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index 0a1e2e58c8..ecbf772e90 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -486,7 +486,7 @@ mod tests { let (session, turn, rx) = crate::session::tests::make_session_and_context_with_rx().await; let expected_path = crate::stream_events_utils::image_generation_artifact_path( &turn.config.codex_home, - &session.conversation_id.to_string(), + &session.thread_id.to_string(), "call-image", ); let invocation = ToolInvocation { diff --git a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs index 2007e3df7d..0d4e17447d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs @@ -53,7 +53,7 @@ async fn handle_close_agent( CollabCloseBeginEvent { call_id: call_id.clone(), started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, + sender_thread_id: session.thread_id, receiver_thread_id: agent_id, } .into(), @@ -77,7 +77,7 @@ async fn handle_close_agent( CollabCloseEndEvent { call_id: call_id.clone(), completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, + sender_thread_id: session.thread_id(), receiver_thread_id: agent_id, receiver_agent_nickname: receiver_agent.agent_nickname.clone(), receiver_agent_role: receiver_agent.agent_role.clone(), @@ -99,7 +99,7 @@ async fn handle_close_agent( CollabCloseEndEvent { call_id, completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, + sender_thread_id: session.thread_id, receiver_thread_id: agent_id, receiver_agent_nickname: receiver_agent.agent_nickname, receiver_agent_role: receiver_agent.agent_role, diff --git a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs index 78b6aafb96..8f95abe4e3 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs @@ -66,7 +66,7 @@ async fn handle_resume_agent( CollabResumeBeginEvent { call_id: call_id.clone(), started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, + sender_thread_id: session.thread_id, receiver_thread_id, receiver_agent_nickname: receiver_agent.agent_nickname.clone(), receiver_agent_role: receiver_agent.agent_role.clone(), @@ -122,7 +122,7 @@ async fn handle_resume_agent( CollabResumeEndEvent { call_id, completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, + sender_thread_id: session.thread_id(), receiver_thread_id, receiver_agent_nickname: receiver_agent.agent_nickname, receiver_agent_role: receiver_agent.agent_role, @@ -186,7 +186,7 @@ async fn try_resume_closed_agent( config, receiver_thread_id, thread_spawn_source( - session.conversation_id, + session.thread_id(), &turn.session_source, child_depth, /*agent_role*/ None, diff --git a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs index d7f316ba3e..1b7018939e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs @@ -58,7 +58,7 @@ impl ToolExecutor for Handler { CollabAgentInteractionBeginEvent { call_id: call_id.clone(), started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, + sender_thread_id: session.thread_id, receiver_thread_id, prompt: prompt.clone(), } @@ -81,7 +81,7 @@ impl ToolExecutor for Handler { CollabAgentInteractionEndEvent { call_id, completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, + sender_thread_id: session.thread_id, receiver_thread_id, receiver_agent_nickname: receiver_agent.agent_nickname, receiver_agent_role: receiver_agent.agent_role, diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 108ddd336a..004dc973b7 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -80,7 +80,7 @@ async fn handle_spawn_agent( CollabAgentSpawnBeginEvent { call_id: call_id.clone(), started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, + sender_thread_id: session.thread_id, prompt: prompt.clone(), model: args.model.clone().unwrap_or_default(), reasoning_effort: args.reasoning_effort.unwrap_or_default(), @@ -121,7 +121,7 @@ async fn handle_spawn_agent( config, input_items, Some(thread_spawn_source( - session.conversation_id, + session.thread_id, &turn.session_source, child_depth, role_name, @@ -130,7 +130,7 @@ async fn handle_spawn_agent( SpawnAgentOptions { fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory), - parent_thread_id: Some(session.conversation_id), + parent_thread_id: Some(session.thread_id), environments: Some(turn.environments.to_selections()), }, )) @@ -183,7 +183,7 @@ async fn handle_spawn_agent( CollabAgentSpawnEndEvent { call_id, completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, + sender_thread_id: session.thread_id, new_thread_id, new_agent_nickname, new_agent_role, diff --git a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs index 063b064578..525ee7f2ed 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs @@ -96,7 +96,7 @@ impl ToolExecutor for Handler { &turn, CollabWaitingBeginEvent { started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, + sender_thread_id: session.thread_id, receiver_thread_ids: receiver_thread_ids.clone(), receiver_agents: receiver_agents.clone(), call_id: call_id.clone(), @@ -126,7 +126,7 @@ impl ToolExecutor for Handler { .send_event( &turn, CollabWaitingEndEvent { - sender_thread_id: session.conversation_id, + sender_thread_id: session.thread_id, call_id: call_id.clone(), completed_at_ms: now_unix_timestamp_ms(), agent_statuses: build_wait_agent_statuses( @@ -195,7 +195,7 @@ impl ToolExecutor for Handler { .send_event( &turn, CollabWaitingEndEvent { - sender_thread_id: session.conversation_id, + sender_thread_id: session.thread_id, call_id, completed_at_ms: now_unix_timestamp_ms(), agent_statuses, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 3d8e8401bf..868847414e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -310,7 +310,7 @@ async fn spawn_agent_fork_context_rejects_agent_type_override() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let err = SpawnAgentHandler::default() .handle(invocation( Arc::new(session), @@ -343,7 +343,7 @@ async fn spawn_agent_fork_context_rejects_child_model_overrides() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let err = SpawnAgentHandler::default() .handle(invocation( @@ -379,7 +379,7 @@ async fn multi_agent_v2_spawn_fork_turns_all_rejects_agent_type_override() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let mut config = (*turn.config).clone(); config .features @@ -424,7 +424,7 @@ async fn multi_agent_v2_spawn_defaults_to_full_fork_and_rejects_child_model_over .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let mut config = (*turn.config).clone(); config .features @@ -471,7 +471,7 @@ async fn spawn_agent_service_tier_override_validates_the_effective_child_model() .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let output = SpawnAgentHandler::default() .handle(invocation( @@ -576,7 +576,7 @@ async fn spawn_agent_service_tier_inheritance_preserves_supported_or_configured_ .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let output = SpawnAgentHandler::default() .handle(invocation( @@ -617,7 +617,7 @@ async fn spawn_agent_service_tier_inheritance_preserves_supported_or_configured_ .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let output = SpawnAgentHandler::default() .handle(invocation( @@ -680,7 +680,7 @@ service_tier = "priority" .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let output = SpawnAgentHandler::default() .handle(invocation( @@ -753,7 +753,7 @@ service_tier = "turbo" .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let output = SpawnAgentHandler::default() .handle(invocation( @@ -850,7 +850,7 @@ async fn spawn_agent_full_history_fork_accepts_explicit_service_tier() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let output = SpawnAgentHandler::default() .handle(invocation( @@ -904,7 +904,7 @@ async fn multi_agent_v2_full_history_fork_accepts_explicit_service_tier() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let session = Arc::new(session); let turn = Arc::new(turn); @@ -928,7 +928,7 @@ async fn multi_agent_v2_full_history_fork_accepts_explicit_service_tier() { .services .agent_control .resolve_agent_reference( - session.conversation_id, + session.thread_id, &turn.session_source, result.task_name.as_str(), ) @@ -957,7 +957,7 @@ async fn multi_agent_v2_spawn_partial_fork_turns_allows_agent_type_override() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let mut config = (*turn.config).clone(); config .features @@ -1041,7 +1041,7 @@ async fn multi_agent_v2_spawn_requires_task_name() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let mut config = (*turn.config).clone(); config .features @@ -1075,7 +1075,7 @@ async fn multi_agent_v2_spawn_rejects_legacy_items_field() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let mut config = (*turn.config).clone(); config .features @@ -1135,7 +1135,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let mut config = (*turn.config).clone(); config .features @@ -1166,11 +1166,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat let child_thread_id = session .services .agent_control - .resolve_agent_reference( - session.conversation_id, - &turn.session_source, - "test_process", - ) + .resolve_agent_reference(session.thread_id, &turn.session_source, "test_process") .await .expect("relative path should resolve"); let child_snapshot = manager @@ -1232,7 +1228,7 @@ async fn multi_agent_v2_spawn_rejects_legacy_fork_context() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let mut config = (*turn.config).clone(); config .features @@ -1272,7 +1268,7 @@ async fn multi_agent_v2_spawn_rejects_invalid_fork_turns_string() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let mut config = (*turn.config).clone(); config .features @@ -1312,7 +1308,7 @@ async fn multi_agent_v2_spawn_rejects_zero_fork_turns() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let mut config = (*turn.config).clone(); config .features @@ -1358,7 +1354,7 @@ async fn multi_agent_v2_send_message_accepts_root_target_from_child() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let child_path = AgentPath::try_from("/root/worker").expect("agent path"); let child_thread_id = session @@ -1383,7 +1379,7 @@ async fn multi_agent_v2_send_message_accepts_root_target_from_child() { .await .expect("worker spawn should succeed") .thread_id; - session.conversation_id = child_thread_id; + session.thread_id = child_thread_id; turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: root.thread_id, depth: 1, @@ -1434,7 +1430,7 @@ async fn multi_agent_v2_followup_task_rejects_root_target_from_child() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let child_path = AgentPath::try_from("/root/worker").expect("agent path"); let child_thread_id = session @@ -1459,7 +1455,7 @@ async fn multi_agent_v2_followup_task_rejects_root_target_from_child() { .await .expect("worker spawn should succeed") .thread_id; - session.conversation_id = child_thread_id; + session.thread_id = child_thread_id; turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: root.thread_id, depth: 1, @@ -1511,7 +1507,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let mut config = (*turn.config).clone(); let _ = config.features.enable(Feature::MultiAgentV2); set_turn_config(&mut turn, config); @@ -1535,7 +1531,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa let agent_id = session .services .agent_control - .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .resolve_agent_reference(session.thread_id, &turn.session_source, "worker") .await .expect("worker path should resolve"); let child_thread = manager @@ -1608,7 +1604,7 @@ async fn multi_agent_v2_list_agents_filters_by_relative_path_prefix() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let researcher_path = AgentPath::from_string("/root/researcher".to_string()).expect("path"); let worker_path = AgentPath::from_string("/root/researcher/worker".to_string()).expect("path"); @@ -1692,7 +1688,7 @@ async fn multi_agent_v2_list_agents_omits_closed_agents() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let mut config = (*turn.config).clone(); let _ = config.features.enable(Feature::MultiAgentV2); set_turn_config(&mut turn, config); @@ -1716,7 +1712,7 @@ async fn multi_agent_v2_list_agents_omits_closed_agents() { let agent_id = session .services .agent_control - .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .resolve_agent_reference(session.thread_id, &turn.session_source, "worker") .await .expect("worker path should resolve"); session @@ -1756,7 +1752,7 @@ async fn multi_agent_v2_send_message_rejects_legacy_items_field() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let mut config = turn.config.as_ref().clone(); let _ = config.features.enable(Feature::MultiAgentV2); set_turn_config(&mut turn, config); @@ -1778,7 +1774,7 @@ async fn multi_agent_v2_send_message_rejects_legacy_items_field() { let agent_id = session .services .agent_control - .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .resolve_agent_reference(session.thread_id, &turn.session_source, "worker") .await .expect("worker should resolve"); let invocation = invocation( @@ -1812,7 +1808,7 @@ async fn multi_agent_v2_send_message_rejects_interrupt_parameter() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let mut config = turn.config.as_ref().clone(); let _ = config.features.enable(Feature::MultiAgentV2); set_turn_config(&mut turn, config); @@ -1834,7 +1830,7 @@ async fn multi_agent_v2_send_message_rejects_interrupt_parameter() { let agent_id = session .services .agent_control - .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .resolve_agent_reference(session.thread_id, &turn.session_source, "worker") .await .expect("worker should resolve"); @@ -1891,7 +1887,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() // and stored its runtime; mirror that before using the synthetic handler. root.thread.codex.session.new_default_turn().await; session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let session = Arc::new(session); let turn = Arc::new(turn); @@ -1910,7 +1906,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() let agent_id = session .services .agent_control - .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .resolve_agent_reference(session.thread_id, &turn.session_source, "worker") .await .expect("worker should resolve"); let thread = manager @@ -2023,7 +2019,7 @@ async fn multi_agent_v2_followup_task_rejects_legacy_items_field() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let mut config = turn.config.as_ref().clone(); let _ = config.features.enable(Feature::MultiAgentV2); set_turn_config(&mut turn, config); @@ -2045,7 +2041,7 @@ async fn multi_agent_v2_followup_task_rejects_legacy_items_field() { let agent_id = session .services .agent_control - .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .resolve_agent_reference(session.thread_id, &turn.session_source, "worker") .await .expect("worker should resolve"); let invocation = invocation( @@ -2076,7 +2072,7 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let mut config = turn.config.as_ref().clone(); let _ = config.features.enable(Feature::MultiAgentV2); set_turn_config(&mut turn, config); @@ -2098,7 +2094,7 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { let agent_id = session .services .agent_control - .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .resolve_agent_reference(session.thread_id, &turn.session_source, "worker") .await .expect("worker should resolve"); let thread = manager @@ -2153,7 +2149,7 @@ async fn multi_agent_v2_spawn_omits_agent_id_when_named() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let mut config = (*turn.config).clone(); config .features @@ -2192,7 +2188,7 @@ async fn multi_agent_v2_spawn_surfaces_task_name_validation_errors() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let mut config = (*turn.config).clone(); config .features @@ -2316,7 +2312,7 @@ async fn spawn_agent_rejects_when_depth_limit_exceeded() { let max_depth = turn.config.agent_max_depth; turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id: session.conversation_id, + parent_thread_id: session.thread_id, depth: max_depth, agent_path: None, agent_nickname: None, @@ -2356,7 +2352,7 @@ async fn spawn_agent_allows_depth_up_to_configured_max_depth() { config.agent_max_depth = DEFAULT_AGENT_MAX_DEPTH + 1; turn.config = Arc::new(config); turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id: session.conversation_id, + parent_thread_id: session.thread_id, depth: DEFAULT_AGENT_MAX_DEPTH, agent_path: None, agent_nickname: None, @@ -2407,7 +2403,7 @@ async fn multi_agent_v2_spawn_agent_ignores_configured_max_depth() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; set_turn_config(&mut turn, config); let parent_path = AgentPath::try_from("/root/parent").expect("agent path"); turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { @@ -2785,7 +2781,7 @@ async fn resume_agent_rejects_when_depth_limit_exceeded() { let max_depth = turn.config.agent_max_depth; turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id: session.conversation_id, + parent_thread_id: session.thread_id, depth: max_depth, agent_path: None, agent_nickname: None, @@ -2875,7 +2871,7 @@ async fn multi_agent_v2_wait_agent_accepts_timeout_only_argument() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let mut config = (*turn.config).clone(); config .features @@ -2900,7 +2896,7 @@ async fn multi_agent_v2_wait_agent_accepts_timeout_only_argument() { let agent_id = session .services .agent_control - .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .resolve_agent_reference(session.thread_id, &turn.session_source, "worker") .await .expect("worker should resolve"); let worker_path = session @@ -3358,7 +3354,7 @@ async fn multi_agent_v2_wait_agent_returns_summary_for_mailbox_activity() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let mut config = (*turn.config).clone(); config .features @@ -3385,11 +3381,7 @@ async fn multi_agent_v2_wait_agent_returns_summary_for_mailbox_activity() { let agent_id = session .services .agent_control - .resolve_agent_reference( - session.conversation_id, - &turn.session_source, - "test_process", - ) + .resolve_agent_reference(session.thread_id, &turn.session_source, "test_process") .await .expect("relative path should resolve"); let worker_path = session @@ -3452,7 +3444,7 @@ async fn multi_agent_v2_wait_agent_returns_for_already_queued_mail() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let mut config = (*turn.config).clone(); config .features @@ -3477,7 +3469,7 @@ async fn multi_agent_v2_wait_agent_returns_for_already_queued_mail() { let agent_id = session .services .agent_control - .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .resolve_agent_reference(session.thread_id, &turn.session_source, "worker") .await .expect("worker should resolve"); let worker_path = session @@ -3533,7 +3525,7 @@ async fn multi_agent_v2_wait_agent_wakes_on_any_mailbox_notification() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let mut config = (*turn.config).clone(); config .features @@ -3560,7 +3552,7 @@ async fn multi_agent_v2_wait_agent_wakes_on_any_mailbox_notification() { let worker_b_id = session .services .agent_control - .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker_b") + .resolve_agent_reference(session.thread_id, &turn.session_source, "worker_b") .await .expect("worker_b should resolve"); let worker_b_path = session @@ -3624,7 +3616,7 @@ async fn multi_agent_v2_wait_agent_does_not_return_completed_content() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let mut config = (*turn.config).clone(); config .features @@ -3649,7 +3641,7 @@ async fn multi_agent_v2_wait_agent_does_not_return_completed_content() { let agent_id = session .services .agent_control - .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .resolve_agent_reference(session.thread_id, &turn.session_source, "worker") .await .expect("worker should resolve"); let worker_path = session @@ -3713,7 +3705,7 @@ async fn multi_agent_v2_close_agent_accepts_task_name_target() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let mut config = (*turn.config).clone(); config .features @@ -3739,7 +3731,7 @@ async fn multi_agent_v2_close_agent_accepts_task_name_target() { let agent_id = session .services .agent_control - .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .resolve_agent_reference(session.thread_id, &turn.session_source, "worker") .await .expect("worker path should resolve"); @@ -3791,7 +3783,7 @@ async fn multi_agent_v2_close_agent_reaps_stale_task_name_target() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; set_turn_config(&mut turn, config.clone()); let session = Arc::new(session); @@ -3812,7 +3804,7 @@ async fn multi_agent_v2_close_agent_reaps_stale_task_name_target() { let agent_id = session .services .agent_control - .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .resolve_agent_reference(session.thread_id, &turn.session_source, "worker") .await .expect("worker path should resolve"); let stale_thread = manager @@ -3872,7 +3864,7 @@ async fn multi_agent_v2_close_agent_reaps_stale_task_name_target() { let replacement_id = session .services .agent_control - .resolve_agent_reference(session.conversation_id, &turn.session_source, "replacement") + .resolve_agent_reference(session.thread_id, &turn.session_source, "replacement") .await .expect("replacement path should resolve"); let _ = session @@ -3892,7 +3884,7 @@ async fn multi_agent_v2_close_agent_rejects_root_target_and_id() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let mut config = (*turn.config).clone(); config .features diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs index 0f7238cdbc..756a1862bb 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs @@ -61,7 +61,7 @@ async fn handle_close_agent( CollabCloseBeginEvent { call_id: call_id.clone(), started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, + sender_thread_id: session.thread_id, receiver_thread_id: agent_id, } .into(), @@ -85,7 +85,7 @@ async fn handle_close_agent( CollabCloseEndEvent { call_id: call_id.clone(), completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, + sender_thread_id: session.thread_id, receiver_thread_id: agent_id, receiver_agent_nickname: receiver_agent.agent_nickname.clone(), receiver_agent_role: receiver_agent.agent_role.clone(), @@ -110,7 +110,7 @@ async fn handle_close_agent( CollabCloseEndEvent { call_id, completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, + sender_thread_id: session.thread_id, receiver_thread_id: agent_id, receiver_agent_nickname: receiver_agent.agent_nickname, receiver_agent_role: receiver_agent.agent_role, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs index 6abf9a30cc..46b29bdf10 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs @@ -30,7 +30,7 @@ impl ToolExecutor for Handler { session .services .agent_control - .register_session_root(session.conversation_id, turn.parent_thread_id); + .register_session_root(session.thread_id, turn.parent_thread_id); let agents = session .services .agent_control diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs index 3226c45322..258d80c85b 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -91,7 +91,7 @@ pub(crate) async fn handle_message_string_tool( CollabAgentInteractionBeginEvent { call_id: call_id.clone(), started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, + sender_thread_id: session.thread_id, receiver_thread_id, prompt: prompt.clone(), } @@ -127,7 +127,7 @@ pub(crate) async fn handle_message_string_tool( CollabAgentInteractionEndEvent { call_id, completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, + sender_thread_id: session.thread_id, receiver_thread_id, receiver_agent_nickname: receiver_agent.agent_nickname, receiver_agent_role: receiver_agent.agent_role, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index ed85d329bd..d0fb6c515e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -72,7 +72,7 @@ async fn handle_spawn_agent( CollabAgentSpawnBeginEvent { call_id: call_id.clone(), started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, + sender_thread_id: session.thread_id, prompt: prompt.clone(), model: args.model.clone().unwrap_or_default(), reasoning_effort: args.reasoning_effort.unwrap_or_default(), @@ -110,7 +110,7 @@ async fn handle_spawn_agent( apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; let spawn_source = thread_spawn_source( - session.conversation_id, + session.thread_id, &turn.session_source, child_depth, role_name, @@ -143,7 +143,7 @@ async fn handle_spawn_agent( SpawnAgentOptions { fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()), fork_mode, - parent_thread_id: Some(session.conversation_id), + parent_thread_id: Some(session.thread_id), environments: Some(turn.environments.to_selections()), }, ), @@ -197,7 +197,7 @@ async fn handle_spawn_agent( CollabAgentSpawnEndEvent { call_id, completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, + sender_thread_id: session.thread_id, new_thread_id, new_agent_nickname, new_agent_role, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs index 0aba86782e..5e37725b77 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs @@ -67,7 +67,7 @@ impl ToolExecutor for Handler { &turn, CollabWaitingBeginEvent { started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, + sender_thread_id: session.thread_id, receiver_thread_ids: Vec::new(), receiver_agents: Vec::new(), call_id: call_id.clone(), @@ -84,7 +84,7 @@ impl ToolExecutor for Handler { .send_event( &turn, CollabWaitingEndEvent { - sender_thread_id: session.conversation_id, + sender_thread_id: session.thread_id, call_id, completed_at_ms: now_unix_timestamp_ms(), agent_statuses: Vec::new(), diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install.rs b/codex-rs/core/src/tools/handlers/request_plugin_install.rs index 51032b27c5..3f7bd92d25 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install.rs @@ -121,7 +121,7 @@ impl ToolExecutor for RequestPluginInstallHandler { let request_id = RequestId::String(format!("request_plugin_install_{call_id}").into()); let params = build_request_plugin_install_elicitation_request( CODEX_APPS_MCP_SERVER_NAME, - session.conversation_id.to_string(), + session.thread_id.to_string(), turn.sub_id.clone(), &args, suggest_reason, diff --git a/codex-rs/core/src/tools/handlers/shell/shell_command.rs b/codex-rs/core/src/tools/handlers/shell/shell_command.rs index 8a501c711c..b4b60997d6 100644 --- a/codex-rs/core/src/tools/handlers/shell/shell_command.rs +++ b/codex-rs/core/src/tools/handlers/shell/shell_command.rs @@ -179,7 +179,7 @@ impl ToolExecutor for ShellCommandHandler { ¶ms, session.as_ref(), turn.as_ref(), - session.conversation_id, + session.thread_id, turn.config.permissions.allow_login_shell, )?; let shell_type = Some(session.user_shell().shell_type.clone()); diff --git a/codex-rs/core/src/tools/handlers/shell_tests.rs b/codex-rs/core/src/tools/handlers/shell_tests.rs index eda23533a3..792eb2bb1e 100644 --- a/codex-rs/core/src/tools/handlers/shell_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_tests.rs @@ -92,7 +92,7 @@ async fn shell_command_handler_to_exec_params_uses_session_shell_and_turn_contex let expected_cwd = turn_context.resolve_path(workdir.clone()); let expected_env = create_env( &turn_context.shell_environment_policy, - Some(session.conversation_id), + Some(session.thread_id), ); let params = ShellCommandToolCallParams { @@ -110,7 +110,7 @@ async fn shell_command_handler_to_exec_params_uses_session_shell_and_turn_contex ¶ms, &session, &turn_context, - session.conversation_id, + session.thread_id, /*allow_login_shell*/ true, ) .expect("login shells should be allowed"); @@ -177,7 +177,7 @@ async fn shell_command_handler_defaults_to_non_login_when_disallowed() { ¶ms, &session, &turn_context, - session.conversation_id, + session.thread_id, /*allow_login_shell*/ false, ) .expect("non-login shells should still be allowed"); diff --git a/codex-rs/core/src/tools/tool_dispatch_trace.rs b/codex-rs/core/src/tools/tool_dispatch_trace.rs index 522e3cd1d8..098dc8ddef 100644 --- a/codex-rs/core/src/tools/tool_dispatch_trace.rs +++ b/codex-rs/core/src/tools/tool_dispatch_trace.rs @@ -74,7 +74,7 @@ fn tool_dispatch_invocation(invocation: &ToolInvocation) -> Option anyhow::Result<()> { - let thread_id = session.conversation_id; + let thread_id = session.thread_id; let rollout_thread_trace = codex_rollout_trace::ThreadTraceContext::start_root_in_root_for_test( root, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 563d486c69..66b8b49a80 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1002,7 +1002,7 @@ impl UnifiedExecProcessManager { let mut env = local_policy_env.clone(); env.insert( CODEX_THREAD_ID_ENV_VAR.to_string(), - context.session.conversation_id.to_string(), + context.session.thread_id.to_string(), ); let env = apply_unified_exec_env(env); let exec_server_env_config = ExecServerEnvConfig { From 96d2d2f68c07ef01650db1cba01071fbb2d98218 Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 3 Jun 2026 16:24:16 +0200 Subject: [PATCH 14/16] Implement v1 skills extension prompt injection (#26167) ## Why The skills extension needs a real turn-time path before host, executor, or remote skills can be routed through it. The previous code was mostly a placeholder catalog/provider sketch, so there was no bounded available-skills fragment, no source-owned `SKILL.md` read, and no place for warnings or per-turn selection state to live. This PR makes `ext/skills` the authority-preserving flow for listing candidate skills and injecting only explicitly selected main prompts, without adding more of that logic to `codex-core`. ## What changed - Expands catalog entries with `main_prompt`, display path, short description, dependency metadata, enabled/prompt visibility flags, and authority/package-aware read requests. - Replaces the placeholder `providers/*` modules with `SkillProviderSource` and `SkillProviders`, routing list/read/search calls by source kind and surfacing provider failures as warnings. - Adds bounded available-skills rendering and `SKILL.md` main-prompt truncation before the fragments enter model context. - Resolves explicit skill selections from structured `UserInput::Skill`, skill-file mentions, `skill://...` paths, and plain `$skill` text mentions, then reads selected prompts through their owning provider. - Stores mutable per-thread skills config and per-turn catalog/selection/warning state. - Adds `install_with_providers` so tests and future host wiring can supply concrete providers. ## Testing - Not run locally. - Added `codex-rs/ext/skills/tests/skills_extension.rs` coverage for available-catalog injection, selected prompt injection through the owning provider, and prompt-hidden skills that remain invokable. --- codex-rs/Cargo.lock | 4 + codex-rs/ext/skills/Cargo.toml | 6 + codex-rs/ext/skills/src/catalog.rs | 126 +++++++- codex-rs/ext/skills/src/extension.rs | 208 +++++++----- codex-rs/ext/skills/src/lib.rs | 7 +- codex-rs/ext/skills/src/provider.rs | 21 +- codex-rs/ext/skills/src/providers/executor.rs | 56 ---- codex-rs/ext/skills/src/providers/host.rs | 65 ---- codex-rs/ext/skills/src/providers/mod.rs | 50 --- codex-rs/ext/skills/src/providers/remote.rs | 58 ---- codex-rs/ext/skills/src/render.rs | 90 ++++++ codex-rs/ext/skills/src/selection.rs | 129 ++++++++ codex-rs/ext/skills/src/sources.rs | 183 +++++++++++ codex-rs/ext/skills/src/state.rs | 33 +- codex-rs/ext/skills/tests/skills_extension.rs | 300 ++++++++++++++++++ 15 files changed, 1001 insertions(+), 335 deletions(-) delete mode 100644 codex-rs/ext/skills/src/providers/executor.rs delete mode 100644 codex-rs/ext/skills/src/providers/host.rs delete mode 100644 codex-rs/ext/skills/src/providers/mod.rs delete mode 100644 codex-rs/ext/skills/src/providers/remote.rs create mode 100644 codex-rs/ext/skills/src/render.rs create mode 100644 codex-rs/ext/skills/src/selection.rs create mode 100644 codex-rs/ext/skills/src/sources.rs create mode 100644 codex-rs/ext/skills/tests/skills_extension.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 0b599b4195..2183d91b64 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3733,7 +3733,11 @@ version = "0.0.0" dependencies = [ "async-trait", "codex-core", + "codex-core-skills", "codex-extension-api", + "codex-protocol", + "pretty_assertions", + "tokio", ] [[package]] diff --git a/codex-rs/ext/skills/Cargo.toml b/codex-rs/ext/skills/Cargo.toml index 55671df6c9..29767d58fe 100644 --- a/codex-rs/ext/skills/Cargo.toml +++ b/codex-rs/ext/skills/Cargo.toml @@ -16,4 +16,10 @@ workspace = true [dependencies] async-trait = { workspace = true } codex-core = { workspace = true } +codex-core-skills = { workspace = true } codex-extension-api = { workspace = true } +codex-protocol = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/codex-rs/ext/skills/src/catalog.rs b/codex-rs/ext/skills/src/catalog.rs index 86ae327d45..a09a81ff79 100644 --- a/codex-rs/ext/skills/src/catalog.rs +++ b/codex-rs/ext/skills/src/catalog.rs @@ -1,9 +1,39 @@ +use codex_core_skills::model::SkillDependencies; + /// Source authority that owns a skill package and must be used to read it. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum SkillSourceKind { + /// Codex-hosted skills, including bundled, user, repo, plugin-installed, + /// and downloaded/materialized remote skills. Host, + /// Skills owned by an execution environment. Executor, + /// Skills read through an authenticated remote catalog/API. Remote, + /// Extension-private source kind for future providers that do not fit an + /// existing transport category. + Custom(String), +} + +impl SkillSourceKind { + pub fn custom(kind: impl Into) -> Self { + Self::Custom(kind.into()) + } + + fn as_str(&self) -> &str { + match self { + Self::Host => "host", + Self::Executor => "executor", + Self::Remote => "remote", + Self::Custom(kind) => kind, + } + } +} + +impl std::fmt::Display for SkillSourceKind { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.as_str().fmt(formatter) + } } /// Opaque authority identity for list/read routing. @@ -37,7 +67,66 @@ pub struct SkillCatalogEntry { pub authority: SkillAuthority, pub name: String, pub description: String, - pub entrypoint: SkillResourceId, + pub short_description: Option, + pub main_prompt: SkillResourceId, + pub display_path: Option, + pub dependencies: Option, + pub enabled: bool, + pub prompt_visible: bool, +} + +impl SkillCatalogEntry { + pub fn new( + id: SkillPackageId, + authority: SkillAuthority, + name: impl Into, + description: impl Into, + main_prompt: SkillResourceId, + ) -> Self { + Self { + id, + authority, + name: name.into(), + description: description.into(), + short_description: None, + main_prompt, + display_path: None, + dependencies: None, + enabled: true, + prompt_visible: true, + } + } + + pub fn with_short_description(mut self, short_description: Option) -> Self { + self.short_description = short_description; + self + } + + pub fn with_display_path(mut self, display_path: impl Into) -> Self { + self.display_path = Some(display_path.into()); + self + } + + pub fn with_dependencies(mut self, dependencies: Option) -> Self { + self.dependencies = dependencies; + self + } + + pub fn disabled(mut self) -> Self { + self.enabled = false; + self + } + + pub fn hidden_from_prompt(mut self) -> Self { + self.prompt_visible = false; + self + } + + pub(crate) fn rendered_path(&self) -> &str { + self.display_path + .as_deref() + .unwrap_or(self.main_prompt.0.as_str()) + } } /// Merged catalog for one turn. @@ -49,12 +138,23 @@ pub struct SkillCatalog { impl SkillCatalog { pub fn extend(&mut self, other: SkillCatalog) { - // TODO(skills-extension): dedupe by authority-bound id first, then - // apply name precedence/conflict rules for user-facing mention - // resolution. Names are not stable identities. - self.entries.extend(other.entries); + for entry in other.entries { + self.push_entry(entry); + } self.warnings.extend(other.warnings); } + + pub fn push_entry(&mut self, entry: SkillCatalogEntry) { + if self + .entries + .iter() + .any(|existing| existing.authority == entry.authority && existing.id == entry.id) + { + return; + } + + self.entries.push(entry); + } } /// Contents returned after resolving a skill resource through its owner. @@ -83,4 +183,20 @@ pub struct SkillProviderError { pub message: String, } +impl SkillProviderError { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl std::fmt::Display for SkillProviderError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.message.fmt(formatter) + } +} + +impl std::error::Error for SkillProviderError {} + pub type SkillProviderResult = Result; diff --git a/codex-rs/ext/skills/src/extension.rs b/codex-rs/ext/skills/src/extension.rs index adbaa8ffee..85ff513acf 100644 --- a/codex-rs/ext/skills/src/extension.rs +++ b/codex-rs/ext/skills/src/extension.rs @@ -1,42 +1,49 @@ -use std::future::Future; -use std::pin::Pin; use std::sync::Arc; use codex_core::config::Config; +use codex_core_skills::SkillInstructions; +use codex_core_skills::injection::SkillInjection; use codex_extension_api::ConfigContributor; -use codex_extension_api::ContextContributor; use codex_extension_api::ContextualUserFragment; use codex_extension_api::ExtensionData; +use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionRegistryBuilder; -use codex_extension_api::PromptFragment; use codex_extension_api::ThreadLifecycleContributor; use codex_extension_api::ThreadStartInput; use codex_extension_api::TurnInputContext; use codex_extension_api::TurnInputContributor; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::WarningEvent; use crate::catalog::SkillAuthority; +use crate::catalog::SkillCatalogEntry; +use crate::catalog::SkillReadResult; use crate::catalog::SkillSourceKind; use crate::provider::SkillListQuery; -use crate::providers::SkillProviders; +use crate::provider::SkillReadRequest; +use crate::render::available_skills_fragment; +use crate::render::truncate_main_prompt_contents; +use crate::selection::collect_explicit_skill_mentions; +use crate::sources::SkillProviders; use crate::state::SkillsExtensionConfig; +use crate::state::SkillsThreadState; use crate::state::SkillsTurnState; -#[derive(Clone, Debug, Default)] +#[derive(Clone)] struct SkillsExtension { providers: SkillProviders, + event_sink: Arc, } #[async_trait::async_trait] impl ThreadLifecycleContributor for SkillsExtension { async fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) { - // TODO(skills-extension): this is only the thread-level config snapshot. - // Skills are loaded per turn today because cwd, plugin roots, config - // layers, and the primary environment filesystem can change between - // turns. The TurnInputContributor below owns per-turn catalog - // resolution. input .thread_store - .insert(SkillsExtensionConfig::from_config(input.config)); + .insert(SkillsThreadState::new(SkillsExtensionConfig::from_config( + input.config, + ))); } } @@ -48,36 +55,12 @@ impl ConfigContributor for SkillsExtension { _previous_config: &Config, new_config: &Config, ) { - // TODO(skills-extension): update any cached/listing state that depends - // on skill config overrides, bundled skills, or include_instructions. - thread_store.insert(SkillsExtensionConfig::from_config(new_config)); - } -} - -impl ContextContributor for SkillsExtension { - fn contribute<'a>( - &'a self, - _session_store: &'a ExtensionData, - thread_store: &'a ExtensionData, - ) -> Pin> + Send + 'a>> { - Box::pin(async move { - let Some(config) = thread_store.get::() else { - return Vec::new(); - }; - if !config.include_instructions { - return Vec::new(); - } - - // TODO(skills-extension): render the available-skills developer - // block only if the final model needs thread-level guidance that - // does not depend on the per-turn SkillCatalog. - // - // TODO(skills-extension): avoid using raw PromptFragment strings - // for final skills context if the extension API grows typed - // contextual fragments. Existing skill blocks are typed so resume - // and history filtering can recognize them reliably. - Vec::new() - }) + let next_config = SkillsExtensionConfig::from_config(new_config); + if let Some(state) = thread_store.get::() { + state.set_config(next_config); + } else { + thread_store.insert(SkillsThreadState::new(next_config)); + } } } @@ -87,67 +70,118 @@ impl TurnInputContributor for SkillsExtension { &self, input: TurnInputContext, _session_store: &ExtensionData, - _thread_store: &ExtensionData, + thread_store: &ExtensionData, turn_store: &ExtensionData, ) -> Vec> { - let executor_authorities = input - .environments - .iter() - .map(|environment| { - SkillAuthority::new( - SkillSourceKind::Executor, - environment.environment_id.clone(), - ) - }) - .collect(); + let Some(thread_state) = thread_store.get::() else { + return Vec::new(); + }; + + let config = thread_state.config(); let query = SkillListQuery { - turn_id: input.turn_id, - executor_authorities, + turn_id: input.turn_id.clone(), + executor_authorities: input + .environments + .iter() + .map(|environment| { + SkillAuthority::new( + SkillSourceKind::Executor, + environment.environment_id.clone(), + ) + }) + .collect(), include_host_skills: true, + include_bundled_skills: config.bundled_skills_enabled, include_remote_skills: true, }; - let catalog = self - .providers - .list_for_turn(query) - .await - .unwrap_or_default(); + let catalog = self.providers.list_for_turn(query).await; + for warning in &catalog.warnings { + self.emit_warning(&input.turn_id, warning.clone()); + } + + let selected_entries = collect_explicit_skill_mentions(&input.user_input, &catalog); + let mut fragments: Vec> = Vec::new(); + if config.include_instructions + && let Some(fragment) = available_skills_fragment(&catalog) + { + fragments.push(Box::new(fragment)); + } + + let mut warnings = catalog.warnings.clone(); + let mut main_prompts_injected = false; + for entry in &selected_entries { + match self.read_main_prompt(entry).await { + Ok(read_result) => { + let (contents, truncated) = + truncate_main_prompt_contents(read_result.contents.as_str()); + if truncated { + let warning = format!( + "Skill `{}` exceeded the main prompt context limit and was truncated.", + entry.name + ); + self.emit_warning(&input.turn_id, warning.clone()); + warnings.push(warning); + } + let injection = SkillInjection { + name: entry.name.clone(), + path: entry.rendered_path().to_string(), + contents, + }; + fragments.push(Box::new(SkillInstructions::from(&injection))); + main_prompts_injected = true; + } + Err(message) => { + let warning = format!("Failed to load skill `{}`: {message}", entry.name); + self.emit_warning(&input.turn_id, warning.clone()); + warnings.push(warning); + } + } + } turn_store.insert(SkillsTurnState { catalog, - entrypoints_injected: false, + selected_entries, + warnings, + main_prompts_injected, }); - // TODO(skills-extension): after catalog resolution, collect explicit - // skill mentions from structured UserInput and text mentions. - // - // TODO(skills-extension): inject selected entrypoints as typed - // contextual user fragments, preserving ... history - // recognition and bounded body size limits. - // - // TODO(skills-extension): move explicit $skill mention resolution, - // SKILL.md reads, skill body injection, and MCP dependency prompting - // out of codex-core's turn assembly once provider implementations - // are ready to preserve behavior. - Vec::new() + fragments + } +} + +impl SkillsExtension { + async fn read_main_prompt(&self, entry: &SkillCatalogEntry) -> Result { + self.providers + .read(SkillReadRequest { + authority: entry.authority.clone(), + package: entry.id.clone(), + resource: entry.main_prompt.clone(), + }) + .await + .map_err(|err| err.message) + } + + fn emit_warning(&self, turn_id: &str, message: String) { + self.event_sink.emit(Event { + id: turn_id.to_string(), + msg: EventMsg::Warning(WarningEvent { message }), + }); } } -/// Installs the skills extension contributor sketch. -/// -/// TODO(skills-extension): pass host capabilities here rather than letting the -/// extension depend on Session. The final extension needs capability objects for -/// loading skill roots, emitting warnings, tracking analytics, prompting for MCP -/// dependency install, refreshing MCP servers, and serving app-server catalog -/// requests. -/// -/// TODO(skills-extension): plugin handling should stay outside the runtime -/// skills model. Plugins are bundle/install units; once installed or refreshed, -/// their skill descriptors/roots should be handed to this extension just like -/// any other host-owned skill source. pub fn install(registry: &mut ExtensionRegistryBuilder) { - let extension = Arc::new(SkillsExtension::default()); + install_with_providers(registry, SkillProviders::default()); +} + +pub fn install_with_providers( + registry: &mut ExtensionRegistryBuilder, + providers: SkillProviders, +) { + let extension = Arc::new(SkillsExtension { + providers, + event_sink: registry.event_sink(), + }); registry.thread_lifecycle_contributor(extension.clone()); registry.config_contributor(extension.clone()); - registry.prompt_contributor(extension.clone()); registry.turn_input_contributor(extension); } diff --git a/codex-rs/ext/skills/src/lib.rs b/codex-rs/ext/skills/src/lib.rs index 6e8a0700a1..b5e224d49b 100644 --- a/codex-rs/ext/skills/src/lib.rs +++ b/codex-rs/ext/skills/src/lib.rs @@ -1,7 +1,12 @@ pub mod catalog; mod extension; pub mod provider; -mod providers; +mod render; +mod selection; +mod sources; mod state; pub use extension::install; +pub use extension::install_with_providers; +pub use sources::SkillProviderSource; +pub use sources::SkillProviders; diff --git a/codex-rs/ext/skills/src/provider.rs b/codex-rs/ext/skills/src/provider.rs index b6f2407526..f9c1b41fea 100644 --- a/codex-rs/ext/skills/src/provider.rs +++ b/codex-rs/ext/skills/src/provider.rs @@ -1,4 +1,5 @@ use std::future::Future; +use std::pin::Pin; use crate::catalog::SkillAuthority; use crate::catalog::SkillCatalog; @@ -13,12 +14,14 @@ pub struct SkillListQuery { pub turn_id: String, pub executor_authorities: Vec, pub include_host_skills: bool, + pub include_bundled_skills: bool, pub include_remote_skills: bool, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct SkillReadRequest { pub authority: SkillAuthority, + pub package: SkillPackageId, pub resource: SkillResourceId, } @@ -29,24 +32,18 @@ pub struct SkillSearchRequest { pub query: String, } +pub type SkillProviderFuture<'a, T> = + Pin> + Send + 'a>>; + /// Source-specific skill catalog and resource access. /// /// Implementations must preserve authority boundaries: a resource listed by a /// provider must be read or searched through the same provider/authority rather /// than converted into an ambient local path. pub trait SkillProvider: Send + Sync { - fn list( - &self, - query: SkillListQuery, - ) -> impl Future> + Send; + fn list(&self, query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog>; - fn read( - &self, - request: SkillReadRequest, - ) -> impl Future> + Send; + fn read(&self, request: SkillReadRequest) -> SkillProviderFuture<'_, SkillReadResult>; - fn search( - &self, - request: SkillSearchRequest, - ) -> impl Future> + Send; + fn search(&self, request: SkillSearchRequest) -> SkillProviderFuture<'_, SkillSearchResult>; } diff --git a/codex-rs/ext/skills/src/providers/executor.rs b/codex-rs/ext/skills/src/providers/executor.rs deleted file mode 100644 index f9f399eebc..0000000000 --- a/codex-rs/ext/skills/src/providers/executor.rs +++ /dev/null @@ -1,56 +0,0 @@ -use std::future; - -use crate::catalog::SkillCatalog; -use crate::catalog::SkillProviderResult; -use crate::catalog::SkillReadResult; -use crate::catalog::SkillSearchResult; -use crate::provider::SkillListQuery; -use crate::provider::SkillProvider; -use crate::provider::SkillReadRequest; -use crate::provider::SkillSearchRequest; - -#[derive(Clone, Debug, Default)] -pub(crate) struct ExecutorSkillProvider; - -impl SkillProvider for ExecutorSkillProvider { - fn list( - &self, - _query: SkillListQuery, - ) -> impl Future> + Send { - future::ready(Ok(SkillCatalog::default())) - // TODO(skills-extension): list repo/workspace skills from each - // executor authority selected for the turn. - // - // TODO(skills-extension): if the executor exposes filesystem reads, - // preserve the existing SKILL.md discovery semantics. If CCA/no-FS - // applies, query an executor catalog/read capability instead. - // - // TODO(skills-extension): include the executor/environment id in skill - // identity so two executors with the same path/name do not collide. - } - - fn read( - &self, - request: SkillReadRequest, - ) -> impl Future> + Send { - future::ready(Err(crate::catalog::SkillProviderError { - message: format!( - "executor skill resource `{}` is not implemented", - request.resource.0 - ), - })) - // TODO(skills-extension): route reads back to the executor authority - // that listed the resource. Do not mint local paths from remote or - // non-filesystem executor resources. - } - - fn search( - &self, - _request: SkillSearchRequest, - ) -> impl Future> + Send { - future::ready(Ok(SkillSearchResult::default())) - // TODO(skills-extension): support search for executor skills only when - // the executor offers a catalog/search API. For ordinary filesystem - // executors, the model can keep using regular file reads/search tools. - } -} diff --git a/codex-rs/ext/skills/src/providers/host.rs b/codex-rs/ext/skills/src/providers/host.rs deleted file mode 100644 index fc4317f859..0000000000 --- a/codex-rs/ext/skills/src/providers/host.rs +++ /dev/null @@ -1,65 +0,0 @@ -use std::future; - -use crate::catalog::SkillCatalog; -use crate::catalog::SkillProviderResult; -use crate::catalog::SkillReadResult; -use crate::catalog::SkillSearchResult; -use crate::provider::SkillListQuery; -use crate::provider::SkillProvider; -use crate::provider::SkillReadRequest; -use crate::provider::SkillSearchRequest; - -#[derive(Clone, Debug, Default)] -pub(crate) struct HostSkillProvider; - -impl SkillProvider for HostSkillProvider { - fn list( - &self, - _query: SkillListQuery, - ) -> impl Future> + Send { - future::ready(Ok(SkillCatalog::default())) - // TODO(skills-extension): list bundled/system/user/plugin-installed - // skills owned by the Codex host. This is the source for skills that - // are not tied to a particular executor authority. - // - // TODO(skills-extension): plugins should be treated as packaging and - // installation only. After a plugin is downloaded, cached, refreshed, - // or installed, its skill roots/descriptors should enter this provider - // and then use the normal skills catalog/read/injection code. - // - // TODO(skills-extension): remote skills that are materialized locally - // by plugin install or explicit download should also hand off here - // rather than remain remote-provider entries at runtime. - // - // TODO(skills-extension): keep current bundled system skill install or - // replace it with embedded host assets so CCA/no-FS hosts do not depend - // on local writable skill cache directories. - } - - fn read( - &self, - request: SkillReadRequest, - ) -> impl Future> + Send { - future::ready(Err(crate::catalog::SkillProviderError { - message: format!( - "host skill resource `{}` is not implemented", - request.resource.0 - ), - })) - // TODO(skills-extension): read host-owned entrypoints and supporting - // resources by opaque id, not by assuming a local filesystem path. - // - // TODO(skills-extension): for plugin-installed skills, route reads - // through the materialized plugin cache/root that produced the catalog - // entry, while keeping the public id opaque and authority-bound. - } - - fn search( - &self, - _request: SkillSearchRequest, - ) -> impl Future> + Send { - future::ready(Ok(SkillSearchResult::default())) - // TODO(skills-extension): decide whether host skills need search, or - // whether direct read by opaque resource id is enough. - } -} diff --git a/codex-rs/ext/skills/src/providers/mod.rs b/codex-rs/ext/skills/src/providers/mod.rs deleted file mode 100644 index fcf2788480..0000000000 --- a/codex-rs/ext/skills/src/providers/mod.rs +++ /dev/null @@ -1,50 +0,0 @@ -mod executor; -mod host; -mod remote; - -use crate::catalog::SkillCatalog; -use crate::catalog::SkillProviderResult; -use crate::provider::SkillListQuery; -use crate::provider::SkillProvider; - -use executor::ExecutorSkillProvider; -use host::HostSkillProvider; -use remote::RemoteSkillProvider; - -#[derive(Clone, Debug, Default)] -pub(crate) struct SkillProviders { - host: HostSkillProvider, - executor: ExecutorSkillProvider, - remote: RemoteSkillProvider, -} - -impl SkillProviders { - pub(crate) async fn list_for_turn( - &self, - query: SkillListQuery, - ) -> SkillProviderResult { - let mut catalog = SkillCatalog::default(); - - if query.include_host_skills { - catalog.extend(self.host.list(query.clone()).await?); - } - - if !query.executor_authorities.is_empty() { - catalog.extend(self.executor.list(query.clone()).await?); - } - - if query.include_remote_skills { - catalog.extend(self.remote.list(query).await?); - } - - // TODO(skills-extension): apply final merged-catalog policy here: - // source precedence, duplicate name handling, disabled-skill rules, - // product/session-source filtering, and telemetry for omitted entries. - // - // TODO(skills-extension): treat plugin-installed skills as ordinary - // host catalog entries by this point. Plugin identity may remain useful - // for display, auth, and uninstall flows, but mention resolution and - // entrypoint injection should not special-case plugin packaging. - Ok(catalog) - } -} diff --git a/codex-rs/ext/skills/src/providers/remote.rs b/codex-rs/ext/skills/src/providers/remote.rs deleted file mode 100644 index 22dd3368a5..0000000000 --- a/codex-rs/ext/skills/src/providers/remote.rs +++ /dev/null @@ -1,58 +0,0 @@ -use std::future; - -use crate::catalog::SkillCatalog; -use crate::catalog::SkillProviderResult; -use crate::catalog::SkillReadResult; -use crate::catalog::SkillSearchResult; -use crate::provider::SkillListQuery; -use crate::provider::SkillProvider; -use crate::provider::SkillReadRequest; -use crate::provider::SkillSearchRequest; - -#[derive(Clone, Debug, Default)] -pub(crate) struct RemoteSkillProvider; - -impl SkillProvider for RemoteSkillProvider { - fn list( - &self, - _query: SkillListQuery, - ) -> impl Future> + Send { - future::ready(Ok(SkillCatalog::default())) - // TODO(skills-extension): list org/account/backend skills from a - // remote catalog only when they are not installed/materialized into the - // host. These skills should use opaque ids and backend authority, not - // paths. - // - // TODO(skills-extension): if a remote skill is downloaded or installed - // as part of a plugin-like bundle, hand it to HostSkillProvider for - // runtime listing/read instead of keeping a separate remote runtime - // path. - // - // TODO(skills-extension): decide how org policy and local enable/disable - // rules combine when the backend supplies a managed skill catalog. - } - - fn read( - &self, - request: SkillReadRequest, - ) -> impl Future> + Send { - future::ready(Err(crate::catalog::SkillProviderError { - message: format!( - "remote skill resource `{}` is not implemented", - request.resource.0 - ), - })) - // TODO(skills-extension): read remote skill entrypoints and supporting - // files through authenticated backend APIs. - } - - fn search( - &self, - _request: SkillSearchRequest, - ) -> impl Future> + Send { - future::ready(Ok(SkillSearchResult::default())) - // TODO(skills-extension): expose model-facing skills/search or resource - // APIs for large remote packages so the model can progressively - // disclose supporting files without filesystem access. - } -} diff --git a/codex-rs/ext/skills/src/render.rs b/codex-rs/ext/skills/src/render.rs new file mode 100644 index 0000000000..157ae4bff3 --- /dev/null +++ b/codex-rs/ext/skills/src/render.rs @@ -0,0 +1,90 @@ +use codex_core_skills::render_available_skills_body; +use codex_extension_api::ContextualUserFragment; +use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG; +use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG; + +use crate::catalog::SkillCatalog; + +const MAX_AVAILABLE_SKILLS_CHARS: usize = 8_000; +const MAX_MAIN_PROMPT_CHARS: usize = 40_000; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AvailableSkillsFragment { + body: String, +} + +impl ContextualUserFragment for AvailableSkillsFragment { + fn role(&self) -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn body(&self) -> String { + self.body.clone() + } + + fn type_markers() -> (&'static str, &'static str) { + (SKILLS_INSTRUCTIONS_OPEN_TAG, SKILLS_INSTRUCTIONS_CLOSE_TAG) + } +} + +pub(crate) fn available_skills_fragment(catalog: &SkillCatalog) -> Option { + let mut total_chars = 0usize; + let mut omitted = 0usize; + let mut skill_lines = Vec::new(); + + for entry in catalog + .entries + .iter() + .filter(|entry| entry.enabled && entry.prompt_visible) + { + let description = entry + .short_description + .as_deref() + .unwrap_or(entry.description.as_str()); + let line = render_skill_line(entry.name.as_str(), description, entry.rendered_path()); + let next_chars = total_chars.saturating_add(line.chars().count()); + if next_chars > MAX_AVAILABLE_SKILLS_CHARS { + omitted = omitted.saturating_add(1); + continue; + } + total_chars = next_chars; + skill_lines.push(line); + } + + if skill_lines.is_empty() { + return None; + } + if omitted > 0 { + let skill_word = if omitted == 1 { "skill" } else { "skills" }; + skill_lines.push(format!( + "- {omitted} additional {skill_word} omitted from this bounded skills list." + )); + } + + Some(AvailableSkillsFragment { + body: render_available_skills_body(&[], &skill_lines), + }) +} + +fn render_skill_line(name: &str, description: &str, path: &str) -> String { + if description.is_empty() { + format!("- {name}: (file: {path})") + } else { + format!("- {name}: {description} (file: {path})") + } +} + +pub(crate) fn truncate_main_prompt_contents(contents: &str) -> (String, bool) { + let mut chars = 0usize; + for (index, _) in contents.char_indices() { + if chars == MAX_MAIN_PROMPT_CHARS { + return (contents[..index].to_string(), true); + } + chars = chars.saturating_add(1); + } + (contents.to_string(), false) +} diff --git a/codex-rs/ext/skills/src/selection.rs b/codex-rs/ext/skills/src/selection.rs new file mode 100644 index 0000000000..c4405142e5 --- /dev/null +++ b/codex-rs/ext/skills/src/selection.rs @@ -0,0 +1,129 @@ +use std::collections::HashSet; + +use codex_core_skills::injection::extract_tool_mentions; +use codex_protocol::user_input::UserInput; + +use crate::catalog::SkillAuthority; +use crate::catalog::SkillCatalog; +use crate::catalog::SkillCatalogEntry; +use crate::catalog::SkillPackageId; + +const SKILL_PATH_PREFIX: &str = "skill://"; + +pub(crate) fn collect_explicit_skill_mentions( + inputs: &[UserInput], + catalog: &SkillCatalog, +) -> Vec { + let mut selected = Vec::new(); + let mut seen = HashSet::new(); + let mut blocked_plain_names = HashSet::new(); + + for input in inputs { + match input { + UserInput::Skill { name, path } => { + blocked_plain_names.insert(name.clone()); + select_by_path(catalog, &path.to_string_lossy(), &mut seen, &mut selected); + } + UserInput::Mention { name, path } if path_is_skill(path) => { + blocked_plain_names.insert(name.clone()); + select_by_path(catalog, path, &mut seen, &mut selected); + } + UserInput::Text { .. } | UserInput::Image { .. } | UserInput::LocalImage { .. } => {} + UserInput::Mention { .. } => {} + _ => {} + } + } + + for input in inputs { + let UserInput::Text { text, .. } = input else { + continue; + }; + + let mentions = extract_tool_mentions(text); + for path in mentions.paths() { + if path_is_skill(path) { + select_by_path( + catalog, + normalize_skill_path(path), + &mut seen, + &mut selected, + ); + } + } + for name in mentions.plain_names() { + if blocked_plain_names.contains(name) { + continue; + } + if let Some(entry) = catalog + .entries + .iter() + .find(|entry| entry.enabled && entry.name == name) + { + push_selected(entry, &mut seen, &mut selected); + } + } + } + + selected +} + +fn select_by_path( + catalog: &SkillCatalog, + path: &str, + seen: &mut HashSet, + selected: &mut Vec, +) { + let normalized_path = normalize_skill_path(path); + for entry in catalog.entries.iter().filter(|entry| entry.enabled) { + if entry_matches_path(entry, normalized_path) { + push_selected(entry, seen, selected); + } + } +} + +fn push_selected( + entry: &SkillCatalogEntry, + seen: &mut HashSet, + selected: &mut Vec, +) { + let key = SkillCatalogEntryKey::from(entry); + if seen.insert(key) { + selected.push(entry.clone()); + } +} + +fn entry_matches_path(entry: &SkillCatalogEntry, path: &str) -> bool { + entry.main_prompt.0 == path + || entry.id.0 == path + || entry + .display_path + .as_deref() + .is_some_and(|display_path| display_path == path) +} + +fn path_is_skill(path: &str) -> bool { + path.starts_with(SKILL_PATH_PREFIX) + || path + .rsplit(['/', '\\']) + .next() + .is_some_and(|file_name| file_name.eq_ignore_ascii_case("SKILL.md")) +} + +fn normalize_skill_path(path: &str) -> &str { + path.strip_prefix(SKILL_PATH_PREFIX).unwrap_or(path) +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct SkillCatalogEntryKey { + authority: SkillAuthority, + package: SkillPackageId, +} + +impl From<&SkillCatalogEntry> for SkillCatalogEntryKey { + fn from(entry: &SkillCatalogEntry) -> Self { + Self { + authority: entry.authority.clone(), + package: entry.id.clone(), + } + } +} diff --git a/codex-rs/ext/skills/src/sources.rs b/codex-rs/ext/skills/src/sources.rs new file mode 100644 index 0000000000..9049fcb550 --- /dev/null +++ b/codex-rs/ext/skills/src/sources.rs @@ -0,0 +1,183 @@ +use std::fmt; +use std::sync::Arc; + +use crate::catalog::SkillCatalog; +use crate::catalog::SkillProviderError; +use crate::catalog::SkillReadResult; +use crate::catalog::SkillSearchResult; +use crate::catalog::SkillSourceKind; +use crate::provider::SkillListQuery; +use crate::provider::SkillProvider; +use crate::provider::SkillReadRequest; +use crate::provider::SkillSearchRequest; + +#[derive(Clone)] +pub struct SkillProviderSource { + kind: SkillSourceKind, + label: String, + provider: Arc, +} + +impl SkillProviderSource { + pub fn new( + kind: SkillSourceKind, + label: impl Into, + provider: Arc, + ) -> Self { + Self { + kind, + label: label.into(), + provider, + } + } + + pub fn host(label: impl Into, provider: Arc) -> Self { + Self::new(SkillSourceKind::Host, label, provider) + } + + pub fn executor(label: impl Into, provider: Arc) -> Self { + Self::new(SkillSourceKind::Executor, label, provider) + } + + pub fn remote(label: impl Into, provider: Arc) -> Self { + Self::new(SkillSourceKind::Remote, label, provider) + } + + fn should_list(&self, query: &SkillListQuery) -> bool { + match &self.kind { + SkillSourceKind::Host => query.include_host_skills, + SkillSourceKind::Executor => !query.executor_authorities.is_empty(), + SkillSourceKind::Remote => query.include_remote_skills, + SkillSourceKind::Custom(_) => true, + } + } + + fn owns_kind(&self, kind: &SkillSourceKind) -> bool { + &self.kind == kind + } +} + +impl fmt::Debug for SkillProviderSource { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SkillProviderSource") + .field("kind", &self.kind) + .field("label", &self.label) + .finish() + } +} + +#[derive(Clone, Default, Debug)] +pub struct SkillProviders { + sources: Vec, +} + +impl SkillProviders { + pub fn new() -> Self { + Self::default() + } + + pub fn with_provider(mut self, source: SkillProviderSource) -> Self { + self.sources.push(source); + self + } + + pub fn with_host_provider(mut self, provider: Arc) -> Self { + self.sources + .push(SkillProviderSource::host("host", provider)); + self + } + + pub fn with_executor_provider(mut self, provider: Arc) -> Self { + self.sources + .push(SkillProviderSource::executor("executor", provider)); + self + } + + pub fn with_remote_provider(mut self, provider: Arc) -> Self { + self.sources + .push(SkillProviderSource::remote("remote", provider)); + self + } + + pub(crate) async fn list_for_turn(&self, query: SkillListQuery) -> SkillCatalog { + let mut catalog = SkillCatalog::default(); + + for source in self + .sources + .iter() + .filter(|source| source.should_list(&query)) + { + extend_catalog( + &mut catalog, + source.provider.list(query.clone()).await, + source.label.as_str(), + ); + } + + catalog + } + + pub(crate) async fn read( + &self, + request: SkillReadRequest, + ) -> Result { + let mut last_error = None; + for source in self + .sources + .iter() + .filter(|source| source.owns_kind(&request.authority.kind)) + { + match source.provider.read(request.clone()).await { + Ok(result) => return Ok(result), + Err(err) => last_error = Some(err), + } + } + + match last_error { + Some(err) => Err(err), + None => Err(SkillProviderError::new(format!( + "{} skill provider is not configured", + request.authority.kind + ))), + } + } + + pub async fn search( + &self, + request: SkillSearchRequest, + ) -> Result { + let mut last_error = None; + for source in self + .sources + .iter() + .filter(|source| source.owns_kind(&request.authority.kind)) + { + match source.provider.search(request.clone()).await { + Ok(result) => return Ok(result), + Err(err) => last_error = Some(err), + } + } + + match last_error { + Some(err) => Err(err), + None => Err(SkillProviderError::new(format!( + "{} skill provider is not configured", + request.authority.kind + ))), + } + } +} + +fn extend_catalog( + catalog: &mut SkillCatalog, + result: Result, + label: &str, +) { + match result { + Ok(source_catalog) => catalog.extend(source_catalog), + Err(err) => catalog + .warnings + .push(format!("{label} skills unavailable: {}", err.message)), + } +} diff --git a/codex-rs/ext/skills/src/state.rs b/codex-rs/ext/skills/src/state.rs index d6c355ed34..4a639eb5f0 100644 --- a/codex-rs/ext/skills/src/state.rs +++ b/codex-rs/ext/skills/src/state.rs @@ -1,6 +1,8 @@ use codex_core::config::Config; +use std::sync::Mutex; use crate::catalog::SkillCatalog; +use crate::catalog::SkillCatalogEntry; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct SkillsExtensionConfig { @@ -17,8 +19,37 @@ impl SkillsExtensionConfig { } } +#[derive(Debug)] +pub(crate) struct SkillsThreadState { + config: Mutex, +} + +impl SkillsThreadState { + pub(crate) fn new(config: SkillsExtensionConfig) -> Self { + Self { + config: Mutex::new(config), + } + } + + pub(crate) fn config(&self) -> SkillsExtensionConfig { + self.config + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + pub(crate) fn set_config(&self, config: SkillsExtensionConfig) { + *self + .config + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = config; + } +} + #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct SkillsTurnState { pub(crate) catalog: SkillCatalog, - pub(crate) entrypoints_injected: bool, + pub(crate) selected_entries: Vec, + pub(crate) warnings: Vec, + pub(crate) main_prompts_injected: bool, } diff --git a/codex-rs/ext/skills/tests/skills_extension.rs b/codex-rs/ext/skills/tests/skills_extension.rs new file mode 100644 index 0000000000..acdb5587be --- /dev/null +++ b/codex-rs/ext/skills/tests/skills_extension.rs @@ -0,0 +1,300 @@ +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + +use codex_core::config::Config; +use codex_extension_api::ExtensionData; +use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::ThreadStartInput; +use codex_extension_api::TurnInputContext; +use codex_extension_api::TurnInputEnvironment; +use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG; +use codex_protocol::protocol::SessionSource; +use codex_protocol::user_input::UserInput; +use codex_skills_extension::SkillProviders; +use codex_skills_extension::catalog::SkillAuthority; +use codex_skills_extension::catalog::SkillCatalog; +use codex_skills_extension::catalog::SkillCatalogEntry; +use codex_skills_extension::catalog::SkillPackageId; +use codex_skills_extension::catalog::SkillReadResult; +use codex_skills_extension::catalog::SkillResourceId; +use codex_skills_extension::catalog::SkillSearchResult; +use codex_skills_extension::catalog::SkillSourceKind; +use codex_skills_extension::install_with_providers; +use codex_skills_extension::provider::SkillListQuery; +use codex_skills_extension::provider::SkillProvider; +use codex_skills_extension::provider::SkillProviderFuture; +use codex_skills_extension::provider::SkillReadRequest; +use codex_skills_extension::provider::SkillSearchRequest; +use pretty_assertions::assert_eq; + +type TestResult = Result<(), Box>; + +static NEXT_CODEX_HOME_ID: AtomicUsize = AtomicUsize::new(0); + +#[tokio::test] +async fn installed_extension_injects_available_catalog_and_selected_entrypoint() -> TestResult { + let host_read_requests = Arc::new(Mutex::new(Vec::new())); + let remote_read_requests = Arc::new(Mutex::new(Vec::new())); + let host_provider = Arc::new(StaticSkillProvider { + catalog: SkillCatalog { + entries: vec![test_entry( + SkillSourceKind::Host, + "host", + "host/lint-fix", + "lint-fix/SKILL.md", + )], + warnings: Vec::new(), + }, + read_requests: Arc::clone(&host_read_requests), + }); + let remote_provider = Arc::new(StaticSkillProvider { + catalog: SkillCatalog { + entries: vec![test_entry( + SkillSourceKind::Remote, + "remote", + "remote/lint-fix", + "lint-fix/SKILL.md", + )], + warnings: Vec::new(), + }, + read_requests: Arc::clone(&remote_read_requests), + }); + let providers = SkillProviders::new() + .with_host_provider(host_provider) + .with_remote_provider(remote_provider); + let mut builder = ExtensionRegistryBuilder::new(); + install_with_providers(&mut builder, providers); + let registry = builder.build(); + + let session_store = ExtensionData::new("session"); + let thread_store = ExtensionData::new("thread"); + let session_source = SessionSource::Cli; + let config = default_config().await?; + registry.thread_lifecycle_contributors()[0] + .on_thread_start(ThreadStartInput { + config: &config, + session_source: &session_source, + persistent_thread_state_available: true, + session_store: &session_store, + thread_store: &thread_store, + }) + .await; + + let turn_store = ExtensionData::new("turn-1"); + let fragments = registry.turn_input_contributors()[0] + .contribute( + TurnInputContext { + turn_id: "turn-1".to_string(), + user_input: vec![UserInput::Text { + text: "$lint-fix please".to_string(), + text_elements: Vec::new(), + }], + environments: vec![TurnInputEnvironment { + environment_id: "env-1".to_string(), + cwd: std::env::temp_dir(), + is_primary: true, + }], + }, + &session_store, + &thread_store, + &turn_store, + ) + .await; + + assert_eq!(2, fragments.len()); + assert_eq!("developer", fragments[0].role()); + assert!( + fragments[0] + .render() + .starts_with(SKILLS_INSTRUCTIONS_OPEN_TAG) + ); + assert!(fragments[0].render().contains("lint-fix")); + assert_eq!("user", fragments[1].role()); + assert!(fragments[1].render().contains("lint-fix")); + assert!(fragments[1].render().contains("# Lint Fix")); + assert_eq!( + vec![SkillReadRequest { + authority: SkillAuthority::new(SkillSourceKind::Host, "host"), + package: SkillPackageId("host/lint-fix".to_string()), + resource: SkillResourceId("lint-fix/SKILL.md".to_string()), + }], + host_read_requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + ); + assert!( + remote_read_requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_empty() + ); + + let next_turn_store = ExtensionData::new("turn-2"); + let next_fragments = registry.turn_input_contributors()[0] + .contribute( + TurnInputContext { + turn_id: "turn-2".to_string(), + user_input: vec![UserInput::Text { + text: "no skill this time".to_string(), + text_elements: Vec::new(), + }], + environments: Vec::new(), + }, + &session_store, + &thread_store, + &next_turn_store, + ) + .await; + + assert_eq!(1, next_fragments.len()); + assert_eq!("developer", next_fragments[0].role()); + assert!(next_fragments[0].render().contains("lint-fix")); + + Ok(()) +} + +#[tokio::test] +async fn prompt_hidden_skill_can_still_be_invoked() -> TestResult { + let read_requests = Arc::new(Mutex::new(Vec::new())); + let provider = Arc::new(StaticSkillProvider { + catalog: SkillCatalog { + entries: vec![ + test_entry( + SkillSourceKind::Host, + "host", + "host/visible-skill", + "visible-skill/SKILL.md", + ), + test_entry( + SkillSourceKind::Host, + "host", + "host/hidden-skill", + "hidden-skill/SKILL.md", + ) + .hidden_from_prompt(), + ], + warnings: Vec::new(), + }, + read_requests: Arc::clone(&read_requests), + }); + let providers = SkillProviders::new().with_host_provider(provider); + let mut builder = ExtensionRegistryBuilder::new(); + install_with_providers(&mut builder, providers); + let registry = builder.build(); + let session_store = ExtensionData::new("session"); + let thread_store = ExtensionData::new("thread"); + let session_source = SessionSource::Cli; + let config = default_config().await?; + registry.thread_lifecycle_contributors()[0] + .on_thread_start(ThreadStartInput { + config: &config, + session_source: &session_source, + persistent_thread_state_available: true, + session_store: &session_store, + thread_store: &thread_store, + }) + .await; + + let fragments = registry.turn_input_contributors()[0] + .contribute( + TurnInputContext { + turn_id: "turn-1".to_string(), + user_input: vec![UserInput::Text { + text: "$hidden-skill".to_string(), + text_elements: Vec::new(), + }], + environments: Vec::new(), + }, + &session_store, + &thread_store, + &ExtensionData::new("turn-1"), + ) + .await; + + assert_eq!(2, fragments.len()); + let catalog_fragment = fragments[0].render(); + assert!(catalog_fragment.contains("visible-skill")); + assert!(!catalog_fragment.contains("hidden-skill")); + assert!(fragments[1].render().contains("hidden-skill")); + assert_eq!( + vec![SkillReadRequest { + authority: SkillAuthority::new(SkillSourceKind::Host, "host"), + package: SkillPackageId("host/hidden-skill".to_string()), + resource: SkillResourceId("hidden-skill/SKILL.md".to_string()), + }], + read_requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + ); + + Ok(()) +} + +#[derive(Clone)] +struct StaticSkillProvider { + catalog: SkillCatalog, + read_requests: Arc>>, +} + +impl SkillProvider for StaticSkillProvider { + fn list(&self, query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog> { + let catalog = self.catalog.clone(); + Box::pin(async move { + assert!(query.include_host_skills); + assert!(query.include_bundled_skills); + Ok(catalog) + }) + } + + fn read(&self, request: SkillReadRequest) -> SkillProviderFuture<'_, SkillReadResult> { + let read_requests = Arc::clone(&self.read_requests); + Box::pin(async move { + read_requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(request.clone()); + Ok(SkillReadResult { + resource: request.resource, + contents: "# Lint Fix\n\nRun the formatter.".to_string(), + }) + }) + } + + fn search(&self, _request: SkillSearchRequest) -> SkillProviderFuture<'_, SkillSearchResult> { + Box::pin(async { Ok(SkillSearchResult::default()) }) + } +} + +fn test_entry( + kind: SkillSourceKind, + authority_id: &str, + package_id: &str, + main_prompt: &str, +) -> SkillCatalogEntry { + let name = package_id.rsplit('/').next().unwrap_or(package_id); + SkillCatalogEntry::new( + SkillPackageId(package_id.to_string()), + SkillAuthority::new(kind, authority_id), + name, + "Fix lint errors.", + SkillResourceId(main_prompt.to_string()), + ) + .with_display_path(format!("skill://{package_id}/SKILL.md")) +} + +async fn default_config() -> std::io::Result { + let id = NEXT_CODEX_HOME_ID.fetch_add(1, Ordering::Relaxed); + let codex_home = std::env::temp_dir().join(format!( + "codex-skills-extension-test-{}-{id}", + std::process::id(), + )); + std::fs::create_dir_all(&codex_home)?; + let config = + Config::load_default_with_cli_overrides_for_codex_home(codex_home.clone(), vec![]).await?; + std::fs::remove_dir_all(codex_home)?; + Ok(config) +} From 4417e4c193bf40e721c5bdf254b86904e3916b72 Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 3 Jun 2026 16:47:34 +0200 Subject: [PATCH 15/16] fix: main (#26176) --- codex-rs/core/src/tools/handlers/multi_agents_tests.rs | 8 ++++---- .../src/tools/handlers/multi_agents_v2/close_agent.rs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 868847414e..d32bd5dd62 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -3940,7 +3940,7 @@ async fn multi_agent_v2_close_agent_rejects_self_target_by_id() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let child_path = AgentPath::try_from("/root/worker").expect("agent path"); let child_thread_id = session @@ -3965,7 +3965,7 @@ async fn multi_agent_v2_close_agent_rejects_self_target_by_id() { .await .expect("worker spawn should succeed") .thread_id; - session.conversation_id = child_thread_id; + session.thread_id = child_thread_id; turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: root.thread_id, depth: 1, @@ -4008,7 +4008,7 @@ async fn multi_agent_v2_close_agent_rejects_self_target_by_task_name() { .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); - session.conversation_id = root.thread_id; + session.thread_id = root.thread_id; let child_path = AgentPath::try_from("/root/worker").expect("agent path"); let child_thread_id = session @@ -4033,7 +4033,7 @@ async fn multi_agent_v2_close_agent_rejects_self_target_by_task_name() { .await .expect("worker spawn should succeed") .thread_id; - session.conversation_id = child_thread_id; + session.thread_id = child_thread_id; turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: root.thread_id, depth: 1, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs index 756a1862bb..7b3f8d776e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs @@ -49,7 +49,7 @@ async fn handle_close_agent( "root is not a spawned agent".to_string(), )); } - if agent_id == session.conversation_id { + if agent_id == session.thread_id { return Err(FunctionCallError::RespondToModel( "an agent cannot close itself; return your result and let the parent close you if needed" .to_string(), From 5d531b0a3e71c2f8dba26fa6d52adb3cda3e1544 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 3 Jun 2026 09:19:24 -0700 Subject: [PATCH 16/16] cli: add package path from install context --- codex-cli/bin/codex.js | 31 ++---------- codex-rs/Cargo.lock | 2 + codex-rs/arg0/Cargo.toml | 4 ++ codex-rs/arg0/src/lib.rs | 104 +++++++++++++++++++++++++++++++-------- 4 files changed, 93 insertions(+), 48 deletions(-) diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js index 1a43ce7e1f..d03d4f114c 100755 --- a/codex-cli/bin/codex.js +++ b/codex-cli/bin/codex.js @@ -83,21 +83,14 @@ const legacyBinaryPath = (vendorRoot) => path.join(vendorRoot, targetTriple, "codex", codexBinaryName); function resolveNativePackage(vendorRoot) { - const packageRoot = path.join(vendorRoot, targetTriple); const binaryPath = packageBinaryPath(vendorRoot); if (existsSync(binaryPath)) { - return { - binaryPath, - pathDir: path.join(packageRoot, "codex-path"), - }; + return binaryPath; } const legacyPath = legacyBinaryPath(vendorRoot); if (existsSync(legacyPath)) { - return { - binaryPath: legacyPath, - pathDir: path.join(packageRoot, "path"), - }; + return legacyPath; } return null; @@ -124,7 +117,7 @@ if (!nativePackage) { ); } -const { binaryPath, pathDir } = nativePackage; +const binaryPath = nativePackage; // Use an asynchronous spawn instead of spawnSync so that Node is able to // respond to signals (e.g. Ctrl-C / SIGINT) while the native binary is @@ -132,16 +125,6 @@ const { binaryPath, pathDir } = nativePackage; // and guarantees that when either the child terminates or the parent // receives a fatal signal, both processes exit in a predictable manner. -function getUpdatedPath(newDirs) { - const pathSep = process.platform === "win32" ? ";" : ":"; - const existingPath = process.env.PATH || ""; - const updatedPath = [ - ...newDirs, - ...existingPath.split(pathSep).filter(Boolean), - ].join(pathSep); - return updatedPath; -} - /** * Use heuristics to detect the package manager that was used to install Codex * in order to give the user a hint about how to update it. @@ -167,13 +150,7 @@ function detectPackageManager() { return userAgent ? "npm" : null; } -const additionalDirs = []; -if (existsSync(pathDir)) { - additionalDirs.push(pathDir); -} -const updatedPath = getUpdatedPath(additionalDirs); - -const env = { ...process.env, PATH: updatedPath }; +const env = { ...process.env }; const packageManagerEnvVar = detectPackageManager() === "bun" ? "CODEX_MANAGED_BY_BUN" diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 2183d91b64..e9c171d778 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2145,12 +2145,14 @@ dependencies = [ "anyhow", "codex-apply-patch", "codex-exec-server", + "codex-install-context", "codex-linux-sandbox", "codex-sandboxing", "codex-shell-escalation", "codex-utils-absolute-path", "codex-utils-home-dir", "dotenvy", + "pretty_assertions", "tempfile", "tokio", ] diff --git a/codex-rs/arg0/Cargo.toml b/codex-rs/arg0/Cargo.toml index 7ee21a770e..55526b4d06 100644 --- a/codex-rs/arg0/Cargo.toml +++ b/codex-rs/arg0/Cargo.toml @@ -16,6 +16,7 @@ workspace = true anyhow = { workspace = true } codex-apply-patch = { workspace = true } codex-exec-server = { workspace = true } +codex-install-context = { workspace = true } codex-linux-sandbox = { workspace = true } codex-sandboxing = { workspace = true } codex-shell-escalation = { workspace = true } @@ -24,3 +25,6 @@ codex-utils-home-dir = { workspace = true } dotenvy = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index 87940f1182..5a385a8fe0 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -1,3 +1,4 @@ +use std::ffi::OsString; use std::fs::File; use std::future::Future; use std::path::Path; @@ -5,6 +6,7 @@ use std::path::PathBuf; use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; use codex_exec_server::CODEX_FS_HELPER_ARG1; +use codex_install_context::InstallContext; use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; use codex_utils_home_dir::find_codex_home; #[cfg(unix)] @@ -285,10 +287,11 @@ where /// - WINDOWS: `apply_patch.bat` batch script to invoke the current executable /// with the hidden `--codex-run-as-apply-patch` flag. /// -/// This temporary directory is prepended to the PATH environment variable so -/// that `apply_patch` can be on the PATH without requiring the user to -/// install a separate `apply_patch` executable, simplifying the deployment of -/// Codex CLI. +/// This temporary directory, followed by the package-managed `codex-path` +/// directory when present, is prepended to the PATH environment variable so +/// that `apply_patch` and bundled package helpers can be on the PATH without +/// requiring the user to install separate executables, simplifying the +/// deployment of Codex CLI. /// Note: In debug builds the temp-dir guard is disabled to ease local testing. /// /// IMPORTANT: This function modifies the PATH environment variable, so it MUST @@ -371,23 +374,8 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result { - let mut path_env_var = - std::ffi::OsString::with_capacity(path.as_os_str().len() + 1 + existing_path.len()); - path_env_var.push(path); - path_env_var.push(PATH_SEPARATOR); - path_env_var.push(existing_path); - path_env_var - } - None => path.as_os_str().to_owned(), - }; + let updated_path_env_var = + path_env_with_codex_entries(path, InstallContext::current(), std::env::var_os("PATH")); unsafe { std::env::set_var("PATH", updated_path_env_var); @@ -420,6 +408,39 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result, +) -> OsString { + #[cfg(unix)] + const PATH_SEPARATOR: &str = ":"; + + #[cfg(windows)] + const PATH_SEPARATOR: &str = ";"; + + let package_path_dir = install_context + .package_layout + .as_ref() + .and_then(|package_layout| package_layout.path_dir.as_ref()); + let capacity = arg0_dir.as_os_str().len() + + package_path_dir.map_or(0, |path_dir| 1 + path_dir.as_os_str().len()) + + existing_path + .as_ref() + .map_or(0, |existing_path| 1 + existing_path.len()); + let mut path_env_var = OsString::with_capacity(capacity); + path_env_var.push(arg0_dir); + if let Some(path_dir) = package_path_dir { + path_env_var.push(PATH_SEPARATOR); + path_env_var.push(path_dir.as_path()); + } + if let Some(existing_path) = existing_path { + path_env_var.push(PATH_SEPARATOR); + path_env_var.push(existing_path); + } + path_env_var +} + fn janitor_cleanup(temp_root: &Path) -> std::io::Result<()> { let entries = match std::fs::read_dir(temp_root) { Ok(entries) => entries, @@ -475,6 +496,11 @@ mod tests { use super::run_main_with_arg0_guard; #[cfg(unix)] use anyhow::ensure; + use codex_install_context::CodexPackageLayout; + use codex_install_context::InstallContext; + use codex_install_context::InstallMethod; + use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; use std::fs; use std::fs::File; use std::path::Path; @@ -513,6 +539,42 @@ mod tests { Ok(()) } + #[test] + fn path_env_includes_arg0_dir_package_path_dir_and_existing_path() -> anyhow::Result<()> { + let temp_dir = TempDir::new()?; + let arg0_dir = temp_dir.path().join("arg0"); + let package_dir = temp_dir.path().join("package"); + let bin_dir = package_dir.join("bin"); + let path_dir = package_dir.join("codex-path"); + let existing_dir = temp_dir.path().join("existing-bin"); + fs::create_dir_all(&arg0_dir)?; + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(&path_dir)?; + fs::create_dir_all(&existing_dir)?; + let path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?; + let install_context = InstallContext { + method: InstallMethod::Other, + package_layout: Some(CodexPackageLayout { + package_dir: AbsolutePathBuf::from_absolute_path(package_dir.canonicalize()?)?, + bin_dir: AbsolutePathBuf::from_absolute_path(bin_dir.canonicalize()?)?, + resources_dir: None, + path_dir: Some(path_dir.clone()), + }), + }; + + let updated_path = super::path_env_with_codex_entries( + &arg0_dir, + &install_context, + Some(existing_dir.as_os_str().to_owned()), + ); + + assert_eq!( + std::env::split_paths(&updated_path).collect::>(), + vec![arg0_dir, path_dir.as_path().to_path_buf(), existing_dir,], + ); + Ok(()) + } + #[cfg(unix)] #[test] fn run_main_with_arg0_guard_keeps_aliases_alive_until_main_returns() -> anyhow::Result<()> {