Merge remote-tracking branch 'origin/main' into nm-codex/resolve-template-app-ids

# Conflicts:
#	codex-rs/core/src/connectors.rs
#	codex-rs/core/src/plugins/discoverable.rs
#	codex-rs/core/src/plugins/discoverable_tests.rs
This commit is contained in:
Noah MacCallum
2026-05-29 15:58:21 -07:00
225 changed files with 6255 additions and 3563 deletions
+2
View File
@@ -12,6 +12,7 @@ jobs:
# Prevent runs on forks (requires OpenAI API key, wastes Actions minutes)
if: github.repository == 'openai/codex' && (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-deduplicate'))
runs-on: ubuntu-latest
environment: issue-triage
permissions:
contents: read
outputs:
@@ -157,6 +158,7 @@ jobs:
needs: normalize-duplicates-all
if: ${{ needs.normalize-duplicates-all.result == 'success' && needs.normalize-duplicates-all.outputs.has_matches != 'true' }}
runs-on: ubuntu-latest
environment: issue-triage
permissions:
contents: read
outputs:
+1
View File
@@ -12,6 +12,7 @@ jobs:
# Prevent runs on forks (requires OpenAI API key, wastes Actions minutes)
if: github.repository == 'openai/codex' && (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-label'))
runs-on: ubuntu-latest
environment: issue-triage
permissions:
contents: read
outputs:
+1
View File
@@ -1,5 +1,6 @@
{
"recommendations": [
"BazelBuild.vscode-bazel",
"rust-lang.rust-analyzer",
"charliermarsh.ruff",
"tamasfe.even-better-toml",
+1 -18
View File
@@ -2430,8 +2430,6 @@ dependencies = [
name = "codex-code-mode"
version = "0.0.0"
dependencies = [
"async-channel",
"async-trait",
"codex-protocol",
"deno_core_icudata",
"pretty_assertions",
@@ -2719,18 +2717,6 @@ dependencies = [
"zip 2.4.2",
]
[[package]]
name = "codex-debug-client"
version = "0.0.0"
dependencies = [
"anyhow",
"clap",
"codex-app-server-protocol",
"pretty_assertions",
"serde",
"serde_json",
]
[[package]]
name = "codex-exec"
version = "0.0.0"
@@ -3045,7 +3031,6 @@ name = "codex-image-generation-extension"
version = "0.0.0"
dependencies = [
"async-trait",
"base64 0.22.1",
"codex-api",
"codex-core",
"codex-extension-api",
@@ -3060,9 +3045,6 @@ dependencies = [
"schemars 0.8.22",
"serde",
"serde_json",
"tempfile",
"tokio",
"tracing",
]
[[package]]
@@ -4186,6 +4168,7 @@ dependencies = [
"pretty_assertions",
"schemars 0.8.22",
"serde_json",
"url",
]
[[package]]
-1
View File
@@ -14,7 +14,6 @@ members = [
"app-server-client",
"app-server-protocol",
"app-server-test-client",
"debug-client",
"apply-patch",
"arg0",
"feedback",
+3 -6
View File
@@ -1069,12 +1069,9 @@ pub(crate) fn subagent_source_name(subagent_source: &SubAgentSource) -> String {
}
pub(crate) fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option<String> {
match subagent_source {
SubAgentSource::ThreadSpawn {
parent_thread_id, ..
} => Some(parent_thread_id.to_string()),
_ => None,
}
subagent_source
.parent_thread_id()
.map(|parent_thread_id| parent_thread_id.to_string())
}
fn analytics_hook_status(status: HookRunStatus) -> HookRunStatus {
@@ -2988,6 +2988,20 @@
],
"type": "object"
},
"SkillsExtraRootsSetParams": {
"properties": {
"extraRoots": {
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
}
},
"required": [
"extraRoots"
],
"type": "object"
},
"SkillsListParams": {
"properties": {
"cwds": {
@@ -4783,6 +4797,30 @@
"title": "Skills/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"skills/extraRoots/set"
],
"title": "Skills/extraRoots/setRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/SkillsExtraRootsSetParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Skills/extraRoots/setRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -709,6 +709,30 @@
"title": "Skills/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"skills/extraRoots/set"
],
"title": "Skills/extraRoots/setRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/SkillsExtraRootsSetParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Skills/extraRoots/setRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -15121,6 +15145,27 @@
"title": "SkillsConfigWriteResponse",
"type": "object"
},
"SkillsExtraRootsSetParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"extraRoots": {
"items": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"type": "array"
}
},
"required": [
"extraRoots"
],
"title": "SkillsExtraRootsSetParams",
"type": "object"
},
"SkillsExtraRootsSetResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "SkillsExtraRootsSetResponse",
"type": "object"
},
"SkillsListEntry": {
"properties": {
"cwd": {
@@ -1457,6 +1457,30 @@
"title": "Skills/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"skills/extraRoots/set"
],
"title": "Skills/extraRoots/setRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/SkillsExtraRootsSetParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Skills/extraRoots/setRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -12945,6 +12969,27 @@
"title": "SkillsConfigWriteResponse",
"type": "object"
},
"SkillsExtraRootsSetParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"extraRoots": {
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
}
},
"required": [
"extraRoots"
],
"title": "SkillsExtraRootsSetParams",
"type": "object"
},
"SkillsExtraRootsSetResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "SkillsExtraRootsSetResponse",
"type": "object"
},
"SkillsListEntry": {
"properties": {
"cwd": {
@@ -0,0 +1,22 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
}
},
"properties": {
"extraRoots": {
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
}
},
"required": [
"extraRoots"
],
"title": "SkillsExtraRootsSetParams",
"type": "object"
}
@@ -0,0 +1,5 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "SkillsExtraRootsSetResponse",
"type": "object"
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
export type SkillsExtraRootsSetParams = { extraRoots: Array<AbsolutePathBuf>, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type SkillsExtraRootsSetResponse = Record<string, never>;
@@ -344,6 +344,8 @@ export type { SkillToolDependency } from "./SkillToolDependency";
export type { SkillsChangedNotification } from "./SkillsChangedNotification";
export type { SkillsConfigWriteParams } from "./SkillsConfigWriteParams";
export type { SkillsConfigWriteResponse } from "./SkillsConfigWriteResponse";
export type { SkillsExtraRootsSetParams } from "./SkillsExtraRootsSetParams";
export type { SkillsExtraRootsSetResponse } from "./SkillsExtraRootsSetResponse";
export type { SkillsListEntry } from "./SkillsListEntry";
export type { SkillsListParams } from "./SkillsListParams";
export type { SkillsListResponse } from "./SkillsListResponse";
@@ -610,6 +610,11 @@ client_request_definitions! {
serialization: global_shared_read("config"),
response: v2::SkillsListResponse,
},
SkillsExtraRootsSet => "skills/extraRoots/set" {
params: v2::SkillsExtraRootsSetParams,
serialization: global("config"),
response: v2::SkillsExtraRootsSetResponse,
},
HooksList => "hooks/list" {
params: v2::HooksListParams,
serialization: global("config"),
@@ -1721,6 +1726,17 @@ mod tests {
Some(ClientRequestSerializationScope::GlobalSharedRead("config"))
);
let skills_extra_roots_set = ClientRequest::SkillsExtraRootsSet {
request_id: request_id(),
params: v2::SkillsExtraRootsSetParams {
extra_roots: vec![absolute_path("/tmp/skills")],
},
};
assert_eq!(
skills_extra_roots_set.serialization_scope(),
Some(ClientRequestSerializationScope::Global("config"))
);
let plugin_list = ClientRequest::PluginList {
request_id: request_id(),
params: v2::PluginListParams {
@@ -35,6 +35,18 @@ pub struct SkillsListResponse {
pub data: Vec<SkillsListEntry>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct SkillsExtraRootsSetParams {
pub extra_roots: Vec<AbsolutePathBuf>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct SkillsExtraRootsSetResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -2626,6 +2626,27 @@ fn skills_list_params_serialization_uses_force_reload() {
);
}
#[test]
fn skills_extra_roots_set_params_serialization_uses_extra_roots() {
assert_eq!(
serde_json::to_value(SkillsExtraRootsSetParams {
extra_roots: vec![absolute_path("tmp/skills")],
})
.unwrap(),
json!({
"extraRoots": [absolute_path_string("tmp/skills")],
}),
);
}
#[test]
fn skills_extra_roots_set_params_rejects_relative_roots() {
let result = serde_json::from_value::<SkillsExtraRootsSetParams>(json!({
"extraRoots": ["relative/path"],
}));
assert!(result.is_err());
}
#[test]
fn plugin_source_serializes_local_git_and_remote_variants() {
let local_path = if cfg!(windows) {
+15 -2
View File
@@ -196,6 +196,7 @@ Example with notification opt-out:
- `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment.
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
- `skills/extraRoots/set` — replace the app-server process runtime extra standalone skill roots. The roots are not persisted; missing directories are accepted and simply load no skills.
- `hooks/list` — list discovered hooks for one or more `cwd` values.
- `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present.
- `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists.
@@ -1506,6 +1507,7 @@ $skill-creator Add a new skill for triaging flaky CI and include step-by-step us
Use `skills/list` to fetch the available skills (optionally scoped by `cwds`, with `forceReload`).
`skills/list` might reuse a cached skills result per `cwd`; setting `forceReload` to `true` refreshes the result from disk.
The server also emits `skills/changed` notifications when watched local skill files change. Treat this as an invalidation signal and re-run `skills/list` with your current params when needed.
Use `skills/extraRoots/set` to replace additional standalone skill roots for the current app-server process. These roots use the same layout as other standalone skill roots: each root contains skill directories, and each skill directory contains `SKILL.md`. Missing roots are accepted and load no skills until they exist. This setting is lost when app-server exits.
```json
{ "method": "skills/list", "id": 25, "params": {
@@ -1542,12 +1544,23 @@ The server also emits `skills/changed` notifications when watched local skill fi
}
```
```json
{
"method": "skills/extraRoots/set",
"id": 26,
"params": {
"extraRoots": ["/Users/me/generated-skills"]
}
}
{ "id": 26, "result": {} }
```
To enable or disable a skill by absolute path:
```json
{
"method": "skills/config/write",
"id": 26,
"id": 27,
"params": {
"path": "/Users/alice/.codex/skills/skill-creator/SKILL.md",
"name": null,
@@ -1561,7 +1574,7 @@ To enable or disable a skill by name:
```json
{
"method": "skills/config/write",
"id": 27,
"id": 28,
"params": {
"path": null,
"name": "github:yeet",
@@ -2095,6 +2095,7 @@ mod tests {
use codex_protocol::items::build_hook_prompt_message;
use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions;
use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
@@ -2110,7 +2111,6 @@ mod tests {
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
@@ -2187,7 +2187,7 @@ mod tests {
agent_path: None,
git_info: None,
approval_mode: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
token_usage: None,
first_user_message: Some("before rollback".to_string()),
history: Some(StoredThreadHistory {
+4 -44
View File
@@ -8,66 +8,24 @@ use codex_app_server_protocol::FsWatchParams;
use codex_app_server_protocol::FsWatchResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ServerNotification;
use codex_file_watcher::DebouncedWatchReceiver;
use codex_file_watcher::FileWatcher;
use codex_file_watcher::FileWatcherEvent;
use codex_file_watcher::FileWatcherSubscriber;
use codex_file_watcher::Receiver;
use codex_file_watcher::WatchPath;
use codex_file_watcher::WatchRegistration;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::hash_map::Entry;
use std::hash::Hash;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex as AsyncMutex;
#[cfg(test)]
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::time::Instant;
use tracing::warn;
const FS_CHANGED_NOTIFICATION_DEBOUNCE: Duration = Duration::from_millis(200);
struct DebouncedReceiver {
rx: Receiver,
interval: Duration,
changed_paths: HashSet<PathBuf>,
next_allowance: Option<Instant>,
}
impl DebouncedReceiver {
fn new(rx: Receiver, interval: Duration) -> Self {
Self {
rx,
interval,
changed_paths: HashSet::new(),
next_allowance: None,
}
}
async fn recv(&mut self) -> Option<FileWatcherEvent> {
while self.changed_paths.is_empty() {
self.changed_paths.extend(self.rx.recv().await?.paths);
}
let next_allowance = *self
.next_allowance
.get_or_insert_with(|| Instant::now() + self.interval);
loop {
tokio::select! {
event = self.rx.recv() => self.changed_paths.extend(event?.paths),
_ = tokio::time::sleep_until(next_allowance) => break,
}
}
Some(FileWatcherEvent {
paths: self.changed_paths.drain().collect(),
})
}
}
#[derive(Clone)]
pub(crate) struct FsWatchManager {
outgoing: Arc<OutgoingMessageSender>,
@@ -151,7 +109,7 @@ impl FsWatchManager {
let task_watch_id = watch_id.clone();
tokio::spawn(async move {
let mut rx = DebouncedReceiver::new(rx, FS_CHANGED_NOTIFICATION_DEBOUNCE);
let mut rx = DebouncedWatchReceiver::new(rx, FS_CHANGED_NOTIFICATION_DEBOUNCE);
tokio::pin!(terminate_rx);
loop {
let event = tokio::select! {
@@ -219,6 +177,8 @@ mod tests {
use super::*;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
use std::path::PathBuf;
use tempfile::TempDir;
fn absolute_path(path: PathBuf) -> AbsolutePathBuf {
@@ -354,6 +354,8 @@ impl MessageProcessor {
app_list_shutdown_token,
);
let catalog_processor = CatalogRequestProcessor::new(
outgoing.clone(),
Arc::clone(&skills_watcher),
auth_manager.clone(),
Arc::clone(&thread_manager),
Arc::clone(&config),
@@ -1108,6 +1110,9 @@ impl MessageProcessor {
ClientRequest::SkillsList { params, .. } => {
self.catalog_processor.skills_list(params).await
}
ClientRequest::SkillsExtraRootsSet { params, .. } => {
self.catalog_processor.skills_extra_roots_set(params).await
}
ClientRequest::HooksList { params, .. } => {
self.catalog_processor.hooks_list(params).await
}
@@ -156,6 +156,8 @@ use codex_app_server_protocol::ServerRequestResolvedNotification;
use codex_app_server_protocol::SkillSummary;
use codex_app_server_protocol::SkillsConfigWriteParams;
use codex_app_server_protocol::SkillsConfigWriteResponse;
use codex_app_server_protocol::SkillsExtraRootsSetParams;
use codex_app_server_protocol::SkillsExtraRootsSetResponse;
use codex_app_server_protocol::SkillsListParams;
use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::SortDirection;
@@ -4,6 +4,8 @@ use futures::StreamExt;
#[derive(Clone)]
pub(crate) struct CatalogRequestProcessor {
pub(super) outgoing: Arc<OutgoingMessageSender>,
pub(super) skills_watcher: Arc<SkillsWatcher>,
pub(super) auth_manager: Arc<AuthManager>,
pub(super) thread_manager: Arc<ThreadManager>,
pub(super) config: Arc<Config>,
@@ -96,6 +98,8 @@ fn errors_to_info(
impl CatalogRequestProcessor {
pub(crate) fn new(
outgoing: Arc<OutgoingMessageSender>,
skills_watcher: Arc<SkillsWatcher>,
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
config: Arc<Config>,
@@ -103,6 +107,8 @@ impl CatalogRequestProcessor {
workspace_settings_cache: Arc<workspace_settings::WorkspaceSettingsCache>,
) -> Self {
Self {
outgoing,
skills_watcher,
auth_manager,
thread_manager,
config,
@@ -138,6 +144,15 @@ impl CatalogRequestProcessor {
.map(|response| Some(response.into()))
}
pub(crate) async fn skills_extra_roots_set(
&self,
params: SkillsExtraRootsSetParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.skills_extra_roots_set_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn model_list(
&self,
params: ModelListParams,
@@ -567,6 +582,24 @@ impl CatalogRequestProcessor {
Ok(SkillsListResponse { data })
}
async fn skills_extra_roots_set_response(
&self,
params: SkillsExtraRootsSetParams,
) -> Result<SkillsExtraRootsSetResponse, JSONRPCErrorError> {
let SkillsExtraRootsSetParams { extra_roots } = params;
self.skills_watcher
.register_runtime_extra_roots(&extra_roots);
self.thread_manager
.skills_manager()
.set_extra_roots(extra_roots);
self.outgoing
.send_server_notification(ServerNotification::SkillsChanged(
codex_app_server_protocol::SkillsChangedNotification {},
))
.await;
Ok(SkillsExtraRootsSetResponse {})
}
/// Handle `hooks/list` by resolving hooks for each requested cwd.
async fn hooks_list_response(
&self,
@@ -68,13 +68,13 @@ mod thread_processor_behavior_tests {
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_state::ThreadMetadataBuilder;
@@ -412,7 +412,7 @@ mod thread_processor_behavior_tests {
agent_path: None,
git_info: None,
approval_mode: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
token_usage: None,
first_user_message: Some("first user message".to_string()),
history: None,
+20
View File
@@ -1,4 +1,5 @@
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use crate::outgoing_message::OutgoingMessageSender;
@@ -15,6 +16,7 @@ use codex_file_watcher::ThrottledWatchReceiver;
use codex_file_watcher::WatchPath;
use codex_file_watcher::WatchRegistration;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_utils_absolute_path::AbsolutePathBuf;
use tokio_util::sync::CancellationToken;
use tokio_util::sync::DropGuard;
use tracing::warn;
@@ -26,6 +28,7 @@ const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_millis(50);
pub(crate) struct SkillsWatcher {
subscriber: FileWatcherSubscriber,
runtime_extra_roots_registration: Mutex<WatchRegistration>,
shutdown_token: CancellationToken,
_shutdown_drop_guard: DropGuard,
}
@@ -48,6 +51,7 @@ impl SkillsWatcher {
Self::spawn_event_loop(rx, skills_manager, outgoing, shutdown_token.child_token());
Arc::new(Self {
subscriber,
runtime_extra_roots_registration: Mutex::new(WatchRegistration::default()),
shutdown_token,
_shutdown_drop_guard: shutdown_drop_guard,
})
@@ -57,6 +61,22 @@ impl SkillsWatcher {
self.shutdown_token.cancel();
}
pub(crate) fn register_runtime_extra_roots(&self, extra_roots: &[AbsolutePathBuf]) {
let roots = extra_roots
.iter()
.map(|root| WatchPath {
path: root.clone().into_path_buf(),
recursive: true,
})
.collect();
let registration = self.subscriber.register_paths(roots);
let mut guard = self
.runtime_extra_roots_registration
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*guard = registration;
}
pub(crate) async fn register_thread_config(
&self,
config: &Config,
@@ -71,6 +71,7 @@ use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewStartParams;
use codex_app_server_protocol::SendAddCreditsNudgeEmailParams;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::SkillsExtraRootsSetParams;
use codex_app_server_protocol::SkillsListParams;
use codex_app_server_protocol::ThreadArchiveParams;
use codex_app_server_protocol::ThreadCompactStartParams;
@@ -675,6 +676,15 @@ impl McpProcess {
self.send_request("skills/list", params).await
}
/// Send a `skills/extraRoots/set` JSON-RPC request.
pub async fn send_skills_extra_roots_set_request(
&mut self,
params: SkillsExtraRootsSetParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("skills/extraRoots/set", params).await
}
/// Send a `hooks/list` JSON-RPC request.
pub async fn send_hooks_list_request(
&mut self,
@@ -52,6 +52,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
input_modalities: default_input_modalities(),
used_fallback_model_metadata: false,
supports_search_tool: false,
tool_mode: None,
}
}
@@ -1,6 +1,7 @@
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::create_fake_rollout;
use app_test_support::create_fake_rollout_with_source;
use app_test_support::to_response;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
@@ -10,6 +11,8 @@ use codex_app_server_protocol::ReviewStartResponse;
use codex_app_server_protocol::ReviewTarget;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadSource;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
@@ -18,6 +21,9 @@ use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnSteerParams;
use codex_app_server_protocol::TurnSteerResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_protocol::ThreadId as CoreThreadId;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
use pretty_assertions::assert_eq;
@@ -290,6 +296,104 @@ async fn review_start_sends_fork_lineage_in_turn_metadata_for_thread_fork_v2() -
Ok(())
}
#[tokio::test]
async fn turn_start_sends_subagent_lineage_after_cold_thread_resume_v2() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let response_mock = responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-1"),
]),
)
.await;
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
&server.uri(),
/*supports_websockets*/ false,
)?;
let parent_thread_id = CoreThreadId::new();
let parent_thread_id_str = parent_thread_id.to_string();
let subagent_thread_id = create_fake_rollout_with_source(
codex_home.path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Saved subagent message",
Some("mock_provider"),
/*git_info*/ None,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
}),
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let resume_req = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: subagent_thread_id.clone(),
..Default::default()
})
.await?;
let resume_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(resume_req)),
)
.await??;
let ThreadResumeResponse { thread, .. } = to_response::<ThreadResumeResponse>(resume_resp)?;
assert_eq!(thread.id, subagent_thread_id);
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "Continue".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
)
.await??;
let TurnStartResponse { turn } = to_response::<TurnStartResponse>(turn_resp)?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let request = response_mock.single_request();
let metadata = request
.header("x-codex-turn-metadata")
.as_deref()
.map(parse_json_header)
.unwrap_or_else(|| panic!("missing x-codex-turn-metadata header"));
assert_eq!(
metadata["parent_thread_id"].as_str(),
Some(parent_thread_id_str.as_str())
);
assert_eq!(metadata["subagent_kind"].as_str(), Some("thread_spawn"));
assert_eq!(metadata["thread_id"].as_str(), Some(thread.id.as_str()));
assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str()));
assert!(metadata.get("forked_from_thread_id").is_none());
Ok(())
}
#[tokio::test]
async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -13,11 +13,14 @@ use codex_app_server_protocol::PluginListParams;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SkillsChangedNotification;
use codex_app_server_protocol::SkillsExtraRootsSetParams;
use codex_app_server_protocol::SkillsExtraRootsSetResponse;
use codex_app_server_protocol::SkillsListParams;
use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_config::types::AuthCredentialsStoreMode;
use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -40,6 +43,23 @@ fn write_skill(root: &TempDir, name: &str) -> Result<()> {
Ok(())
}
async fn expect_skills_changed_notification(
mcp: &mut McpProcess,
timeout_duration: Duration,
) -> Result<()> {
let notification = timeout(
timeout_duration,
mcp.read_stream_until_notification_message("skills/changed"),
)
.await??;
let params = notification
.params
.context("skills/changed params must be present")?;
let notification: SkillsChangedNotification = serde_json::from_value(params)?;
assert_eq!(notification, SkillsChangedNotification {});
Ok(())
}
fn write_plugins_enabled_config_with_base_url(
codex_home: &std::path::Path,
base_url: &str,
@@ -573,6 +593,150 @@ async fn skills_list_uses_cached_result_until_force_reload() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn skills_extra_roots_set_updates_process_runtime_roots() -> Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let extra_root = TempDir::new()?;
let extra_skills_root = extra_root.path().join("skills");
let skill_dir = extra_skills_root.join("runtime-skill");
std::fs::create_dir_all(&skill_dir)?;
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: runtime-skill\ndescription: runtime skill\n---\n\n# Body\n",
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let set_request_id = mcp
.send_skills_extra_roots_set_request(SkillsExtraRootsSetParams {
extra_roots: vec![AbsolutePathBuf::from_absolute_path(&extra_skills_root)?],
})
.await?;
let set_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(set_request_id)),
)
.await??;
let _: SkillsExtraRootsSetResponse = to_response(set_response)?;
expect_skills_changed_notification(&mut mcp, DEFAULT_TIMEOUT).await?;
let skills_request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![cwd.path().to_path_buf()],
force_reload: false,
})
.await?;
let skills_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(skills_request_id)),
)
.await??;
let SkillsListResponse { data } = to_response(skills_response)?;
assert_eq!(data.len(), 1);
assert_eq!(data[0].errors, Vec::new());
assert!(
data[0]
.skills
.iter()
.any(|skill| skill.name == "runtime-skill")
);
let missing_root = extra_root.path().join("missing-skills");
let reset_request_id = mcp
.send_skills_extra_roots_set_request(SkillsExtraRootsSetParams {
extra_roots: vec![AbsolutePathBuf::from_absolute_path(&missing_root)?],
})
.await?;
let reset_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(reset_request_id)),
)
.await??;
let _: SkillsExtraRootsSetResponse = to_response(reset_response)?;
expect_skills_changed_notification(&mut mcp, DEFAULT_TIMEOUT).await?;
let skills_request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![cwd.path().to_path_buf()],
force_reload: false,
})
.await?;
let skills_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(skills_request_id)),
)
.await??;
let SkillsListResponse { data } = to_response(skills_response)?;
assert_eq!(data.len(), 1);
assert_eq!(data[0].errors, Vec::new());
assert!(
data[0]
.skills
.iter()
.all(|skill| skill.name != "runtime-skill")
);
let clear_request_id = mcp
.send_skills_extra_roots_set_request(SkillsExtraRootsSetParams {
extra_roots: Vec::new(),
})
.await?;
let clear_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(clear_request_id)),
)
.await??;
let _: SkillsExtraRootsSetResponse = to_response(clear_response)?;
expect_skills_changed_notification(&mut mcp, DEFAULT_TIMEOUT).await?;
let skills_request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![cwd.path().to_path_buf()],
force_reload: false,
})
.await?;
let skills_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(skills_request_id)),
)
.await??;
let SkillsListResponse { data } = to_response(skills_response)?;
assert_eq!(data.len(), 1);
assert_eq!(data[0].errors, Vec::new());
assert!(
data[0]
.skills
.iter()
.all(|skill| skill.name != "runtime-skill")
);
drop(mcp);
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let skills_request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![cwd.path().to_path_buf()],
force_reload: false,
})
.await?;
let skills_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(skills_request_id)),
)
.await??;
let SkillsListResponse { data } = to_response(skills_response)?;
assert_eq!(data.len(), 1);
assert_eq!(data[0].errors, Vec::new());
assert!(
data[0]
.skills
.iter()
.all(|skill| skill.name != "runtime-skill")
);
Ok(())
}
#[tokio::test]
async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
@@ -7,13 +7,19 @@ use app_test_support::ChatGptAuthFixture;
use app_test_support::McpProcess;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_app_server_protocol::WebSearchAction;
use codex_config::types::AuthCredentialsStoreMode;
use core_test_support::responses;
use pretty_assertions::assert_eq;
@@ -84,10 +90,11 @@ async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> {
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let thread_id = thread.id.clone();
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
thread_id: thread_id.clone(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "Search the web".to_string(),
@@ -103,6 +110,13 @@ async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> {
.await??;
let _turn: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp)?;
let started = timeout(DEFAULT_READ_TIMEOUT, wait_for_web_search_started(&mut mcp)).await??;
let completed = timeout(
DEFAULT_READ_TIMEOUT,
wait_for_web_search_completed(&mut mcp),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
@@ -126,6 +140,7 @@ async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> {
);
let search_body = search_request_body(&server).await?;
assert_eq!(search_body["model"], json!("mock-model"));
assert_eq!(
search_body["commands"],
json!({
@@ -159,10 +174,83 @@ async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> {
}],
})
);
assert_eq!(
started.item,
ThreadItem::WebSearch {
id: call_id.to_string(),
query: String::new(),
action: Some(WebSearchAction::Other),
}
);
let expected_completed_item = ThreadItem::WebSearch {
id: call_id.to_string(),
query: "standalone web search".to_string(),
action: Some(WebSearchAction::Search {
query: Some("standalone web search".to_string()),
queries: None,
}),
};
assert_eq!(completed.item, expected_completed_item);
drop(mcp);
let mut reloaded_mcp =
McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
timeout(DEFAULT_READ_TIMEOUT, reloaded_mcp.initialize()).await??;
let read_req = reloaded_mcp
.send_thread_read_request(ThreadReadParams {
thread_id,
include_turns: true,
})
.await?;
let read_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
reloaded_mcp.read_stream_until_response_message(RequestId::Integer(read_req)),
)
.await??;
let ThreadReadResponse { thread, .. } = to_response::<ThreadReadResponse>(read_resp)?;
let persisted_web_searches: Vec<&ThreadItem> = thread
.turns
.iter()
.flat_map(|turn| &turn.items)
.filter(|item| matches!(item, ThreadItem::WebSearch { .. }))
.collect();
assert_eq!(persisted_web_searches, vec![&expected_completed_item]);
Ok(())
}
async fn wait_for_web_search_started(mcp: &mut McpProcess) -> Result<ItemStartedNotification> {
loop {
let notification = mcp
.read_stream_until_notification_message("item/started")
.await?;
let started: ItemStartedNotification = serde_json::from_value(
notification
.params
.context("item/started notification should include params")?,
)?;
if matches!(&started.item, ThreadItem::WebSearch { .. }) {
return Ok(started);
}
}
}
async fn wait_for_web_search_completed(mcp: &mut McpProcess) -> Result<ItemCompletedNotification> {
loop {
let notification = mcp
.read_stream_until_notification_message("item/completed")
.await?;
let completed: ItemCompletedNotification = serde_json::from_value(
notification
.params
.context("item/completed notification should include params")?,
)?;
if matches!(&completed.item, ThreadItem::WebSearch { .. }) {
return Ok(completed);
}
}
}
async fn mount_search_response(server: &MockServer) {
Mock::given(method("POST"))
.and(path("/api/codex/alpha/search"))
+12
View File
@@ -50,6 +50,8 @@ mod marketplace_cmd;
mod mcp_cmd;
mod plugin_cmd;
mod remote_control_cmd;
#[cfg(target_os = "windows")]
mod sandbox_setup;
mod state_db_recovery;
#[cfg(not(windows))]
mod wsl_paths;
@@ -1250,6 +1252,16 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
.await?;
}
Some(Subcommand::Sandbox(mut sandbox_cli)) => {
#[cfg(target_os = "windows")]
if let Some(setup_cli) = sandbox_setup::parse_setup_command(&sandbox_cli.command)? {
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"sandbox setup",
)?;
sandbox_setup::run(setup_cli).await?;
return Ok(());
}
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
+206
View File
@@ -0,0 +1,206 @@
use std::path::PathBuf;
use clap::ArgAction;
use clap::ArgGroup;
use clap::Parser;
use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::config::find_codex_home;
#[derive(Debug, Parser)]
#[command(group(
ArgGroup::new("sandbox_user")
.required(true)
.args(["user", "current_user"])
))]
pub(crate) struct SandboxSetupCommand {
/// Set up the elevated Windows sandbox.
#[arg(long = "elevated", action = ArgAction::SetTrue)]
elevated_sandbox_level: bool,
/// Windows user that will run Codex after managed deployment.
#[arg(
long = "user",
value_name = "USER",
conflicts_with = "current_user",
requires = "codex_home"
)]
user: Option<String>,
/// Use the current Windows user as the Codex user.
#[arg(
long = "current-user",
default_value_t = false,
conflicts_with = "user"
)]
current_user: bool,
/// CODEX_HOME for the Codex user. Required with --user.
#[arg(long = "codex-home", value_name = "DIR")]
codex_home: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SandboxSetupLevel {
Elevated,
}
impl SandboxSetupCommand {
fn setup_level(&self) -> anyhow::Result<SandboxSetupLevel> {
if self.elevated_sandbox_level {
Ok(SandboxSetupLevel::Elevated)
} else {
anyhow::bail!("`codex sandbox setup` currently requires --elevated");
}
}
}
pub(crate) async fn run(cmd: SandboxSetupCommand) -> anyhow::Result<()> {
match cmd.setup_level()? {
SandboxSetupLevel::Elevated => run_elevated(cmd).await,
}
}
pub(crate) fn parse_setup_command(
sandbox_command: &[String],
) -> anyhow::Result<Option<SandboxSetupCommand>> {
if sandbox_command
.first()
.is_none_or(|command| command != "setup")
{
return Ok(None);
}
SandboxSetupCommand::try_parse_from(sandbox_command.iter().map(String::as_str))
.map(Some)
.map_err(anyhow::Error::from)
}
async fn run_elevated(cmd: SandboxSetupCommand) -> anyhow::Result<()> {
let identity = resolve_sandbox_setup_identity(&cmd)?;
codex_core::windows_sandbox::run_elevated_provisioning_setup(
identity.codex_home.as_path(),
identity.real_user.as_str(),
)?;
ConfigEditsBuilder::new(identity.codex_home.as_path())
.set_windows_sandbox_mode("elevated")
.apply()
.await
.map_err(|err| {
anyhow::anyhow!(
"sandbox provisioning succeeded, but failed to persist elevated sandbox config: {err}"
)
})?;
println!(
"Windows elevated sandbox setup completed for {} at {}.",
identity.real_user,
identity.codex_home.display()
);
Ok(())
}
struct SandboxSetupIdentity {
real_user: String,
codex_home: PathBuf,
}
fn resolve_sandbox_setup_identity(
cmd: &SandboxSetupCommand,
) -> anyhow::Result<SandboxSetupIdentity> {
if cmd.current_user {
let real_user = std::env::var("USERNAME")
.or_else(|_| std::env::var("USER"))
.map_err(|err| {
anyhow::anyhow!("failed to determine current user from environment: {err}")
})?;
let codex_home = match cmd.codex_home.clone() {
Some(codex_home) => codex_home,
None => find_codex_home()?.to_path_buf(),
};
return Ok(SandboxSetupIdentity {
real_user,
codex_home,
});
}
let real_user = cmd
.user
.clone()
.ok_or_else(|| anyhow::anyhow!("--user or --current-user is required"))?;
let codex_home = cmd
.codex_home
.clone()
.ok_or_else(|| anyhow::anyhow!("--codex-home is required with --user"))?;
Ok(SandboxSetupIdentity {
real_user,
codex_home,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_managed_user_identity() {
let command = SandboxSetupCommand::try_parse_from([
"setup",
"--elevated",
"--user",
"DOMAIN\\alice",
"--codex-home",
r"C:\Users\alice\.codex",
])
.expect("parse");
assert!(command.elevated_sandbox_level);
assert_eq!(command.user.as_deref(), Some(r"DOMAIN\alice"));
assert!(!command.current_user);
assert_eq!(
command.codex_home.as_deref(),
Some(std::path::Path::new(r"C:\Users\alice\.codex"))
);
}
#[test]
fn requires_explicit_user_identity() {
let err = SandboxSetupCommand::try_parse_from(["setup", "--elevated"])
.expect_err("parse should fail");
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}
#[test]
fn requires_codex_home_for_managed_user() {
let err =
SandboxSetupCommand::try_parse_from(["setup", "--elevated", "--user", "DOMAIN\\alice"])
.expect_err("parse should fail");
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}
#[test]
fn parses_setup_from_sandbox_command_args() {
let command = parse_setup_command(&[
"setup".to_string(),
"--elevated".to_string(),
"--user".to_string(),
r"DOMAIN\alice".to_string(),
"--codex-home".to_string(),
r"C:\Users\alice\.codex".to_string(),
])
.expect("parse")
.expect("setup command");
assert_eq!(command.user.as_deref(), Some(r"DOMAIN\alice"));
}
#[test]
fn ignores_non_setup_sandbox_command_args() {
let command =
parse_setup_command(&["echo".to_string(), "hello".to_string()]).expect("parse");
assert!(command.is_none());
}
}
-2
View File
@@ -16,8 +16,6 @@ sandbox = ["v8/v8_enable_sandbox"]
workspace = true
[dependencies]
async-channel = { workspace = true }
async-trait = { workspace = true }
codex-protocol = { workspace = true }
deno_core_icudata = { workspace = true }
serde = { workspace = true, features = ["derive"] }
+5 -5
View File
@@ -17,8 +17,8 @@ const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/co
- Runs raw JavaScript -- no Node, no file system, no network access, no console.
- Accepts raw JavaScript source text, not JSON, quoted strings, or markdown code fences.
- You may optionally start the tool input with a first-line pragma like `// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}`.
- `yield_time_ms` asks `exec` to yield early after that many milliseconds if the script is still running.
- `max_output_tokens` sets the token budget for direct `exec` results. By default the result is truncated to 10000 tokens.
- `yield_time_ms` asks `exec` to yield early if the script is still running. Defaults to 10000 ms.
- `max_output_tokens` sets the token budget for direct `exec` results. Defaults to 10000 tokens.
- When the JS code is fully evaluated, the isolate's lifetime ends and unawaited promises are silently discarded.
- Global helpers:
@@ -34,9 +34,9 @@ const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/co
- `yield_control()`: yields the accumulated output to the model immediately while the script keeps running."#;
const WAIT_DESCRIPTION_TEMPLATE: &str = r#"- Use `wait` only after `exec` returns `Script running with cell ID ...`.
- `cell_id` identifies the running `exec` cell to resume.
- `yield_time_ms` controls how long to wait for more output before yielding again. If omitted, `wait` uses its default wait timeout.
- `max_tokens` limits how much new output this wait call returns.
- `terminate: true` stops the running cell instead of waiting for more output.
- `yield_time_ms` controls how long to wait for more output before yielding again. Defaults to 10000 ms.
- `max_tokens` limits how much new output this wait call returns. Defaults to 10000 tokens.
- `terminate: true` stops the running cell; false or omitted waits for output.
- `wait` returns only the new output since the last yield, or the final completion or termination result for that cell.
- If the cell is still running, `wait` may yield again with the same `cell_id`.
- If the cell has already finished, `wait` returns the completed result and closes the cell."#;
+11 -2
View File
@@ -29,9 +29,18 @@ pub use runtime::WaitOutcome;
pub use runtime::WaitRequest;
pub use runtime::WaitToPendingOutcome;
pub use runtime::WaitToPendingRequest;
pub use service::CellId;
pub use service::CodeModeService;
pub use service::CodeModeTurnHost;
pub use service::CodeModeTurnWorker;
pub use service::CodeModeSession;
pub use service::CodeModeSessionDelegate;
pub use service::CodeModeSessionProvider;
pub use service::CodeModeSessionProviderFuture;
pub use service::CodeModeSessionResultFuture;
pub use service::InProcessCodeModeSessionProvider;
pub use service::NoopCodeModeSessionDelegate;
pub use service::NotificationFuture;
pub use service::StartedCell;
pub use service::ToolInvocationFuture;
pub const PUBLIC_TOOL_NAME: &str = "exec";
pub const WAIT_TOOL_NAME: &str = "wait";
+8 -24
View File
@@ -19,6 +19,7 @@ use crate::description::EnabledToolMetadata;
use crate::description::ToolDefinition;
use crate::description::enabled_tool_metadata;
use crate::response::FunctionCallOutputContentItem;
use crate::service::CellId;
pub const DEFAULT_EXEC_YIELD_TIME_MS: u64 = 10_000;
pub const DEFAULT_WAIT_YIELD_TIME_MS: u64 = 10_000;
@@ -27,11 +28,6 @@ const EXIT_SENTINEL: &str = "__codex_code_mode_exit__";
#[derive(Clone, Debug)]
pub struct ExecuteRequest {
/// Runtime cell id for this execution.
///
/// Callers allocate this before execution so tracing, waits, and nested tool
/// calls can refer to the cell as soon as JavaScript starts.
pub cell_id: String,
pub tool_call_id: String,
pub enabled_tools: Vec<ToolDefinition>,
pub source: String,
@@ -41,14 +37,13 @@ pub struct ExecuteRequest {
#[derive(Clone, Debug)]
pub struct WaitRequest {
pub cell_id: String,
pub cell_id: CellId,
pub yield_time_ms: u64,
pub terminate: bool,
}
#[derive(Clone, Debug)]
pub struct WaitToPendingRequest {
pub cell_id: String,
pub cell_id: CellId,
}
/// Result of waiting on a code-mode cell.
@@ -73,7 +68,7 @@ pub enum ExecuteToPendingOutcome {
/// The cell is waiting for more runtime input after draining the runtime
/// input queue that was ready at the pending boundary.
Pending {
cell_id: String,
cell_id: CellId,
content_items: Vec<FunctionCallOutputContentItem>,
/// Runtime tool-call ids emitted before this paused execution frontier
/// sealed. Hosts can use these ids to drain their tool-call transport
@@ -105,15 +100,15 @@ impl From<WaitOutcome> for RuntimeResponse {
#[derive(Debug, PartialEq, Serialize)]
pub enum RuntimeResponse {
Yielded {
cell_id: String,
cell_id: CellId,
content_items: Vec<FunctionCallOutputContentItem>,
},
Terminated {
cell_id: String,
cell_id: CellId,
content_items: Vec<FunctionCallOutputContentItem>,
},
Result {
cell_id: String,
cell_id: CellId,
content_items: Vec<FunctionCallOutputContentItem>,
error_text: Option<String>,
},
@@ -126,23 +121,13 @@ pub enum RuntimeResponse {
/// if their tool-call graph requires globally unique ids.
#[derive(Debug)]
pub struct CodeModeNestedToolCall {
pub cell_id: String,
pub cell_id: CellId,
pub runtime_tool_call_id: String,
pub tool_name: ToolName,
pub tool_kind: CodeModeToolKind,
pub input: Option<JsonValue>,
}
#[derive(Debug)]
pub(crate) enum TurnMessage {
ToolCall(CodeModeNestedToolCall),
Notify {
cell_id: String,
call_id: String,
text: String,
},
}
#[derive(Debug)]
pub(crate) enum RuntimeCommand {
ToolResponse { id: String, result: JsonValue },
@@ -460,7 +445,6 @@ mod tests {
fn execute_request(source: &str) -> ExecuteRequest {
ExecuteRequest {
cell_id: "1".to_string(),
tool_call_id: "call_1".to_string(),
enabled_tools: Vec::new(),
source: source.to_string(),
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -145,7 +145,7 @@ mod tests {
.search(
&SearchRequest {
id: "search-session".to_string(),
model: Some("gpt-test".to_string()),
model: "gpt-test".to_string(),
reasoning: None,
input: Some(SearchInput::Items(vec![ResponseItem::Message {
id: None,
+1 -2
View File
@@ -7,8 +7,7 @@ use serde::Serialize;
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct SearchRequest {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<Reasoning>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -98,6 +98,7 @@ async fn models_client_hits_models_endpoint() {
input_modalities: default_input_modalities(),
used_fallback_model_metadata: false,
supports_search_tool: false,
tool_mode: None,
}],
};
+2
View File
@@ -223,6 +223,8 @@ pub struct TuiVimNormalKeymap {
pub move_line_end: Option<KeybindingsSpec>,
/// Delete character under cursor (`x`).
pub delete_char: Option<KeybindingsSpec>,
/// Delete character under cursor and enter insert mode (`s`).
pub substitute_char: Option<KeybindingsSpec>,
/// Delete from cursor to end of line (`D`).
pub delete_to_line_end: Option<KeybindingsSpec>,
/// Change from cursor to end of line and enter insert mode (`C`).
+5 -1
View File
@@ -5,7 +5,11 @@ pub fn connector_display_label(connector: &AppInfo) -> String {
}
pub fn connector_mention_slug(connector: &AppInfo) -> String {
crate::connector_name_slug(&connector_display_label(connector))
connector_mention_slug_from_name(&connector_display_label(connector))
}
pub fn connector_mention_slug_from_name(name: &str) -> String {
crate::connector_name_slug(name)
}
pub fn connector_install_url(name: &str, connector_id: &str) -> String {
+19 -1
View File
@@ -235,6 +235,7 @@ pub(crate) async fn skill_roots(
config_layer_stack: &ConfigLayerStack,
cwd: &AbsolutePathBuf,
plugin_skill_roots: Vec<PluginSkillRoot>,
extra_skill_roots: Vec<AbsolutePathBuf>,
) -> Vec<SkillRoot> {
let home_dir =
home_dir().and_then(|path| AbsolutePathBuf::from_absolute_path_checked(path).ok());
@@ -244,6 +245,7 @@ pub(crate) async fn skill_roots(
cwd,
home_dir.as_ref(),
plugin_skill_roots,
extra_skill_roots,
)
.await
}
@@ -254,6 +256,7 @@ async fn skill_roots_with_home_dir(
cwd: &AbsolutePathBuf,
home_dir: Option<&AbsolutePathBuf>,
plugin_skill_roots: Vec<PluginSkillRoot>,
extra_skill_roots: Vec<AbsolutePathBuf>,
) -> Vec<SkillRoot> {
let mut roots = skill_roots_from_layer_stack_inner(config_layer_stack, home_dir, fs.clone());
roots.extend(plugin_skill_roots.into_iter().map(|root| SkillRoot {
@@ -263,6 +266,13 @@ async fn skill_roots_with_home_dir(
plugin_id: Some(root.plugin_id),
plugin_root: Some(root.plugin_root),
}));
roots.extend(extra_skill_roots.into_iter().map(|path| SkillRoot {
path,
scope: SkillScope::User,
file_system: Arc::clone(&LOCAL_FS),
plugin_id: None,
plugin_root: None,
}));
roots.extend(repo_agents_skill_roots(fs, config_layer_stack, cwd).await);
dedupe_skill_roots_by_path(&mut roots);
roots
@@ -1051,7 +1061,15 @@ pub(crate) async fn skill_roots_from_layer_stack(
cwd: &AbsolutePathBuf,
home_dir: Option<&AbsolutePathBuf>,
) -> Vec<SkillRoot> {
skill_roots_with_home_dir(Some(fs), config_layer_stack, cwd, home_dir, Vec::new()).await
skill_roots_with_home_dir(
Some(fs),
config_layer_stack,
cwd,
home_dir,
Vec::new(),
Vec::new(),
)
.await
}
#[cfg(test)]
+1
View File
@@ -1900,6 +1900,7 @@ async fn skill_roots_include_admin_with_lowest_priority() {
&cfg.config_layer_stack,
&cfg.cwd,
Vec::new(),
Vec::new(),
)
.await
.into_iter()
+22
View File
@@ -51,6 +51,7 @@ impl SkillsLoadInput {
pub struct SkillsManager {
codex_home: AbsolutePathBuf,
restriction_product: Option<Product>,
extra_roots: RwLock<Vec<AbsolutePathBuf>>,
cache_by_cwd: RwLock<HashMap<AbsolutePathBuf, SkillLoadOutcome>>,
cache_by_config: RwLock<HashMap<ConfigSkillsCacheKey, SkillLoadOutcome>>,
}
@@ -68,6 +69,7 @@ impl SkillsManager {
let manager = Self {
codex_home,
restriction_product,
extra_roots: RwLock::new(Vec::new()),
cache_by_cwd: RwLock::new(HashMap::new()),
cache_by_config: RwLock::new(HashMap::new()),
};
@@ -81,6 +83,17 @@ impl SkillsManager {
manager
}
pub fn set_extra_roots(&self, extra_roots: Vec<AbsolutePathBuf>) {
{
let mut roots = self
.extra_roots
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*roots = extra_roots;
}
self.clear_cache();
}
/// Load skills for an already-constructed [`Config`], avoiding any additional config-layer
/// loading.
///
@@ -118,6 +131,7 @@ impl SkillsManager {
&input.config_layer_stack,
&input.cwd,
input.effective_skill_roots.clone(),
self.extra_roots(),
)
.await;
if !input.bundled_skills_enabled {
@@ -145,6 +159,7 @@ impl SkillsManager {
&input.config_layer_stack,
&input.cwd,
input.effective_skill_roots.clone(),
self.extra_roots(),
)
.await;
if !bundled_skills_enabled_from_stack(&input.config_layer_stack) {
@@ -214,6 +229,13 @@ impl SkillsManager {
Err(err) => err.into_inner().get(cache_key).cloned(),
}
}
fn extra_roots(&self) -> Vec<AbsolutePathBuf> {
match self.extra_roots.read() {
Ok(roots) => roots.clone(),
Err(err) => err.into_inner().clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+122
View File
@@ -221,6 +221,128 @@ async fn skills_for_config_reuses_cache_for_same_effective_config() {
assert_eq!(outcome2.skills, outcome1.skills);
}
#[tokio::test]
async fn set_extra_roots_replaces_runtime_roots_and_clears_cache() {
let codex_home = tempfile::tempdir().expect("tempdir");
let cwd = tempfile::tempdir().expect("tempdir");
let extra_root = tempfile::tempdir().expect("tempdir");
let config_layer_stack = config_stack(&codex_home, "");
let skills_manager = SkillsManager::new(
codex_home.path().abs(),
/*bundled_skills_enabled*/ true,
);
let skills_input = SkillsLoadInput::new(
cwd.path().abs(),
Vec::new(),
config_layer_stack.clone(),
bundled_skills_enabled_from_stack(&config_layer_stack),
);
let empty_outcome = skills_manager
.skills_for_cwd(
&skills_input,
/*force_reload*/ false,
Some(Arc::clone(&LOCAL_FS)),
)
.await;
assert!(
empty_outcome
.skills
.iter()
.all(|skill| skill.name != "runtime-skill")
);
let extra_skills_root = extra_root.path().join("skills");
let skill_dir = extra_skills_root.join("runtime-skill");
fs::create_dir_all(&skill_dir).expect("create skill dir");
fs::write(
skill_dir.join("SKILL.md"),
"---\nname: runtime-skill\ndescription: runtime skill\n---\n\n# Body\n",
)
.expect("write skill");
skills_manager.set_extra_roots(vec![extra_skills_root.abs()]);
let runtime_outcome = skills_manager
.skills_for_cwd(
&skills_input,
/*force_reload*/ false,
Some(Arc::clone(&LOCAL_FS)),
)
.await;
assert!(
runtime_outcome
.skills
.iter()
.any(|skill| skill.name == "runtime-skill")
);
skills_manager.set_extra_roots(vec![extra_root.path().join("missing-skills").abs()]);
let replaced_outcome = skills_manager
.skills_for_cwd(
&skills_input,
/*force_reload*/ false,
Some(Arc::clone(&LOCAL_FS)),
)
.await;
assert_eq!(replaced_outcome.errors, Vec::new());
assert!(
replaced_outcome
.skills
.iter()
.all(|skill| skill.name != "runtime-skill")
);
}
#[tokio::test]
async fn set_extra_roots_applies_to_config_loads_and_empty_clears() {
let codex_home = tempfile::tempdir().expect("tempdir");
let cwd = tempfile::tempdir().expect("tempdir");
let extra_root = tempfile::tempdir().expect("tempdir");
let config_layer_stack = config_stack(&codex_home, "");
let skills_manager = SkillsManager::new(
codex_home.path().abs(),
/*bundled_skills_enabled*/ true,
);
let empty_outcome =
skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await;
assert!(
empty_outcome
.skills
.iter()
.all(|skill| skill.name != "runtime-skill")
);
let extra_skills_root = extra_root.path().join("skills");
let skill_dir = extra_skills_root.join("runtime-skill");
fs::create_dir_all(&skill_dir).expect("create skill dir");
fs::write(
skill_dir.join("SKILL.md"),
"---\nname: runtime-skill\ndescription: runtime skill\n---\n\n# Body\n",
)
.expect("write skill");
skills_manager.set_extra_roots(vec![extra_skills_root.abs()]);
let runtime_outcome =
skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await;
assert!(
runtime_outcome
.skills
.iter()
.any(|skill| skill.name == "runtime-skill")
);
skills_manager.set_extra_roots(Vec::new());
let cleared_outcome =
skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await;
assert!(
cleared_outcome
.skills
.iter()
.all(|skill| skill.name != "runtime-skill")
);
}
#[tokio::test]
async fn skills_for_config_disables_plugin_skills_by_name() {
let codex_home = tempfile::tempdir().expect("tempdir");
+10
View File
@@ -2858,6 +2858,7 @@
"start_change_operator": null,
"start_delete_operator": null,
"start_yank_operator": null,
"substitute_char": null,
"yank_line": null
},
"vim_operator": {
@@ -3548,6 +3549,7 @@
"start_change_operator": null,
"start_delete_operator": null,
"start_yank_operator": null,
"substitute_char": null,
"yank_line": null
}
},
@@ -3975,6 +3977,14 @@
],
"description": "Begin yank operator; next key selects motion (`y`)."
},
"substitute_char": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Delete character under cursor and enter insert mode (`s`)."
},
"yank_line": {
"allOf": [
{
+1 -6
View File
@@ -1264,12 +1264,7 @@ impl AgentControl {
}
fn thread_spawn_parent_thread_id(session_source: &SessionSource) -> Option<ThreadId> {
match session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id, ..
}) => Some(*parent_thread_id),
_ => None,
}
session_source.parent_thread_id()
}
fn agent_matches_prefix(agent_path: Option<&AgentPath>, prefix: &AgentPath) -> bool {
+3 -13
View File
@@ -1734,19 +1734,9 @@ fn subagent_header_value(session_source: &SessionSource) -> Option<String> {
}
fn parent_thread_id_header_value(session_source: &SessionSource) -> Option<String> {
match session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id, ..
}) => Some(parent_thread_id.to_string()),
SessionSource::Cli
| SessionSource::VSCode
| SessionSource::Exec
| SessionSource::Mcp
| SessionSource::Custom(_)
| SessionSource::Internal(_)
| SessionSource::SubAgent(_)
| SessionSource::Unknown => None,
}
session_source
.parent_thread_id()
.map(|parent_thread_id| parent_thread_id.to_string())
}
const RESPONSE_STREAM_CHANNEL_CAPACITY: usize = 1600;
+8 -13
View File
@@ -18,7 +18,6 @@ use codex_protocol::mcp::CallToolResult;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::ContentItem;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AdditionalContextEntry;
@@ -265,20 +264,16 @@ impl CodexThread {
.await
}
/// Injects hidden model-visible items into the currently active turn.
/// Injects model-visible items into the currently active turn.
///
/// This is the runtime-owned counterpart to user-facing `steer_input`.
/// This is the thread-level bridge to `Session::inject_if_running` for
/// callers that only hold a `CodexThread`.
/// It returns the unchanged items when this thread has no active turn.
pub async fn inject_response_items_into_active_turn(
pub async fn inject_if_running(
&self,
items: Vec<ResponseInputItem>,
) -> Result<(), Vec<ResponseInputItem>> {
let response_items = items.iter().cloned().map(ResponseItem::from).collect();
self.codex
.session
.inject_if_running(response_items)
.await
.map_err(|_| items)
items: Vec<ResponseItem>,
) -> Result<(), Vec<ResponseItem>> {
self.codex.session.inject_if_running(items).await
}
pub async fn set_app_server_client_info(
@@ -396,7 +391,7 @@ impl CodexThread {
.await;
}
/// Append raw Responses API items to the thread's model-visible history.
/// Record raw Responses API items without starting a new turn.
pub async fn inject_response_items(&self, items: Vec<ResponseItem>) -> CodexResult<()> {
if items.is_empty() {
return Err(CodexErr::InvalidRequest(
+2 -313
View File
@@ -24,6 +24,8 @@ use toml_edit::value;
const NOTICE_TABLE_KEY: &str = "notice";
mod document_helpers;
/// Discrete config mutations supported by the persistence engine.
#[derive(Clone, Debug)]
pub enum ConfigEdit {
@@ -198,319 +200,6 @@ pub fn model_availability_nux_count_edits(shown_count: &HashMap<String, u32>) ->
edits
}
// TODO(jif) move to a dedicated file
mod document_helpers {
use codex_config::types::AppToolApproval;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerEnvVar;
use codex_config::types::McpServerToolConfig;
use codex_config::types::McpServerTransportConfig;
use codex_config::types::ToolSuggestDisabledTool;
use codex_config::types::ToolSuggestDiscoverableType;
use toml_edit::Array as TomlArray;
use toml_edit::InlineTable;
use toml_edit::Item as TomlItem;
use toml_edit::Table as TomlTable;
use toml_edit::Value as TomlValue;
use toml_edit::value;
pub(super) fn ensure_table_for_write(item: &mut TomlItem) -> Option<&mut TomlTable> {
match item {
TomlItem::Table(table) => Some(table),
TomlItem::Value(value) => {
if let Some(inline) = value.as_inline_table() {
*item = TomlItem::Table(table_from_inline(inline));
item.as_table_mut()
} else {
*item = TomlItem::Table(new_implicit_table());
item.as_table_mut()
}
}
TomlItem::None => {
*item = TomlItem::Table(new_implicit_table());
item.as_table_mut()
}
_ => None,
}
}
pub(super) fn ensure_table_for_read(item: &mut TomlItem) -> Option<&mut TomlTable> {
match item {
TomlItem::Table(table) => Some(table),
TomlItem::Value(value) => {
let inline = value.as_inline_table()?;
*item = TomlItem::Table(table_from_inline(inline));
item.as_table_mut()
}
_ => None,
}
}
fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable {
let mut entry = TomlTable::new();
entry.set_implicit(false);
match &config.transport {
McpServerTransportConfig::Stdio {
command,
args,
env,
env_vars,
cwd,
} => {
entry["command"] = value(command.clone());
if !args.is_empty() {
entry["args"] = array_from_iter(args.iter().cloned());
}
if let Some(env) = env
&& !env.is_empty()
{
entry["env"] = table_from_pairs(env.iter());
}
if !env_vars.is_empty() {
entry["env_vars"] = array_from_env_vars(env_vars);
}
if let Some(cwd) = cwd {
entry["cwd"] = value(cwd.to_string_lossy().to_string());
}
}
McpServerTransportConfig::StreamableHttp {
url,
bearer_token_env_var,
http_headers,
env_http_headers,
} => {
entry["url"] = value(url.clone());
if let Some(env_var) = bearer_token_env_var {
entry["bearer_token_env_var"] = value(env_var.clone());
}
if let Some(headers) = http_headers
&& !headers.is_empty()
{
entry["http_headers"] = table_from_pairs(headers.iter());
}
if let Some(headers) = env_http_headers
&& !headers.is_empty()
{
entry["env_http_headers"] = table_from_pairs(headers.iter());
}
}
}
if !config.enabled {
entry["enabled"] = value(false);
}
if !config.is_local_environment() {
entry["environment_id"] = value(config.environment_id.clone());
}
if config.required {
entry["required"] = value(true);
}
if config.supports_parallel_tool_calls {
entry["supports_parallel_tool_calls"] = value(true);
}
if let Some(timeout) = config.startup_timeout_sec {
entry["startup_timeout_sec"] = value(timeout.as_secs_f64());
}
if let Some(timeout) = config.tool_timeout_sec {
entry["tool_timeout_sec"] = value(timeout.as_secs_f64());
}
if let Some(approval_mode) = config.default_tools_approval_mode {
entry["default_tools_approval_mode"] = value(match approval_mode {
AppToolApproval::Auto => "auto",
AppToolApproval::Prompt => "prompt",
AppToolApproval::Approve => "approve",
});
}
if let Some(enabled_tools) = &config.enabled_tools
&& !enabled_tools.is_empty()
{
entry["enabled_tools"] = array_from_iter(enabled_tools.iter().cloned());
}
if let Some(disabled_tools) = &config.disabled_tools
&& !disabled_tools.is_empty()
{
entry["disabled_tools"] = array_from_iter(disabled_tools.iter().cloned());
}
if let Some(scopes) = &config.scopes
&& !scopes.is_empty()
{
entry["scopes"] = array_from_iter(scopes.iter().cloned());
}
if let Some(oauth) = &config.oauth
&& let Some(client_id) = &oauth.client_id
&& !client_id.is_empty()
{
let mut oauth_table = TomlTable::new();
oauth_table.set_implicit(false);
oauth_table["client_id"] = value(client_id.clone());
entry["oauth"] = TomlItem::Table(oauth_table);
}
if let Some(resource) = &config.oauth_resource
&& !resource.is_empty()
{
entry["oauth_resource"] = value(resource.clone());
}
if !config.tools.is_empty() {
let mut tools = new_implicit_table();
let mut tool_entries: Vec<_> = config.tools.iter().collect();
tool_entries.sort_by_key(|(name, _)| *name);
for (name, tool_config) in tool_entries {
tools.insert(name, serialize_mcp_server_tool(tool_config));
}
entry.insert("tools", TomlItem::Table(tools));
}
entry
}
fn serialize_mcp_server_tool(config: &McpServerToolConfig) -> TomlItem {
let mut entry = TomlTable::new();
entry.set_implicit(false);
if let Some(approval_mode) = config.approval_mode {
entry["approval_mode"] = value(match approval_mode {
AppToolApproval::Auto => "auto",
AppToolApproval::Prompt => "prompt",
AppToolApproval::Approve => "approve",
});
}
TomlItem::Table(entry)
}
pub(super) fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem {
TomlItem::Table(serialize_mcp_server_table(config))
}
pub(super) fn serialize_mcp_server_inline(config: &McpServerConfig) -> InlineTable {
serialize_mcp_server_table(config).into_inline_table()
}
pub(super) fn merge_inline_table(existing: &mut InlineTable, replacement: InlineTable) {
existing.retain(|key, _| replacement.get(key).is_some());
for (key, value) in replacement.iter() {
if let Some(existing_value) = existing.get_mut(key) {
let mut updated_value = value.clone();
*updated_value.decor_mut() = existing_value.decor().clone();
*existing_value = updated_value;
} else {
existing.insert(key.to_string(), value.clone());
}
}
}
fn table_from_inline(inline: &InlineTable) -> TomlTable {
let mut table = new_implicit_table();
for (key, value) in inline.iter() {
let mut value = value.clone();
let decor = value.decor_mut();
decor.set_suffix("");
table.insert(key, TomlItem::Value(value));
}
table
}
pub(super) fn new_implicit_table() -> TomlTable {
let mut table = TomlTable::new();
table.set_implicit(true);
table
}
pub(super) fn parse_tool_suggest_disabled_tool(
value: &TomlValue,
) -> Option<ToolSuggestDisabledTool> {
let table = value.as_inline_table()?;
let kind = match table.get("type").and_then(TomlValue::as_str) {
Some("connector") => ToolSuggestDiscoverableType::Connector,
Some("plugin") => ToolSuggestDiscoverableType::Plugin,
_ => return None,
};
let id = table.get("id").and_then(TomlValue::as_str)?;
Some(ToolSuggestDisabledTool {
kind,
id: id.to_string(),
})
}
pub(super) fn parse_tool_suggest_disabled_tool_table(
table: &TomlTable,
) -> Option<ToolSuggestDisabledTool> {
let kind = match table.get("type").and_then(TomlItem::as_str) {
Some("connector") => ToolSuggestDiscoverableType::Connector,
Some("plugin") => ToolSuggestDiscoverableType::Plugin,
_ => return None,
};
let id = table.get("id").and_then(TomlItem::as_str)?;
Some(ToolSuggestDisabledTool {
kind,
id: id.to_string(),
})
}
pub(super) fn tool_suggest_disabled_tools_value(
disabled_tools: &[ToolSuggestDisabledTool],
) -> TomlItem {
let mut array = TomlArray::new();
for disabled_tool in disabled_tools {
let mut table = InlineTable::new();
table.insert(
"type",
match disabled_tool.kind {
ToolSuggestDiscoverableType::Connector => "connector",
ToolSuggestDiscoverableType::Plugin => "plugin",
}
.into(),
);
table.insert("id", disabled_tool.id.clone().into());
array.push(table);
}
TomlItem::Value(array.into())
}
fn array_from_iter<I>(iter: I) -> TomlItem
where
I: Iterator<Item = String>,
{
let mut array = TomlArray::new();
for value in iter {
array.push(value);
}
TomlItem::Value(array.into())
}
fn array_from_env_vars(env_vars: &[McpServerEnvVar]) -> TomlItem {
let mut array = TomlArray::new();
for env_var in env_vars {
match env_var {
McpServerEnvVar::Name(name) => array.push(name.clone()),
McpServerEnvVar::Config { name, source } => {
let mut table = InlineTable::new();
table.insert("name", name.clone().into());
if let Some(source) = source {
table.insert("source", source.clone().into());
}
array.push(table);
}
}
}
TomlItem::Value(array.into())
}
fn table_from_pairs<'a, I>(pairs: I) -> TomlItem
where
I: IntoIterator<Item = (&'a String, &'a String)>,
{
let mut entries: Vec<_> = pairs.into_iter().collect();
entries.sort_by_key(|(key, _)| *key);
let mut table = TomlTable::new();
table.set_implicit(false);
for (key, val) in entries {
table.insert(key, value(val.clone()));
}
TomlItem::Table(table)
}
}
struct ConfigDocument {
doc: DocumentMut,
}
@@ -0,0 +1,309 @@
use codex_config::types::AppToolApproval;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerEnvVar;
use codex_config::types::McpServerToolConfig;
use codex_config::types::McpServerTransportConfig;
use codex_config::types::ToolSuggestDisabledTool;
use codex_config::types::ToolSuggestDiscoverableType;
use toml_edit::Array as TomlArray;
use toml_edit::InlineTable;
use toml_edit::Item as TomlItem;
use toml_edit::Table as TomlTable;
use toml_edit::Value as TomlValue;
use toml_edit::value;
pub(super) fn ensure_table_for_write(item: &mut TomlItem) -> Option<&mut TomlTable> {
match item {
TomlItem::Table(table) => Some(table),
TomlItem::Value(value) => {
if let Some(inline) = value.as_inline_table() {
*item = TomlItem::Table(table_from_inline(inline));
item.as_table_mut()
} else {
*item = TomlItem::Table(new_implicit_table());
item.as_table_mut()
}
}
TomlItem::None => {
*item = TomlItem::Table(new_implicit_table());
item.as_table_mut()
}
_ => None,
}
}
pub(super) fn ensure_table_for_read(item: &mut TomlItem) -> Option<&mut TomlTable> {
match item {
TomlItem::Table(table) => Some(table),
TomlItem::Value(value) => {
let inline = value.as_inline_table()?;
*item = TomlItem::Table(table_from_inline(inline));
item.as_table_mut()
}
_ => None,
}
}
fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable {
let mut entry = TomlTable::new();
entry.set_implicit(false);
match &config.transport {
McpServerTransportConfig::Stdio {
command,
args,
env,
env_vars,
cwd,
} => {
entry["command"] = value(command.clone());
if !args.is_empty() {
entry["args"] = array_from_iter(args.iter().cloned());
}
if let Some(env) = env
&& !env.is_empty()
{
entry["env"] = table_from_pairs(env.iter());
}
if !env_vars.is_empty() {
entry["env_vars"] = array_from_env_vars(env_vars);
}
if let Some(cwd) = cwd {
entry["cwd"] = value(cwd.to_string_lossy().to_string());
}
}
McpServerTransportConfig::StreamableHttp {
url,
bearer_token_env_var,
http_headers,
env_http_headers,
} => {
entry["url"] = value(url.clone());
if let Some(env_var) = bearer_token_env_var {
entry["bearer_token_env_var"] = value(env_var.clone());
}
if let Some(headers) = http_headers
&& !headers.is_empty()
{
entry["http_headers"] = table_from_pairs(headers.iter());
}
if let Some(headers) = env_http_headers
&& !headers.is_empty()
{
entry["env_http_headers"] = table_from_pairs(headers.iter());
}
}
}
if !config.enabled {
entry["enabled"] = value(false);
}
if !config.is_local_environment() {
entry["environment_id"] = value(config.environment_id.clone());
}
if config.required {
entry["required"] = value(true);
}
if config.supports_parallel_tool_calls {
entry["supports_parallel_tool_calls"] = value(true);
}
if let Some(timeout) = config.startup_timeout_sec {
entry["startup_timeout_sec"] = value(timeout.as_secs_f64());
}
if let Some(timeout) = config.tool_timeout_sec {
entry["tool_timeout_sec"] = value(timeout.as_secs_f64());
}
if let Some(approval_mode) = config.default_tools_approval_mode {
entry["default_tools_approval_mode"] = value(match approval_mode {
AppToolApproval::Auto => "auto",
AppToolApproval::Prompt => "prompt",
AppToolApproval::Approve => "approve",
});
}
if let Some(enabled_tools) = &config.enabled_tools
&& !enabled_tools.is_empty()
{
entry["enabled_tools"] = array_from_iter(enabled_tools.iter().cloned());
}
if let Some(disabled_tools) = &config.disabled_tools
&& !disabled_tools.is_empty()
{
entry["disabled_tools"] = array_from_iter(disabled_tools.iter().cloned());
}
if let Some(scopes) = &config.scopes
&& !scopes.is_empty()
{
entry["scopes"] = array_from_iter(scopes.iter().cloned());
}
if let Some(oauth) = &config.oauth
&& let Some(client_id) = &oauth.client_id
&& !client_id.is_empty()
{
let mut oauth_table = TomlTable::new();
oauth_table.set_implicit(false);
oauth_table["client_id"] = value(client_id.clone());
entry["oauth"] = TomlItem::Table(oauth_table);
}
if let Some(resource) = &config.oauth_resource
&& !resource.is_empty()
{
entry["oauth_resource"] = value(resource.clone());
}
if !config.tools.is_empty() {
let mut tools = new_implicit_table();
let mut tool_entries: Vec<_> = config.tools.iter().collect();
tool_entries.sort_by_key(|(name, _)| *name);
for (name, tool_config) in tool_entries {
tools.insert(name, serialize_mcp_server_tool(tool_config));
}
entry.insert("tools", TomlItem::Table(tools));
}
entry
}
fn serialize_mcp_server_tool(config: &McpServerToolConfig) -> TomlItem {
let mut entry = TomlTable::new();
entry.set_implicit(false);
if let Some(approval_mode) = config.approval_mode {
entry["approval_mode"] = value(match approval_mode {
AppToolApproval::Auto => "auto",
AppToolApproval::Prompt => "prompt",
AppToolApproval::Approve => "approve",
});
}
TomlItem::Table(entry)
}
pub(super) fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem {
TomlItem::Table(serialize_mcp_server_table(config))
}
pub(super) fn serialize_mcp_server_inline(config: &McpServerConfig) -> InlineTable {
serialize_mcp_server_table(config).into_inline_table()
}
pub(super) fn merge_inline_table(existing: &mut InlineTable, replacement: InlineTable) {
existing.retain(|key, _| replacement.get(key).is_some());
for (key, value) in replacement.iter() {
if let Some(existing_value) = existing.get_mut(key) {
let mut updated_value = value.clone();
*updated_value.decor_mut() = existing_value.decor().clone();
*existing_value = updated_value;
} else {
existing.insert(key.to_string(), value.clone());
}
}
}
fn table_from_inline(inline: &InlineTable) -> TomlTable {
let mut table = new_implicit_table();
for (key, value) in inline.iter() {
let mut value = value.clone();
let decor = value.decor_mut();
decor.set_suffix("");
table.insert(key, TomlItem::Value(value));
}
table
}
pub(super) fn new_implicit_table() -> TomlTable {
let mut table = TomlTable::new();
table.set_implicit(true);
table
}
pub(super) fn parse_tool_suggest_disabled_tool(
value: &TomlValue,
) -> Option<ToolSuggestDisabledTool> {
let table = value.as_inline_table()?;
let kind = match table.get("type").and_then(TomlValue::as_str) {
Some("connector") => ToolSuggestDiscoverableType::Connector,
Some("plugin") => ToolSuggestDiscoverableType::Plugin,
_ => return None,
};
let id = table.get("id").and_then(TomlValue::as_str)?;
Some(ToolSuggestDisabledTool {
kind,
id: id.to_string(),
})
}
pub(super) fn parse_tool_suggest_disabled_tool_table(
table: &TomlTable,
) -> Option<ToolSuggestDisabledTool> {
let kind = match table.get("type").and_then(TomlItem::as_str) {
Some("connector") => ToolSuggestDiscoverableType::Connector,
Some("plugin") => ToolSuggestDiscoverableType::Plugin,
_ => return None,
};
let id = table.get("id").and_then(TomlItem::as_str)?;
Some(ToolSuggestDisabledTool {
kind,
id: id.to_string(),
})
}
pub(super) fn tool_suggest_disabled_tools_value(
disabled_tools: &[ToolSuggestDisabledTool],
) -> TomlItem {
let mut array = TomlArray::new();
for disabled_tool in disabled_tools {
let mut table = InlineTable::new();
table.insert(
"type",
match disabled_tool.kind {
ToolSuggestDiscoverableType::Connector => "connector",
ToolSuggestDiscoverableType::Plugin => "plugin",
}
.into(),
);
table.insert("id", disabled_tool.id.clone().into());
array.push(table);
}
TomlItem::Value(array.into())
}
fn array_from_iter<I>(iter: I) -> TomlItem
where
I: Iterator<Item = String>,
{
let mut array = TomlArray::new();
for value in iter {
array.push(value);
}
TomlItem::Value(array.into())
}
fn array_from_env_vars(env_vars: &[McpServerEnvVar]) -> TomlItem {
let mut array = TomlArray::new();
for env_var in env_vars {
match env_var {
McpServerEnvVar::Name(name) => array.push(name.clone()),
McpServerEnvVar::Config { name, source } => {
let mut table = InlineTable::new();
table.insert("name", name.clone().into());
if let Some(source) = source {
table.insert("source", source.clone().into());
}
array.push(table);
}
}
}
TomlItem::Value(array.into())
}
fn table_from_pairs<'a, I>(pairs: I) -> TomlItem
where
I: IntoIterator<Item = (&'a String, &'a String)>,
{
let mut entries: Vec<_> = pairs.into_iter().collect();
entries.sort_by_key(|(key, _)| *key);
let mut table = TomlTable::new();
table.set_implicit(false);
for (key, val) in entries {
table.insert(key, value(val.clone()));
}
TomlItem::Table(table)
}
@@ -6,7 +6,7 @@ use super::AdditionalContextUserFragment;
use super::EnvironmentContext;
use super::FragmentRegistration;
use super::FragmentRegistrationProxy;
use super::GoalContext;
use super::InternalModelContextFragment;
use super::LegacyApplyPatchExecCommandWarning;
use super::LegacyModelMismatchWarning;
use super::LegacyUnifiedExecProcessLimitWarning;
@@ -30,8 +30,9 @@ static TURN_ABORTED_REGISTRATION: FragmentRegistrationProxy<TurnAborted> =
FragmentRegistrationProxy::new();
static SUBAGENT_NOTIFICATION_REGISTRATION: FragmentRegistrationProxy<SubagentNotification> =
FragmentRegistrationProxy::new();
static GOAL_CONTEXT_REGISTRATION: FragmentRegistrationProxy<GoalContext> =
FragmentRegistrationProxy::new();
static INTERNAL_MODEL_CONTEXT_REGISTRATION: FragmentRegistrationProxy<
InternalModelContextFragment,
> = FragmentRegistrationProxy::new();
static LEGACY_UNIFIED_EXEC_PROCESS_LIMIT_WARNING_REGISTRATION: FragmentRegistrationProxy<
LegacyUnifiedExecProcessLimitWarning,
> = FragmentRegistrationProxy::new();
@@ -50,7 +51,7 @@ static CONTEXTUAL_USER_FRAGMENTS: &[&dyn FragmentRegistration] = &[
&USER_SHELL_COMMAND_REGISTRATION,
&TURN_ABORTED_REGISTRATION,
&SUBAGENT_NOTIFICATION_REGISTRATION,
&GOAL_CONTEXT_REGISTRATION,
&INTERNAL_MODEL_CONTEXT_REGISTRATION,
&LEGACY_UNIFIED_EXEC_PROCESS_LIMIT_WARNING_REGISTRATION,
&LEGACY_APPLY_PATCH_EXEC_COMMAND_WARNING_REGISTRATION,
&LEGACY_MODEL_MISMATCH_WARNING_REGISTRATION,
@@ -1,6 +1,7 @@
use super::*;
use crate::context::ContextualUserFragment;
use crate::context::GoalContext;
use crate::context::InternalContextSource;
use crate::context::InternalModelContextFragment;
use crate::context::SubagentNotification;
use codex_protocol::items::HookPromptFragment;
use codex_protocol::items::build_hook_prompt_message;
@@ -30,23 +31,55 @@ fn detects_subagent_notification_fragment_case_insensitively() {
}
#[test]
fn detects_goal_context_fragment() {
let text = GoalContext::new("Continue working toward the active thread goal.").render();
fn detects_internal_model_context_fragment() {
let text = InternalModelContextFragment::new(
InternalContextSource::from_static("goal"),
"Continue working toward the active thread goal.",
)
.render();
assert_eq!(
text,
"<codex_internal_context source=\"goal\">\nContinue working toward the active thread goal.\n</codex_internal_context>"
);
assert!(is_contextual_user_fragment(&ContentItem::InputText {
text
}));
}
#[test]
fn detects_legacy_goal_context_fragment() {
assert!(is_contextual_user_fragment(&ContentItem::InputText {
text: "<goal_context>\nContinue working toward the active thread goal.\n</goal_context>"
.to_string(),
}));
}
#[test]
fn does_not_hide_arbitrary_context_tags() {
assert!(!is_contextual_user_fragment(&ContentItem::InputText {
text: "<project_context>\nbody\n</project_context>".to_string(),
}));
}
#[test]
fn rejects_invalid_internal_model_context_source() {
assert!(!is_contextual_user_fragment(&ContentItem::InputText {
text: "<codex_internal_context source=\"Goal\">\nbody\n</codex_internal_context>"
.to_string(),
}));
}
#[test]
fn contextual_user_fragment_is_dyn_compatible() {
let fragment: Box<dyn ContextualUserFragment> = Box::new(GoalContext::new(
let fragment: Box<dyn ContextualUserFragment> = Box::new(InternalModelContextFragment::new(
InternalContextSource::from_static("goal"),
"Continue working toward the active thread goal.",
));
assert_eq!(
fragment.render(),
"<goal_context>\nContinue working toward the active thread goal.\n</goal_context>"
"<codex_internal_context source=\"goal\">\nContinue working toward the active thread goal.\n</codex_internal_context>"
);
}
-36
View File
@@ -1,36 +0,0 @@
//! Hidden user-context fragment for runtime-owned goal steering prompts.
use super::ContextualUserFragment;
/// Hidden runtime-owned goal steering context injected into model input.
#[derive(Debug, Clone, PartialEq)]
pub struct GoalContext {
prompt: String,
}
impl GoalContext {
/// Creates goal context around an already-rendered steering prompt.
pub fn new(prompt: impl Into<String>) -> Self {
Self {
prompt: prompt.into(),
}
}
}
impl ContextualUserFragment for GoalContext {
fn role() -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("<goal_context>", "</goal_context>")
}
fn body(&self) -> String {
format!("\n{}\n", self.prompt)
}
}
@@ -0,0 +1,129 @@
//! Hidden user-context fragment for extension-owned model steering.
use super::ContextualUserFragment;
use std::error::Error;
use std::fmt;
const CONTEXT_START_MARKER: &str = "<codex_internal_context";
const CONTEXT_END_MARKER: &str = "</codex_internal_context>";
const LEGACY_GOAL_CONTEXT_START_MARKER: &str = "<goal_context>";
const LEGACY_GOAL_CONTEXT_END_MARKER: &str = "</goal_context>";
const SOURCE_ATTR_START: &str = " source=\"";
const SOURCE_ATTR_END: &str = "\">";
/// Source label for hidden internal model context.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InternalContextSource(String);
impl InternalContextSource {
/// Creates a source label for an internal model-context fragment.
///
/// Sources are intentionally constrained so the value can be embedded in the
/// wrapper without escaping and still remain easy to audit in stored history.
pub fn new(source: impl Into<String>) -> Result<Self, InvalidInternalContextSource> {
let source = source.into();
if is_valid_source(&source) {
Ok(Self(source))
} else {
Err(InvalidInternalContextSource { source })
}
}
/// Creates a source label from a trusted static string.
pub fn from_static(source: &'static str) -> Self {
Self::new(source)
.unwrap_or_else(|_| panic!("invalid static internal context source: {source}"))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Error returned when an internal model-context source is invalid.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InvalidInternalContextSource {
source: String,
}
impl fmt::Display for InvalidInternalContextSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let source = &self.source;
write!(
f,
"invalid internal model context source {source:?}; expected [a-z][a-z0-9_]*"
)
}
}
impl Error for InvalidInternalContextSource {}
/// Hidden runtime-owned context injected into model input.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InternalModelContextFragment {
source: InternalContextSource,
body: String,
}
impl InternalModelContextFragment {
/// Creates hidden model context with an extension-owned source label.
pub fn new(source: InternalContextSource, body: impl Into<String>) -> Self {
Self {
source,
body: body.into(),
}
}
}
impl ContextualUserFragment for InternalModelContextFragment {
fn role() -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
(CONTEXT_START_MARKER, CONTEXT_END_MARKER)
}
fn matches_text(text: &str) -> bool {
let trimmed = text.trim();
if matches_legacy_goal_context(trimmed) {
return true;
}
let Some(rest) = trimmed.strip_prefix(CONTEXT_START_MARKER) else {
return false;
};
let Some(rest) = rest.strip_prefix(SOURCE_ATTR_START) else {
return false;
};
let Some((source, body_and_close)) = rest.split_once(SOURCE_ATTR_END) else {
return false;
};
is_valid_source(source) && body_and_close.ends_with(CONTEXT_END_MARKER)
}
fn body(&self) -> String {
let source = self.source.as_str();
let body = &self.body;
format!(" source=\"{source}\">\n{body}\n")
}
}
fn matches_legacy_goal_context(text: &str) -> bool {
text.starts_with(LEGACY_GOAL_CONTEXT_START_MARKER)
&& text.ends_with(LEGACY_GOAL_CONTEXT_END_MARKER)
}
fn is_valid_source(source: &str) -> bool {
let mut chars = source.chars();
let Some(first) = chars.next() else {
return false;
};
first.is_ascii_lowercase()
&& chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
}
+4 -2
View File
@@ -9,10 +9,10 @@ mod contextual_user_message;
mod environment_context;
mod fragment;
mod fragments;
mod goal_context;
mod guardian_followup_review_reminder;
mod hook_additional_context;
mod image_generation_instructions;
mod internal_model_context;
mod legacy_apply_patch_exec_command_warning;
mod legacy_model_mismatch_warning;
mod legacy_unified_exec_process_limit_warning;
@@ -43,10 +43,12 @@ pub(crate) use fragment::FragmentRegistration;
pub(crate) use fragment::FragmentRegistrationProxy;
pub(crate) use fragments::AdditionalContextDeveloperFragment;
pub(crate) use fragments::AdditionalContextUserFragment;
pub use goal_context::GoalContext;
pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder;
pub(crate) use hook_additional_context::HookAdditionalContext;
pub(crate) use image_generation_instructions::ImageGenerationInstructions;
pub use internal_model_context::InternalContextSource;
pub use internal_model_context::InternalModelContextFragment;
pub use internal_model_context::InvalidInternalContextSource;
pub(crate) use legacy_apply_patch_exec_command_warning::LegacyApplyPatchExecCommandWarning;
pub(crate) use legacy_model_mismatch_warning::LegacyModelMismatchWarning;
pub(crate) use legacy_unified_exec_process_limit_warning::LegacyUnifiedExecProcessLimitWarning;
+8 -3
View File
@@ -1,6 +1,7 @@
use super::parse_turn_item;
use crate::context::ContextualUserFragment;
use crate::context::GoalContext;
use crate::context::InternalContextSource;
use crate::context::InternalModelContextFragment;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::HookPromptFragment;
use codex_protocol::items::TurnItem;
@@ -317,12 +318,16 @@ fn parses_hook_prompt_and_hides_other_contextual_fragments() {
}
#[test]
fn goal_context_does_not_parse_as_visible_turn_item() {
fn internal_model_context_does_not_parse_as_visible_turn_item() {
let item = ResponseItem::Message {
id: Some("msg-1".to_string()),
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: GoalContext::new("Continue working toward the active thread goal.").render(),
text: InternalModelContextFragment::new(
InternalContextSource::from_static("goal"),
"Continue working toward the active thread goal.",
)
.render(),
}],
phase: None,
};
+7 -3
View File
@@ -6,7 +6,8 @@
use crate::StateDbHandle;
use crate::context::ContextualUserFragment;
use crate::context::GoalContext;
use crate::context::InternalContextSource;
use crate::context::InternalModelContextFragment;
use crate::session::TurnInput;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
@@ -1597,7 +1598,10 @@ fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseItem {
}
fn goal_context_input_item(prompt: String) -> ResponseItem {
ResponseItem::from(GoalContext::new(prompt).into_response_input_item())
ContextualUserFragment::into(InternalModelContextFragment::new(
InternalContextSource::from_static("goal"),
prompt,
))
}
pub(crate) fn protocol_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal {
@@ -1799,7 +1803,7 @@ mod tests {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "<goal_context>\nContinue working.\n</goal_context>".to_string(),
text: "<codex_internal_context source=\"goal\">\nContinue working.\n</codex_internal_context>".to_string(),
}],
phase: None,
}
+2 -2
View File
@@ -13,10 +13,10 @@ use chrono::Utc;
use codex_git_utils::GitSha;
use codex_protocol::ThreadId;
use codex_protocol::models::ContentItem;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::GitInfo;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_thread_store::StoredThread;
use core_test_support::PathBufExt;
@@ -59,7 +59,7 @@ fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThre
repository_url: None,
}),
approval_mode: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
token_usage: None,
first_user_message: Some(first_user_message.to_string()),
history: None,
+3
View File
@@ -630,6 +630,9 @@ async fn shutdown_session_runtime(sess: &Arc<Session>) {
.unified_exec_manager
.terminate_all_processes()
.await;
if let Err(err) = sess.services.code_mode_service.shutdown().await {
warn!("failed to shutdown code mode session: {err}");
}
let mcp_shutdown = {
let mut manager = sess.services.mcp_connection_manager.write().await;
manager.begin_shutdown()
+12
View File
@@ -1,4 +1,5 @@
use super::*;
use codex_protocol::openai_models::ToolMode;
use std::sync::atomic::AtomicBool;
/// Spawn a review thread using the given prompt.
@@ -47,6 +48,15 @@ pub(super) async fn spawn_review_thread(
let mut per_turn_config = (*config).clone();
per_turn_config.model = Some(model.clone());
per_turn_config.features = review_features.clone();
let tool_mode = model_info.tool_mode.unwrap_or_else(|| {
if per_turn_config.features.enabled(Feature::CodeModeOnly) {
ToolMode::CodeModeOnly
} else if per_turn_config.features.enabled(Feature::CodeMode) {
ToolMode::CodeMode
} else {
ToolMode::Direct
}
});
if let Err(err) = per_turn_config.web_search_mode.set(review_web_search_mode) {
let fallback_value = per_turn_config.web_search_mode.value();
tracing::warn!(
@@ -80,6 +90,7 @@ pub(super) async fn spawn_review_thread(
sess.session_id().to_string(),
sess.thread_id().to_string(),
forked_from_thread_id,
&session_source,
parent_turn_context.thread_source,
review_turn_id.clone(),
#[allow(deprecated)]
@@ -96,6 +107,7 @@ pub(super) async fn spawn_review_thread(
config: per_turn_config,
auth_manager: auth_manager_for_context,
model_info: model_info.clone(),
tool_mode,
session_telemetry: session_telemetry_for_context,
provider: provider_for_context,
reasoning_effort,
+8 -19
View File
@@ -8710,24 +8710,13 @@ async fn active_goal_continuation_runs_again_after_no_tool_turn() -> anyhow::Res
})
.await??;
let continuation_request = responses
let goal_context_text = responses
.requests()
.into_iter()
.find(|request| request.body_contains_text("<goal_context>"))
.expect("expected a goal continuation request");
let body = continuation_request.body_json();
let goal_context_message = body["input"]
.as_array()
.expect("input should be an array")
.iter()
.find(|item| item.to_string().contains("<goal_context>"))
.flat_map(|request| request.message_input_texts("user"))
.find(|text| text.contains("<codex_internal_context source=\"goal\">"))
.expect("goal context message should be present");
assert_eq!(goal_context_message["role"].as_str(), Some("user"));
assert!(
goal_context_message
.to_string()
.contains("Continue working toward the active thread goal.")
);
assert!(goal_context_text.contains("Continue working toward the active thread goal."));
Ok(())
}
@@ -8997,8 +8986,8 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh
let [ContentItem::InputText { text }] = content.as_slice() else {
panic!("expected one text span in budget-limit steering message, got {content:#?}");
};
assert!(text.starts_with("<goal_context>"));
assert!(text.trim_end().ends_with("</goal_context>"));
assert!(text.starts_with("<codex_internal_context source=\"goal\">"));
assert!(text.trim_end().ends_with("</codex_internal_context>"));
assert!(text.contains("budget_limited"));
assert!(text.to_lowercase().contains("wrap up this turn soon"));
assert!(sess.active_turn.lock().await.is_some());
@@ -9220,8 +9209,8 @@ async fn external_objective_change_steers_active_turn() -> anyhow::Result<()> {
&& content.iter().any(|content| matches!(
content,
ContentItem::InputText { text }
if text.starts_with("<goal_context>")
&& text.trim_end().ends_with("</goal_context>")
if text.starts_with("<codex_internal_context source=\"goal\">")
&& text.trim_end().ends_with("</codex_internal_context>")
&& text.contains("The active thread goal objective was edited")
&& text.contains("Write a concise benchmark summary")
))
+6 -10
View File
@@ -943,16 +943,12 @@ async fn run_sampling_request(
Arc::clone(&turn_context),
Arc::clone(&turn_diff_tracker),
);
let _code_mode_worker = sess
.services
.code_mode_service
.start_turn_worker(
&sess,
&turn_context,
Arc::clone(&router),
Arc::clone(&turn_diff_tracker),
)
.await;
let _code_mode_worker = sess.services.code_mode_service.start_turn_worker(
&sess,
&turn_context,
Arc::clone(&router),
Arc::clone(&turn_diff_tracker),
);
let max_retries = turn_context.provider.info().stream_max_retries();
let mut retries = 0;
let mut initial_input = Some(input);
+23
View File
@@ -6,6 +6,7 @@ use codex_model_provider::SharedModelProvider;
use codex_model_provider::create_model_provider;
use codex_protocol::SessionId;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::openai_models::ToolMode;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile;
@@ -55,6 +56,7 @@ pub struct TurnContext {
pub config: Arc<Config>,
pub(crate) auth_manager: Option<Arc<AuthManager>>,
pub(crate) model_info: ModelInfo,
pub(crate) tool_mode: ToolMode,
pub(crate) session_telemetry: SessionTelemetry,
pub(crate) provider: SharedModelProvider,
pub(crate) reasoning_effort: Option<ReasoningEffortConfig>,
@@ -172,6 +174,15 @@ impl TurnContext {
let model_info = models_manager
.get_model_info(model.as_str(), &config.to_models_manager_config())
.await;
let tool_mode = model_info.tool_mode.unwrap_or_else(|| {
if config.features.enabled(Feature::CodeModeOnly) {
ToolMode::CodeModeOnly
} else if config.features.enabled(Feature::CodeMode) {
ToolMode::CodeMode
} else {
ToolMode::Direct
}
});
let truncation_policy = model_info.truncation_policy.into();
let supported_reasoning_levels = model_info
.supported_reasoning_levels
@@ -212,6 +223,7 @@ impl TurnContext {
config: Arc::new(config),
auth_manager: self.auth_manager.clone(),
model_info: model_info.clone(),
tool_mode,
session_telemetry: self
.session_telemetry
.clone()
@@ -475,6 +487,15 @@ impl Session {
);
let mut per_turn_config = per_turn_config;
let tool_mode = model_info.tool_mode.unwrap_or_else(|| {
if per_turn_config.features.enabled(Feature::CodeModeOnly) {
ToolMode::CodeModeOnly
} else if per_turn_config.features.enabled(Feature::CodeMode) {
ToolMode::CodeMode
} else {
ToolMode::Direct
}
});
per_turn_config.service_tier = get_service_tier(
per_turn_config.service_tier,
per_turn_config.features.enabled(Feature::FastMode),
@@ -485,6 +506,7 @@ impl Session {
session_id.to_string(),
thread_id.to_string(),
session_configuration.forked_from_thread_id,
&session_configuration.session_source,
session_configuration.thread_source,
sub_id.clone(),
cwd.clone(),
@@ -501,6 +523,7 @@ impl Session {
config: per_turn_config.clone(),
auth_manager: auth_manager_for_context,
model_info: model_info.clone(),
tool_mode,
session_telemetry: session_telemetry_for_context,
provider: provider_for_context,
reasoning_effort,
+64 -43
View File
@@ -5,6 +5,7 @@ use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_extension_api::ExtensionData;
use codex_protocol::config_types::ModeKind;
use codex_protocol::items::ImageGenerationItem;
use codex_protocol::items::TurnItem;
use codex_utils_stream_parser::strip_citations;
use tokio_util::sync::CancellationToken;
@@ -125,6 +126,68 @@ async fn save_image_generation_result(
Ok(path)
}
pub(crate) async fn persist_image_generation_item(
sess: &Session,
turn_context: &TurnContext,
image_item: &mut ImageGenerationItem,
) -> Option<AbsolutePathBuf> {
let session_id = sess.conversation_id.to_string();
match save_image_generation_result(
&turn_context.config.codex_home,
&session_id,
&image_item.id,
&image_item.result,
)
.await
{
Ok(path) => {
image_item.saved_path = Some(path.clone());
Some(path)
}
Err(err) => {
let output_path = image_generation_artifact_path(
&turn_context.config.codex_home,
&session_id,
&image_item.id,
);
let output_dir = output_path
.parent()
.unwrap_or_else(|| turn_context.config.codex_home.clone());
tracing::warn!(
call_id = %image_item.id,
output_dir = %output_dir.display(),
"failed to save generated image: {err}"
);
None
}
}
}
pub(crate) async fn finalize_image_generation_item(
sess: &Session,
turn_context: &TurnContext,
image_item: &mut ImageGenerationItem,
) {
if persist_image_generation_item(sess, turn_context, image_item)
.await
.is_none()
{
return;
}
let session_id = sess.conversation_id.to_string();
let image_output_path =
image_generation_artifact_path(&turn_context.config.codex_home, &session_id, "<image_id>");
let image_output_dir = image_output_path
.parent()
.unwrap_or_else(|| turn_context.config.codex_home.clone());
let message: ResponseItem = ContextualUserFragment::into(ImageGenerationInstructions::new(
image_output_dir.display(),
image_output_path.display(),
));
sess.record_conversation_items(turn_context, &[message])
.await;
}
/// Persist a completed model response item and record any cited memory usage.
pub(crate) async fn record_completed_response_item(
sess: &Session,
@@ -487,49 +550,7 @@ pub(crate) async fn handle_non_tool_response_item(
}
}
if let TurnItem::ImageGeneration(image_item) = &mut turn_item {
let session_id = sess.conversation_id.to_string();
match save_image_generation_result(
&turn_context.config.codex_home,
&session_id,
&image_item.id,
&image_item.result,
)
.await
{
Ok(path) => {
image_item.saved_path = Some(path);
let image_output_path = image_generation_artifact_path(
&turn_context.config.codex_home,
&session_id,
"<image_id>",
);
let image_output_dir = image_output_path
.parent()
.unwrap_or_else(|| turn_context.config.codex_home.clone());
let message: ResponseItem =
ContextualUserFragment::into(ImageGenerationInstructions::new(
image_output_dir.display(),
image_output_path.display(),
));
sess.record_conversation_items(turn_context, &[message])
.await;
}
Err(err) => {
let output_path = image_generation_artifact_path(
&turn_context.config.codex_home,
&session_id,
&image_item.id,
);
let output_dir = output_path
.parent()
.unwrap_or_else(|| turn_context.config.codex_home.clone());
tracing::warn!(
call_id = %image_item.id,
output_dir = %output_dir.display(),
"failed to save generated image: {err}"
);
}
}
finalize_image_generation_item(sess, turn_context, image_item).await;
}
Some(turn_item)
}
+20 -10
View File
@@ -589,12 +589,12 @@ impl ThreadManager {
options: StartThreadOptions,
forked_from_thread_id: Option<ThreadId>,
) -> CodexResult<NewThread> {
let session_source = options
.session_source
.unwrap_or_else(|| self.state.session_source.clone());
let thread_source = options
.thread_source
.or_else(|| options.initial_history.get_resumed_thread_source());
let (resumed_session_source, resumed_thread_source) = options
.initial_history
.get_resumed_session_sources()
.unwrap_or_else(|| (self.state.session_source.clone(), None));
let session_source = options.session_source.unwrap_or(resumed_session_source);
let thread_source = options.thread_source.or(resumed_thread_source);
Box::pin(self.state.spawn_thread_with_source(
options.config,
options.initial_history,
@@ -676,17 +676,22 @@ impl ThreadManager {
self.state.environment_manager.as_ref(),
&config.cwd,
);
let thread_source = initial_history.get_resumed_thread_source();
Box::pin(self.state.spawn_thread(
let (session_source, thread_source) = initial_history
.get_resumed_session_sources()
.unwrap_or_else(|| (self.state.session_source.clone(), None));
Box::pin(self.state.spawn_thread_with_source(
config,
initial_history,
auth_manager,
self.agent_control(),
session_source,
/*forked_from_thread_id*/ None,
thread_source,
Vec::new(),
persist_extended_history,
/*metrics_service_name*/ None,
/*inherited_shell_snapshot*/ None,
/*inherited_exec_policy*/ None,
parent_trace,
environments,
/*user_shell_override*/ None,
@@ -732,17 +737,22 @@ impl ThreadManager {
self.state.environment_manager.as_ref(),
&config.cwd,
);
let thread_source = initial_history.get_resumed_thread_source();
Box::pin(self.state.spawn_thread(
let (session_source, thread_source) = initial_history
.get_resumed_session_sources()
.unwrap_or_else(|| (self.state.session_source.clone(), None));
Box::pin(self.state.spawn_thread_with_source(
config,
initial_history,
auth_manager,
self.agent_control(),
session_source,
/*forked_from_thread_id*/ None,
thread_source,
Vec::new(),
/*persist_extended_history*/ false,
/*metrics_service_name*/ None,
/*inherited_shell_snapshot*/ None,
/*inherited_exec_policy*/ None,
/*parent_trace*/ None,
environments,
/*user_shell_override*/ Some(user_shell_override),
@@ -0,0 +1,311 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex;
use codex_code_mode::CellId;
use codex_code_mode::CodeModeNestedToolCall;
use codex_code_mode::CodeModeSessionDelegate;
use codex_code_mode::NotificationFuture;
use codex_code_mode::ToolInvocationFuture;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseItem;
use serde_json::Value as JsonValue;
use tokio::sync::oneshot;
use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
use super::ExecContext;
use super::PUBLIC_TOOL_NAME;
use super::call_nested_tool;
use crate::tools::ToolRouter;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::parallel::ToolCallRuntime;
pub(super) struct CodeModeDispatchBroker {
dispatch_tx: async_channel::Sender<DispatchMessage>,
dispatch_rx: async_channel::Receiver<DispatchMessage>,
dispatch_gates: Arc<Mutex<HashMap<CellId, watch::Sender<bool>>>>,
}
impl CodeModeDispatchBroker {
pub(super) fn new() -> Self {
let (dispatch_tx, dispatch_rx) = async_channel::unbounded();
Self {
dispatch_tx,
dispatch_rx,
dispatch_gates: Arc::new(Mutex::new(HashMap::new())),
}
}
pub(super) fn mark_cell_ready_for_dispatch(&self, cell_id: &CellId) {
dispatch_gate(&self.dispatch_gates, cell_id).send_replace(true);
}
pub(super) fn close_cell(&self, cell_id: &CellId) {
remove_dispatch_gate(&self.dispatch_gates, cell_id);
}
pub(super) fn start_turn_worker(
&self,
exec: ExecContext,
router: Arc<ToolRouter>,
tracker: SharedTurnDiffTracker,
) -> CodeModeDispatchWorker {
let tool_runtime = ToolCallRuntime::new(
router,
Arc::clone(&exec.session),
Arc::clone(&exec.turn),
tracker,
);
let host = Arc::new(CoreTurnHost { exec, tool_runtime });
let dispatch_rx = self.dispatch_rx.clone();
let dispatch_gates = Arc::clone(&self.dispatch_gates);
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
tokio::spawn(async move {
loop {
let message = tokio::select! {
_ = &mut shutdown_rx => break,
message = dispatch_rx.recv() => message.ok(),
};
let Some(message) = message else {
break;
};
match message {
DispatchMessage::Notify {
call_id,
cell_id,
text,
cancellation_token,
response_tx,
} => {
let response = if wait_until_cell_ready_for_dispatch(
&dispatch_gates,
&cell_id,
&cancellation_token,
)
.await
{
host.notify(call_id, cell_id, text).await
} else {
remove_dispatch_gate(&dispatch_gates, &cell_id);
Err("code mode notification cancelled".to_string())
};
let _ = response_tx.send(response);
}
DispatchMessage::InvokeTool {
invocation,
cancellation_token,
response_tx,
} => {
let cell_id = invocation.cell_id.clone();
if !wait_until_cell_ready_for_dispatch(
&dispatch_gates,
&cell_id,
&cancellation_token,
)
.await
{
remove_dispatch_gate(&dispatch_gates, &cell_id);
continue;
}
let host = Arc::clone(&host);
tokio::spawn(async move {
let response = tokio::select! {
response = host.invoke_tool(
invocation,
cancellation_token.clone(),
) => response,
_ = cancellation_token.cancelled() => return,
};
let _ = response_tx.send(response);
});
}
}
}
});
CodeModeDispatchWorker {
shutdown_tx: Some(shutdown_tx),
}
}
}
fn dispatch_gate(
dispatch_gates: &Mutex<HashMap<CellId, watch::Sender<bool>>>,
cell_id: &CellId,
) -> watch::Sender<bool> {
let mut dispatch_gates = match dispatch_gates.lock() {
Ok(dispatch_gates) => dispatch_gates,
Err(poisoned) => poisoned.into_inner(),
};
dispatch_gates
.entry(cell_id.clone())
.or_insert_with(|| watch::channel(false).0)
.clone()
}
fn remove_dispatch_gate(
dispatch_gates: &Mutex<HashMap<CellId, watch::Sender<bool>>>,
cell_id: &CellId,
) {
let mut dispatch_gates = match dispatch_gates.lock() {
Ok(dispatch_gates) => dispatch_gates,
Err(poisoned) => poisoned.into_inner(),
};
dispatch_gates.remove(cell_id);
}
async fn wait_until_cell_ready_for_dispatch(
dispatch_gates: &Mutex<HashMap<CellId, watch::Sender<bool>>>,
cell_id: &CellId,
cancellation_token: &CancellationToken,
) -> bool {
if cancellation_token.is_cancelled() {
return false;
}
let mut ready_rx = dispatch_gate(dispatch_gates, cell_id).subscribe();
loop {
if *ready_rx.borrow_and_update() {
return true;
}
tokio::select! {
changed = ready_rx.changed() => {
if changed.is_err() {
return false;
}
}
_ = cancellation_token.cancelled() => return false,
}
}
}
impl CodeModeSessionDelegate for CodeModeDispatchBroker {
fn invoke_tool<'a>(
&'a self,
invocation: CodeModeNestedToolCall,
cancellation_token: CancellationToken,
) -> ToolInvocationFuture<'a> {
Box::pin(async move {
if cancellation_token.is_cancelled() {
return Err("code mode nested tool call cancelled".to_string());
}
let (response_tx, response_rx) = oneshot::channel();
self.dispatch_tx
.send(DispatchMessage::InvokeTool {
invocation,
cancellation_token: cancellation_token.clone(),
response_tx,
})
.await
.map_err(|_| "code mode nested tool dispatcher is unavailable".to_string())?;
tokio::select! {
response = response_rx => response
.map_err(|_| "code mode nested tool dispatcher stopped".to_string())?,
_ = cancellation_token.cancelled() => {
Err("code mode nested tool call cancelled".to_string())
}
}
})
}
fn notify<'a>(
&'a self,
call_id: String,
cell_id: CellId,
text: String,
cancellation_token: CancellationToken,
) -> NotificationFuture<'a> {
Box::pin(async move {
if cancellation_token.is_cancelled() {
return Err("code mode notification cancelled".to_string());
}
let (response_tx, response_rx) = oneshot::channel();
self.dispatch_tx
.send(DispatchMessage::Notify {
call_id,
cell_id,
text,
cancellation_token: cancellation_token.clone(),
response_tx,
})
.await
.map_err(|_| "code mode notification dispatcher is unavailable".to_string())?;
tokio::select! {
response = response_rx => response
.map_err(|_| "code mode notification dispatcher stopped".to_string())?,
_ = cancellation_token.cancelled() => {
Err("code mode notification cancelled".to_string())
}
}
})
}
fn cell_closed(&self, cell_id: &CellId) {
self.close_cell(cell_id);
}
}
enum DispatchMessage {
InvokeTool {
invocation: CodeModeNestedToolCall,
cancellation_token: CancellationToken,
response_tx: oneshot::Sender<Result<JsonValue, String>>,
},
Notify {
call_id: String,
cell_id: CellId,
text: String,
cancellation_token: CancellationToken,
response_tx: oneshot::Sender<Result<(), String>>,
},
}
pub(crate) struct CodeModeDispatchWorker {
shutdown_tx: Option<oneshot::Sender<()>>,
}
impl Drop for CodeModeDispatchWorker {
fn drop(&mut self) {
if let Some(shutdown_tx) = self.shutdown_tx.take() {
let _ = shutdown_tx.send(());
}
}
}
struct CoreTurnHost {
exec: ExecContext,
tool_runtime: ToolCallRuntime,
}
impl CoreTurnHost {
async fn invoke_tool(
&self,
invocation: CodeModeNestedToolCall,
cancellation_token: CancellationToken,
) -> Result<JsonValue, String> {
call_nested_tool(
self.exec.clone(),
self.tool_runtime.clone(),
invocation,
cancellation_token,
)
.await
.map_err(|error| error.to_string())
}
async fn notify(&self, call_id: String, cell_id: CellId, text: String) -> Result<(), String> {
if text.trim().is_empty() {
return Ok(());
}
self.exec
.session
.inject_if_running(vec![ResponseItem::CustomToolCallOutput {
call_id,
name: Some(PUBLIC_TOOL_NAME.to_string()),
output: FunctionCallOutputPayload::from_text(text),
}])
.await
.map_err(|_| {
format!("failed to inject exec notify message for cell {cell_id}: no active turn")
})
}
}
@@ -38,9 +38,22 @@ impl CodeModeExecuteHandler {
let exec = ExecContext { session, turn };
let enabled_tools =
codex_tools::collect_code_mode_tool_definitions(&self.nested_tool_specs);
// Allocate before starting V8 so the trace can create the parent
// CodeCell before model-authored JavaScript issues nested tool calls.
let runtime_cell_id = exec.session.services.code_mode_service.allocate_cell_id();
let started_at = std::time::Instant::now();
let started_cell = exec
.session
.services
.code_mode_service
.execute(codex_code_mode::ExecuteRequest {
tool_call_id: call_id.clone(),
enabled_tools,
source: args.code.clone(),
yield_time_ms: args.yield_time_ms,
max_output_tokens: args.max_output_tokens,
})
.await
.map_err(FunctionCallError::RespondToModel)?;
let cell_id = started_cell.cell_id.clone();
let runtime_cell_id = cell_id.to_string();
let code_cell_trace = exec
.session
.services
@@ -51,19 +64,12 @@ impl CodeModeExecuteHandler {
call_id.as_str(),
args.code.as_str(),
);
let started_at = std::time::Instant::now();
let response = exec
.session
exec.session
.services
.code_mode_service
.execute(codex_code_mode::ExecuteRequest {
cell_id: runtime_cell_id,
tool_call_id: call_id,
enabled_tools,
source: args.code,
yield_time_ms: args.yield_time_ms,
max_output_tokens: args.max_output_tokens,
})
.mark_cell_ready_for_dispatch(&cell_id);
let response = started_cell
.initial_response()
.await
.map_err(FunctionCallError::RespondToModel)?;
// Record the raw runtime boundary. The model-visible custom-tool output
@@ -74,6 +80,10 @@ impl CodeModeExecuteHandler {
// here when the first response also ended the runtime.
if !matches!(response, codex_code_mode::RuntimeResponse::Yielded { .. }) {
code_cell_trace.record_ended(&response);
exec.session
.services
.code_mode_service
.finish_cell_dispatch(&cell_id);
}
handle_runtime_response(&exec, response, args.max_output_tokens, started_at)
.await
+51 -58
View File
@@ -1,3 +1,4 @@
mod delegate;
mod execute_handler;
pub(crate) mod execute_spec;
mod response_adapter;
@@ -7,13 +8,12 @@ pub(crate) mod wait_spec;
use std::sync::Arc;
use std::time::Duration;
use codex_code_mode::CellId;
use codex_code_mode::CodeModeNestedToolCall;
use codex_code_mode::CodeModeSession;
use codex_code_mode::CodeModeToolKind;
use codex_code_mode::CodeModeTurnHost;
use codex_code_mode::RuntimeResponse;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseItem;
use serde_json::Value as JsonValue;
use tokio_util::sync::CancellationToken;
@@ -30,12 +30,14 @@ use crate::tools::parallel::ToolCallRuntime;
use crate::tools::router::ToolCall;
use crate::tools::router::ToolCallSource;
use crate::unified_exec::resolve_max_tokens;
use codex_features::Feature;
use codex_protocol::openai_models::ToolMode;
use codex_tools::ToolName;
use codex_utils_output_truncation::TruncationPolicy;
use codex_utils_output_truncation::formatted_truncate_text_content_items_with_policy;
use codex_utils_output_truncation::truncate_function_output_items_with_policy;
use delegate::CodeModeDispatchBroker;
use delegate::CodeModeDispatchWorker;
pub(crate) use execute_handler::CodeModeExecuteHandler;
use response_adapter::into_function_call_output_content_items;
pub(crate) use wait_handler::CodeModeWaitHandler;
@@ -56,42 +58,67 @@ pub(crate) struct ExecContext {
}
pub(crate) struct CodeModeService {
inner: codex_code_mode::CodeModeService,
session: Option<Arc<dyn CodeModeSession>>,
dispatch_broker: Arc<CodeModeDispatchBroker>,
}
impl CodeModeService {
pub(crate) fn new() -> Self {
let dispatch_broker = Arc::new(CodeModeDispatchBroker::new());
Self {
inner: codex_code_mode::CodeModeService::new(),
session: Some(Arc::new(codex_code_mode::CodeModeService::with_delegate(
dispatch_broker.clone(),
))),
dispatch_broker,
}
}
pub(crate) fn allocate_cell_id(&self) -> String {
self.inner.allocate_cell_id()
}
pub(crate) async fn execute(
&self,
request: codex_code_mode::ExecuteRequest,
) -> Result<RuntimeResponse, String> {
self.inner.execute(request).await
) -> Result<codex_code_mode::StartedCell, String> {
self.session()?.execute(request).await
}
pub(crate) async fn wait(
&self,
request: codex_code_mode::WaitRequest,
) -> Result<codex_code_mode::WaitOutcome, String> {
self.inner.wait(request).await
self.session()?.wait(request).await
}
pub(crate) async fn start_turn_worker(
pub(crate) async fn terminate(
&self,
cell_id: CellId,
) -> Result<codex_code_mode::WaitOutcome, String> {
self.session()?.terminate(cell_id).await
}
pub(crate) async fn shutdown(&self) -> Result<(), String> {
match &self.session {
Some(session) => session.shutdown().await,
None => Ok(()),
}
}
pub(crate) fn mark_cell_ready_for_dispatch(&self, cell_id: &codex_code_mode::CellId) {
self.dispatch_broker.mark_cell_ready_for_dispatch(cell_id);
}
pub(crate) fn finish_cell_dispatch(&self, cell_id: &CellId) {
self.dispatch_broker.close_cell(cell_id);
}
pub(crate) fn start_turn_worker(
&self,
session: &Arc<Session>,
turn: &Arc<TurnContext>,
router: Arc<ToolRouter>,
tracker: SharedTurnDiffTracker,
) -> Option<codex_code_mode::CodeModeTurnWorker> {
if !turn.features.enabled(Feature::CodeMode) {
) -> Option<CodeModeDispatchWorker> {
if !matches!(turn.tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly)
|| self.session.is_none()
{
return None;
}
@@ -99,50 +126,16 @@ impl CodeModeService {
session: Arc::clone(session),
turn: Arc::clone(turn),
};
let tool_runtime =
ToolCallRuntime::new(router, Arc::clone(session), Arc::clone(turn), tracker);
let host = Arc::new(CoreTurnHost { exec, tool_runtime });
Some(self.inner.start_turn_worker(host))
}
}
struct CoreTurnHost {
exec: ExecContext,
tool_runtime: ToolCallRuntime,
}
#[async_trait::async_trait]
impl CodeModeTurnHost for CoreTurnHost {
async fn invoke_tool(
&self,
invocation: CodeModeNestedToolCall,
cancellation_token: CancellationToken,
) -> Result<JsonValue, String> {
call_nested_tool(
self.exec.clone(),
self.tool_runtime.clone(),
invocation,
cancellation_token,
Some(
self.dispatch_broker
.start_turn_worker(exec, router, tracker),
)
.await
.map_err(|error| error.to_string())
}
async fn notify(&self, call_id: String, cell_id: String, text: String) -> Result<(), String> {
if text.trim().is_empty() {
return Ok(());
}
self.exec
.session
.inject_if_running(vec![ResponseItem::CustomToolCallOutput {
call_id,
name: Some(PUBLIC_TOOL_NAME.to_string()),
output: FunctionCallOutputPayload::from_text(text),
}])
.await
.map_err(|_| {
format!("failed to inject exec notify message for cell {cell_id}: no active turn")
})
fn session(&self) -> Result<&Arc<dyn CodeModeSession>, String> {
self.session
.as_ref()
.ok_or_else(|| "code mode is unavailable".to_string())
}
}
@@ -273,7 +266,7 @@ async fn call_nested_tool(
.handle_tool_call_with_source(
call,
ToolCallSource::CodeMode {
cell_id,
cell_id: cell_id.to_string(),
runtime_tool_call_id,
},
cancellation_token,
@@ -73,17 +73,24 @@ impl ToolExecutor<ToolInvocation> for CodeModeWaitHandler {
let args: ExecWaitArgs = parse_arguments(&arguments)?;
let exec = ExecContext { session, turn };
let started_at = std::time::Instant::now();
let wait_response = exec
.session
.services
.code_mode_service
.wait(codex_code_mode::WaitRequest {
cell_id: args.cell_id,
yield_time_ms: args.yield_time_ms,
terminate: args.terminate,
})
.await
.map_err(FunctionCallError::RespondToModel)?;
let cell_id = codex_code_mode::CellId::new(args.cell_id);
let wait_response = if args.terminate {
exec.session
.services
.code_mode_service
.terminate(cell_id)
.await
} else {
exec.session
.services
.code_mode_service
.wait(codex_code_mode::WaitRequest {
cell_id,
yield_time_ms: args.yield_time_ms,
})
.await
}
.map_err(FunctionCallError::RespondToModel)?;
if let codex_code_mode::WaitOutcome::LiveCell(response) = &wait_response
&& !matches!(response, codex_code_mode::RuntimeResponse::Yielded { .. })
{
@@ -98,8 +105,15 @@ impl ToolExecutor<ToolInvocation> for CodeModeWaitHandler {
exec.session
.services
.rollout_thread_trace
.code_cell_trace_context(exec.turn.sub_id.as_str(), runtime_cell_id)
.code_cell_trace_context(
exec.turn.sub_id.as_str(),
runtime_cell_id.as_str(),
)
.record_ended(response);
exec.session
.services
.code_mode_service
.finish_cell_dispatch(runtime_cell_id);
}
handle_runtime_response(&exec, wait_response.into(), args.max_tokens, started_at)
.await
@@ -12,20 +12,19 @@ pub(crate) fn create_wait_tool() -> ToolSpec {
(
"yield_time_ms".to_string(),
JsonSchema::number(Some(
"How long to wait (in milliseconds) for more output before yielding again."
.to_string(),
"Wait before yielding more output. Defaults to 10000 ms.".to_string(),
)),
),
(
"max_tokens".to_string(),
JsonSchema::number(Some(
"Maximum number of output tokens to return for this wait call.".to_string(),
"Output token budget for this wait call. Defaults to 10000 tokens.".to_string(),
)),
),
(
"terminate".to_string(),
JsonSchema::boolean(Some(
"Whether to terminate the running exec cell.".to_string(),
"True stops the running exec cell; false or omitted waits for output.".to_string(),
)),
),
]);
@@ -77,20 +76,21 @@ mod tests {
(
"max_tokens".to_string(),
JsonSchema::number(Some(
"Maximum number of output tokens to return for this wait call."
"Output token budget for this wait call. Defaults to 10000 tokens."
.to_string(),
)),
),
(
"terminate".to_string(),
JsonSchema::boolean(Some(
"Whether to terminate the running exec cell.".to_string(),
"True stops the running exec cell; false or omitted waits for output."
.to_string(),
)),
),
(
"yield_time_ms".to_string(),
JsonSchema::number(Some(
"How long to wait (in milliseconds) for more output before yielding again."
"Wait before yielding more output. Defaults to 10000 ms."
.to_string(),
)),
),
@@ -4,6 +4,14 @@ use codex_tools::ToolSpec;
use std::collections::BTreeMap;
pub fn create_spawn_agents_on_csv_tool() -> ToolSpec {
let mut output_schema = JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None,
);
output_schema.description =
Some("JSON Schema for each worker result. Omit to accept any result object.".to_string());
let properties = BTreeMap::from([
(
"csv_path".to_string(),
@@ -19,12 +27,15 @@ pub fn create_spawn_agents_on_csv_tool() -> ToolSpec {
(
"id_column".to_string(),
JsonSchema::string(Some(
"Optional column name to use as stable item id.".to_string(),
"CSV column to use as stable item id. Omit to use row numbers.".to_string(),
)),
),
(
"output_csv_path".to_string(),
JsonSchema::string(Some("Optional output CSV path for exported results.".to_string())),
JsonSchema::string(Some(
"Output CSV path for exported results. Omit to create one next to the input CSV."
.to_string(),
)),
),
(
"max_concurrency".to_string(),
@@ -36,20 +47,17 @@ pub fn create_spawn_agents_on_csv_tool() -> ToolSpec {
(
"max_workers".to_string(),
JsonSchema::number(Some(
"Alias for max_concurrency. Set to 1 to run sequentially.".to_string(),
"Alias for max_concurrency. Defaults to 16 and is capped by config.".to_string(),
)),
),
(
"max_runtime_seconds".to_string(),
JsonSchema::number(Some(
"Maximum runtime per worker before it is failed. Defaults to 1800 seconds."
"Maximum runtime per worker before failure. Defaults to 1800 seconds; config may set a different default."
.to_string(),
)),
),
(
"output_schema".to_string(),
JsonSchema::object(BTreeMap::new(), /*required*/ None, /*additional_properties*/ None),
),
("output_schema".to_string(), output_schema),
]);
ToolSpec::Function(ResponsesApiTool {
@@ -64,6 +72,13 @@ pub fn create_spawn_agents_on_csv_tool() -> ToolSpec {
}
pub fn create_report_agent_job_result_tool() -> ToolSpec {
let mut result_schema = JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None,
);
result_schema.description = Some("Result object for this job item.".to_string());
let properties = BTreeMap::from([
(
"job_id".to_string(),
@@ -73,14 +88,11 @@ pub fn create_report_agent_job_result_tool() -> ToolSpec {
"item_id".to_string(),
JsonSchema::string(Some("Identifier of the job item.".to_string())),
),
(
"result".to_string(),
JsonSchema::object(BTreeMap::new(), /*required*/ None, /*additional_properties*/ None),
),
("result".to_string(), result_schema),
(
"stop".to_string(),
JsonSchema::boolean(Some(
"Optional. When true, cancels the remaining job items after this result is recorded."
"True cancels remaining job items after this result is recorded; false or omitted continues the job."
.to_string(),
)),
),
@@ -3,6 +3,16 @@ use codex_tools::JsonSchema;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
fn described_object(description: &str) -> JsonSchema {
let mut schema = JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None,
);
schema.description = Some(description.to_string());
schema
}
#[test]
fn spawn_agents_on_csv_tool_requires_csv_and_instruction() {
assert_eq!(
@@ -30,13 +40,15 @@ fn spawn_agents_on_csv_tool_requires_csv_and_instruction() {
(
"id_column".to_string(),
JsonSchema::string(Some(
"Optional column name to use as stable item id.".to_string(),
"CSV column to use as stable item id. Omit to use row numbers."
.to_string(),
)),
),
(
"output_csv_path".to_string(),
JsonSchema::string(Some(
"Optional output CSV path for exported results.".to_string(),
"Output CSV path for exported results. Omit to create one next to the input CSV."
.to_string(),
)),
),
(
@@ -49,22 +61,21 @@ fn spawn_agents_on_csv_tool_requires_csv_and_instruction() {
(
"max_workers".to_string(),
JsonSchema::number(Some(
"Alias for max_concurrency. Set to 1 to run sequentially.".to_string(),
"Alias for max_concurrency. Defaults to 16 and is capped by config."
.to_string(),
)),
),
(
"max_runtime_seconds".to_string(),
JsonSchema::number(Some(
"Maximum runtime per worker before it is failed. Defaults to 1800 seconds."
"Maximum runtime per worker before failure. Defaults to 1800 seconds; config may set a different default."
.to_string(),
)),
),
(
"output_schema".to_string(),
JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None,
described_object(
"JSON Schema for each worker result. Omit to accept any result object.",
),
),
]), Some(vec!["csv_path".to_string(), "instruction".to_string()]), Some(false.into())),
@@ -95,16 +106,12 @@ fn report_agent_job_result_tool_requires_result_payload() {
),
(
"result".to_string(),
JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None,
),
described_object("Result object for this job item."),
),
(
"stop".to_string(),
JsonSchema::boolean(Some(
"Optional. When true, cancels the remaining job items after this result is recorded."
"True cancels remaining job items after this result is recorded; false or omitted continues the job."
.to_string(),
)),
),
@@ -4,15 +4,19 @@ use std::sync::Weak;
use codex_protocol::items::TurnItem;
use codex_tools::ConversationHistory;
use codex_tools::ExtensionTurnItem;
use codex_tools::ImageGenerationCompletionFuture;
use codex_tools::ToolCall as ExtensionToolCall;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use codex_tools::TurnItemEmissionFuture;
use codex_tools::TurnItemEmitter;
use crate::context::ContextualUserFragment;
use crate::context::ImageGenerationInstructions;
use crate::function_tool::FunctionCallError;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::stream_events_utils::persist_image_generation_item;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
@@ -90,6 +94,50 @@ impl TurnItemEmitter for CoreTurnItemEmitter {
session.emit_turn_item_completed(turn.as_ref(), item).await;
})
}
fn image_generation_completed<'a>(
&'a self,
call_id: String,
prompt: String,
result: String,
) -> ImageGenerationCompletionFuture<'a> {
Box::pin(async move {
let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else {
return None;
};
let mut item = codex_protocol::items::ImageGenerationItem {
id: call_id,
status: "completed".to_string(),
revised_prompt: Some(prompt),
result,
saved_path: None,
};
let output_hint =
persist_image_generation_item(session.as_ref(), turn.as_ref(), &mut item)
.await
.map(|saved_path| {
let output_dir = saved_path
.parent()
.unwrap_or_else(|| turn.config.codex_home.clone());
ImageGenerationInstructions::new(output_dir.display(), saved_path.display())
.body()
});
let started_item = codex_protocol::items::ImageGenerationItem {
id: item.id.clone(),
status: "in_progress".to_string(),
revised_prompt: None,
result: String::new(),
saved_path: None,
};
session
.emit_turn_item_started(turn.as_ref(), &TurnItem::ImageGeneration(started_item))
.await;
session
.emit_turn_item_completed(turn.as_ref(), TurnItem::ImageGeneration(item))
.await;
output_hint
})
}
}
async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall {
@@ -99,6 +147,7 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall {
turn_id: invocation.turn.sub_id.clone(),
call_id: invocation.call_id.clone(),
tool_name: invocation.tool_name.clone(),
model: invocation.turn.model_info.slug.clone(),
truncation_policy: invocation.turn.truncation_policy,
conversation_history,
turn_item_emitter: Arc::new(CoreTurnItemEmitter {
@@ -259,6 +308,7 @@ mod tests {
let weak_session = Arc::downgrade(&session);
let weak_turn = Arc::downgrade(&turn);
let turn_id = turn.sub_id.clone();
let model = turn.model_info.slug.clone();
let truncation_policy = turn.truncation_policy;
let history_item = ResponseItem::Message {
id: None,
@@ -302,6 +352,7 @@ mod tests {
captured_call.tool_name,
codex_tools::ToolName::plain("extension_echo")
);
assert_eq!(captured_call.model, model);
assert_eq!(captured_call.truncation_policy, truncation_policy);
assert_eq!(
captured_call.conversation_history.items(),
@@ -352,4 +403,130 @@ mod tests {
assert_eq!(end.query, expected.query);
assert_eq!(end.action, expected.action);
}
struct ImageGenerationExtensionExecutor {
output_hint: Arc<Mutex<Option<String>>>,
}
#[async_trait::async_trait]
impl codex_extension_api::ToolExecutor<codex_tools::ToolCall> for ImageGenerationExtensionExecutor {
fn tool_name(&self) -> codex_tools::ToolName {
codex_tools::ToolName::namespaced("image_gen", "imagegen")
}
fn spec(&self) -> codex_tools::ToolSpec {
codex_tools::ToolSpec::Function(codex_tools::ResponsesApiTool {
name: "imagegen".to_string(),
description: "Generates an image.".to_string(),
strict: false,
parameters: codex_tools::JsonSchema::default(),
output_schema: None,
defer_loading: None,
})
}
async fn handle(
&self,
call: codex_tools::ToolCall,
) -> Result<Box<dyn codex_tools::ToolOutput>, codex_tools::FunctionCallError> {
let output_hint = call
.turn_item_emitter
.image_generation_completed(
call.call_id,
"A tiny blue square".to_string(),
"cG5n".to_string(),
)
.await;
*self.output_hint.lock().await = output_hint;
Ok(Box::new(codex_tools::JsonToolOutput::new(
json!({ "ok": true }),
)))
}
}
#[tokio::test]
async fn image_generation_publication_is_finalized_by_core() {
let output_hint = Arc::new(Mutex::new(None));
let handler = ExtensionToolAdapter::new(Arc::new(ImageGenerationExtensionExecutor {
output_hint: Arc::clone(&output_hint),
}));
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(),
"call-image",
);
let invocation = ToolInvocation {
session,
turn,
cancellation_token: tokio_util::sync::CancellationToken::new(),
tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),
call_id: "call-image".to_string(),
tool_name: codex_tools::ToolName::namespaced("image_gen", "imagegen"),
source: ToolCallSource::Direct,
payload: ToolPayload::Function {
arguments: "{}".to_string(),
},
};
crate::tools::registry::ToolExecutor::handle(&handler, invocation)
.await
.expect("extension call should succeed");
let started = rx.recv().await.expect("item started event");
let EventMsg::ItemStarted(started) = started.msg else {
panic!("expected item started event");
};
let TurnItem::ImageGeneration(started_item) = started.item else {
panic!("expected image generation item");
};
let begin = rx.recv().await.expect("legacy image start event");
assert!(matches!(begin.msg, EventMsg::ImageGenerationBegin(_)));
let completed = rx.recv().await.expect("item completed event");
let EventMsg::ItemCompleted(completed) = completed.msg else {
panic!("expected item completed event");
};
let TurnItem::ImageGeneration(completed_item) = completed.item else {
panic!("expected image generation item");
};
let end = rx.recv().await.expect("legacy image end event");
assert!(matches!(end.msg, EventMsg::ImageGenerationEnd(_)));
assert_eq!(
started_item,
codex_protocol::items::ImageGenerationItem {
id: "call-image".to_string(),
status: "in_progress".to_string(),
revised_prompt: None,
result: String::new(),
saved_path: None,
}
);
assert_eq!(
completed_item,
codex_protocol::items::ImageGenerationItem {
id: "call-image".to_string(),
status: "completed".to_string(),
revised_prompt: Some("A tiny blue square".to_string()),
result: "cG5n".to_string(),
saved_path: Some(expected_path.clone()),
}
);
assert_eq!(
std::fs::read(&expected_path).expect("generated artifact should be saved"),
b"png"
);
assert_eq!(
*output_hint.lock().await,
Some(format!(
"Generated images are saved to {} as {} by default.\n\
If you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.",
expected_path
.parent()
.expect("generated image path should have a parent")
.display(),
expected_path.display(),
))
);
}
}
@@ -37,7 +37,8 @@ pub fn create_create_goal_tool() -> ToolSpec {
(
"token_budget".to_string(),
JsonSchema::integer(Some(
"Optional positive token budget for the new active goal.".to_string(),
"Positive token budget for the new goal. Omit unless explicitly requested."
.to_string(),
)),
),
]);
@@ -8,14 +8,13 @@ pub fn create_list_mcp_resources_tool() -> ToolSpec {
(
"server".to_string(),
JsonSchema::string(Some(
"Optional MCP server name. When omitted, lists resources from every configured server."
.to_string(),
"MCP server name. Omit to list resources from every configured server.".to_string(),
)),
),
(
"cursor".to_string(),
JsonSchema::string(Some(
"Opaque cursor returned by a previous list_mcp_resources call for the same server."
"Opaque cursor from a previous list_mcp_resources call; omit for the first page."
.to_string(),
)),
),
@@ -36,14 +35,14 @@ pub fn create_list_mcp_resource_templates_tool() -> ToolSpec {
(
"server".to_string(),
JsonSchema::string(Some(
"Optional MCP server name. When omitted, lists resource templates from all configured servers."
"MCP server name. Omit to list resource templates from every configured server."
.to_string(),
)),
),
(
"cursor".to_string(),
JsonSchema::string(Some(
"Opaque cursor returned by a previous list_mcp_resource_templates call for the same server."
"Opaque cursor from a previous list_mcp_resource_templates call; omit for the first page."
.to_string(),
)),
),
@@ -16,14 +16,14 @@ fn list_mcp_resources_tool_matches_expected_spec() {
(
"server".to_string(),
JsonSchema::string(Some(
"Optional MCP server name. When omitted, lists resources from every configured server."
"MCP server name. Omit to list resources from every configured server."
.to_string(),
),),
),
(
"cursor".to_string(),
JsonSchema::string(Some(
"Opaque cursor returned by a previous list_mcp_resources call for the same server."
"Opaque cursor from a previous list_mcp_resources call; omit for the first page."
.to_string(),
),),
),
@@ -46,14 +46,14 @@ fn list_mcp_resource_templates_tool_matches_expected_spec() {
(
"server".to_string(),
JsonSchema::string(Some(
"Optional MCP server name. When omitted, lists resource templates from all configured servers."
"MCP server name. Omit to list resource templates from every configured server."
.to_string(),
),),
),
(
"cursor".to_string(),
JsonSchema::string(Some(
"Opaque cursor returned by a previous list_mcp_resource_templates call for the same server."
"Opaque cursor from a previous list_mcp_resource_templates call; omit for the first page."
.to_string(),
),),
),
@@ -11,9 +11,11 @@ use std::collections::BTreeMap;
pub const MULTI_AGENT_V1_NAMESPACE: &str = "multi_agent_v1";
const MULTI_AGENT_V1_NAMESPACE_DESCRIPTION: &str = "Tools for spawning and managing sub-agents.";
const SPAWN_AGENT_INHERITED_MODEL_GUIDANCE: &str = "Spawned agents inherit your current model by default. Omit `model` to use that preferred default; set `model` only when an explicit override is needed.";
const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str = "Optional model override for the new agent. Leave unset to inherit the same model as the parent, which is the preferred default. Only set this when the user explicitly asks for a different model or the task clearly requires one.";
const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str = "Optional service tier override for the new agent. Leave unset unless the user explicitly asks for one.";
const SPAWN_AGENT_INHERITED_MODEL_GUIDANCE: &str = "Spawned agents inherit your current model by default. If provided, `model` specifies the model to use for the spawned agent.";
const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str =
"Model override for the new agent. Omit to inherit the parent model.";
const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str =
"Service tier override for the new agent. Omit unless explicitly requested.";
const MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT_DESCRIPTION: usize = 5;
#[derive(Debug, Clone, Default)]
@@ -125,7 +127,7 @@ pub fn create_send_input_tool_v1() -> ToolSpec {
(
"interrupt".to_string(),
JsonSchema::boolean(Some(
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message."
"True interrupts the current task and handles this message immediately; false or omitted queues it."
.to_string(),
)),
),
@@ -258,7 +260,7 @@ pub fn create_list_agents_tool() -> ToolSpec {
let properties = BTreeMap::from([(
"path_prefix".to_string(),
JsonSchema::string(Some(
"Optional task-path prefix (not ending with trailing slash). Accepts the same relative or absolute task-path syntax."
"Task-path prefix filter without a trailing slash. Omit to list all live agents."
.to_string(),
)),
)]);
@@ -555,7 +557,7 @@ fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap<St
(
"fork_context".to_string(),
JsonSchema::boolean(Some(
"When true, fork the current thread history into the new agent before sending the initial prompt. This must be used when you want the new agent to have exactly the same context as you."
"True forks the current thread history into the new agent; false or omitted starts with only the initial prompt."
.to_string(),
)),
),
@@ -568,7 +570,7 @@ fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap<St
(
"reasoning_effort".to_string(),
JsonSchema::string(Some(
"Optional reasoning effort override for the new agent. Replaces the inherited reasoning effort."
"Reasoning effort override for the new agent. Omit to inherit the parent effort."
.to_string(),
)),
),
@@ -607,7 +609,7 @@ fn spawn_agent_common_properties_v2(agent_type_description: &str) -> BTreeMap<St
(
"reasoning_effort".to_string(),
JsonSchema::string(Some(
"Optional reasoning effort override for the new agent. Replaces the inherited reasoning effort."
"Reasoning effort override for the new agent. Omit to inherit the parent effort."
.to_string(),
)),
),
@@ -807,7 +809,7 @@ fn wait_agent_tool_parameters_v1(options: WaitAgentTimeoutOptions) -> JsonSchema
(
"timeout_ms".to_string(),
JsonSchema::number(Some(format!(
"Optional timeout in milliseconds. Defaults to {}, min {}, max {}. Prefer longer waits (minutes) to avoid busy polling.",
"Timeout in milliseconds. Defaults to {}, min {}, max {}. Prefer longer waits (minutes) to avoid busy polling.",
options.default_timeout_ms, options.min_timeout_ms, options.max_timeout_ms,
))),
),
@@ -824,7 +826,7 @@ fn wait_agent_tool_parameters_v2(options: WaitAgentTimeoutOptions) -> JsonSchema
let properties = BTreeMap::from([(
"timeout_ms".to_string(),
JsonSchema::number(Some(format!(
"Optional timeout in milliseconds. Defaults to {}, min {}, max {}.",
"Timeout in milliseconds. Defaults to {}, min {}, max {}.",
options.default_timeout_ms, options.min_timeout_ms, options.max_timeout_ms,
))),
)]);
@@ -306,7 +306,7 @@ fn wait_agent_tool_v2_uses_timeout_only_summary_output() {
properties
.get("timeout_ms")
.and_then(|schema| schema.description.as_deref()),
Some("Optional timeout in milliseconds. Defaults to 30000, min 10000, max 3600000.")
Some("Timeout in milliseconds. Defaults to 30000, min 10000, max 3600000.")
);
assert_eq!(parameters.required.as_ref(), None);
assert_eq!(
@@ -338,9 +338,7 @@ fn list_agents_tool_includes_path_prefix_and_agent_fields() {
properties
.get("path_prefix")
.and_then(|schema| schema.description.as_deref()),
Some(
"Optional task-path prefix (not ending with trailing slash). Accepts the same relative or absolute task-path syntax."
)
Some("Task-path prefix filter without a trailing slash. Omit to list all live agents.")
);
assert_eq!(
output_schema.expect("list_agents output schema")["properties"]["agents"]["items"]["required"],
+12 -3
View File
@@ -1,21 +1,30 @@
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use serde_json::json;
use std::collections::BTreeMap;
pub fn create_update_plan_tool() -> ToolSpec {
let plan_item_properties = BTreeMap::from([
("step".to_string(), JsonSchema::string(/*description*/ None)),
(
"step".to_string(),
JsonSchema::string(Some("Task step text.".to_string())),
),
(
"status".to_string(),
JsonSchema::string(Some("One of: pending, in_progress, completed".to_string())),
JsonSchema::string_enum(
vec![json!("pending"), json!("in_progress"), json!("completed")],
Some("Step status.".to_string()),
),
),
]);
let properties = BTreeMap::from([
(
"explanation".to_string(),
JsonSchema::string(/*description*/ None),
JsonSchema::string(Some(
"Optional explanation for this plan update.".to_string(),
)),
),
(
"plan".to_string(),
+60 -42
View File
@@ -28,7 +28,7 @@ pub(crate) fn create_exec_command_tool_with_environment_id(
(
"workdir".to_string(),
JsonSchema::string(Some(
"Optional working directory to run the command in; defaults to the turn cwd."
"Working directory for the command. Defaults to the turn cwd."
.to_string(),
)),
),
@@ -41,20 +41,20 @@ pub(crate) fn create_exec_command_tool_with_environment_id(
(
"tty".to_string(),
JsonSchema::boolean(Some(
"Whether to allocate a TTY for the command. Defaults to false (plain pipes); set to true to open a PTY and access TTY process."
"True allocates a PTY for the command; false or omitted uses plain pipes."
.to_string(),
)),
),
(
"yield_time_ms".to_string(),
JsonSchema::number(Some(
"How long to wait (in milliseconds) for output before yielding.".to_string(),
"Wait before yielding output. Defaults to 10000 ms; effective range is 250-30000 ms.".to_string(),
)),
),
(
"max_output_tokens".to_string(),
JsonSchema::number(Some(
"Maximum number of tokens to return. Excess output will be truncated.".to_string(),
"Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.".to_string(),
)),
),
]);
@@ -62,7 +62,8 @@ pub(crate) fn create_exec_command_tool_with_environment_id(
properties.insert(
"login".to_string(),
JsonSchema::boolean(Some(
"Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(),
"True runs the shell with -l/-i semantics; false disables them. Defaults to true."
.to_string(),
)),
);
}
@@ -70,7 +71,8 @@ pub(crate) fn create_exec_command_tool_with_environment_id(
properties.insert(
"environment_id".to_string(),
JsonSchema::string(Some(
"Optional environment id from the <environment_context> block. If omitted, uses the primary environment.".to_string(),
"Environment id from <environment_context>. Omit to use the primary environment."
.to_string(),
)),
);
}
@@ -111,19 +113,19 @@ pub fn create_write_stdin_tool() -> ToolSpec {
(
"chars".to_string(),
JsonSchema::string(Some(
"Bytes to write to stdin (may be empty to poll).".to_string(),
"Bytes to write to stdin. Defaults to empty, which polls without writing.".to_string(),
)),
),
(
"yield_time_ms".to_string(),
JsonSchema::number(Some(
"How long to wait (in milliseconds) for output before yielding.".to_string(),
"Wait before yielding output. Non-empty writes default to 250 ms and cap at 30000 ms; empty polls wait 5000-300000 ms by default.".to_string(),
)),
),
(
"max_output_tokens".to_string(),
JsonSchema::number(Some(
"Maximum number of tokens to return. Excess output will be truncated.".to_string(),
"Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.".to_string(),
)),
),
]);
@@ -149,19 +151,19 @@ pub fn create_shell_command_tool(options: CommandToolOptions) -> ToolSpec {
(
"command".to_string(),
JsonSchema::string(Some(
"The shell script to execute in the user's default shell".to_string(),
"Shell script to run in the user's default shell.".to_string(),
)),
),
(
"workdir".to_string(),
JsonSchema::string(Some(
"The working directory to execute the command in".to_string(),
"Working directory for the command. Defaults to the turn cwd.".to_string(),
)),
),
(
"timeout_ms".to_string(),
JsonSchema::number(Some(
"The timeout for the command in milliseconds".to_string(),
"Maximum command runtime. Defaults to 10000 ms.".to_string(),
)),
),
]);
@@ -169,7 +171,7 @@ pub fn create_shell_command_tool(options: CommandToolOptions) -> ToolSpec {
properties.insert(
"login".to_string(),
JsonSchema::boolean(Some(
"Whether to run the shell with login shell semantics. Defaults to true."
"True runs with login shell semantics; false disables them. Defaults to true."
.to_string(),
)),
);
@@ -281,92 +283,108 @@ fn unified_exec_output_schema() -> Value {
fn create_approval_parameters(
exec_permission_approvals_enabled: bool,
) -> BTreeMap<String, JsonSchema> {
let mut sandbox_permission_values = vec![json!("use_default")];
if exec_permission_approvals_enabled {
sandbox_permission_values.push(json!("with_additional_permissions"));
}
sandbox_permission_values.push(json!("require_escalated"));
let sandbox_permissions_description = if exec_permission_approvals_enabled {
"Per-command sandbox override. Defaults to `use_default`; use `with_additional_permissions` with `additional_permissions`, or `require_escalated` for unsandboxed execution."
} else {
"Per-command sandbox override. Defaults to `use_default`; use `require_escalated` for unsandboxed execution."
};
let mut properties = BTreeMap::from([
(
"sandbox_permissions".to_string(),
JsonSchema::string(Some(
if exec_permission_approvals_enabled {
"Sandbox permissions for the command. Use \"with_additional_permissions\" to request additional sandboxed filesystem or network permissions (preferred), or \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
} else {
"Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
}
.to_string(),
)),
JsonSchema::string_enum(
sandbox_permission_values,
Some(sandbox_permissions_description.to_string()),
),
),
(
"justification".to_string(),
JsonSchema::string(Some(
r#"Only set if sandbox_permissions is \"require_escalated\".
Request approval from the user to run this command outside the sandbox.
Phrased as a simple question that summarizes the purpose of the
command as it relates to the task at hand - e.g. 'Do you want to
fetch and pull the latest version of this git branch?'"#
.to_string(),
"User-facing approval question for `require_escalated`; omit otherwise.".to_string(),
)),
),
(
"prefix_rule".to_string(),
JsonSchema::array(JsonSchema::string(/*description*/ None), Some(
r#"Only specify when sandbox_permissions is `require_escalated`.
Suggest a prefix command pattern that will allow you to fulfill similar requests from the user in the future.
Should be a short but reasonable prefix, e.g. [\"git\", \"pull\"] or [\"uv\", \"run\"] or [\"pytest\"]."#.to_string(),
r#"Reusable approval prefix for `cmd`, only with `sandbox_permissions: "require_escalated"`; for example ["git", "pull"]."#.to_string(),
)),
),
]);
if exec_permission_approvals_enabled {
properties.insert(
"additional_permissions".to_string(),
permission_profile_schema(),
let mut additional_permissions = permission_profile_schema();
additional_permissions.description = Some(
"Sandboxed filesystem or network access for this command; only with `sandbox_permissions: \"with_additional_permissions\"`."
.to_string(),
);
properties.insert("additional_permissions".to_string(), additional_permissions);
}
properties
}
fn permission_profile_schema() -> JsonSchema {
JsonSchema::object(
let mut schema = JsonSchema::object(
BTreeMap::from([
("network".to_string(), network_permissions_schema()),
("file_system".to_string(), file_system_permissions_schema()),
]),
/*required*/ None,
Some(false.into()),
)
);
schema.description = Some("Filesystem or network access request.".to_string());
schema
}
fn network_permissions_schema() -> JsonSchema {
JsonSchema::object(
let mut schema = JsonSchema::object(
BTreeMap::from([(
"enabled".to_string(),
JsonSchema::boolean(Some("Set to true to request network access.".to_string())),
JsonSchema::boolean(Some(
"True requests network access; false or omitted requests none.".to_string(),
)),
)]),
/*required*/ None,
Some(false.into()),
)
);
schema.description = Some("Network access request.".to_string());
schema
}
fn file_system_permissions_schema() -> JsonSchema {
JsonSchema::object(
let mut schema = JsonSchema::object(
BTreeMap::from([
(
"read".to_string(),
JsonSchema::array(
JsonSchema::string(/*description*/ None),
Some("Absolute paths to grant read access to.".to_string()),
Some(
"Absolute paths to grant read access; omit when none are needed."
.to_string(),
),
),
),
(
"write".to_string(),
JsonSchema::array(
JsonSchema::string(/*description*/ None),
Some("Absolute paths to grant write access to.".to_string()),
Some(
"Absolute paths to grant write access; omit when none are needed."
.to_string(),
),
),
),
]),
/*required*/ None,
Some(false.into()),
)
);
schema.description = Some("Filesystem access request.".to_string());
schema
}
fn windows_shell_guidance() -> &'static str {
@@ -31,7 +31,7 @@ fn exec_command_tool_matches_expected_spec() {
(
"workdir".to_string(),
JsonSchema::string(Some(
"Optional working directory to run the command in; defaults to the turn cwd."
"Working directory for the command. Defaults to the turn cwd."
.to_string(),
)),
),
@@ -44,27 +44,26 @@ fn exec_command_tool_matches_expected_spec() {
(
"tty".to_string(),
JsonSchema::boolean(Some(
"Whether to allocate a TTY for the command. Defaults to false (plain pipes); set to true to open a PTY and access TTY process."
"True allocates a PTY for the command; false or omitted uses plain pipes."
.to_string(),
)),
),
(
"yield_time_ms".to_string(),
JsonSchema::number(Some(
"How long to wait (in milliseconds) for output before yielding.".to_string(),
"Wait before yielding output. Defaults to 10000 ms; effective range is 250-30000 ms.".to_string(),
)),
),
(
"max_output_tokens".to_string(),
JsonSchema::number(Some(
"Maximum number of tokens to return. Excess output will be truncated."
.to_string(),
"Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.".to_string(),
)),
),
(
"login".to_string(),
JsonSchema::boolean(Some(
"Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(),
"True runs the shell with -l/-i semantics; false disables them. Defaults to true.".to_string(),
)),
),
]);
@@ -103,19 +102,19 @@ fn write_stdin_tool_matches_expected_spec() {
(
"chars".to_string(),
JsonSchema::string(Some(
"Bytes to write to stdin (may be empty to poll).".to_string(),
"Bytes to write to stdin. Defaults to empty, which polls without writing.".to_string(),
)),
),
(
"yield_time_ms".to_string(),
JsonSchema::number(Some(
"How long to wait (in milliseconds) for output before yielding.".to_string(),
"Wait before yielding output. Non-empty writes default to 250 ms and cap at 30000 ms; empty polls wait 5000-300000 ms by default.".to_string(),
)),
),
(
"max_output_tokens".to_string(),
JsonSchema::number(Some(
"Maximum number of tokens to return. Excess output will be truncated.".to_string(),
"Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.".to_string(),
)),
),
]);
@@ -201,25 +200,25 @@ Examples of valid command strings:
(
"command".to_string(),
JsonSchema::string(Some(
"The shell script to execute in the user's default shell".to_string(),
"Shell script to run in the user's default shell.".to_string(),
)),
),
(
"workdir".to_string(),
JsonSchema::string(Some(
"The working directory to execute the command in".to_string(),
"Working directory for the command. Defaults to the turn cwd.".to_string(),
)),
),
(
"timeout_ms".to_string(),
JsonSchema::number(Some(
"The timeout for the command in milliseconds".to_string(),
"Maximum command runtime. Defaults to 10000 ms.".to_string(),
)),
),
(
"login".to_string(),
JsonSchema::boolean(Some(
"Whether to run the shell with login shell semantics. Defaults to true."
"True runs with login shell semantics; false disables them. Defaults to true."
.to_string(),
)),
),
@@ -20,7 +20,7 @@ pub fn create_test_sync_tool() -> ToolSpec {
(
"timeout_ms".to_string(),
JsonSchema::number(Some(
"Maximum time in milliseconds to wait at the barrier".to_string(),
"Maximum barrier wait in milliseconds. Defaults to 1000.".to_string(),
)),
),
]);
@@ -29,13 +29,13 @@ pub fn create_test_sync_tool() -> ToolSpec {
(
"sleep_before_ms".to_string(),
JsonSchema::number(Some(
"Optional delay in milliseconds before any other action".to_string(),
"Delay before any other action. Defaults to no delay.".to_string(),
)),
),
(
"sleep_after_ms".to_string(),
JsonSchema::number(Some(
"Optional delay in milliseconds after completing the barrier".to_string(),
"Delay after completing the barrier. Defaults to no delay.".to_string(),
)),
),
(
@@ -35,7 +35,7 @@ fn test_sync_tool_matches_expected_spec() {
(
"timeout_ms".to_string(),
JsonSchema::number(Some(
"Maximum time in milliseconds to wait at the barrier"
"Maximum barrier wait in milliseconds. Defaults to 1000."
.to_string(),
)),
),
@@ -47,14 +47,14 @@ fn test_sync_tool_matches_expected_spec() {
(
"sleep_after_ms".to_string(),
JsonSchema::number(Some(
"Optional delay in milliseconds after completing the barrier"
"Delay after completing the barrier. Defaults to no delay."
.to_string(),
)),
),
(
"sleep_before_ms".to_string(),
JsonSchema::number(Some(
"Optional delay in milliseconds before any other action".to_string(),
"Delay before any other action. Defaults to no delay.".to_string(),
)),
),
]), /*required*/ None, Some(false.into())),
@@ -16,7 +16,7 @@ pub(crate) fn create_tool_search_tool(
(
"limit".to_string(),
JsonSchema::number(Some(format!(
"Maximum number of tools to return (defaults to {default_limit})."
"Maximum number of tools to return. Defaults to {default_limit}."
))),
),
]);
@@ -98,7 +98,7 @@ mod tests {
(
"limit".to_string(),
JsonSchema::number(Some(
"Maximum number of tools to return (defaults to 8)."
"Maximum number of tools to return. Defaults to 8."
.to_string(),
),),
),
@@ -15,7 +15,7 @@ pub struct ViewImageToolOptions {
pub fn create_view_image_tool(options: ViewImageToolOptions) -> ToolSpec {
let mut properties = BTreeMap::from([(
"path".to_string(),
JsonSchema::string(Some("Local filesystem path to an image file".to_string())),
JsonSchema::string(Some("Local filesystem path to an image file.".to_string())),
)]);
if options.can_request_original_image_detail {
properties.insert(
@@ -23,7 +23,7 @@ pub fn create_view_image_tool(options: ViewImageToolOptions) -> ToolSpec {
JsonSchema::string_enum(
vec![json!("high"), json!("original")],
Some(
"Optional detail override. Supported values are `high` and `original`; omit this field for default high resized behavior. Use `original` to preserve the file's original resolution instead of resizing to fit. This is important when high-fidelity image perception or precise localization is needed, especially for CUA agents.".to_string(),
"Image detail level. Defaults to `high`; use `original` to preserve exact resolution.".to_string(),
),
),
);
@@ -32,7 +32,7 @@ pub fn create_view_image_tool(options: ViewImageToolOptions) -> ToolSpec {
properties.insert(
"environment_id".to_string(),
JsonSchema::string(Some(
"Optional selected environment id to target. Omit this to use the primary environment."
"Environment id from <environment_context>. Omit to use the primary environment."
.to_string(),
)),
);
+31 -11
View File
@@ -28,6 +28,7 @@ use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::default_exec_approval_requirement;
use crate::tools::sandboxing::sandbox_override_for_first_attempt;
use crate::tools::sandboxing::unsandboxed_execution_allowed;
use codex_hooks::PermissionRequestDecision;
use codex_otel::ToolDecisionSource;
use codex_protocol::error::CodexErr;
@@ -294,6 +295,8 @@ impl ToolOrchestrator {
network_policy_decision,
})));
}
let unsandboxed_allowed =
unsandboxed_execution_allowed(&file_system_sandbox_policy);
// Under `Never` or `OnRequest`, do not retry without sandbox;
// surface a concise sandbox denial that preserves the
// original output.
@@ -315,6 +318,12 @@ impl ToolOrchestrator {
})));
}
}
if !unsandboxed_allowed && network_approval_context.is_none() {
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
output,
network_policy_decision,
})));
}
let retry_reason =
if let Some(network_approval_context) = network_approval_context.as_ref() {
format!(
@@ -357,14 +366,30 @@ impl ToolOrchestrator {
.await?;
}
let escalated_attempt = SandboxAttempt {
sandbox: SandboxType::None,
let retry_sandbox = if unsandboxed_allowed {
SandboxType::None
} else {
self.sandbox.select_initial(
&file_system_sandbox_policy,
network_sandbox_policy,
tool.sandbox_preference(),
turn_ctx.windows_sandbox_level,
managed_network_active,
)
};
let retry_codex_linux_sandbox_exe = if unsandboxed_allowed {
None
} else {
turn_ctx.codex_linux_sandbox_exe.as_ref()
};
let retry_attempt = SandboxAttempt {
sandbox: retry_sandbox,
permissions: &turn_ctx.permission_profile,
enforce_managed_network: managed_network_active,
manager: &self.sandbox,
sandbox_cwd,
workspace_roots: workspace_roots.as_slice(),
codex_linux_sandbox_exe: None,
codex_linux_sandbox_exe: retry_codex_linux_sandbox_exe,
use_legacy_landlock,
windows_sandbox_level: turn_ctx.windows_sandbox_level,
windows_sandbox_private_desktop: turn_ctx
@@ -375,14 +400,9 @@ impl ToolOrchestrator {
};
// Second attempt.
let (retry_result, retry_deferred_network_approval) = Self::run_attempt(
tool,
req,
tool_ctx,
&escalated_attempt,
managed_network_active,
)
.await;
let (retry_result, retry_deferred_network_approval) =
Self::run_attempt(tool, req, tool_ctx, &retry_attempt, managed_network_active)
.await;
retry_result.map(|output| OrchestratorRunResult {
output,
deferred_network_approval: retry_deferred_network_approval,
+14 -3
View File
@@ -36,6 +36,7 @@ use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::managed_network_for_sandbox_permissions;
use crate::tools::sandboxing::sandbox_permissions_preserving_denied_reads;
use crate::tools::sandboxing::with_cached_approval;
use codex_network_proxy::NetworkProxy;
use codex_protocol::exec_output::ExecToolCallOutput;
@@ -207,8 +208,13 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
req: &ShellRequest,
ctx: &ToolCtx,
) -> Option<NetworkApprovalSpec> {
let file_system_sandbox_policy = ctx.turn.file_system_sandbox_policy();
let sandbox_permissions = sandbox_permissions_preserving_denied_reads(
req.sandbox_permissions,
&file_system_sandbox_policy,
);
let network =
managed_network_for_sandbox_permissions(req.network.as_ref(), req.sandbox_permissions)?;
managed_network_for_sandbox_permissions(req.network.as_ref(), sandbox_permissions)?;
Some(NetworkApprovalSpec {
network: Some(network.clone()),
mode: NetworkApprovalMode::Immediate,
@@ -233,9 +239,14 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
ctx: &ToolCtx,
) -> Result<ExecToolCallOutput, ToolError> {
let session_shell = ctx.session.user_shell();
let (file_system_sandbox_policy, _) = attempt.permissions.to_runtime_permissions();
let sandbox_permissions = sandbox_permissions_preserving_denied_reads(
req.sandbox_permissions,
&file_system_sandbox_policy,
);
let managed_network =
managed_network_for_sandbox_permissions(req.network.as_ref(), req.sandbox_permissions);
let env = exec_env_for_sandbox_permissions(&req.env, req.sandbox_permissions);
managed_network_for_sandbox_permissions(req.network.as_ref(), sandbox_permissions);
let env = exec_env_for_sandbox_permissions(&req.env, sandbox_permissions);
let explicit_env_overrides = req.explicit_env_overrides.clone();
#[cfg(unix)]
let (env, explicit_env_overrides) = {
@@ -22,6 +22,8 @@ use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::managed_network_for_sandbox_permissions;
use crate::tools::sandboxing::sandbox_permissions_preserving_denied_reads;
use crate::tools::sandboxing::unsandboxed_execution_allowed;
use codex_execpolicy::Decision;
use codex_execpolicy::Evaluation;
use codex_execpolicy::MatchOptions;
@@ -117,6 +119,15 @@ pub(super) async fn try_run_zsh_fork(
return Ok(None);
}
let (attempt_file_system_sandbox_policy, _) = attempt.permissions.to_runtime_permissions();
let sandbox_permissions = sandbox_permissions_preserving_denied_reads(
req.sandbox_permissions,
&attempt_file_system_sandbox_policy,
);
let req = &ShellRequest {
sandbox_permissions,
..req.clone()
};
let mut env = exec_env_for_sandbox_permissions(&req.env, req.sandbox_permissions);
prepend_zsh_fork_bin_to_path(&mut env, shell_zsh_path);
let command =
@@ -372,11 +383,18 @@ impl CoreShellActionProvider {
fn shell_request_escalation_execution(
sandbox_permissions: SandboxPermissions,
permission_profile: &PermissionProfile,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
additional_permissions: Option<&AdditionalPermissionProfile>,
) -> EscalationExecution {
match sandbox_permissions {
SandboxPermissions::UseDefault => EscalationExecution::TurnDefault,
SandboxPermissions::RequireEscalated => EscalationExecution::Unsandboxed,
SandboxPermissions::RequireEscalated => {
if unsandboxed_execution_allowed(file_system_sandbox_policy) {
EscalationExecution::Unsandboxed
} else {
EscalationExecution::TurnDefault
}
}
SandboxPermissions::WithAdditionalPermissions => additional_permissions
.map(|_| {
// Shell request additional permissions were already normalized and
@@ -616,8 +634,10 @@ impl EscalationPolicy for CoreShellActionProvider {
// fallback function.
let decision_driven_by_policy =
Self::decision_driven_by_policy(&evaluation.matched_rules, evaluation.decision);
let needs_escalation =
self.sandbox_permissions.requires_escalated_permissions() || decision_driven_by_policy;
let unsandboxed_allowed = unsandboxed_execution_allowed(&self.file_system_sandbox_policy);
let needs_escalation = unsandboxed_allowed
&& (self.sandbox_permissions.requires_escalated_permissions()
|| decision_driven_by_policy);
let decision_source = if decision_driven_by_policy {
DecisionSource::PrefixRule
@@ -625,10 +645,12 @@ impl EscalationPolicy for CoreShellActionProvider {
DecisionSource::UnmatchedCommandFallback
};
let escalation_execution = match decision_source {
DecisionSource::PrefixRule => EscalationExecution::Unsandboxed,
DecisionSource::PrefixRule if unsandboxed_allowed => EscalationExecution::Unsandboxed,
DecisionSource::PrefixRule => EscalationExecution::TurnDefault,
DecisionSource::UnmatchedCommandFallback => Self::shell_request_escalation_execution(
self.sandbox_permissions,
&self.permission_profile,
&self.file_system_sandbox_policy,
self.prompt_permissions.as_ref(),
),
};
@@ -66,6 +66,23 @@ fn read_only_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
}])
}
fn denied_read_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: "**/*.env".to_string(),
},
access: FileSystemAccessMode::Deny,
},
])
}
fn test_sandbox_cwd() -> AbsolutePathBuf {
AbsolutePathBuf::try_from(host_absolute_path(&["workspace"])).unwrap()
}
@@ -289,11 +306,13 @@ fn shell_request_escalation_execution_is_explicit() {
&file_system_sandbox_policy,
network_sandbox_policy,
);
let read_only_file_system_policy = read_only_file_system_sandbox_policy();
assert_eq!(
CoreShellActionProvider::shell_request_escalation_execution(
crate::sandboxing::SandboxPermissions::UseDefault,
&permission_profile,
&file_system_sandbox_policy,
/*additional_permissions*/ None,
),
EscalationExecution::TurnDefault,
@@ -302,14 +321,25 @@ fn shell_request_escalation_execution_is_explicit() {
CoreShellActionProvider::shell_request_escalation_execution(
crate::sandboxing::SandboxPermissions::RequireEscalated,
&permission_profile,
&read_only_file_system_policy,
/*additional_permissions*/ None,
),
EscalationExecution::Unsandboxed,
);
assert_eq!(
CoreShellActionProvider::shell_request_escalation_execution(
crate::sandboxing::SandboxPermissions::RequireEscalated,
&permission_profile,
&file_system_sandbox_policy,
/*additional_permissions*/ None,
),
EscalationExecution::TurnDefault,
);
assert_eq!(
CoreShellActionProvider::shell_request_escalation_execution(
crate::sandboxing::SandboxPermissions::WithAdditionalPermissions,
&permission_profile,
&file_system_sandbox_policy,
Some(&requested_permissions),
),
EscalationExecution::Permissions(EscalationPermissions::ResolvedPermissionProfile(
@@ -614,6 +644,102 @@ host_executable(name = "git", paths = ["{git_path_literal}"])
));
}
#[tokio::test(flavor = "current_thread")]
async fn denied_reads_keep_prefix_rule_allow_inside_sandbox() -> anyhow::Result<()> {
let cat_path = host_absolute_path(&["usr", "bin", "cat"]);
let cat_path_literal = starlark_string(&cat_path);
let policy_src = format!(
r#"
prefix_rule(pattern = ["{cat_path_literal}"], decision = "allow")
"#
);
let mut parser = PolicyParser::new();
parser.parse("test.rules", &policy_src).unwrap();
let policy = parser.build();
let (session, turn_context) = make_session_and_context().await;
let file_system_sandbox_policy = denied_read_file_system_sandbox_policy();
let permission_profile = PermissionProfile::from_runtime_permissions(
&file_system_sandbox_policy,
NetworkSandboxPolicy::Restricted,
);
let workdir = test_sandbox_cwd();
let provider = CoreShellActionProvider {
policy: Arc::new(RwLock::new(policy)),
session: Arc::new(session),
turn: Arc::new(turn_context),
call_id: "deny-read-prefix-allow".to_string(),
tool_name: GuardianCommandSource::Shell,
approval_policy: AskForApproval::OnRequest,
permission_profile,
file_system_sandbox_policy,
sandbox_policy_cwd: workdir.clone(),
sandbox_permissions: SandboxPermissions::UseDefault,
approval_sandbox_permissions: SandboxPermissions::UseDefault,
prompt_permissions: None,
stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)),
};
let action = codex_shell_escalation::EscalationPolicy::determine_action(
&provider,
&AbsolutePathBuf::try_from(cat_path).unwrap(),
&["cat".to_string(), "/tmp/visible.txt".to_string()],
&workdir,
)
.await?;
assert_eq!(action, codex_shell_escalation::EscalationDecision::Run);
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn denied_reads_keep_granular_sandbox_rejection_for_escalation() -> anyhow::Result<()> {
let (session, turn_context) = make_session_and_context().await;
let file_system_sandbox_policy = denied_read_file_system_sandbox_policy();
let permission_profile = PermissionProfile::from_runtime_permissions(
&file_system_sandbox_policy,
NetworkSandboxPolicy::Restricted,
);
let workdir = test_sandbox_cwd();
let provider = CoreShellActionProvider {
policy: Arc::new(RwLock::new(PolicyParser::new().build())),
session: Arc::new(session),
turn: Arc::new(turn_context),
call_id: "deny-read-granular-sandbox-reject".to_string(),
tool_name: GuardianCommandSource::Shell,
approval_policy: AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: false,
rules: true,
skill_approval: true,
request_permissions: true,
mcp_elicitations: true,
}),
permission_profile,
file_system_sandbox_policy,
sandbox_policy_cwd: workdir.clone(),
sandbox_permissions: SandboxPermissions::RequireEscalated,
approval_sandbox_permissions: SandboxPermissions::RequireEscalated,
prompt_permissions: None,
stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)),
};
let action = codex_shell_escalation::EscalationPolicy::determine_action(
&provider,
&AbsolutePathBuf::try_from(host_absolute_path(&["usr", "bin", "printf"])).unwrap(),
&["printf".to_string(), "hello".to_string()],
&workdir,
)
.await?;
assert_eq!(
action,
codex_shell_escalation::EscalationDecision::Deny {
reason: Some("Execution forbidden by policy".to_string())
}
);
Ok(())
}
#[test]
fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default() {
let policy = PolicyParser::new().build();
@@ -34,6 +34,7 @@ use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::managed_network_for_sandbox_permissions;
use crate::tools::sandboxing::sandbox_permissions_preserving_denied_reads;
use crate::tools::sandboxing::with_cached_approval;
use crate::unified_exec::NoopSpawnLifecycle;
use crate::unified_exec::UnifiedExecError;
@@ -230,8 +231,13 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
req: &UnifiedExecRequest,
ctx: &ToolCtx,
) -> Option<NetworkApprovalSpec> {
let file_system_sandbox_policy = ctx.turn.file_system_sandbox_policy();
let sandbox_permissions = sandbox_permissions_preserving_denied_reads(
req.sandbox_permissions,
&file_system_sandbox_policy,
);
let network =
managed_network_for_sandbox_permissions(req.network.as_ref(), req.sandbox_permissions)?;
managed_network_for_sandbox_permissions(req.network.as_ref(), sandbox_permissions)?;
Some(NetworkApprovalSpec {
network: Some(network.clone()),
mode: NetworkApprovalMode::Deferred,
@@ -257,6 +263,15 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
) -> Result<UnifiedExecProcess, ToolError> {
let base_command = &req.command;
let session_shell = ctx.session.user_shell();
let (file_system_sandbox_policy, _) = attempt.permissions.to_runtime_permissions();
let sandbox_permissions = sandbox_permissions_preserving_denied_reads(
req.sandbox_permissions,
&file_system_sandbox_policy,
);
let req = &UnifiedExecRequest {
sandbox_permissions,
..req.clone()
};
let managed_network =
managed_network_for_sandbox_permissions(req.network.as_ref(), req.sandbox_permissions);
let mut env = exec_env_for_sandbox_permissions(&req.env, req.sandbox_permissions);
+35 -6
View File
@@ -248,6 +248,13 @@ pub(crate) fn sandbox_override_for_first_attempt(
exec_approval_requirement: &ExecApprovalRequirement,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
) -> SandboxOverride {
// Deny-read restrictions are part of the active permission policy. Running
// without a filesystem sandbox would discard them, even if the command was
// otherwise approved by rules or explicit escalation.
if !unsandboxed_execution_allowed(file_system_sandbox_policy) {
return SandboxOverride::NoOverride;
}
// ExecPolicy `Allow` can intentionally imply full trust (Skip + bypass_sandbox=true),
// which supersedes `with_additional_permissions` sandboxed execution hints.
if matches!(
@@ -260,12 +267,6 @@ pub(crate) fn sandbox_override_for_first_attempt(
return SandboxOverride::BypassSandboxFirstAttempt;
}
// Deny-read restrictions suppress explicit escalation because that path
// would otherwise discard the filesystem policy entirely.
if file_system_sandbox_policy.has_denied_read_restrictions() {
return SandboxOverride::NoOverride;
}
if sandbox_permissions.requires_escalated_permissions() {
SandboxOverride::BypassSandboxFirstAttempt
} else {
@@ -273,6 +274,34 @@ pub(crate) fn sandbox_override_for_first_attempt(
}
}
/// Returns true when the active filesystem policy can be represented by
/// running without a filesystem sandbox.
///
/// Denied reads only exist inside the sandbox. If a policy contains any
/// denied-read paths, bypassing the sandbox would silently grant those reads,
/// so escalation must keep the command sandboxed with the denied reads intact.
pub(crate) fn unsandboxed_execution_allowed(
file_system_sandbox_policy: &FileSystemSandboxPolicy,
) -> bool {
!file_system_sandbox_policy.has_denied_read_restrictions()
}
pub(crate) fn sandbox_permissions_preserving_denied_reads(
sandbox_permissions: SandboxPermissions,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
) -> SandboxPermissions {
if sandbox_permissions.requires_escalated_permissions()
&& !unsandboxed_execution_allowed(file_system_sandbox_policy)
{
// `RequireEscalated` normally asks the executor to bypass the sandbox.
// When denied reads are active, that would drop the only mechanism that
// enforces them, so fall back to the default sandboxed attempt instead.
SandboxPermissions::UseDefault
} else {
sandbox_permissions
}
}
pub(crate) fn managed_network_for_sandbox_permissions(
network: Option<&NetworkProxy>,
sandbox_permissions: SandboxPermissions,
+25 -3
View File
@@ -138,7 +138,7 @@ fn guardian_bypasses_sandbox_for_explicit_escalation_on_first_attempt() {
}
#[test]
fn deny_read_blocks_explicit_escalation_but_preserves_policy_bypass() {
fn deny_read_blocks_explicit_escalation_and_policy_bypass() {
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: "**/*.env".to_string(),
@@ -158,6 +158,28 @@ fn deny_read_blocks_explicit_escalation_but_preserves_policy_bypass() {
SandboxOverride::NoOverride,
"explicit escalation would drop deny-read filesystem policy, so keep the first attempt sandboxed",
);
assert!(!unsandboxed_execution_allowed(&file_system_policy));
assert_eq!(
sandbox_permissions_preserving_denied_reads(
SandboxPermissions::RequireEscalated,
&file_system_policy,
),
SandboxPermissions::UseDefault,
);
assert_eq!(
sandbox_permissions_preserving_denied_reads(
SandboxPermissions::WithAdditionalPermissions,
&file_system_policy,
),
SandboxPermissions::WithAdditionalPermissions,
);
assert_eq!(
sandbox_permissions_preserving_denied_reads(
SandboxPermissions::RequireEscalated,
&FileSystemSandboxPolicy::default(),
),
SandboxPermissions::RequireEscalated,
);
assert_eq!(
sandbox_override_for_first_attempt(
SandboxPermissions::WithAdditionalPermissions,
@@ -167,7 +189,7 @@ fn deny_read_blocks_explicit_escalation_but_preserves_policy_bypass() {
},
&file_system_policy,
),
SandboxOverride::BypassSandboxFirstAttempt,
"exec-policy allow rules intentionally bypass sandbox even when deny-read entries exist",
SandboxOverride::NoOverride,
"exec-policy allow rules would drop deny-read filesystem policy, so keep the first attempt sandboxed",
);
}
+15 -14
View File
@@ -60,6 +60,7 @@ use codex_mcp::ToolInfo;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ToolMode;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_tools::DiscoverableTool;
@@ -230,8 +231,10 @@ fn spec_for_model_request(
exposure: ToolExposure,
spec: ToolSpec,
) -> ToolSpec {
if code_mode_enabled(turn_context)
&& exposure != ToolExposure::DirectModelOnly
if matches!(
turn_context.tool_mode,
ToolMode::CodeMode | ToolMode::CodeModeOnly
) && exposure != ToolExposure::DirectModelOnly
&& codex_code_mode::is_code_mode_nested_tool(spec.name())
{
codex_tools::augment_tool_spec_for_code_mode(spec)
@@ -282,14 +285,6 @@ fn namespace_tools_enabled(turn_context: &TurnContext) -> bool {
turn_context.provider.capabilities().namespace_tools
}
fn code_mode_enabled(turn_context: &TurnContext) -> bool {
turn_context.features.get().enabled(Feature::CodeMode)
}
fn code_mode_only_enabled(turn_context: &TurnContext) -> bool {
code_mode_enabled(turn_context) && turn_context.features.get().enabled(Feature::CodeModeOnly)
}
fn multi_agent_v2_enabled(turn_context: &TurnContext) -> bool {
turn_context.features.get().enabled(Feature::MultiAgentV2)
}
@@ -398,7 +393,7 @@ fn is_hidden_by_code_mode_only(
tool_name: &ToolName,
exposure: ToolExposure,
) -> bool {
code_mode_only_enabled(turn_context)
turn_context.tool_mode == ToolMode::CodeModeOnly
&& exposure != ToolExposure::DirectModelOnly
&& codex_code_mode::is_code_mode_nested_tool(&codex_tools::code_mode_name_for_tool_name(
tool_name,
@@ -410,7 +405,10 @@ fn build_code_mode_executors(
executors: &[Arc<dyn CoreToolRuntime>],
deferred_tools_available: bool,
) -> Vec<Arc<dyn CoreToolRuntime>> {
if !code_mode_enabled(turn_context) {
if !matches!(
turn_context.tool_mode,
ToolMode::CodeMode | ToolMode::CodeModeOnly
) {
return vec![];
}
@@ -444,7 +442,7 @@ fn build_code_mode_executors(
create_code_mode_tool(
&enabled_tools,
&namespace_descriptions,
code_mode_only_enabled(turn_context),
turn_context.tool_mode == ToolMode::CodeModeOnly,
deferred_tools_available,
),
code_mode_nested_tool_specs,
@@ -849,7 +847,10 @@ fn append_extension_tool_executors(
.iter()
.map(|executor| executor.tool_name())
.collect::<HashSet<_>>();
if code_mode_enabled(turn_context) {
if matches!(
turn_context.tool_mode,
ToolMode::CodeMode | ToolMode::CodeModeOnly
) {
reserved_tool_names.insert(ToolName::plain(codex_code_mode::PUBLIC_TOOL_NAME));
reserved_tool_names.insert(ToolName::plain(codex_code_mode::WAIT_TOOL_NAME));
}
@@ -13,6 +13,7 @@ use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::openai_models::ApplyPatchToolType;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ToolMode;
use codex_protocol::openai_models::WebSearchToolType;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
@@ -215,6 +216,15 @@ fn set_feature(turn: &mut TurnContext, feature: Feature, enabled: bool) {
.expect("test feature should be disableable in config");
}
turn.config = Arc::new(config);
turn.tool_mode = turn.model_info.tool_mode.unwrap_or_else(|| {
if turn.config.features.enabled(Feature::CodeModeOnly) {
ToolMode::CodeModeOnly
} else if turn.config.features.enabled(Feature::CodeMode) {
ToolMode::CodeMode
} else {
ToolMode::Direct
}
});
}
fn set_features(turn: &mut TurnContext, features: &[Feature]) {
@@ -797,6 +807,20 @@ async fn multi_agent_feature_selects_one_agent_tool_family() {
);
}
#[tokio::test]
async fn tool_mode_selector_overrides_feature_flags() {
let direct = probe(|turn| {
set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]);
turn.model_info.tool_mode = Some(ToolMode::Direct);
turn.tool_mode = ToolMode::Direct;
})
.await;
direct.assert_visible_lacks(&[
codex_code_mode::PUBLIC_TOOL_NAME,
codex_code_mode::WAIT_TOOL_NAME,
]);
}
#[tokio::test]
async fn v1_multi_agent_tools_defer_when_tool_search_available() {
let plan = probe(|turn| {
+46 -39
View File
@@ -25,6 +25,7 @@ use codex_protocol::ThreadId;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadSource;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -117,9 +118,10 @@ impl From<WorkspaceGitMetadata> for TurnMetadataWorkspace {
/// Base payload for the outbound model request `x-codex-turn-metadata` header.
///
/// Turn-owned state populates identity fields, including optional fork lineage. A concrete
/// request kind is added at outbound model dispatch so turns, startup prewarm, and compaction
/// remain distinguishable. Detached memory requests are constructed as `memory` directly.
/// Turn-owned state populates identity fields, including optional fork and subagent lineage. A
/// concrete request kind is added at outbound model dispatch so turns, startup prewarm, and
/// compaction remain distinguishable. Detached memory requests are constructed as `memory`
/// directly.
#[derive(Clone, Debug, Serialize)]
pub(crate) struct TurnMetadataBag {
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -131,6 +133,10 @@ pub(crate) struct TurnMetadataBag {
#[serde(default, skip_serializing_if = "Option::is_none")]
forked_from_thread_id: Option<ThreadId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
parent_thread_id: Option<ThreadId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
subagent_kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
thread_source: Option<ThreadSource>,
#[serde(default, skip_serializing_if = "Option::is_none")]
turn_id: Option<String>,
@@ -141,27 +147,6 @@ pub(crate) struct TurnMetadataBag {
}
impl TurnMetadataBag {
fn new(
request_kind: Option<TurnMetadataRequestKind>,
session_id: Option<String>,
thread_id: Option<String>,
forked_from_thread_id: Option<ThreadId>,
thread_source: Option<ThreadSource>,
turn_id: Option<String>,
sandbox: Option<String>,
) -> Self {
Self {
request_kind,
session_id,
thread_id,
forked_from_thread_id,
thread_source,
turn_id,
workspaces: BTreeMap::new(),
sandbox,
}
}
fn with_workspace_git_metadata(
mut self,
repo_root: Option<String>,
@@ -206,6 +191,8 @@ fn merge_turn_metadata(
| "turn_id"
| TURN_STARTED_AT_UNIX_MS_KEY
| "forked_from_thread_id"
| "parent_thread_id"
| "subagent_kind"
| REQUEST_KIND_KEY
| COMPACTION_KEY
| WINDOW_ID_KEY
@@ -232,15 +219,18 @@ pub async fn build_turn_metadata_header(
get_has_changes(cwd),
);
let latest_git_commit_hash = head_commit_hash.map(|sha| sha.0);
TurnMetadataBag::new(
Some(TurnMetadataRequestKind::Memory),
/*session_id*/ None,
/*thread_id*/ None,
/*forked_from_thread_id*/ None,
/*thread_source*/ None,
/*turn_id*/ None,
sandbox.map(ToString::to_string),
)
TurnMetadataBag {
request_kind: Some(TurnMetadataRequestKind::Memory),
session_id: None,
thread_id: None,
forked_from_thread_id: None,
parent_thread_id: None,
subagent_kind: None,
thread_source: None,
turn_id: None,
workspaces: BTreeMap::new(),
sandbox: sandbox.map(ToString::to_string),
}
.with_workspace_git_metadata(
repo_root,
Some(WorkspaceGitMetadata {
@@ -271,6 +261,7 @@ impl TurnMetadataState {
session_id: String,
thread_id: String,
forked_from_thread_id: Option<ThreadId>,
session_source: &SessionSource,
thread_source: Option<ThreadSource>,
turn_id: String,
cwd: AbsolutePathBuf,
@@ -287,15 +278,31 @@ impl TurnMetadataState {
)
.to_string(),
);
let base_metadata = TurnMetadataBag::new(
/*request_kind*/ None,
Some(session_id),
Some(thread_id),
let (parent_thread_id, subagent_kind) = match session_source {
SessionSource::SubAgent(subagent_source) => (
subagent_source.parent_thread_id().or(forked_from_thread_id),
Some(subagent_source.kind().to_string()),
),
SessionSource::Cli
| SessionSource::VSCode
| SessionSource::Exec
| SessionSource::Mcp
| SessionSource::Custom(_)
| SessionSource::Internal(_)
| SessionSource::Unknown => (None, None),
};
let base_metadata = TurnMetadataBag {
request_kind: None,
session_id: Some(session_id),
thread_id: Some(thread_id),
forked_from_thread_id,
parent_thread_id,
subagent_kind,
thread_source,
Some(turn_id),
turn_id: Some(turn_id),
workspaces: BTreeMap::new(),
sandbox,
);
};
let base_header = base_metadata.to_header_value();
Self {
+222 -4
View File
@@ -3,12 +3,16 @@ use super::*;
use crate::sandbox_tags::permission_profile_sandbox_tag;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::ThreadSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::PathBufExt;
use core_test_support::PathExt;
use pretty_assertions::assert_eq;
use serde_json::Value;
use std::collections::HashMap;
use std::time::Duration;
use tempfile::TempDir;
use tokio::process::Command;
@@ -19,10 +23,9 @@ fn test_mcp_turn_metadata_context() -> McpTurnMetadataContext<'static> {
}
}
#[tokio::test]
async fn build_turn_metadata_header_marks_detached_memory_without_turn_identity() {
async fn create_clean_git_repo(repo_name: &str) -> (TempDir, AbsolutePathBuf) {
let temp_dir = TempDir::new().expect("temp dir");
let repo_path = temp_dir.path().join("repo-東京").abs();
let repo_path = temp_dir.path().join(repo_name).abs();
std::fs::create_dir_all(&repo_path).expect("create repo");
Command::new("git")
@@ -43,7 +46,6 @@ async fn build_turn_metadata_header_marks_detached_memory_without_turn_identity(
.output()
.await
.expect("git config user.email");
std::fs::write(repo_path.join("README.md"), "hello").expect("write file");
Command::new("git")
.args(["add", "."])
@@ -58,6 +60,13 @@ async fn build_turn_metadata_header_marks_detached_memory_without_turn_identity(
.await
.expect("git commit");
(temp_dir, repo_path)
}
#[tokio::test]
async fn build_turn_metadata_header_marks_detached_memory_without_turn_identity() {
let (_temp_dir, repo_path) = create_clean_git_repo("repo-東京").await;
let header = build_turn_metadata_header(&repo_path, Some("none"))
.await
.expect("header");
@@ -113,6 +122,7 @@ fn turn_metadata_state_uses_platform_sandbox_tag() {
"session-a".to_string(),
"thread-a".to_string(),
/*forked_from_thread_id*/ None,
&SessionSource::Exec,
Some(ThreadSource::User),
"turn-a".to_string(),
cwd,
@@ -138,6 +148,9 @@ fn turn_metadata_state_uses_platform_sandbox_tag() {
assert_eq!(session_id, Some("session-a"));
assert_eq!(thread_id, Some("thread-a"));
assert_eq!(thread_source, Some("user"));
assert!(json.get("forked_from_thread_id").is_none());
assert!(json.get("parent_thread_id").is_none());
assert!(json.get("subagent_kind").is_none());
assert!(json.get("session_source").is_none());
}
@@ -150,6 +163,7 @@ fn turn_metadata_state_uses_explicit_subagent_thread_source() {
"session-a".to_string(),
"thread-a".to_string(),
/*forked_from_thread_id*/ None,
&SessionSource::Exec,
Some(ThreadSource::Subagent),
"turn-a".to_string(),
cwd,
@@ -177,6 +191,7 @@ fn turn_metadata_state_includes_root_fork_lineage() {
"session-a".to_string(),
"thread-a".to_string(),
Some(source_thread_id),
&SessionSource::Exec,
Some(ThreadSource::User),
"turn-a".to_string(),
cwd,
@@ -192,6 +207,122 @@ fn turn_metadata_state_includes_root_fork_lineage() {
json["forked_from_thread_id"].as_str(),
Some("11111111-1111-4111-8111-111111111111")
);
assert!(json.get("parent_thread_id").is_none());
assert!(json.get("subagent_kind").is_none());
}
#[test]
fn turn_metadata_state_includes_thread_spawn_subagent_parent_without_fork() {
let temp_dir = TempDir::new().expect("temp dir");
let cwd = temp_dir.path().abs();
let permission_profile = PermissionProfile::read_only();
let parent_thread_id =
ThreadId::from_string("22222222-2222-4222-8222-222222222222").expect("thread id");
let state = TurnMetadataState::new(
"session-a".to_string(),
"thread-a".to_string(),
/*forked_from_thread_id*/ None,
&SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
}),
Some(ThreadSource::Subagent),
"turn-a".to_string(),
cwd,
&permission_profile,
WindowsSandboxLevel::Disabled,
/*enforce_managed_network*/ false,
);
let header = state.current_header_value().expect("header");
let json: Value = serde_json::from_str(&header).expect("json");
assert!(json.get("forked_from_thread_id").is_none());
assert_eq!(
json["parent_thread_id"].as_str(),
Some("22222222-2222-4222-8222-222222222222")
);
assert_eq!(json["subagent_kind"].as_str(), Some("thread_spawn"));
}
#[test]
fn turn_metadata_state_includes_forked_thread_spawn_subagent_lineage() {
let temp_dir = TempDir::new().expect("temp dir");
let cwd = temp_dir.path().abs();
let permission_profile = PermissionProfile::read_only();
let parent_thread_id =
ThreadId::from_string("33333333-3333-4333-8333-333333333333").expect("thread id");
let state = TurnMetadataState::new(
"session-a".to_string(),
"thread-a".to_string(),
Some(parent_thread_id),
&SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
}),
Some(ThreadSource::Subagent),
"turn-a".to_string(),
cwd,
&permission_profile,
WindowsSandboxLevel::Disabled,
/*enforce_managed_network*/ false,
);
let header = state.current_header_value().expect("header");
let json: Value = serde_json::from_str(&header).expect("json");
assert_eq!(
json["forked_from_thread_id"].as_str(),
Some("33333333-3333-4333-8333-333333333333")
);
assert_eq!(
json["parent_thread_id"].as_str(),
Some("33333333-3333-4333-8333-333333333333")
);
assert_eq!(json["subagent_kind"].as_str(), Some("thread_spawn"));
}
#[test]
fn turn_metadata_state_includes_known_parent_for_other_subagent() {
let temp_dir = TempDir::new().expect("temp dir");
let cwd = temp_dir.path().abs();
let permission_profile = PermissionProfile::read_only();
let parent_thread_id =
ThreadId::from_string("44444444-4444-4444-8444-444444444444").expect("thread id");
let state = TurnMetadataState::new(
"session-a".to_string(),
"thread-a".to_string(),
Some(parent_thread_id),
&SessionSource::SubAgent(SubAgentSource::Other("guardian".to_string())),
Some(ThreadSource::Subagent),
"turn-a".to_string(),
cwd,
&permission_profile,
WindowsSandboxLevel::Disabled,
/*enforce_managed_network*/ false,
);
let header = state.current_header_value().expect("header");
let json: Value = serde_json::from_str(&header).expect("json");
assert_eq!(
json["forked_from_thread_id"].as_str(),
Some("44444444-4444-4444-8444-444444444444")
);
assert_eq!(
json["parent_thread_id"].as_str(),
Some("44444444-4444-4444-8444-444444444444")
);
assert_eq!(json["subagent_kind"].as_str(), Some("guardian"));
}
#[test]
@@ -204,6 +335,7 @@ fn turn_metadata_state_includes_turn_started_at_unix_ms_after_start() {
"session-a".to_string(),
"thread-a".to_string(),
/*forked_from_thread_id*/ None,
&SessionSource::Exec,
Some(ThreadSource::User),
"turn-a".to_string(),
cwd,
@@ -232,6 +364,7 @@ fn turn_metadata_state_includes_model_and_reasoning_effort_only_in_request_meta(
"session-a".to_string(),
"thread-a".to_string(),
/*forked_from_thread_id*/ None,
&SessionSource::Exec,
/*thread_source*/ None,
"turn-a".to_string(),
cwd,
@@ -279,6 +412,7 @@ fn turn_metadata_state_marks_user_input_requested_during_turn_only_for_mcp_reque
"session-a".to_string(),
"thread-a".to_string(),
/*forked_from_thread_id*/ None,
&SessionSource::Exec,
/*thread_source*/ None,
"turn-a".to_string(),
cwd,
@@ -330,6 +464,7 @@ fn turn_metadata_state_ignores_client_reserved_metadata_before_start() {
"session-a".to_string(),
"thread-a".to_string(),
/*forked_from_thread_id*/ None,
&SessionSource::Exec,
Some(ThreadSource::User),
"turn-a".to_string(),
cwd,
@@ -346,6 +481,11 @@ fn turn_metadata_state_ignores_client_reserved_metadata_before_start() {
"forked_from_thread_id".to_string(),
"client-supplied".to_string(),
),
(
"parent_thread_id".to_string(),
"client-supplied".to_string(),
),
("subagent_kind".to_string(), "client-supplied".to_string()),
]));
let header = state.current_header_value().expect("header");
@@ -353,6 +493,8 @@ fn turn_metadata_state_ignores_client_reserved_metadata_before_start() {
assert!(json.get("turn_started_at_unix_ms").is_none());
assert!(json.get("forked_from_thread_id").is_none());
assert!(json.get("parent_thread_id").is_none());
assert!(json.get("subagent_kind").is_none());
}
#[test]
@@ -362,11 +504,20 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(
let permission_profile = PermissionProfile::read_only();
let source_thread_id =
ThreadId::from_string("44444444-4444-4444-8444-444444444444").expect("thread id");
let parent_thread_id =
ThreadId::from_string("55555555-5555-4555-8555-555555555555").expect("thread id");
let state = TurnMetadataState::new(
"session-a".to_string(),
"thread-a".to_string(),
Some(source_thread_id),
&SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
}),
Some(ThreadSource::User),
"turn-a".to_string(),
cwd,
@@ -388,6 +539,11 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(
"forked_from_thread_id".to_string(),
"client-supplied".to_string(),
),
(
"parent_thread_id".to_string(),
"client-supplied".to_string(),
),
("subagent_kind".to_string(), "client-supplied".to_string()),
("turn_id".to_string(), "client-supplied".to_string()),
(WINDOW_ID_KEY.to_string(), "client-supplied".to_string()),
("thread_source".to_string(), "client-supplied".to_string()),
@@ -414,6 +570,11 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(
json["forked_from_thread_id"].as_str(),
Some("44444444-4444-4444-8444-444444444444")
);
assert_eq!(
json["parent_thread_id"].as_str(),
Some("55555555-5555-4555-8555-555555555555")
);
assert_eq!(json["subagent_kind"].as_str(), Some("thread_spawn"));
assert_eq!(json["thread_source"].as_str(), Some("user"));
assert_eq!(json["turn_id"].as_str(), Some("turn-a"));
assert!(json.get("request_kind").is_none());
@@ -451,6 +612,7 @@ fn turn_metadata_state_overlays_compaction_only_on_compaction_requests() {
"session-a".to_string(),
"thread-a".to_string(),
/*forked_from_thread_id*/ None,
&SessionSource::Exec,
Some(ThreadSource::User),
"turn-a".to_string(),
cwd,
@@ -497,3 +659,59 @@ fn turn_metadata_state_overlays_compaction_only_on_compaction_requests() {
assert_eq!(regular_json[WINDOW_ID_KEY].as_str(), Some("thread-a:3"));
assert!(regular_json.get("compaction").is_none());
}
#[tokio::test]
async fn turn_metadata_state_preserves_lineage_after_git_enrichment() {
let (_temp_dir, repo_path) = create_clean_git_repo("repo").await;
let permission_profile = PermissionProfile::read_only();
let parent_thread_id =
ThreadId::from_string("66666666-6666-4666-8666-666666666666").expect("thread id");
let state = TurnMetadataState::new(
"session-a".to_string(),
"thread-a".to_string(),
Some(parent_thread_id),
&SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
}),
Some(ThreadSource::Subagent),
"turn-a".to_string(),
repo_path,
&permission_profile,
WindowsSandboxLevel::Disabled,
/*enforce_managed_network*/ false,
);
state.spawn_git_enrichment_task();
let json = tokio::time::timeout(Duration::from_secs(2), async {
loop {
let header = state.current_header_value().expect("header");
let json: Value = serde_json::from_str(&header).expect("json");
if json
.get("workspaces")
.and_then(Value::as_object)
.is_some_and(|workspaces| !workspaces.is_empty())
{
return json;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("git enrichment should complete");
assert_eq!(
json["forked_from_thread_id"].as_str(),
Some("66666666-6666-4666-8666-666666666666")
);
assert_eq!(
json["parent_thread_id"].as_str(),
Some("66666666-6666-4666-8666-666666666666")
);
assert_eq!(json["subagent_kind"].as_str(), Some("thread_spawn"));
}
+10
View File
@@ -169,6 +169,11 @@ pub fn run_elevated_setup(
)
}
#[cfg(target_os = "windows")]
pub fn run_elevated_provisioning_setup(codex_home: &Path, real_user: &str) -> anyhow::Result<()> {
codex_windows_sandbox::run_elevated_provisioning_setup(codex_home, real_user)
}
#[cfg(not(target_os = "windows"))]
pub fn run_elevated_setup(
_permission_profile: &PermissionProfile,
@@ -180,6 +185,11 @@ pub fn run_elevated_setup(
anyhow::bail!("elevated Windows sandbox setup is only supported on Windows")
}
#[cfg(not(target_os = "windows"))]
pub fn run_elevated_provisioning_setup(_codex_home: &Path, _real_user: &str) -> anyhow::Result<()> {
anyhow::bail!("elevated Windows sandbox setup is only supported on Windows")
}
#[cfg(target_os = "windows")]
pub fn run_legacy_setup_preflight(
permission_profile: &PermissionProfile,

Some files were not shown because too many files have changed in this diff Show More