Merge branch 'abhinav/hooks-updated-input' into abhinav/hooks-updated-input-permission-request

This commit is contained in:
Abhinav
2026-05-06 13:10:43 -07:00
committed by GitHub
241 changed files with 7557 additions and 2132 deletions
+2 -2
View File
@@ -11,11 +11,11 @@
"path": "codex"
},
"linux-x86_64": {
"regex": "^codex-x86_64-unknown-linux-musl\\.zst$",
"regex": "^codex-x86_64-unknown-linux-musl-bundle\\.tar\\.zst$",
"path": "codex"
},
"linux-aarch64": {
"regex": "^codex-aarch64-unknown-linux-musl\\.zst$",
"regex": "^codex-aarch64-unknown-linux-musl-bundle\\.tar\\.zst$",
"path": "codex"
},
"windows-x86_64": {
+14 -3
View File
@@ -379,6 +379,17 @@ jobs:
fi
done
if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.bundle }}" == "primary" ]]; then
bundle_root="${RUNNER_TEMP}/codex-${{ matrix.target }}-bundle"
rm -rf "$bundle_root"
mkdir -p "$bundle_root/codex-resources"
cp "$dest/codex-${{ matrix.target }}" "$bundle_root/codex"
cp "$dest/bwrap-${{ matrix.target }}" "$bundle_root/codex-resources/bwrap"
chmod 0755 "$bundle_root/codex" "$bundle_root/codex-resources/bwrap"
tar -C "$bundle_root" -cf - codex codex-resources/bwrap |
zstd -T0 -19 -o "$dest/codex-${{ matrix.target }}-bundle.tar.zst"
fi
if [[ "${{ matrix.build_dmg }}" == "true" ]]; then
cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg"
fi
@@ -402,7 +413,7 @@ jobs:
base="$(basename "$f")"
# Skip files that are already archives (shouldn't happen, but be
# safe).
if [[ "$base" == *.tar.gz || "$base" == *.zip || "$base" == *.dmg ]]; then
if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then
continue
fi
@@ -422,8 +433,8 @@ jobs:
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: ${{ matrix.artifact_name }}
# Upload the per-binary .zst files as well as the new .tar.gz
# equivalents we generated in the previous step.
# Upload the per-binary .zst files, .tar.gz equivalents, and any
# prebuilt archives staged above.
path: |
codex-rs/dist/${{ matrix.target }}/*
+29
View File
@@ -1944,6 +1944,7 @@ dependencies = [
"pretty_assertions",
"serde",
"serde_json",
"tempfile",
"tokio",
"tokio-tungstenite",
"toml 0.9.11+spec-1.1.0",
@@ -2125,6 +2126,17 @@ dependencies = [
"serde_with",
]
[[package]]
name = "codex-builtin-mcps"
version = "0.0.0"
dependencies = [
"anyhow",
"codex-config",
"codex-memories-mcp",
"codex-utils-absolute-path",
"pretty_assertions",
]
[[package]]
name = "codex-bwrap"
version = "0.0.0"
@@ -2170,6 +2182,7 @@ dependencies = [
"codex-app-server-protocol",
"codex-app-server-test-client",
"codex-arg0",
"codex-builtin-mcps",
"codex-chatgpt",
"codex-cloud-tasks",
"codex-config",
@@ -2997,6 +3010,7 @@ dependencies = [
"async-channel",
"codex-api",
"codex-async-utils",
"codex-builtin-mcps",
"codex-config",
"codex-exec-server",
"codex-login",
@@ -3118,6 +3132,19 @@ dependencies = [
"wiremock",
]
[[package]]
name = "codex-message-history"
version = "0.0.0"
dependencies = [
"codex-config",
"pretty_assertions",
"serde",
"serde_json",
"tempfile",
"tokio",
"tracing",
]
[[package]]
name = "codex-model-provider"
version = "0.0.0"
@@ -3660,6 +3687,7 @@ dependencies = [
"codex-install-context",
"codex-login",
"codex-mcp",
"codex-message-history",
"codex-model-provider",
"codex-model-provider-info",
"codex-models-manager",
@@ -4247,6 +4275,7 @@ dependencies = [
"reqwest",
"serde_json",
"shlex",
"similar",
"tempfile",
"tokio",
"tokio-tungstenite",
+3
View File
@@ -5,6 +5,7 @@ members = [
"agent-graph-store",
"agent-identity",
"backend-client",
"builtin-mcps",
"bwrap",
"ansi-escape",
"async-utils",
@@ -138,6 +139,7 @@ codex-apply-patch = { path = "apply-patch" }
codex-arg0 = { path = "arg0" }
codex-async-utils = { path = "async-utils" }
codex-backend-client = { path = "backend-client" }
codex-builtin-mcps = { path = "builtin-mcps" }
codex-chatgpt = { path = "chatgpt" }
codex-cli = { path = "cli" }
codex-client = { path = "codex-client" }
@@ -170,6 +172,7 @@ codex-keyring-store = { path = "keyring-store" }
codex-linux-sandbox = { path = "linux-sandbox" }
codex-lmstudio = { path = "lmstudio" }
codex-login = { path = "login" }
codex-message-history = { path = "message-history" }
codex-memories-mcp = { path = "memories/mcp" }
codex-memories-read = { path = "memories/read" }
codex-memories-write = { path = "memories/write" }
@@ -128,6 +128,7 @@ fn sample_thread_with_metadata(
) -> Thread {
Thread {
id: thread_id.to_string(),
session_id: format!("session-{thread_id}"),
forked_from_id: None,
preview: "first prompt".to_string(),
ephemeral,
+1
View File
@@ -76,6 +76,7 @@ fn sample_thread_archive_request() -> ClientRequest {
fn sample_thread(thread_id: &str) -> Thread {
Thread {
id: thread_id.to_string(),
session_id: format!("session-{thread_id}"),
forked_from_id: None,
preview: "first prompt".to_string(),
ephemeral: false,
+1
View File
@@ -33,4 +33,5 @@ url = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
serde_json = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+55 -9
View File
@@ -72,12 +72,9 @@ pub mod legacy_core {
pub use codex_core::DEFAULT_AGENTS_MD_FILENAME;
pub use codex_core::LOCAL_AGENTS_MD_FILENAME;
pub use codex_core::McpManager;
pub use codex_core::append_message_history_entry;
pub use codex_core::check_execpolicy_for_warnings;
pub use codex_core::format_exec_policy_error_with_source;
pub use codex_core::grant_read_root_non_elevated;
pub use codex_core::lookup_message_history_entry;
pub use codex_core::message_history_metadata;
pub use codex_core::web_search_detail;
pub mod config {
@@ -954,9 +951,13 @@ mod tests {
use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_app_server_protocol::ToolRequestUserInputQuestion;
use codex_core::config::ConfigBuilder;
use codex_core::init_state_db_from_config;
use futures::SinkExt;
use futures::StreamExt;
use pretty_assertions::assert_eq;
use std::ops::Deref;
use std::path::Path;
use tempfile::TempDir;
use tokio::net::TcpListener;
use tokio::time::Duration;
use tokio::time::timeout;
@@ -975,19 +976,59 @@ mod tests {
}
}
async fn build_test_config_for_codex_home(codex_home: &Path) -> Config {
match ConfigBuilder::default()
.codex_home(codex_home.to_path_buf())
.build()
.await
{
Ok(config) => config,
Err(_) => Config::load_default_with_cli_overrides_for_codex_home(
codex_home.to_path_buf(),
Vec::new(),
)
.await
.expect("default config should load"),
}
}
struct TestClient {
_codex_home: TempDir,
client: InProcessAppServerClient,
}
impl Deref for TestClient {
type Target = InProcessAppServerClient;
fn deref(&self) -> &Self::Target {
&self.client
}
}
impl TestClient {
async fn shutdown(self) -> IoResult<()> {
self.client.shutdown().await
}
}
async fn start_test_client_with_capacity(
session_source: SessionSource,
channel_capacity: usize,
) -> InProcessAppServerClient {
InProcessAppServerClient::start(InProcessClientStartArgs {
) -> TestClient {
let codex_home = TempDir::new().expect("temp dir");
let config = Arc::new(build_test_config_for_codex_home(codex_home.path()).await);
let state_db = init_state_db_from_config(config.as_ref())
.await
.expect("state db should initialize for in-process test");
let client = InProcessAppServerClient::start(InProcessClientStartArgs {
arg0_paths: Arg0DispatchPaths::default(),
config: Arc::new(build_test_config().await),
config,
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
state_db: None,
state_db: Some(state_db),
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
config_warnings: Vec::new(),
session_source,
@@ -999,10 +1040,15 @@ mod tests {
channel_capacity,
})
.await
.expect("in-process app-server client should start")
.expect("in-process app-server client should start");
TestClient {
_codex_home: codex_home,
client,
}
}
async fn start_test_client(session_source: SessionSource) -> InProcessAppServerClient {
async fn start_test_client(session_source: SessionSource) -> TestClient {
start_test_client_with_capacity(session_source, DEFAULT_IN_PROCESS_CHANNEL_CAPACITY).await
}
+106 -65
View File
@@ -2197,11 +2197,37 @@
],
"type": "object"
},
"PluginShareDiscoverability": {
"enum": [
"LISTED",
"UNLISTED",
"PRIVATE"
],
"type": "string"
},
"PluginShareListParams": {
"type": "object"
},
"PluginSharePrincipalType": {
"enum": [
"user",
"group",
"workspace"
],
"type": "string"
},
"PluginShareSaveParams": {
"properties": {
"discoverability": {
"anyOf": [
{
"$ref": "#/definitions/PluginShareDiscoverability"
},
{
"type": "null"
}
]
},
"pluginPath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
@@ -2210,6 +2236,15 @@
"string",
"null"
]
},
"shareTargets": {
"items": {
"$ref": "#/definitions/PluginShareTarget"
},
"type": [
"array",
"null"
]
}
},
"required": [
@@ -2217,6 +2252,39 @@
],
"type": "object"
},
"PluginShareTarget": {
"properties": {
"principalId": {
"type": "string"
},
"principalType": {
"$ref": "#/definitions/PluginSharePrincipalType"
}
},
"required": [
"principalId",
"principalType"
],
"type": "object"
},
"PluginShareUpdateTargetsParams": {
"properties": {
"remotePluginId": {
"type": "string"
},
"shareTargets": {
"items": {
"$ref": "#/definitions/PluginShareTarget"
},
"type": "array"
}
},
"required": [
"remotePluginId",
"shareTargets"
],
"type": "object"
},
"PluginSkillReadParams": {
"properties": {
"remoteMarketplaceName": {
@@ -3256,13 +3324,6 @@
],
"type": "object"
},
"ServiceTier": {
"enum": [
"fast",
"flex"
],
"type": "string"
},
"SessionMigration": {
"properties": {
"cwd": {
@@ -3540,20 +3601,9 @@
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"threadId": {
@@ -3962,20 +4012,9 @@
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"threadId": {
@@ -4159,20 +4198,9 @@
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"sessionStartSource": {
@@ -4339,22 +4367,11 @@
"description": "Override the sandbox policy for this turn and subsequent turns."
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
],
"description": "Override the service tier for this turn and subsequent turns."
"description": "Override the service tier for this turn and subsequent turns.",
"type": [
"string",
"null"
]
},
"summary": {
"anyOf": [
@@ -5177,6 +5194,30 @@
"title": "Plugin/share/saveRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"plugin/share/updateTargets"
],
"title": "Plugin/share/updateTargetsRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/PluginShareUpdateTargetsParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/share/updateTargetsRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -3072,6 +3072,10 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"source": {
"allOf": [
{
@@ -3120,6 +3124,7 @@
"id",
"modelProvider",
"preview",
"sessionId",
"source",
"status",
"turns",
@@ -810,6 +810,30 @@
"title": "Plugin/share/saveRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"plugin/share/updateTargets"
],
"title": "Plugin/share/updateTargetsRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/PluginShareUpdateTargetsParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/share/updateTargetsRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -7200,13 +7224,9 @@
]
},
"service_tier": {
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"tools": {
@@ -12479,6 +12499,14 @@
"title": "PluginShareDeleteResponse",
"type": "object"
},
"PluginShareDiscoverability": {
"enum": [
"LISTED",
"UNLISTED",
"PRIVATE"
],
"type": "string"
},
"PluginShareListItem": {
"properties": {
"localPluginPath": {
@@ -12525,9 +12553,46 @@
"title": "PluginShareListResponse",
"type": "object"
},
"PluginSharePrincipal": {
"properties": {
"name": {
"type": "string"
},
"principalId": {
"type": "string"
},
"principalType": {
"$ref": "#/definitions/v2/PluginSharePrincipalType"
}
},
"required": [
"name",
"principalId",
"principalType"
],
"type": "object"
},
"PluginSharePrincipalType": {
"enum": [
"user",
"group",
"workspace"
],
"type": "string"
},
"PluginShareSaveParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"discoverability": {
"anyOf": [
{
"$ref": "#/definitions/v2/PluginShareDiscoverability"
},
{
"type": "null"
}
]
},
"pluginPath": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
@@ -12536,6 +12601,15 @@
"string",
"null"
]
},
"shareTargets": {
"items": {
"$ref": "#/definitions/v2/PluginShareTarget"
},
"type": [
"array",
"null"
]
}
},
"required": [
@@ -12561,6 +12635,57 @@
"title": "PluginShareSaveResponse",
"type": "object"
},
"PluginShareTarget": {
"properties": {
"principalId": {
"type": "string"
},
"principalType": {
"$ref": "#/definitions/v2/PluginSharePrincipalType"
}
},
"required": [
"principalId",
"principalType"
],
"type": "object"
},
"PluginShareUpdateTargetsParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"remotePluginId": {
"type": "string"
},
"shareTargets": {
"items": {
"$ref": "#/definitions/v2/PluginShareTarget"
},
"type": "array"
}
},
"required": [
"remotePluginId",
"shareTargets"
],
"title": "PluginShareUpdateTargetsParams",
"type": "object"
},
"PluginShareUpdateTargetsResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"principals": {
"items": {
"$ref": "#/definitions/v2/PluginSharePrincipal"
},
"type": "array"
}
},
"required": [
"principals"
],
"title": "PluginShareUpdateTargetsResponse",
"type": "object"
},
"PluginSkillReadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -12960,13 +13085,9 @@
]
},
"service_tier": {
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"tools": {
@@ -14495,13 +14616,6 @@
"title": "ServerRequestResolvedNotification",
"type": "object"
},
"ServiceTier": {
"enum": [
"fast",
"flex"
],
"type": "string"
},
"SessionMigration": {
"properties": {
"cwd": {
@@ -15216,6 +15330,10 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"source": {
"allOf": [
{
@@ -15264,6 +15382,7 @@
"id",
"modelProvider",
"preview",
"sessionId",
"source",
"status",
"turns",
@@ -15439,20 +15558,9 @@
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"threadId": {
@@ -15526,13 +15634,9 @@
"description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions."
},
"serviceTier": {
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"thread": {
@@ -16957,20 +17061,9 @@
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"threadId": {
@@ -17033,13 +17126,9 @@
"description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions."
},
"serviceTier": {
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"thread": {
@@ -17265,20 +17354,9 @@
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"sessionStartSource": {
@@ -17356,13 +17434,9 @@
"description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions."
},
"serviceTier": {
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"thread": {
@@ -18029,22 +18103,11 @@
"description": "Override the sandbox policy for this turn and subsequent turns."
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
],
"description": "Override the service tier for this turn and subsequent turns."
"description": "Override the service tier for this turn and subsequent turns.",
"type": [
"string",
"null"
]
},
"summary": {
"anyOf": [
@@ -1569,6 +1569,30 @@
"title": "Plugin/share/saveRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"plugin/share/updateTargets"
],
"title": "Plugin/share/updateTargetsRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/PluginShareUpdateTargetsParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/share/updateTargetsRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -3656,13 +3680,9 @@
]
},
"service_tier": {
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"tools": {
@@ -9090,6 +9110,14 @@
"title": "PluginShareDeleteResponse",
"type": "object"
},
"PluginShareDiscoverability": {
"enum": [
"LISTED",
"UNLISTED",
"PRIVATE"
],
"type": "string"
},
"PluginShareListItem": {
"properties": {
"localPluginPath": {
@@ -9136,9 +9164,46 @@
"title": "PluginShareListResponse",
"type": "object"
},
"PluginSharePrincipal": {
"properties": {
"name": {
"type": "string"
},
"principalId": {
"type": "string"
},
"principalType": {
"$ref": "#/definitions/PluginSharePrincipalType"
}
},
"required": [
"name",
"principalId",
"principalType"
],
"type": "object"
},
"PluginSharePrincipalType": {
"enum": [
"user",
"group",
"workspace"
],
"type": "string"
},
"PluginShareSaveParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"discoverability": {
"anyOf": [
{
"$ref": "#/definitions/PluginShareDiscoverability"
},
{
"type": "null"
}
]
},
"pluginPath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
@@ -9147,6 +9212,15 @@
"string",
"null"
]
},
"shareTargets": {
"items": {
"$ref": "#/definitions/PluginShareTarget"
},
"type": [
"array",
"null"
]
}
},
"required": [
@@ -9172,6 +9246,57 @@
"title": "PluginShareSaveResponse",
"type": "object"
},
"PluginShareTarget": {
"properties": {
"principalId": {
"type": "string"
},
"principalType": {
"$ref": "#/definitions/PluginSharePrincipalType"
}
},
"required": [
"principalId",
"principalType"
],
"type": "object"
},
"PluginShareUpdateTargetsParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"remotePluginId": {
"type": "string"
},
"shareTargets": {
"items": {
"$ref": "#/definitions/PluginShareTarget"
},
"type": "array"
}
},
"required": [
"remotePluginId",
"shareTargets"
],
"title": "PluginShareUpdateTargetsParams",
"type": "object"
},
"PluginShareUpdateTargetsResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"principals": {
"items": {
"$ref": "#/definitions/PluginSharePrincipal"
},
"type": "array"
}
},
"required": [
"principals"
],
"title": "PluginShareUpdateTargetsResponse",
"type": "object"
},
"PluginSkillReadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -9571,13 +9696,9 @@
]
},
"service_tier": {
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"tools": {
@@ -12381,13 +12502,6 @@
"title": "ServerRequestResolvedNotification",
"type": "object"
},
"ServiceTier": {
"enum": [
"fast",
"flex"
],
"type": "string"
},
"SessionMigration": {
"properties": {
"cwd": {
@@ -13102,6 +13216,10 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"source": {
"allOf": [
{
@@ -13150,6 +13268,7 @@
"id",
"modelProvider",
"preview",
"sessionId",
"source",
"status",
"turns",
@@ -13325,20 +13444,9 @@
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"threadId": {
@@ -13412,13 +13520,9 @@
"description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions."
},
"serviceTier": {
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"thread": {
@@ -14843,20 +14947,9 @@
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"threadId": {
@@ -14919,13 +15012,9 @@
"description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions."
},
"serviceTier": {
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"thread": {
@@ -15151,20 +15240,9 @@
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"sessionStartSource": {
@@ -15242,13 +15320,9 @@
"description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions."
},
"serviceTier": {
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"thread": {
@@ -15915,22 +15989,11 @@
"description": "Override the sandbox policy for this turn and subsequent turns."
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
],
"description": "Override the service tier for this turn and subsequent turns."
"description": "Override the service tier for this turn and subsequent turns.",
"type": [
"string",
"null"
]
},
"summary": {
"anyOf": [
@@ -352,13 +352,9 @@
]
},
"service_tier": {
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"tools": {
@@ -658,13 +654,9 @@
]
},
"service_tier": {
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"tools": {
@@ -754,13 +746,6 @@
},
"type": "object"
},
"ServiceTier": {
"enum": [
"fast",
"flex"
],
"type": "string"
},
"ToolsV2": {
"properties": {
"view_image": {
@@ -4,9 +4,50 @@
"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"
},
"PluginShareDiscoverability": {
"enum": [
"LISTED",
"UNLISTED",
"PRIVATE"
],
"type": "string"
},
"PluginSharePrincipalType": {
"enum": [
"user",
"group",
"workspace"
],
"type": "string"
},
"PluginShareTarget": {
"properties": {
"principalId": {
"type": "string"
},
"principalType": {
"$ref": "#/definitions/PluginSharePrincipalType"
}
},
"required": [
"principalId",
"principalType"
],
"type": "object"
}
},
"properties": {
"discoverability": {
"anyOf": [
{
"$ref": "#/definitions/PluginShareDiscoverability"
},
{
"type": "null"
}
]
},
"pluginPath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
@@ -15,6 +56,15 @@
"string",
"null"
]
},
"shareTargets": {
"items": {
"$ref": "#/definitions/PluginShareTarget"
},
"type": [
"array",
"null"
]
}
},
"required": [
@@ -0,0 +1,45 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"PluginSharePrincipalType": {
"enum": [
"user",
"group",
"workspace"
],
"type": "string"
},
"PluginShareTarget": {
"properties": {
"principalId": {
"type": "string"
},
"principalType": {
"$ref": "#/definitions/PluginSharePrincipalType"
}
},
"required": [
"principalId",
"principalType"
],
"type": "object"
}
},
"properties": {
"remotePluginId": {
"type": "string"
},
"shareTargets": {
"items": {
"$ref": "#/definitions/PluginShareTarget"
},
"type": "array"
}
},
"required": [
"remotePluginId",
"shareTargets"
],
"title": "PluginShareUpdateTargetsParams",
"type": "object"
}
@@ -0,0 +1,45 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"PluginSharePrincipal": {
"properties": {
"name": {
"type": "string"
},
"principalId": {
"type": "string"
},
"principalType": {
"$ref": "#/definitions/PluginSharePrincipalType"
}
},
"required": [
"name",
"principalId",
"principalType"
],
"type": "object"
},
"PluginSharePrincipalType": {
"enum": [
"user",
"group",
"workspace"
],
"type": "string"
}
},
"properties": {
"principals": {
"items": {
"$ref": "#/definitions/PluginSharePrincipal"
},
"type": "array"
}
},
"required": [
"principals"
],
"title": "PluginShareUpdateTargetsResponse",
"type": "object"
}
@@ -131,13 +131,6 @@
],
"type": "string"
},
"ServiceTier": {
"enum": [
"fast",
"flex"
],
"type": "string"
},
"ThreadSource": {
"enum": [
"user",
@@ -222,20 +215,9 @@
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"threadId": {
@@ -1177,13 +1177,6 @@
}
]
},
"ServiceTier": {
"enum": [
"fast",
"flex"
],
"type": "string"
},
"SessionSource": {
"oneOf": [
{
@@ -1403,6 +1396,10 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"source": {
"allOf": [
{
@@ -1451,6 +1448,7 @@
"id",
"modelProvider",
"preview",
"sessionId",
"source",
"status",
"turns",
@@ -2610,13 +2608,9 @@
"description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions."
},
"serviceTier": {
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"thread": {
@@ -853,6 +853,10 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"source": {
"allOf": [
{
@@ -901,6 +905,7 @@
"id",
"modelProvider",
"preview",
"sessionId",
"source",
"status",
"turns",
@@ -853,6 +853,10 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"source": {
"allOf": [
{
@@ -901,6 +905,7 @@
"id",
"modelProvider",
"preview",
"sessionId",
"source",
"status",
"turns",
@@ -853,6 +853,10 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"source": {
"allOf": [
{
@@ -901,6 +905,7 @@
"id",
"modelProvider",
"preview",
"sessionId",
"source",
"status",
"turns",
@@ -1010,13 +1010,6 @@
"danger-full-access"
],
"type": "string"
},
"ServiceTier": {
"enum": [
"fast",
"flex"
],
"type": "string"
}
},
"description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.",
@@ -1101,20 +1094,9 @@
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"threadId": {
@@ -1177,13 +1177,6 @@
}
]
},
"ServiceTier": {
"enum": [
"fast",
"flex"
],
"type": "string"
},
"SessionSource": {
"oneOf": [
{
@@ -1403,6 +1396,10 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"source": {
"allOf": [
{
@@ -1451,6 +1448,7 @@
"id",
"modelProvider",
"preview",
"sessionId",
"source",
"status",
"turns",
@@ -2610,13 +2608,9 @@
"description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions."
},
"serviceTier": {
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"thread": {
@@ -853,6 +853,10 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"source": {
"allOf": [
{
@@ -901,6 +905,7 @@
"id",
"modelProvider",
"preview",
"sessionId",
"source",
"status",
"turns",
@@ -165,13 +165,6 @@
],
"type": "string"
},
"ServiceTier": {
"enum": [
"fast",
"flex"
],
"type": "string"
},
"ThreadSource": {
"enum": [
"user",
@@ -295,20 +288,9 @@
]
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"sessionStartSource": {
@@ -1177,13 +1177,6 @@
}
]
},
"ServiceTier": {
"enum": [
"fast",
"flex"
],
"type": "string"
},
"SessionSource": {
"oneOf": [
{
@@ -1403,6 +1396,10 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"source": {
"allOf": [
{
@@ -1451,6 +1448,7 @@
"id",
"modelProvider",
"preview",
"sessionId",
"source",
"status",
"turns",
@@ -2610,13 +2608,9 @@
"description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions."
},
"serviceTier": {
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"thread": {
@@ -853,6 +853,10 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"source": {
"allOf": [
{
@@ -901,6 +905,7 @@
"id",
"modelProvider",
"preview",
"sessionId",
"source",
"status",
"turns",
@@ -853,6 +853,10 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
},
"source": {
"allOf": [
{
@@ -901,6 +905,7 @@
"id",
"modelProvider",
"preview",
"sessionId",
"source",
"status",
"turns",
@@ -312,13 +312,6 @@
}
]
},
"ServiceTier": {
"enum": [
"fast",
"flex"
],
"type": "string"
},
"Settings": {
"description": "Settings for a collaboration mode.",
"properties": {
@@ -586,22 +579,11 @@
"description": "Override the sandbox policy for this turn and subsequent turns."
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
],
"description": "Override the service tier for this turn and subsequent turns."
"description": "Override the service tier for this turn and subsequent turns.",
"type": [
"string",
"null"
]
},
"summary": {
"anyOf": [
File diff suppressed because one or more lines are too long
-1
View File
@@ -64,7 +64,6 @@ export type { ResponseItem } from "./ResponseItem";
export type { ReviewDecision } from "./ReviewDecision";
export type { ServerNotification } from "./ServerNotification";
export type { ServerRequest } from "./ServerRequest";
export type { ServiceTier } from "./ServiceTier";
export type { SessionSource } from "./SessionSource";
export type { Settings } from "./Settings";
export type { SubAgentSource } from "./SubAgentSource";
+1 -2
View File
@@ -4,7 +4,6 @@
import type { ForcedLoginMethod } from "../ForcedLoginMethod";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { ReasoningSummary } from "../ReasoningSummary";
import type { ServiceTier } from "../ServiceTier";
import type { Verbosity } from "../Verbosity";
import type { WebSearchMode } from "../WebSearchMode";
import type { JsonValue } from "../serde_json/JsonValue";
@@ -20,4 +19,4 @@ export type Config = {model: string | null, review_model: string | null, model_c
* [UNSTABLE] Optional default for where approval requests are routed for
* review.
*/
approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: ServiceTier | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null });
approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: string | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null });
@@ -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 PluginShareDiscoverability = "LISTED" | "UNLISTED" | "PRIVATE";
@@ -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 { PluginSharePrincipalType } from "./PluginSharePrincipalType";
export type PluginSharePrincipal = { principalType: PluginSharePrincipalType, principalId: string, name: string, };
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ServiceTier = "fast" | "flex";
export type PluginSharePrincipalType = "user" | "group" | "workspace";
@@ -2,5 +2,7 @@
// 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";
import type { PluginShareDiscoverability } from "./PluginShareDiscoverability";
import type { PluginShareTarget } from "./PluginShareTarget";
export type PluginShareSaveParams = { pluginPath: AbsolutePathBuf, remotePluginId?: string | null, };
export type PluginShareSaveParams = { pluginPath: AbsolutePathBuf, remotePluginId?: string | null, discoverability?: PluginShareDiscoverability | null, shareTargets?: Array<PluginShareTarget> | null, };
@@ -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 { PluginSharePrincipalType } from "./PluginSharePrincipalType";
export type PluginShareTarget = { principalType: PluginSharePrincipalType, principalId: string, };
@@ -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 { PluginShareTarget } from "./PluginShareTarget";
export type PluginShareUpdateTargetsParams = { remotePluginId: string, shareTargets: Array<PluginShareTarget>, };
@@ -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 { PluginSharePrincipal } from "./PluginSharePrincipal";
export type PluginShareUpdateTargetsResponse = { principals: Array<PluginSharePrincipal>, };
@@ -3,7 +3,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ReasoningEffort } from "../ReasoningEffort";
import type { ReasoningSummary } from "../ReasoningSummary";
import type { ServiceTier } from "../ServiceTier";
import type { Verbosity } from "../Verbosity";
import type { WebSearchMode } from "../WebSearchMode";
import type { JsonValue } from "../serde_json/JsonValue";
@@ -16,4 +15,4 @@ export type ProfileV2 = {model: string | null, model_provider: string | null, ap
* are routed for review. If omitted, the enclosing config default is
* used.
*/
approvals_reviewer: ApprovalsReviewer | null, service_tier: ServiceTier | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, chatgpt_base_url: string | null} & ({ [key in string]?: number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null });
approvals_reviewer: ApprovalsReviewer | null, service_tier: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, chatgpt_base_url: string | null} & ({ [key in string]?: number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null });
@@ -9,6 +9,10 @@ import type { ThreadStatus } from "./ThreadStatus";
import type { Turn } from "./Turn";
export type Thread = { id: string,
/**
* Session id shared by threads that belong to the same session tree.
*/
sessionId: string,
/**
* Source thread id when this thread was created by forking another thread.
*/
@@ -1,7 +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 { ServiceTier } from "../ServiceTier";
import type { JsonValue } from "../serde_json/JsonValue";
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
import type { AskForApproval } from "./AskForApproval";
@@ -20,7 +19,7 @@ import type { ThreadSource } from "./ThreadSource";
export type ThreadForkParams = {threadId: string, /**
* Configuration overrides for the forked thread, if any.
*/
model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /**
model?: string | null, modelProvider?: string | null, serviceTier?: string | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /**
* Override where approval requests are routed for review on this thread
* and subsequent turns.
*/
@@ -3,13 +3,12 @@
// 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";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { ServiceTier } from "../ServiceTier";
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
import type { Thread } from "./Thread";
export type ThreadForkResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /**
export type ThreadForkResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /**
* Instruction source files currently loaded for this thread.
*/
instructionSources: Array<AbsolutePathBuf>, approvalPolicy: AskForApproval, /**
@@ -2,7 +2,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Personality } from "../Personality";
import type { ServiceTier } from "../ServiceTier";
import type { JsonValue } from "../serde_json/JsonValue";
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
import type { AskForApproval } from "./AskForApproval";
@@ -22,7 +21,7 @@ import type { SandboxMode } from "./SandboxMode";
export type ThreadResumeParams = {threadId: string, /**
* Configuration overrides for the resumed thread, if any.
*/
model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /**
model?: string | null, modelProvider?: string | null, serviceTier?: string | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /**
* Override where approval requests are routed for review on this thread
* and subsequent turns.
*/
@@ -3,13 +3,12 @@
// 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";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { ServiceTier } from "../ServiceTier";
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
import type { Thread } from "./Thread";
export type ThreadResumeResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /**
export type ThreadResumeResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /**
* Instruction source files currently loaded for this thread.
*/
instructionSources: Array<AbsolutePathBuf>, approvalPolicy: AskForApproval, /**
@@ -2,7 +2,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Personality } from "../Personality";
import type { ServiceTier } from "../ServiceTier";
import type { JsonValue } from "../serde_json/JsonValue";
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
import type { AskForApproval } from "./AskForApproval";
@@ -10,7 +9,7 @@ import type { SandboxMode } from "./SandboxMode";
import type { ThreadSource } from "./ThreadSource";
import type { ThreadStartSource } from "./ThreadStartSource";
export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /**
export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, serviceTier?: string | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /**
* Override where approval requests are routed for review on this thread
* and subsequent turns.
*/
@@ -3,13 +3,12 @@
// 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";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { ServiceTier } from "../ServiceTier";
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
import type { Thread } from "./Thread";
export type ThreadStartResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /**
export type ThreadStartResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /**
* Instruction source files currently loaded for this thread.
*/
instructionSources: Array<AbsolutePathBuf>, approvalPolicy: AskForApproval, /**
@@ -4,7 +4,6 @@
import type { Personality } from "../Personality";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { ReasoningSummary } from "../ReasoningSummary";
import type { ServiceTier } from "../ServiceTier";
import type { JsonValue } from "../serde_json/JsonValue";
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
import type { AskForApproval } from "./AskForApproval";
@@ -30,7 +29,7 @@ sandboxPolicy?: SandboxPolicy | null, /**
model?: string | null, /**
* Override the service tier for this turn and subsequent turns.
*/
serviceTier?: ServiceTier | null | null, /**
serviceTier?: string | null | null, /**
* Override the reasoning effort for this turn and subsequent turns.
*/
effort?: ReasoningEffort | null, /**
@@ -285,11 +285,17 @@ export type { PluginReadParams } from "./PluginReadParams";
export type { PluginReadResponse } from "./PluginReadResponse";
export type { PluginShareDeleteParams } from "./PluginShareDeleteParams";
export type { PluginShareDeleteResponse } from "./PluginShareDeleteResponse";
export type { PluginShareDiscoverability } from "./PluginShareDiscoverability";
export type { PluginShareListItem } from "./PluginShareListItem";
export type { PluginShareListParams } from "./PluginShareListParams";
export type { PluginShareListResponse } from "./PluginShareListResponse";
export type { PluginSharePrincipal } from "./PluginSharePrincipal";
export type { PluginSharePrincipalType } from "./PluginSharePrincipalType";
export type { PluginShareSaveParams } from "./PluginShareSaveParams";
export type { PluginShareSaveResponse } from "./PluginShareSaveResponse";
export type { PluginShareTarget } from "./PluginShareTarget";
export type { PluginShareUpdateTargetsParams } from "./PluginShareUpdateTargetsParams";
export type { PluginShareUpdateTargetsResponse } from "./PluginShareUpdateTargetsResponse";
export type { PluginSkillReadParams } from "./PluginSkillReadParams";
export type { PluginSkillReadResponse } from "./PluginSkillReadResponse";
export type { PluginSource } from "./PluginSource";
@@ -628,6 +628,11 @@ client_request_definitions! {
serialization: global("config"),
response: v2::PluginShareSaveResponse,
},
PluginShareUpdateTargets => "plugin/share/updateTargets" {
params: v2::PluginShareUpdateTargetsParams,
serialization: global("config"),
response: v2::PluginShareUpdateTargetsResponse,
},
PluginShareList => "plugin/share/list" {
params: v2::PluginShareListParams,
serialization: global("config"),
@@ -2173,6 +2178,7 @@ mod tests {
response: v2::ThreadStartResponse {
thread: v2::Thread {
id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(),
session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(),
forked_from_id: None,
preview: "first prompt".to_string(),
ephemeral: true,
@@ -2214,6 +2220,7 @@ mod tests {
"response": {
"thread": {
"id": "67e55044-10b1-426f-9247-bb680e5fe0c8",
"sessionId": "67e55044-10b1-426f-9247-bb680e5fe0c7",
"forkedFromId": null,
"preview": "first prompt",
"ephemeral": true,
@@ -5,7 +5,6 @@ use super::shared::default_enabled;
use codex_experimental_api_macros::ExperimentalApi;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::Verbosity;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::config_types::WebSearchToolConfig;
@@ -137,7 +136,7 @@ pub struct ProfileV2 {
/// used.
#[experimental("config/read.approvalsReviewer")]
pub approvals_reviewer: Option<ApprovalsReviewer>,
pub service_tier: Option<ServiceTier>,
pub service_tier: Option<String>,
pub model_reasoning_effort: Option<ReasoningEffort>,
pub model_reasoning_summary: Option<ReasoningSummary>,
pub model_verbosity: Option<Verbosity>,
@@ -248,7 +247,7 @@ pub struct Config {
pub model_reasoning_effort: Option<ReasoningEffort>,
pub model_reasoning_summary: Option<ReasoningSummary>,
pub model_verbosity: Option<Verbosity>,
pub service_tier: Option<ServiceTier>,
pub service_tier: Option<String>,
pub analytics: Option<AnalyticsConfig>,
#[experimental("config/read.apps")]
#[serde(default)]
@@ -194,6 +194,10 @@ pub struct PluginShareSaveParams {
pub plugin_path: AbsolutePathBuf,
#[ts(optional = nullable)]
pub remote_plugin_id: Option<String>,
#[ts(optional = nullable)]
pub discoverability: Option<PluginShareDiscoverability>,
#[ts(optional = nullable)]
pub share_targets: Option<Vec<PluginShareTarget>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -204,6 +208,21 @@ pub struct PluginShareSaveResponse {
pub share_url: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginShareUpdateTargetsParams {
pub remote_plugin_id: String,
pub share_targets: Vec<PluginShareTarget>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginShareUpdateTargetsResponse {
pub principals: Vec<PluginSharePrincipal>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -237,6 +256,51 @@ pub struct PluginShareListItem {
pub local_plugin_path: Option<AbsolutePathBuf>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[ts(export_to = "v2/")]
pub enum PluginShareDiscoverability {
#[serde(rename = "LISTED")]
#[ts(rename = "LISTED")]
Listed,
#[serde(rename = "UNLISTED")]
#[ts(rename = "UNLISTED")]
Unlisted,
#[serde(rename = "PRIVATE")]
#[ts(rename = "PRIVATE")]
Private,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[ts(export_to = "v2/")]
pub enum PluginSharePrincipalType {
#[serde(rename = "user")]
#[ts(rename = "user")]
User,
#[serde(rename = "group")]
#[ts(rename = "group")]
Group,
#[serde(rename = "workspace")]
#[ts(rename = "workspace")]
Workspace,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginShareTarget {
pub principal_type: PluginSharePrincipalType,
pub principal_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginSharePrincipal {
pub principal_type: PluginSharePrincipalType,
pub principal_id: String,
pub name: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
@@ -2981,11 +2981,15 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() {
serde_json::to_value(PluginShareSaveParams {
plugin_path: plugin_path.clone(),
remote_plugin_id: None,
discoverability: None,
share_targets: None,
})
.unwrap(),
json!({
"pluginPath": plugin_path_json,
"remotePluginId": null,
"discoverability": null,
"shareTargets": null,
}),
);
@@ -2993,11 +2997,33 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() {
serde_json::to_value(PluginShareSaveParams {
plugin_path,
remote_plugin_id: Some("plugins~Plugin_00000000000000000000000000000000".to_string(),),
discoverability: Some(PluginShareDiscoverability::Private),
share_targets: Some(vec![
PluginShareTarget {
principal_type: PluginSharePrincipalType::User,
principal_id: "user-1".to_string(),
},
PluginShareTarget {
principal_type: PluginSharePrincipalType::Workspace,
principal_id: "workspace-1".to_string(),
},
]),
})
.unwrap(),
json!({
"pluginPath": plugin_path_json,
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
"discoverability": "PRIVATE",
"shareTargets": [
{
"principalType": "user",
"principalId": "user-1",
},
{
"principalType": "workspace",
"principalId": "workspace-1",
},
],
}),
);
@@ -3013,6 +3039,42 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() {
}),
);
assert_eq!(
serde_json::to_value(PluginShareUpdateTargetsParams {
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
share_targets: vec![PluginShareTarget {
principal_type: PluginSharePrincipalType::Group,
principal_id: "group-1".to_string(),
}],
})
.unwrap(),
json!({
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
"shareTargets": [{
"principalType": "group",
"principalId": "group-1",
}],
}),
);
assert_eq!(
serde_json::to_value(PluginShareUpdateTargetsResponse {
principals: vec![PluginSharePrincipal {
principal_type: PluginSharePrincipalType::User,
principal_id: "user-1".to_string(),
name: "Gavin".to_string(),
}],
})
.unwrap(),
json!({
"principals": [{
"principalType": "user",
"principalId": "user-1",
"name": "Gavin",
}],
}),
);
assert_eq!(
serde_json::from_value::<PluginShareListParams>(json!({})).unwrap(),
PluginShareListParams {},
@@ -3375,10 +3437,11 @@ fn thread_start_params_preserve_explicit_null_service_tier() {
}
#[test]
fn thread_lifecycle_responses_default_missing_compat_fields() {
fn thread_lifecycle_responses_default_missing_optional_fields() {
let response = json!({
"thread": {
"id": "thread-id",
"sessionId": "thread-id",
"forkedFromId": null,
"preview": "",
"ephemeral": false,
@@ -12,7 +12,6 @@ use super::TurnEnvironmentParams;
use super::shared::v2_enum_from_core;
use codex_experimental_api_macros::ExperimentalApi;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::ThreadGoalStatus as CoreThreadGoalStatus;
@@ -103,7 +102,7 @@ pub struct ThreadStartParams {
skip_serializing_if = "Option::is_none"
)]
#[ts(optional = nullable)]
pub service_tier: Option<Option<ServiceTier>>,
pub service_tier: Option<Option<String>>,
#[ts(optional = nullable)]
pub cwd: Option<String>,
#[experimental(nested)]
@@ -192,7 +191,7 @@ pub struct ThreadStartResponse {
pub thread: Thread,
pub model: String,
pub model_provider: String,
pub service_tier: Option<ServiceTier>,
pub service_tier: Option<String>,
pub cwd: AbsolutePathBuf,
/// Instruction source files currently loaded for this thread.
#[serde(default)]
@@ -260,7 +259,7 @@ pub struct ThreadResumeParams {
skip_serializing_if = "Option::is_none"
)]
#[ts(optional = nullable)]
pub service_tier: Option<Option<ServiceTier>>,
pub service_tier: Option<Option<String>>,
#[ts(optional = nullable)]
pub cwd: Option<String>,
#[experimental(nested)]
@@ -307,7 +306,7 @@ pub struct ThreadResumeResponse {
pub thread: Thread,
pub model: String,
pub model_provider: String,
pub service_tier: Option<ServiceTier>,
pub service_tier: Option<String>,
pub cwd: AbsolutePathBuf,
/// Instruction source files currently loaded for this thread.
#[serde(default)]
@@ -366,7 +365,7 @@ pub struct ThreadForkParams {
skip_serializing_if = "Option::is_none"
)]
#[ts(optional = nullable)]
pub service_tier: Option<Option<ServiceTier>>,
pub service_tier: Option<Option<String>>,
#[ts(optional = nullable)]
pub cwd: Option<String>,
#[experimental(nested)]
@@ -416,7 +415,7 @@ pub struct ThreadForkResponse {
pub thread: Thread,
pub model: String,
pub model_provider: String,
pub service_tier: Option<ServiceTier>,
pub service_tier: Option<String>,
pub cwd: AbsolutePathBuf,
/// Instruction source files currently loaded for this thread.
#[serde(default)]
@@ -104,6 +104,8 @@ pub struct GitInfo {
#[ts(export_to = "v2/")]
pub struct Thread {
pub id: String,
/// Session id shared by threads that belong to the same session tree.
pub session_id: String,
/// Source thread id when this thread was created by forking another thread.
pub forked_from_id: Option<String>,
/// Usually the first user message in the thread, if available.
@@ -7,7 +7,6 @@ use codex_experimental_api_macros::ExperimentalApi;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg;
use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus;
@@ -94,7 +93,7 @@ pub struct TurnStartParams {
skip_serializing_if = "Option::is_none"
)]
#[ts(optional = nullable)]
pub service_tier: Option<Option<ServiceTier>>,
pub service_tier: Option<Option<String>>,
/// Override the reasoning effort for this turn and subsequent turns.
#[ts(optional = nullable)]
pub effort: Option<ReasoningEffort>,
+8 -2
View File
@@ -303,11 +303,11 @@ Example:
{ "id": 12, "result": { "thread": { "id": "thr_123", "turns": [], … } } }
```
To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. When the source history includes persisted token usage, the server also emits `thread/tokenUsage/updated` for the new thread immediately after the response. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only:
To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. The returned `thread.sessionId` identifies the current live session tree root. Root threads use their own `thread.id` as `thread.sessionId`; stored threads that are not loaded also report their own `thread.id`, because resuming one makes it the root of a new live session tree. When the source history includes persisted token usage, the server also emits `thread/tokenUsage/updated` for the new thread immediately after the response. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only:
```json
{ "method": "thread/fork", "id": 12, "params": { "threadId": "thr_123", "ephemeral": true } }
{ "id": 12, "result": { "thread": { "id": "thr_456", … } } }
{ "id": 12, "result": { "thread": { "id": "thr_456", "sessionId": "thr_456", … } } }
{ "method": "thread/started", "params": { "thread": { … } } }
```
@@ -1393,6 +1393,12 @@ If the session approval policy uses `Granular` with `request_permissions: false`
`dynamicTools` on `thread/start` and the corresponding `item/tool/call` request/response flow are experimental APIs. To enable them, set `initialize.params.capabilities.experimentalApi = true`.
Dynamic tool identifiers follow the same constraints as Responses function tools:
- `name` must match `^[a-zA-Z0-9_-]+$` and be between 1 and 128 characters.
- `namespace`, when present, must match `^[a-zA-Z0-9_-]+$` and be between 1 and 64 characters.
- `namespace` must not collide with reserved Responses runtime namespaces such as `functions`, `multi_tool_use`, `file_search`, `web`, `browser`, `image_gen`, `computer`, `container`, `terminal`, `python`, `python_user_visible`, `api_tool`, `tool_search`, or `submodel_delegator`.
Each dynamic tool may set `deferLoading`. When omitted, it defaults to `false`. Set it to `true` to keep the tool registered and callable by runtime features such as `code_mode`, while excluding it from the model-facing tool list sent on ordinary turns. When `tool_search` is available, deferred dynamic tools are searchable and can be exposed by a matching search result.
When a dynamic tool is invoked during a turn, the server sends an `item/tool/call` JSON-RPC request to the client:
@@ -1190,6 +1190,7 @@ pub(crate) async fn apply_bespoke_event_handling(
.await;
let response = match thread_rollback_response_from_stored_thread(
stored_thread,
conversation.session_configured().session_id.to_string(),
fallback_model_provider.as_str(),
&fallback_cwd,
loaded_status,
@@ -1543,6 +1544,7 @@ async fn handle_thread_rollback_failed(
fn thread_rollback_response_from_stored_thread(
stored_thread: codex_thread_store::StoredThread,
session_id: String,
fallback_model_provider: &str,
fallback_cwd: &AbsolutePathBuf,
loaded_status: ThreadStatus,
@@ -1550,6 +1552,7 @@ fn thread_rollback_response_from_stored_thread(
let thread_id = stored_thread.thread_id;
let (mut thread, history) =
thread_from_stored_thread(stored_thread, fallback_model_provider, fallback_cwd);
thread.session_id = session_id;
let Some(history) = history else {
return Err(format!(
"thread {thread_id} did not include persisted history after rollback"
@@ -2200,6 +2203,7 @@ mod tests {
let response = thread_rollback_response_from_stored_thread(
stored_thread,
thread_id.to_string(),
"fallback-provider",
&fallback_cwd,
ThreadStatus::NotLoaded,
+15
View File
@@ -140,6 +140,21 @@ impl ConfigManager {
.await
}
pub(crate) async fn load_latest_config_for_thread(
&self,
thread_config: &Config,
) -> std::io::Result<Config> {
let refreshed_config = self
.load_latest_config(Some(thread_config.cwd.to_path_buf()))
.await?;
let mut config = thread_config
.rebuild_preserving_session_layers(&refreshed_config)
.await?;
self.apply_runtime_feature_enablement(&mut config);
self.apply_arg0_paths(&mut config);
Ok(config)
}
pub(crate) async fn load_default_config(&self) -> std::io::Result<Config> {
let mut config = Config::load_default_with_cli_overrides_for_codex_home(
self.codex_home.clone(),
+7 -4
View File
@@ -83,6 +83,7 @@ use codex_config::LoaderOverrides;
use codex_config::ThreadConfigLoader;
use codex_core::config::Config;
use codex_core::init_state_db_from_config;
use codex_core::resolve_installation_id;
use codex_exec_server::EnvironmentManager;
use codex_feedback::CodexFeedback;
use codex_login::AuthManager;
@@ -345,7 +346,7 @@ impl InProcessClientHandle {
/// the runtime is shut down and an `InvalidData` error is returned.
pub async fn start(args: InProcessStartArgs) -> IoResult<InProcessClientHandle> {
let initialize = args.initialize.clone();
let client = start_uninitialized(args).await;
let client = start_uninitialized(args).await?;
let initialize_response = client
.request(ClientRequest::Initialize {
@@ -365,12 +366,13 @@ pub async fn start(args: InProcessStartArgs) -> IoResult<InProcessClientHandle>
Ok(client)
}
async fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
async fn start_uninitialized(args: InProcessStartArgs) -> IoResult<InProcessClientHandle> {
let channel_capacity = args.channel_capacity.max(1);
let state_db = match args.state_db.clone() {
Some(state_db) => Some(state_db),
None => init_state_db_from_config(args.config.as_ref()).await,
};
let installation_id = resolve_installation_id(&args.config.codex_home).await?;
let (client_tx, mut client_rx) = mpsc::channel::<InProcessClientMessage>(channel_capacity);
let (event_tx, event_rx) = mpsc::channel::<InProcessServerEvent>(channel_capacity);
@@ -438,6 +440,7 @@ async fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle
config_warnings: args.config_warnings,
session_source: args.session_source,
auth_manager,
installation_id,
rpc_transport: AppServerRpcTransport::InProcess,
remote_control_handle: None,
plugin_startup_tasks: crate::PluginStartupTasks::Start,
@@ -718,13 +721,13 @@ async fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle
}
});
InProcessClientHandle {
Ok(InProcessClientHandle {
client: InProcessClientSender { client_tx },
event_rx,
runtime_handle,
#[cfg(test)]
_test_codex_home: None,
}
})
}
#[cfg(test)]
+4
View File
@@ -7,6 +7,7 @@ use codex_config::NoopThreadConfigLoader;
use codex_config::RemoteThreadConfigLoader;
use codex_config::ThreadConfigLoader;
use codex_core::config::Config;
use codex_core::resolve_installation_id;
use codex_exec_server::EnvironmentManagerArgs;
use codex_features::Feature;
use codex_login::AuthManager;
@@ -85,6 +86,7 @@ mod filters;
mod fs_watch;
mod fuzzy_file_search;
pub mod in_process;
mod mcp_refresh;
mod message_processor;
mod models;
mod outgoing_message;
@@ -620,6 +622,7 @@ pub async fn run_main_with_transport_options(
None => error!("{}", warning.summary),
}
}
let installation_id = resolve_installation_id(&config.codex_home).await?;
let transport_shutdown_token = CancellationToken::new();
let mut transport_accept_handles = Vec::<JoinHandle<()>>::new();
@@ -763,6 +766,7 @@ pub async fn run_main_with_transport_options(
config_warnings,
session_source,
auth_manager,
installation_id,
rpc_transport: analytics_rpc_transport(&transport),
remote_control_handle: Some(remote_control_handle.clone()),
plugin_startup_tasks: runtime_options.plugin_startup_tasks,
+242
View File
@@ -0,0 +1,242 @@
use crate::config_manager::ConfigManager;
use codex_core::CodexThread;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_protocol::ThreadId;
use codex_protocol::protocol::McpServerRefreshConfig;
use codex_protocol::protocol::Op;
use std::io;
use std::sync::Arc;
use tracing::warn;
pub(crate) async fn queue_strict_refresh(
thread_manager: &Arc<ThreadManager>,
config_manager: &ConfigManager,
) -> io::Result<()> {
config_manager
.load_latest_config(/*fallback_cwd*/ None)
.await?;
let mut refreshes = Vec::new();
for thread_id in thread_manager.list_thread_ids().await {
let thread = thread_manager
.get_thread(thread_id)
.await
.map_err(|err| io::Error::other(format!("failed to load thread {thread_id}: {err}")))?;
let config =
build_refresh_config(thread_manager, config_manager, thread.config().await).await?;
refreshes.push((thread_id, thread, config));
}
for (thread_id, thread, config) in refreshes {
queue_refresh(thread_id, thread, config).await?;
}
Ok(())
}
pub(crate) async fn queue_best_effort_refresh(
thread_manager: &Arc<ThreadManager>,
config_manager: &ConfigManager,
) {
for thread_id in thread_manager.list_thread_ids().await {
let thread = match thread_manager.get_thread(thread_id).await {
Ok(thread) => thread,
Err(err) => {
warn!("failed to load thread {thread_id} for MCP refresh: {err}");
continue;
}
};
let config =
match build_refresh_config(thread_manager, config_manager, thread.config().await).await
{
Ok(config) => config,
Err(err) => {
warn!("failed to build MCP refresh config for thread {thread_id}: {err}");
continue;
}
};
if let Err(err) = queue_refresh(thread_id, thread, config).await {
warn!("{err}");
}
}
}
async fn build_refresh_config(
thread_manager: &ThreadManager,
config_manager: &ConfigManager,
thread_config: Arc<Config>,
) -> io::Result<McpServerRefreshConfig> {
let config = config_manager
.load_latest_config_for_thread(thread_config.as_ref())
.await?;
let mcp_servers = thread_manager
.mcp_manager()
.configured_servers(&config)
.await;
Ok(McpServerRefreshConfig {
mcp_servers: serde_json::to_value(mcp_servers).map_err(io::Error::other)?,
mcp_oauth_credentials_store_mode: serde_json::to_value(
config.mcp_oauth_credentials_store_mode,
)
.map_err(io::Error::other)?,
})
}
async fn queue_refresh(
thread_id: ThreadId,
thread: Arc<CodexThread>,
config: McpServerRefreshConfig,
) -> io::Result<()> {
thread
.submit(Op::RefreshMcpServers { config })
.await
.map(|_| ())
.map_err(|err| {
io::Error::other(format!(
"failed to queue MCP refresh for thread {thread_id}: {err}"
))
})
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudRequirementsLoader;
use codex_config::LoaderOverrides;
use codex_config::ThreadConfigContext;
use codex_config::ThreadConfigLoadError;
use codex_config::ThreadConfigLoadErrorCode;
use codex_config::ThreadConfigLoader;
use codex_config::ThreadConfigSource;
use codex_core::agent_graph_store_from_state_db;
use codex_core::config::ConfigOverrides;
use codex_core::init_state_db_from_config;
use codex_core::thread_store_from_config;
use codex_exec_server::EnvironmentManager;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_protocol::protocol::SessionSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use tempfile::TempDir;
#[tokio::test]
async fn strict_refresh_reports_thread_planning_failures() -> anyhow::Result<()> {
let (_temp_dir, thread_manager, config_manager, _loader) = refresh_test_state().await?;
let err = queue_strict_refresh(&thread_manager, &config_manager)
.await
.expect_err("strict refresh should fail");
assert_eq!(err.to_string(), "failed to load refresh config");
Ok(())
}
#[tokio::test]
async fn best_effort_refresh_attempts_every_loaded_thread() -> anyhow::Result<()> {
let (_temp_dir, thread_manager, config_manager, loader) = refresh_test_state().await?;
queue_best_effort_refresh(&thread_manager, &config_manager).await;
assert_eq!(loader.good_loads.load(Ordering::Relaxed), 1);
assert_eq!(loader.bad_loads.load(Ordering::Relaxed), 1);
Ok(())
}
async fn refresh_test_state() -> anyhow::Result<(
TempDir,
Arc<ThreadManager>,
ConfigManager,
Arc<CountingThreadConfigLoader>,
)> {
let temp_dir = TempDir::new()?;
let good_cwd = temp_dir.path().join("good");
let bad_cwd = temp_dir.path().join("bad");
std::fs::create_dir_all(&good_cwd)?;
std::fs::create_dir_all(&bad_cwd)?;
let initial_config_manager =
ConfigManager::without_managed_config_for_tests(temp_dir.path().to_path_buf());
let good_config = initial_config_manager
.load_for_cwd(
/*request_overrides*/ None,
ConfigOverrides::default(),
Some(good_cwd.clone()),
)
.await?;
let bad_config = initial_config_manager
.load_for_cwd(
/*request_overrides*/ None,
ConfigOverrides::default(),
Some(bad_cwd.clone()),
)
.await?;
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy"));
let state_db = init_state_db_from_config(&good_config)
.await
.expect("refresh tests require state db");
let thread_store = thread_store_from_config(&good_config, state_db.clone());
let agent_graph_store = agent_graph_store_from_state_db(state_db.clone());
let thread_manager = Arc::new(ThreadManager::new(
&good_config,
auth_manager,
SessionSource::Exec,
Arc::new(EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
state_db,
thread_store,
agent_graph_store,
"11111111-1111-4111-8111-111111111111".to_string(),
));
thread_manager.start_thread(good_config).await?;
thread_manager.start_thread(bad_config).await?;
let loader = Arc::new(CountingThreadConfigLoader {
good_cwd: AbsolutePathBuf::try_from(good_cwd)?,
bad_cwd: AbsolutePathBuf::try_from(bad_cwd)?,
good_loads: AtomicUsize::new(0),
bad_loads: AtomicUsize::new(0),
});
let config_manager = ConfigManager::new(
temp_dir.path().to_path_buf(),
Vec::new(),
LoaderOverrides::without_managed_config_for_tests(),
CloudRequirementsLoader::default(),
Arg0DispatchPaths::default(),
loader.clone(),
);
Ok((temp_dir, thread_manager, config_manager, loader))
}
struct CountingThreadConfigLoader {
good_cwd: AbsolutePathBuf,
bad_cwd: AbsolutePathBuf,
good_loads: AtomicUsize,
bad_loads: AtomicUsize,
}
#[async_trait]
impl ThreadConfigLoader for CountingThreadConfigLoader {
async fn load(
&self,
context: ThreadConfigContext,
) -> Result<Vec<ThreadConfigSource>, ThreadConfigLoadError> {
if context.cwd.as_ref() == Some(&self.good_cwd) {
self.good_loads.fetch_add(1, Ordering::Relaxed);
}
if context.cwd.as_ref() == Some(&self.bad_cwd) {
self.bad_loads.fetch_add(1, Ordering::Relaxed);
return Err(ThreadConfigLoadError::new(
ThreadConfigLoadErrorCode::Internal,
/*status_code*/ None,
"failed to load refresh config",
));
}
Ok(Vec::new())
}
}
}
+9 -1
View File
@@ -259,6 +259,7 @@ pub(crate) struct MessageProcessorArgs {
pub(crate) config_warnings: Vec<ConfigWarningNotification>,
pub(crate) session_source: SessionSource,
pub(crate) auth_manager: Arc<AuthManager>,
pub(crate) installation_id: String,
pub(crate) rpc_transport: AppServerRpcTransport,
pub(crate) remote_control_handle: Option<RemoteControlHandle>,
pub(crate) plugin_startup_tasks: crate::PluginStartupTasks,
@@ -281,6 +282,7 @@ impl MessageProcessor {
config_warnings,
session_source,
auth_manager,
installation_id,
rpc_transport,
remote_control_handle,
plugin_startup_tasks,
@@ -302,6 +304,7 @@ impl MessageProcessor {
state_db.clone(),
Arc::clone(&thread_store),
agent_graph_store.clone(),
installation_id,
));
thread_manager
.plugins_manager()
@@ -416,7 +419,7 @@ impl MessageProcessor {
if matches!(plugin_startup_tasks, crate::PluginStartupTasks::Start) {
// Keep plugin startup warmups aligned at app-server startup.
let on_effective_plugins_changed =
plugin_processor.effective_plugins_changed_callback((*config).clone());
plugin_processor.effective_plugins_changed_callback();
thread_manager
.plugins_manager()
.maybe_start_plugin_startup_tasks_for_config(
@@ -1087,6 +1090,11 @@ impl MessageProcessor {
ClientRequest::PluginShareSave { params, .. } => {
self.plugin_processor.plugin_share_save(params).await
}
ClientRequest::PluginShareUpdateTargets { params, .. } => {
self.plugin_processor
.plugin_share_update_targets(params)
.await
}
ClientRequest::PluginShareList { params, .. } => {
self.plugin_processor.plugin_share_list(params).await
}
@@ -298,6 +298,7 @@ async fn build_test_processor(
config_warnings: Vec::new(),
session_source: SessionSource::VSCode,
auth_manager,
installation_id: "11111111-1111-4111-8111-111111111111".to_string(),
rpc_transport: AppServerRpcTransport::Stdio,
remote_control_handle: None,
plugin_startup_tasks: crate::PluginStartupTasks::Start,
@@ -115,11 +115,17 @@ use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginShareDeleteParams;
use codex_app_server_protocol::PluginShareDeleteResponse;
use codex_app_server_protocol::PluginShareDiscoverability;
use codex_app_server_protocol::PluginShareListItem;
use codex_app_server_protocol::PluginShareListParams;
use codex_app_server_protocol::PluginShareListResponse;
use codex_app_server_protocol::PluginSharePrincipal;
use codex_app_server_protocol::PluginSharePrincipalType;
use codex_app_server_protocol::PluginShareSaveParams;
use codex_app_server_protocol::PluginShareSaveResponse;
use codex_app_server_protocol::PluginShareTarget;
use codex_app_server_protocol::PluginShareUpdateTargetsParams;
use codex_app_server_protocol::PluginShareUpdateTargetsResponse;
use codex_app_server_protocol::PluginSkillReadParams;
use codex_app_server_protocol::PluginSkillReadResponse;
use codex_app_server_protocol::PluginSource;
@@ -355,7 +361,6 @@ use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::GitInfo as CoreGitInfo;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus;
use codex_protocol::protocol::McpServerRefreshConfig;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
use codex_protocol::protocol::RealtimeVoicesList;
@@ -168,7 +168,7 @@ impl AccountRequestProcessor {
{
Ok(config) => {
let refresh_thread_manager = Arc::clone(thread_manager);
let refresh_config = config.clone();
let refresh_config_manager = config_manager.clone();
thread_manager
.plugins_manager()
.maybe_start_remote_installed_plugins_cache_refresh(
@@ -177,7 +177,7 @@ impl AccountRequestProcessor {
Some(Arc::new(move || {
Self::spawn_effective_plugins_changed_task(
Arc::clone(&refresh_thread_manager),
refresh_config.clone(),
refresh_config_manager.clone(),
);
})),
);
@@ -190,19 +190,17 @@ impl AccountRequestProcessor {
}
}
fn spawn_effective_plugins_changed_task(thread_manager: Arc<ThreadManager>, config: Config) {
fn spawn_effective_plugins_changed_task(
thread_manager: Arc<ThreadManager>,
config_manager: ConfigManager,
) {
tokio::spawn(async move {
thread_manager.plugins_manager().clear_cache();
thread_manager.skills_manager().clear_cache();
if thread_manager.list_thread_ids().await.is_empty() {
return;
}
if let Err(err) =
McpRequestProcessor::queue_mcp_server_refresh_for_config(&thread_manager, &config)
.await
{
warn!("failed to queue MCP refresh after effective plugins changed: {err:?}");
}
crate::mcp_refresh::queue_best_effort_refresh(&thread_manager, &config_manager).await;
});
}
@@ -77,8 +77,9 @@ impl McpRequestProcessor {
&self,
_params: Option<()>,
) -> Result<McpServerRefreshResponse, JSONRPCErrorError> {
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
Self::queue_mcp_server_refresh_for_config(&self.thread_manager, &config).await?;
crate::mcp_refresh::queue_strict_refresh(&self.thread_manager, &self.config_manager)
.await
.map_err(|err| internal_error(format!("failed to refresh MCP servers: {err}")))?;
Ok(McpServerRefreshResponse {})
}
@@ -108,44 +109,6 @@ impl McpRequestProcessor {
Ok((thread_id, thread))
}
pub(super) async fn queue_mcp_server_refresh_for_config(
thread_manager: &Arc<ThreadManager>,
config: &Config,
) -> Result<(), JSONRPCErrorError> {
let configured_servers = thread_manager
.mcp_manager()
.configured_servers(config)
.await;
let mcp_servers = match serde_json::to_value(configured_servers) {
Ok(value) => value,
Err(err) => {
return Err(internal_error(format!(
"failed to serialize MCP servers: {err}"
)));
}
};
let mcp_oauth_credentials_store_mode =
match serde_json::to_value(config.mcp_oauth_credentials_store_mode) {
Ok(value) => value,
Err(err) => {
return Err(internal_error(format!(
"failed to serialize MCP OAuth credentials store mode: {err}"
)));
}
};
let refresh_config = McpServerRefreshConfig {
mcp_servers,
mcp_oauth_credentials_store_mode,
};
// Refresh requests are queued per thread; each thread rebuilds MCP connections on its next
// active turn to avoid work for threads that never resume.
thread_manager.refresh_mcp_servers(refresh_config).await;
Ok(())
}
async fn mcp_server_oauth_login_response(
&self,
params: McpServerOauthLoginParams,
@@ -86,6 +86,66 @@ fn marketplace_plugin_source_to_info(source: MarketplacePluginSource) -> PluginS
}
}
fn remote_plugin_share_discoverability(
discoverability: PluginShareDiscoverability,
) -> codex_core_plugins::remote::RemotePluginShareDiscoverability {
match discoverability {
PluginShareDiscoverability::Listed => {
codex_core_plugins::remote::RemotePluginShareDiscoverability::Listed
}
PluginShareDiscoverability::Unlisted => {
codex_core_plugins::remote::RemotePluginShareDiscoverability::Unlisted
}
PluginShareDiscoverability::Private => {
codex_core_plugins::remote::RemotePluginShareDiscoverability::Private
}
}
}
fn remote_plugin_share_targets(
targets: Vec<PluginShareTarget>,
) -> Vec<codex_core_plugins::remote::RemotePluginShareTarget> {
targets
.into_iter()
.map(
|target| codex_core_plugins::remote::RemotePluginShareTarget {
principal_type: match target.principal_type {
PluginSharePrincipalType::User => {
codex_core_plugins::remote::RemotePluginSharePrincipalType::User
}
PluginSharePrincipalType::Group => {
codex_core_plugins::remote::RemotePluginSharePrincipalType::Group
}
PluginSharePrincipalType::Workspace => {
codex_core_plugins::remote::RemotePluginSharePrincipalType::Workspace
}
},
principal_id: target.principal_id,
},
)
.collect()
}
fn plugin_share_principal_from_remote(
principal: codex_core_plugins::remote::RemotePluginSharePrincipal,
) -> PluginSharePrincipal {
PluginSharePrincipal {
principal_type: match principal.principal_type {
codex_core_plugins::remote::RemotePluginSharePrincipalType::User => {
PluginSharePrincipalType::User
}
codex_core_plugins::remote::RemotePluginSharePrincipalType::Group => {
PluginSharePrincipalType::Group
}
codex_core_plugins::remote::RemotePluginSharePrincipalType::Workspace => {
PluginSharePrincipalType::Workspace
}
},
principal_id: principal.principal_id,
name: principal.name,
}
}
impl PluginRequestProcessor {
pub(crate) fn new(
auth_manager: Arc<AuthManager>,
@@ -141,6 +201,15 @@ impl PluginRequestProcessor {
.map(|response| Some(response.into()))
}
pub(crate) async fn plugin_share_update_targets(
&self,
params: PluginShareUpdateTargetsParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.plugin_share_update_targets_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn plugin_share_list(
&self,
params: PluginShareListParams,
@@ -177,33 +246,35 @@ impl PluginRequestProcessor {
.map(|response| Some(response.into()))
}
pub(crate) fn effective_plugins_changed_callback(
&self,
config: Config,
) -> Arc<dyn Fn() + Send + Sync> {
pub(crate) fn effective_plugins_changed_callback(&self) -> Arc<dyn Fn() + Send + Sync> {
let thread_manager = Arc::clone(&self.thread_manager);
let config_manager = self.config_manager.clone();
Arc::new(move || {
Self::spawn_effective_plugins_changed_task(Arc::clone(&thread_manager), config.clone());
Self::spawn_effective_plugins_changed_task(
Arc::clone(&thread_manager),
config_manager.clone(),
);
})
}
fn on_effective_plugins_changed(&self, config: Config) {
Self::spawn_effective_plugins_changed_task(Arc::clone(&self.thread_manager), config);
fn on_effective_plugins_changed(&self) {
Self::spawn_effective_plugins_changed_task(
Arc::clone(&self.thread_manager),
self.config_manager.clone(),
);
}
fn spawn_effective_plugins_changed_task(thread_manager: Arc<ThreadManager>, config: Config) {
fn spawn_effective_plugins_changed_task(
thread_manager: Arc<ThreadManager>,
config_manager: ConfigManager,
) {
tokio::spawn(async move {
thread_manager.plugins_manager().clear_cache();
thread_manager.skills_manager().clear_cache();
if thread_manager.list_thread_ids().await.is_empty() {
return;
}
if let Err(err) =
McpRequestProcessor::queue_mcp_server_refresh_for_config(&thread_manager, &config)
.await
{
warn!("failed to queue MCP refresh after effective plugins changed: {err:?}");
}
crate::mcp_refresh::queue_best_effort_refresh(&thread_manager, &config_manager).await;
});
}
@@ -273,7 +344,7 @@ impl PluginRequestProcessor {
&plugins_input,
auth.clone(),
&roots,
Some(self.effective_plugins_changed_callback(config.clone())),
Some(self.effective_plugins_changed_callback()),
);
let config_for_marketplace_listing = plugins_input.clone();
@@ -570,22 +641,34 @@ impl PluginRequestProcessor {
let PluginShareSaveParams {
plugin_path,
remote_plugin_id,
discoverability,
share_targets,
} = params;
if let Some(remote_plugin_id) = remote_plugin_id.as_ref()
&& (remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(remote_plugin_id))
{
return Err(invalid_request("invalid remote plugin id"));
}
if remote_plugin_id.is_some() && (discoverability.is_some() || share_targets.is_some()) {
return Err(invalid_request(
"discoverability and shareTargets are only supported when creating a plugin share; use plugin/share/updateTargets to update share targets",
));
}
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
let access_policy = codex_core_plugins::remote::RemotePluginShareAccessPolicy {
discoverability: discoverability.map(remote_plugin_share_discoverability),
share_targets: share_targets.map(remote_plugin_share_targets),
};
let result = codex_core_plugins::remote::save_remote_plugin_share(
&remote_plugin_service_config,
auth.as_ref(),
config.codex_home.as_path(),
&plugin_path,
remote_plugin_id.as_deref(),
access_policy,
)
.await
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "save remote plugin share"))?;
@@ -597,6 +680,42 @@ impl PluginRequestProcessor {
})
}
async fn plugin_share_update_targets_response(
&self,
params: PluginShareUpdateTargetsParams,
) -> Result<PluginShareUpdateTargetsResponse, JSONRPCErrorError> {
let (config, auth) = self.load_plugin_share_config_and_auth().await?;
let PluginShareUpdateTargetsParams {
remote_plugin_id,
share_targets,
} = params;
if remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(&remote_plugin_id) {
return Err(invalid_request("invalid remote plugin id"));
}
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
let result = codex_core_plugins::remote::update_remote_plugin_share_targets(
&remote_plugin_service_config,
auth.as_ref(),
&remote_plugin_id,
remote_plugin_share_targets(share_targets),
)
.await
.map_err(|err| {
remote_plugin_catalog_error_to_jsonrpc(err, "update remote plugin share targets")
})?;
self.clear_plugin_related_caches();
Ok(PluginShareUpdateTargetsResponse {
principals: result
.principals
.into_iter()
.map(plugin_share_principal_from_remote)
.collect(),
})
}
async fn plugin_share_list_response(
&self,
_params: PluginShareListParams,
@@ -723,7 +842,7 @@ impl PluginRequestProcessor {
}
};
self.on_effective_plugins_changed(config.clone());
self.on_effective_plugins_changed();
let plugin_mcp_servers = load_plugin_mcp_servers(result.installed_path.as_path()).await;
if !plugin_mcp_servers.is_empty() {
@@ -834,7 +953,7 @@ impl PluginRequestProcessor {
.maybe_start_remote_installed_plugins_cache_refresh_after_mutation(
&config.plugins_config_input(),
auth.clone(),
Some(self.effective_plugins_changed_callback(config.clone())),
Some(self.effective_plugins_changed_callback()),
);
let mut plugin_metadata =
@@ -1027,7 +1146,7 @@ impl PluginRequestProcessor {
.await
.map_err(Self::plugin_uninstall_error)?;
match self.load_latest_config(/*fallback_cwd*/ None).await {
Ok(config) => self.on_effective_plugins_changed(config),
Ok(_) => self.on_effective_plugins_changed(),
Err(err) => {
warn!(
"failed to reload config after plugin uninstall, clearing plugin-related caches only: {err:?}"
@@ -1128,12 +1247,12 @@ impl PluginRequestProcessor {
) {
let plugins_manager = self.thread_manager.plugins_manager();
if plugins_manager.clear_remote_installed_plugins_cache() {
self.on_effective_plugins_changed(config.clone());
self.on_effective_plugins_changed();
}
plugins_manager.maybe_start_remote_installed_plugins_cache_refresh_after_mutation(
&config.plugins_config_input(),
auth.clone(),
Some(self.effective_plugins_changed_callback(config.clone())),
Some(self.effective_plugins_changed_callback()),
);
}
@@ -598,6 +598,8 @@ pub(super) async fn handle_pending_thread_resume_request(
let sandbox = thread_response_sandbox_policy(&permission_profile, cwd.as_path());
let active_permission_profile =
thread_response_active_permission_profile(active_permission_profile);
let session_id = conversation.session_configured().session_id.to_string();
thread.session_id = session_id;
let response = ThreadResumeResponse {
thread,
@@ -184,6 +184,53 @@ fn has_model_resume_override(
}
fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> {
const DYNAMIC_TOOL_NAME_MAX_LEN: usize = 128;
const DYNAMIC_TOOL_NAMESPACE_MAX_LEN: usize = 64;
const DYNAMIC_TOOL_IDENTIFIER_PATTERN: &str = "^[a-zA-Z0-9_-]+$";
const RESERVED_RESPONSES_NAMESPACES: &[&str] = &[
"api_tool",
"browser",
"computer",
"container",
"file_search",
"functions",
"image_gen",
"multi_tool_use",
"python",
"python_user_visible",
"submodel_delegator",
"terminal",
"tool_search",
"web",
];
fn escape_identifier_for_error(value: &str) -> String {
value.escape_default().to_string()
}
fn validate_dynamic_tool_identifier(
value: &str,
label: &str,
max_len: usize,
) -> Result<(), String> {
if !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
{
return Err(format!(
"{label} must match {DYNAMIC_TOOL_IDENTIFIER_PATTERN} to match Responses API: {}",
escape_identifier_for_error(value),
));
}
if value.chars().count() > max_len {
return Err(format!(
"{label} must be at most {max_len} characters to match Responses API: {}",
escape_identifier_for_error(value),
));
}
Ok(())
}
let mut seen = HashSet::new();
for tool in tools {
let name = tool.name.trim();
@@ -193,9 +240,10 @@ fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> {
if name != tool.name {
return Err(format!(
"dynamic tool name has leading/trailing whitespace: {}",
tool.name
escape_identifier_for_error(&tool.name),
));
}
validate_dynamic_tool_identifier(name, "dynamic tool name", DYNAMIC_TOOL_NAME_MAX_LEN)?;
if name == "mcp" || name.starts_with("mcp__") {
return Err(format!("dynamic tool name is reserved: {name}"));
}
@@ -209,13 +257,25 @@ fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> {
if Some(namespace) != tool.namespace.as_deref() {
return Err(format!(
"dynamic tool namespace has leading/trailing whitespace for {name}: {namespace}",
name = escape_identifier_for_error(name),
namespace = escape_identifier_for_error(namespace),
));
}
validate_dynamic_tool_identifier(
namespace,
"dynamic tool namespace",
DYNAMIC_TOOL_NAMESPACE_MAX_LEN,
)?;
if namespace == "mcp" || namespace.starts_with("mcp__") {
return Err(format!(
"dynamic tool namespace is reserved for {name}: {namespace}"
));
}
if RESERVED_RESPONSES_NAMESPACES.contains(&namespace) {
return Err(format!(
"dynamic tool namespace collides with a reserved Responses API namespace for {name}: {namespace}",
));
}
}
if !seen.insert((namespace, name)) {
if let Some(namespace) = namespace {
@@ -1036,6 +1096,7 @@ impl ThreadRequestProcessor {
.await;
let mut thread = build_thread_from_snapshot(
thread_id,
session_configured.session_id.to_string(),
&config_snapshot,
session_configured.rollout_path.clone(),
);
@@ -1127,7 +1188,7 @@ impl ThreadRequestProcessor {
&self,
model: Option<String>,
model_provider: Option<String>,
service_tier: Option<Option<codex_protocol::config_types::ServiceTier>>,
service_tier: Option<Option<String>>,
cwd: Option<String>,
approval_policy: Option<codex_app_server_protocol::AskForApproval>,
approvals_reviewer: Option<codex_app_server_protocol::ApprovalsReviewer>,
@@ -1468,9 +1529,9 @@ impl ThreadRequestProcessor {
..Default::default()
};
let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok();
let updated_thread = {
let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?;
let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok();
if let Some(loaded_thread) = loaded_thread.as_ref() {
if loaded_thread.config_snapshot().await.ephemeral {
return Err(invalid_request(format!(
@@ -1491,12 +1552,14 @@ impl ThreadRequestProcessor {
}
.map_err(|err| thread_store_write_error("update thread metadata", err))?
};
let (mut thread, _) = thread_from_stored_thread(
updated_thread,
self.config.model_provider_id.as_str(),
&self.config.cwd,
);
if let Some(loaded_thread) = loaded_thread.as_ref() {
thread.session_id = loaded_thread.session_configured().session_id.to_string();
}
self.attach_thread_name(thread_uuid, &mut thread).await;
thread.status = resolve_thread_status(
self.thread_watch_manager
@@ -1542,18 +1605,16 @@ impl ThreadRequestProcessor {
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;
let fallback_provider = self.config.model_provider_id.clone();
let mut thread = self
let stored_thread = self
.thread_store
.unarchive_thread(StoreArchiveThreadParams { thread_id })
.await
.map_err(|err| thread_store_archive_error("unarchive", err))
.and_then(|stored_thread| {
summary_from_stored_thread(stored_thread, fallback_provider.as_str())
.map(|summary| summary_to_thread(summary, &self.config.cwd))
.ok_or_else(|| {
internal_error(format!("failed to read unarchived thread {thread_id}"))
})
.map_err(|err| thread_store_archive_error("unarchive", err))?;
let summary = summary_from_stored_thread(stored_thread, fallback_provider.as_str())
.ok_or_else(|| {
internal_error(format!("failed to read unarchived thread {thread_id}"))
})?;
let mut thread = summary_to_thread(summary, &self.config.cwd);
thread.status = resolve_thread_status(
self.thread_watch_manager
@@ -1979,6 +2040,7 @@ impl ThreadRequestProcessor {
if thread.path.is_none() {
thread.path = fallback_thread.path.clone();
}
thread.session_id.clone_from(&fallback_thread.session_id);
thread.ephemeral = fallback_thread.ephemeral;
thread
} else {
@@ -2162,8 +2224,12 @@ impl ThreadRequestProcessor {
) {
if let Ok(thread) = self.thread_manager.get_thread(thread_id).await {
let config_snapshot = thread.config_snapshot().await;
let loaded_thread =
build_thread_from_snapshot(thread_id, &config_snapshot, thread.rollout_path());
let loaded_thread = build_thread_from_snapshot(
thread_id,
thread.session_configured().session_id.to_string(),
&config_snapshot,
thread.rollout_path(),
);
self.thread_watch_manager.upsert_thread(loaded_thread).await;
}
@@ -2589,11 +2655,12 @@ impl ThreadRequestProcessor {
}
let mut summary_source_thread = source_thread;
summary_source_thread.history = None;
let thread_summary = self.stored_thread_to_api_thread(
let mut thread_summary = self.stored_thread_to_api_thread(
summary_source_thread,
config_snapshot.model_provider_id.as_str(),
/*include_turns*/ false,
);
thread_summary.session_id = existing_thread.session_configured().session_id.to_string();
let mut config_for_instruction_sources = self.config.as_ref().clone();
config_for_instruction_sources.cwd = config_snapshot.cwd.clone();
let instruction_sources =
@@ -2762,6 +2829,7 @@ impl ThreadRequestProcessor {
include_turns: bool,
) -> std::result::Result<Thread, String> {
let config_snapshot = thread.config_snapshot().await;
let session_id = thread.session_configured().session_id.to_string();
let thread = match thread_history {
InitialHistory::Resumed(resumed) => {
let fallback_provider = config_snapshot.model_provider_id.as_str();
@@ -2823,6 +2891,7 @@ impl ThreadRequestProcessor {
InitialHistory::Forked(items) => {
let mut thread = build_thread_from_snapshot(
thread_id,
session_id.clone(),
&config_snapshot,
Some(rollout_path.into()),
);
@@ -2835,6 +2904,7 @@ impl ThreadRequestProcessor {
};
let mut thread = thread?;
thread.id = thread_id.to_string();
thread.session_id = session_id;
thread.path = Some(rollout_path.to_path_buf());
if include_turns {
let history_items = thread_history.get_rollout_items();
@@ -3016,8 +3086,12 @@ impl ThreadRequestProcessor {
} else {
let config_snapshot = forked_thread.config_snapshot().await;
// forked thread names do not inherit the source thread name
let mut thread =
build_thread_from_snapshot(thread_id, &config_snapshot, /*path*/ None);
let mut thread = build_thread_from_snapshot(
thread_id,
session_configured.session_id.to_string(),
&config_snapshot,
/*path*/ None,
);
thread.preview = preview_from_rollout_items(&history_items);
thread.forked_from_id = Some(source_thread_id.to_string());
if include_turns {
@@ -3029,6 +3103,7 @@ impl ThreadRequestProcessor {
}
thread
};
thread.session_id = session_configured.session_id.to_string();
thread.thread_source = forked_thread
.config_snapshot()
.await
@@ -3617,8 +3692,10 @@ pub(crate) fn thread_from_stored_thread(
thread.agent_role.clone(),
);
let history = thread.history;
let thread_id = thread.thread_id.to_string();
let thread = Thread {
id: thread.thread_id.to_string(),
id: thread_id.clone(),
session_id: thread_id,
forked_from_id: thread.forked_from_id.map(|id| id.to_string()),
preview: thread.first_user_message.unwrap_or(thread.preview),
ephemeral: false,
@@ -3815,12 +3892,14 @@ fn permission_profile_trusts_project(
fn build_thread_from_snapshot(
thread_id: ThreadId,
session_id: String,
config_snapshot: &ThreadConfigSnapshot,
path: Option<PathBuf>,
) -> Thread {
let now = time::OffsetDateTime::now_utc().unix_timestamp();
Thread {
id: thread_id.to_string(),
session_id,
forked_from_id: None,
preview: String::new(),
ephemeral: config_snapshot.ephemeral,
@@ -3846,7 +3925,12 @@ fn build_thread_from_loaded_snapshot(
config_snapshot: &ThreadConfigSnapshot,
loaded_thread: &CodexThread,
) -> Thread {
build_thread_from_snapshot(thread_id, config_snapshot, loaded_thread.rollout_path())
build_thread_from_snapshot(
thread_id,
loaded_thread.session_configured().session_id.to_string(),
config_snapshot,
loaded_thread.rollout_path(),
)
}
#[cfg(test)]
@@ -156,6 +156,22 @@ mod thread_processor_behavior_tests {
validate_dynamic_tools(&tools).expect("valid schema");
}
#[test]
fn validate_dynamic_tools_accepts_responses_compatible_identifiers() {
let tools = vec![ApiDynamicToolSpec {
namespace: Some("Codex-App_2".to_string()),
name: "lookup-ticket_2".to_string(),
description: "test".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: true,
}];
validate_dynamic_tools(&tools).expect("valid schema");
}
#[test]
fn validate_dynamic_tools_rejects_duplicate_name_in_same_namespace() {
let tools = vec![
@@ -260,6 +276,104 @@ mod thread_processor_behavior_tests {
assert!(err.contains("reserved"), "unexpected error: {err}");
}
#[test]
fn validate_dynamic_tools_rejects_name_not_supported_by_responses() {
let tools = vec![ApiDynamicToolSpec {
namespace: None,
name: "lookup.ticket".to_string(),
description: "test".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: false,
}];
let err = validate_dynamic_tools(&tools).expect_err("invalid name");
assert!(err.contains("lookup.ticket"), "unexpected error: {err}");
assert!(
err.contains("Responses API") && err.contains("^[a-zA-Z0-9_-]+$"),
"unexpected error: {err}"
);
}
#[test]
fn validate_dynamic_tools_rejects_namespace_not_supported_by_responses() {
let tools = vec![ApiDynamicToolSpec {
namespace: Some("codex.app".to_string()),
name: "lookup_ticket".to_string(),
description: "test".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: true,
}];
let err = validate_dynamic_tools(&tools).expect_err("invalid namespace");
assert!(err.contains("codex.app"), "unexpected error: {err}");
assert!(
err.contains("Responses API") && err.contains("^[a-zA-Z0-9_-]+$"),
"unexpected error: {err}"
);
}
#[test]
fn validate_dynamic_tools_rejects_name_longer_than_responses_limit() {
let long_name = "a".repeat(129);
let tools = vec![ApiDynamicToolSpec {
namespace: None,
name: long_name.clone(),
description: "test".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: false,
}];
let err = validate_dynamic_tools(&tools).expect_err("name too long");
assert!(err.contains("at most 128"), "unexpected error: {err}");
assert!(err.contains(&long_name), "unexpected error: {err}");
}
#[test]
fn validate_dynamic_tools_rejects_namespace_longer_than_responses_limit() {
let long_namespace = "a".repeat(65);
let tools = vec![ApiDynamicToolSpec {
namespace: Some(long_namespace.clone()),
name: "lookup_ticket".to_string(),
description: "test".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: true,
}];
let err = validate_dynamic_tools(&tools).expect_err("namespace too long");
assert!(err.contains("at most 64"), "unexpected error: {err}");
assert!(err.contains(&long_namespace), "unexpected error: {err}");
}
#[test]
fn validate_dynamic_tools_rejects_reserved_responses_namespace() {
let tools = vec![ApiDynamicToolSpec {
namespace: Some("functions".to_string()),
name: "lookup_ticket".to_string(),
description: "test".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: true,
}];
let err = validate_dynamic_tools(&tools).expect_err("reserved Responses namespace");
assert!(err.contains("functions"), "unexpected error: {err}");
assert!(err.contains("Responses API"), "unexpected error: {err}");
}
#[test]
fn summary_from_stored_thread_preserves_millisecond_precision() {
let created_at =
@@ -515,7 +629,7 @@ mod thread_processor_behavior_tests {
path: None,
model: None,
model_provider: None,
service_tier: Some(Some(codex_protocol::config_types::ServiceTier::Fast)),
service_tier: Some(Some("priority".to_string())),
cwd: None,
approval_policy: None,
approvals_reviewer: None,
@@ -531,7 +645,7 @@ mod thread_processor_behavior_tests {
let config_snapshot = ThreadConfigSnapshot {
model: "gpt-5".to_string(),
model_provider_id: "openai".to_string(),
service_tier: Some(codex_protocol::config_types::ServiceTier::Flex),
service_tier: Some("flex".to_string()),
approval_policy: codex_protocol::protocol::AskForApproval::OnRequest,
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User,
permission_profile: codex_protocol::models::PermissionProfile::Disabled,
@@ -546,7 +660,7 @@ mod thread_processor_behavior_tests {
assert_eq!(
collect_resume_override_mismatches(&request, &config_snapshot),
vec!["service_tier requested=Some(Fast) active=Some(Flex)".to_string()]
vec!["service_tier requested=Some(\"priority\") active=Some(\"flex\")".to_string()]
);
}
@@ -263,8 +263,10 @@ pub(crate) fn summary_to_thread(
fallback_cwd.clone()
});
let thread_id = conversation_id.to_string();
Thread {
id: conversation_id.to_string(),
id: thread_id.clone(),
session_id: thread_id,
forked_from_id: None,
preview,
ephemeral: false,
@@ -438,7 +438,7 @@ impl TurnRequestProcessor {
model: model.clone(),
effort,
summary,
service_tier,
service_tier: service_tier.clone(),
collaboration_mode: collaboration_mode.clone(),
personality,
})
@@ -935,6 +935,7 @@ impl TurnRequestProcessor {
Ok(stored_thread) => {
let (mut thread, _) =
thread_from_stored_thread(stored_thread, fallback_provider, &self.config.cwd);
thread.session_id = review_thread.session_configured().session_id.to_string();
self.thread_watch_manager
.upsert_thread_silently(thread.clone())
.await;
+1
View File
@@ -889,6 +889,7 @@ mod tests {
fn test_thread(thread_id: &str, source: codex_app_server_protocol::SessionSource) -> Thread {
Thread {
id: thread_id.to_string(),
session_id: thread_id.to_string(),
forked_from_id: None,
preview: String::new(),
ephemeral: false,
@@ -239,6 +239,46 @@ async fn thread_start_rejects_hidden_dynamic_tools_without_namespace() -> Result
Ok(())
}
#[tokio::test]
async fn thread_start_rejects_dynamic_tools_not_supported_by_responses() -> Result<()> {
let server = MockServer::start().await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let dynamic_tool = DynamicToolSpec {
namespace: Some("codex.app".to_string()),
name: "lookup.ticket".to_string(),
description: "Invalid dynamic tool".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false,
}),
defer_loading: false,
};
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
dynamic_tools: Some(vec![dynamic_tool]),
..Default::default()
})
.await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(thread_req)),
)
.await??;
assert_eq!(error.error.code, -32600);
assert!(error.error.message.contains("Responses API"));
assert!(error.error.message.contains("lookup.ticket"));
Ok(())
}
/// Exercises the full dynamic tool call path (server request, client response, model output).
#[tokio::test]
async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Result<()> {
@@ -65,6 +65,9 @@ const TEST_TOOL_NAME: &str = "echo_tool";
const LARGE_RESPONSE_MESSAGE: &str = "large";
const ELICITATION_TRIGGER_MESSAGE: &str = "confirm";
const ELICITATION_MESSAGE: &str = "Allow this request?";
const URL_ELICITATION_TRIGGER_MESSAGE: &str = "auth";
const URL_ELICITATION_MESSAGE: &str = "Sign in to GitHub to continue.";
const URL_ELICITATION_URL: &str = "https://github.example/login/device";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mcp_server_tool_call_returns_tool_result() -> Result<()> {
@@ -294,6 +297,109 @@ url = "{mcp_server_url}/mcp"
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mcp_server_tool_call_forwards_url_elicitation() -> Result<()> {
let responses_server = responses::start_mock_server().await;
let (mcp_server_url, mcp_server_handle) = start_mcp_server().await?;
let codex_home = TempDir::new()?;
write_mock_responses_config_toml(
codex_home.path(),
&responses_server.uri(),
&BTreeMap::new(),
/*auto_compact_limit*/ 1024,
/*requires_openai_auth*/ None,
"mock_provider",
"compact",
)?;
let config_path = codex_home.path().join("config.toml");
let mut config_toml = std::fs::read_to_string(&config_path)?;
config_toml.push_str(&format!(
r#"
[mcp_servers.{TEST_SERVER_NAME}]
url = "{mcp_server_url}/mcp"
"#
));
std::fs::write(config_path, config_toml)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_start_id = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
approval_policy: Some(codex_app_server_protocol::AskForApproval::UnlessTrusted),
..Default::default()
})
.await?;
let thread_start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?;
let tool_call_request_id = mcp
.send_mcp_server_tool_call_request(McpServerToolCallParams {
thread_id: thread.id.clone(),
server: TEST_SERVER_NAME.to_string(),
tool: TEST_TOOL_NAME.to_string(),
arguments: Some(json!({
"message": URL_ELICITATION_TRIGGER_MESSAGE,
})),
meta: None,
})
.await?;
let server_req = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_request_message(),
)
.await??;
let ServerRequest::McpServerElicitationRequest { request_id, params } = server_req else {
panic!("expected McpServerElicitationRequest request, got: {server_req:?}");
};
assert_eq!(
params,
McpServerElicitationRequestParams {
thread_id: thread.id,
turn_id: None,
server_name: TEST_SERVER_NAME.to_string(),
request: McpServerElicitationRequest::Url {
meta: None,
message: URL_ELICITATION_MESSAGE.to_string(),
url: URL_ELICITATION_URL.to_string(),
elicitation_id: "github-auth-123".to_string(),
},
}
);
mcp.send_response(
request_id,
serde_json::to_value(McpServerElicitationRequestResponse {
action: McpServerElicitationAction::Accept,
content: None,
meta: None,
})?,
)
.await?;
let tool_call_response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(tool_call_request_id)),
)
.await??;
let response: McpServerToolCallResponse = to_response(tool_call_response)?;
assert_eq!(response.content.len(), 1);
assert_eq!(response.content[0].get("type"), Some(&json!("text")));
assert_eq!(response.content[0].get("text"), Some(&json!("accepted")));
mcp_server_handle.abort();
let _ = mcp_server_handle.await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mcp_tool_call_completion_notification_contains_truncated_large_result() -> Result<()> {
let call_id = "call-large-mcp";
@@ -528,6 +634,28 @@ impl ServerHandler for ToolAppsMcpServer {
return Ok(CallToolResult::success(vec![Content::text(output)]));
}
if message == URL_ELICITATION_TRIGGER_MESSAGE {
let result = context
.peer
.create_elicitation(CreateElicitationRequestParams::UrlElicitationParams {
meta: None,
message: URL_ELICITATION_MESSAGE.to_string(),
url: URL_ELICITATION_URL.to_string(),
elicitation_id: "github-auth-123".to_string(),
})
.await
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?;
let output = match result.action {
ElicitationAction::Accept => {
assert_eq!(result.content, Some(json!({})));
"accepted"
}
ElicitationAction::Decline => "declined",
ElicitationAction::Cancel => "cancelled",
};
return Ok(CallToolResult::success(vec![Content::text(output)]));
}
let mut result = CallToolResult::structured(json!({
"echoed": message,
"threadId": thread_id,
@@ -7,6 +7,7 @@ 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::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
@@ -14,7 +15,10 @@ use codex_app_server_protocol::PluginInterface;
use codex_app_server_protocol::PluginShareDeleteResponse;
use codex_app_server_protocol::PluginShareListItem;
use codex_app_server_protocol::PluginShareListResponse;
use codex_app_server_protocol::PluginSharePrincipal;
use codex_app_server_protocol::PluginSharePrincipalType;
use codex_app_server_protocol::PluginShareSaveResponse;
use codex_app_server_protocol::PluginShareUpdateTargetsResponse;
use codex_app_server_protocol::PluginSource;
use codex_app_server_protocol::PluginSummary;
use codex_app_server_protocol::RequestId;
@@ -174,6 +178,154 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn plugin_share_save_forwards_access_policy() -> Result<()> {
let codex_home = TempDir::new()?;
let plugin_root = TempDir::new()?;
let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?;
let server = MockServer::start().await;
write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
Mock::given(method("POST"))
.and(path("/backend-api/public/plugins/workspace/upload-url"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"file_id": "file_123",
"upload_url": format!("{}/upload/file_123", server.uri()),
"etag": "\"upload_etag_123\"",
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/upload/file_123"))
.respond_with(ResponseTemplate::new(201).insert_header("etag", "\"blob_etag_123\""))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/backend-api/public/plugins/workspace"))
.and(body_json(json!({
"file_id": "file_123",
"etag": "\"upload_etag_123\"",
"discoverability": "PRIVATE",
"share_targets": [
{
"principal_type": "user",
"principal_id": "user-1",
},
{
"principal_type": "workspace",
"principal_id": "workspace-1",
},
],
})))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"plugin_id": "plugins_123",
"share_url": "https://chatgpt.example/plugins/share/share-key-1",
})))
.expect(1)
.mount(&server)
.await;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let expected_plugin_path = AbsolutePathBuf::try_from(plugin_path)?;
let request_id = mcp
.send_raw_request(
"plugin/share/save",
Some(json!({
"pluginPath": expected_plugin_path,
"discoverability": "PRIVATE",
"shareTargets": [
{
"principalType": "user",
"principalId": "user-1",
},
{
"principalType": "workspace",
"principalId": "workspace-1",
},
],
})),
)
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginShareSaveResponse = to_response(response)?;
assert_eq!(
response,
PluginShareSaveResponse {
remote_plugin_id: "plugins_123".to_string(),
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
}
);
Ok(())
}
#[tokio::test]
async fn plugin_share_save_rejects_access_policy_for_existing_plugin() -> Result<()> {
let codex_home = TempDir::new()?;
let plugin_root = TempDir::new()?;
let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?;
let server = MockServer::start().await;
write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_raw_request(
"plugin/share/save",
Some(json!({
"pluginPath": AbsolutePathBuf::try_from(plugin_path)?,
"remotePluginId": "plugins_123",
"discoverability": "PRIVATE",
"shareTargets": [
{
"principalType": "user",
"principalId": "user-1",
},
],
})),
)
.await?;
let error: JSONRPCError = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(error.error.code, -32600);
assert_eq!(
error.error.message,
"discoverability and shareTargets are only supported when creating a plugin share; use plugin/share/updateTargets to update share targets"
);
Ok(())
}
#[tokio::test]
async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -250,6 +402,82 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn plugin_share_update_targets_updates_share_targets() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
Mock::given(method("PUT"))
.and(path("/backend-api/public/plugins/plugins_123/shares"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.and(body_json(json!({
"targets": [
{
"principal_type": "user",
"principal_id": "user-1",
},
],
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"principals": [
{
"principal_type": "user",
"principal_id": "user-1",
"name": "Gavin",
},
],
})))
.expect(1)
.mount(&server)
.await;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_raw_request(
"plugin/share/updateTargets",
Some(json!({
"remotePluginId": "plugins_123",
"shareTargets": [
{
"principalType": "user",
"principalId": "user-1",
},
],
})),
)
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginShareUpdateTargetsResponse = to_response(response)?;
assert_eq!(
response,
PluginShareUpdateTargetsResponse {
principals: vec![PluginSharePrincipal {
principal_type: PluginSharePrincipalType::User,
principal_id: "user-1".to_string(),
name: "Gavin".to_string(),
}],
}
);
Ok(())
}
#[tokio::test]
async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -363,6 +363,7 @@ async fn review_start_with_detached_delivery_returns_new_thread_id() -> Result<(
let started: ThreadStartedNotification =
serde_json::from_value(notification.params.expect("params must be present"))?;
assert_eq!(started.thread.id, review_thread_id);
assert_eq!(started.thread.session_id, review_thread_id);
Ok(())
}
@@ -108,11 +108,21 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
.get("thread")
.and_then(Value::as_object)
.expect("thread/fork result.thread must be an object");
assert_eq!(
thread_json.get("sessionId").and_then(Value::as_str),
Some(thread.session_id.as_str()),
"forked threads should serialize `sessionId` on the thread object"
);
assert_eq!(
thread_json.get("name"),
Some(&Value::Null),
"forked threads do not inherit a name; expected `name: null`"
);
assert_eq!(
fork_result.get("sessionId"),
None,
"thread/fork should not serialize a top-level `sessionId`"
);
let after_contents = std::fs::read_to_string(&original_path)?;
assert_eq!(
@@ -121,6 +131,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
);
assert_ne!(thread.id, conversation_id);
assert_eq!(thread.session_id, thread.id);
assert_eq!(thread.forked_from_id, Some(conversation_id.clone()));
assert_eq!(thread.preview, preview);
assert_eq!(thread.model_provider, "mock_provider");
@@ -879,6 +879,7 @@ async fn thread_list_filters_by_source_kind_subagent_thread_spawn() -> Result<()
assert_eq!(ids, vec![subagent_id.as_str()]);
assert_ne!(cli_id, subagent_id);
assert!(matches!(data[0].source, SessionSource::SubAgent(_)));
assert_eq!(data[0].session_id, subagent_id);
Ok(())
}
@@ -77,6 +77,7 @@ async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread()
to_response::<ThreadMetadataUpdateResponse>(update_resp)?;
assert_eq!(updated.id, thread.id);
assert_eq!(updated.session_id, thread.session_id);
assert_eq!(
updated.git_info,
Some(GitInfo {
@@ -90,6 +91,10 @@ async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread()
.get("thread")
.and_then(Value::as_object)
.expect("thread/metadata/update result.thread must be an object");
assert_eq!(
updated_thread_json.get("sessionId").and_then(Value::as_str),
Some(thread.session_id.as_str())
);
let updated_git_info_json = updated_thread_json
.get("gitInfo")
.and_then(Value::as_object)
@@ -271,6 +271,10 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> {
)
.await??;
let ThreadResumeResponse { thread, .. } = to_response::<ThreadResumeResponse>(resume_resp)?;
assert!(
!thread.session_id.is_empty(),
"session id should not be empty"
);
assert_eq!(thread.thread_source, Some(ThreadSource::User));
let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?;
@@ -119,11 +119,16 @@ async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<()
.and_then(Value::as_object)
.expect("thread/rollback result.thread must be an object");
assert_eq!(rolled_back_thread.name, None);
assert_eq!(rolled_back_thread.session_id, thread.session_id);
assert_eq!(
thread_json.get("name"),
Some(&Value::Null),
"thread/rollback must serialize `name: null` when unset"
);
assert_eq!(
thread_json.get("sessionId").and_then(Value::as_str),
Some(thread.session_id.as_str())
);
assert_eq!(rolled_back_thread.turns.len(), 1);
assert_eq!(rolled_back_thread.status, ThreadStatus::Idle);
@@ -28,7 +28,6 @@ use codex_core::config::set_project_trust_level;
use codex_exec_server::LOCAL_FS;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
@@ -125,6 +124,10 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
model_provider,
..
} = to_response::<ThreadStartResponse>(resp)?;
assert!(
!thread.session_id.is_empty(),
"session id should not be empty"
);
assert!(!thread.id.is_empty(), "thread id should not be empty");
assert!(
thread.preview.is_empty(),
@@ -153,11 +156,21 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
.get("thread")
.and_then(Value::as_object)
.expect("thread/start result.thread must be an object");
assert_eq!(
thread_json.get("sessionId").and_then(Value::as_str),
Some(thread.session_id.as_str()),
"new threads should serialize `sessionId` on the thread object"
);
assert_eq!(
thread_json.get("name"),
Some(&Value::Null),
"new threads should serialize `name: null`"
);
assert_eq!(
resp_result.get("sessionId"),
None,
"thread/start should not serialize a top-level `sessionId`"
);
assert_eq!(
thread_json.get("ephemeral").and_then(Value::as_bool),
Some(false),
@@ -388,7 +401,7 @@ model_reasoning_effort = "high"
}
#[tokio::test]
async fn thread_start_accepts_flex_service_tier() -> Result<()> {
async fn thread_start_accepts_arbitrary_service_tier_id() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
@@ -397,9 +410,10 @@ async fn thread_start_accepts_flex_service_tier() -> Result<()> {
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let service_tier_id = "experimental-tier-id".to_string();
let req_id = mcp
.send_thread_start_request(ThreadStartParams {
service_tier: Some(Some(ServiceTier::Flex)),
service_tier: Some(Some(service_tier_id.clone())),
..Default::default()
})
.await?;
@@ -411,7 +425,7 @@ async fn thread_start_accepts_flex_service_tier() -> Result<()> {
.await??;
let ThreadStartResponse { service_tier, .. } = to_response::<ThreadStartResponse>(resp)?;
assert_eq!(service_tier, Some(ServiceTier::Flex));
assert_eq!(service_tier, Some(service_tier_id));
Ok(())
}
@@ -358,6 +358,71 @@ async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills(
Ok(())
}
#[tokio::test]
async fn turn_start_sends_service_tier_id_to_model_request() -> Result<()> {
let server = responses::start_mock_server().await;
let body = responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-1"),
]);
let response_mock = responses::mount_sse_once(&server, body).await;
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
&server.uri(),
"never",
&BTreeMap::default(),
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let service_tier_id = "experimental-tier-id".to_string();
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
service_tier: Some(Some(service_tier_id.clone())),
input: vec![V2UserInput::Text {
text: "Hello".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
assert_eq!(
response_mock.single_request().body_json()["service_tier"],
json!(service_tier_id)
);
Ok(())
}
#[tokio::test]
async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> Result<()> {
let server = responses::start_mock_server().await;
+6
View File
@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "builtin-mcps",
crate_name = "codex_builtin_mcps",
)
+21
View File
@@ -0,0 +1,21 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-builtin-mcps"
version.workspace = true
[lib]
name = "codex_builtin_mcps"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
anyhow = { workspace = true }
codex-config = { workspace = true }
codex-memories-mcp = { workspace = true }
codex-utils-absolute-path = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
+132
View File
@@ -0,0 +1,132 @@
//! Built-in MCP servers shipped with Codex.
//!
//! Built-ins use the same stdio MCP path as user-configured servers, but are
//! declared here so product-owned MCPs do not need to live in `codex-core`.
use codex_config::McpServerConfig;
use codex_config::McpServerTransportConfig;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashMap;
use std::path::Path;
pub const MEMORIES_MCP_SERVER_NAME: &str = "memories";
const BUILTIN_MCP_SUBCOMMAND: &str = "builtin-mcp";
#[derive(Debug, Clone, Copy)]
pub struct BuiltinMcpServerOptions<'a> {
pub codex_self_exe: Option<&'a Path>,
pub codex_home: &'a Path,
pub memories_enabled: bool,
}
pub fn configured_builtin_mcp_servers(
options: BuiltinMcpServerOptions<'_>,
) -> HashMap<String, McpServerConfig> {
let Some(codex_self_exe) = options.codex_self_exe else {
return HashMap::new();
};
let mut servers = HashMap::new();
if options.memories_enabled {
servers.insert(
MEMORIES_MCP_SERVER_NAME.to_string(),
builtin_stdio_server_config(
codex_self_exe,
options.codex_home,
MEMORIES_MCP_SERVER_NAME,
),
);
}
servers
}
pub async fn run_builtin_mcp_server(
name: &str,
codex_home: &AbsolutePathBuf,
) -> anyhow::Result<()> {
match name {
MEMORIES_MCP_SERVER_NAME => codex_memories_mcp::run_stdio_server(codex_home).await,
_ => anyhow::bail!("unknown built-in MCP server: {name}"),
}
}
fn builtin_stdio_server_config(
codex_self_exe: &Path,
codex_home: &Path,
name: &str,
) -> McpServerConfig {
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: codex_self_exe.to_string_lossy().into_owned(),
args: vec![
BUILTIN_MCP_SUBCOMMAND.to_string(),
name.to_string(),
"--codex-home".to_string(),
codex_home.to_string_lossy().into_owned(),
],
env: None,
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
enabled: true,
required: false,
supports_parallel_tool_calls: true,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
tools: HashMap::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn configured_builtin_mcp_servers_adds_memories_when_enabled() {
let codex_home = AbsolutePathBuf::try_from("/tmp/codex-home").expect("absolute codex home");
let servers = configured_builtin_mcp_servers(BuiltinMcpServerOptions {
codex_self_exe: Some(Path::new("/tmp/codex")),
codex_home: codex_home.as_path(),
memories_enabled: true,
});
let server = servers
.get(MEMORIES_MCP_SERVER_NAME)
.expect("memories server should exist");
assert_eq!(
server.transport,
McpServerTransportConfig::Stdio {
command: "/tmp/codex".to_string(),
args: vec![
"builtin-mcp".to_string(),
"memories".to_string(),
"--codex-home".to_string(),
codex_home.as_path().to_string_lossy().into_owned(),
],
env: None,
env_vars: Vec::new(),
cwd: None,
}
);
}
#[test]
fn configured_builtin_mcp_servers_requires_reexec_path() {
let codex_home = AbsolutePathBuf::try_from("/tmp/codex-home").expect("absolute codex home");
let servers = configured_builtin_mcp_servers(BuiltinMcpServerOptions {
codex_self_exe: None,
codex_home: codex_home.as_path(),
memories_enabled: true,
});
assert!(servers.is_empty());
}
}
+7
View File
@@ -35,6 +35,7 @@ fn try_build_bwrap() -> Result<(), String> {
let out_dir = PathBuf::from(env::var("OUT_DIR").map_err(|err| err.to_string())?);
let src_dir = resolve_bwrap_source_dir(&manifest_dir)?;
let libcap = pkg_config::Config::new()
.cargo_metadata(false)
.probe("libcap")
.map_err(|err| format!("libcap not available via pkg-config: {err}"))?;
@@ -65,6 +66,12 @@ fn try_build_bwrap() -> Result<(), String> {
}
build.compile("standalone_bwrap");
for link_path in libcap.link_paths {
println!("cargo:rustc-link-search=native={}", link_path.display());
}
for lib in libcap.libs {
println!("cargo:rustc-link-lib={lib}");
}
println!("cargo:rustc-cfg=bwrap_available");
Ok(())
}
+1
View File
@@ -24,6 +24,7 @@ codex-app-server = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-app-server-test-client = { workspace = true }
codex-arg0 = { workspace = true }
codex-builtin-mcps = { workspace = true }
codex-chatgpt = { workspace = true }
codex-cloud-tasks = { path = "../cloud-tasks" }
codex-utils-cli = { workspace = true }
+20
View File
@@ -123,6 +123,10 @@ enum Subcommand {
/// Start Codex as an MCP server (stdio).
McpServer,
/// Internal: start a Codex-shipped MCP server (stdio).
#[clap(hide = true, name = "builtin-mcp")]
BuiltinMcp(BuiltinMcpCommand),
/// [experimental] Run the app server or related tooling.
AppServer(AppServerCommand),
@@ -175,6 +179,13 @@ enum Subcommand {
Features(FeaturesCli),
}
#[derive(Debug, Args)]
struct BuiltinMcpCommand {
name: String,
#[arg(long)]
codex_home: PathBuf,
}
#[derive(Debug, Parser)]
#[command(bin_name = "codex plugin")]
struct PluginCli {
@@ -809,6 +820,15 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
)?;
codex_mcp_server::run_main(arg0_paths.clone(), root_config_overrides).await?;
}
Some(Subcommand::BuiltinMcp(command)) => {
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"builtin-mcp",
)?;
let codex_home = AbsolutePathBuf::try_from(command.codex_home)?;
codex_builtin_mcps::run_builtin_mcp_server(&command.name, &codex_home).await?;
}
Some(Subcommand::Mcp(mut mcp_cli)) => {
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
+11
View File
@@ -32,6 +32,10 @@ pub struct CompactionInput<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<Reasoning>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_tier: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<TextControls>,
}
@@ -235,6 +239,11 @@ pub struct ResponseCreateWsRequest {
pub client_metadata: Option<HashMap<String, String>>,
}
#[derive(Debug, Serialize)]
pub struct ResponseProcessedWsRequest {
pub response_id: String,
}
pub fn response_create_client_metadata(
client_metadata: Option<HashMap<String, String>>,
trace: Option<&W3cTraceContext>,
@@ -263,6 +272,8 @@ pub fn response_create_client_metadata(
pub enum ResponsesWsRequest {
#[serde(rename = "response.create")]
ResponseCreate(ResponseCreateWsRequest),
#[serde(rename = "response.processed")]
ResponseProcessed(ResponseProcessedWsRequest),
}
pub fn create_text_param_for_request(
+8 -6
View File
@@ -6,7 +6,7 @@ use crate::error::ApiError;
use crate::provider::Provider;
use crate::requests::Compression;
use crate::requests::attach_item_ids;
use crate::requests::headers::build_conversation_headers;
use crate::requests::headers::build_session_headers;
use crate::requests::headers::insert_header;
use crate::requests::headers::subagent_header;
use crate::sse::spawn_response_stream;
@@ -30,7 +30,8 @@ pub struct ResponsesClient<T: HttpTransport> {
#[derive(Default)]
pub struct ResponsesOptions {
pub conversation_id: Option<String>,
pub session_id: Option<String>,
pub thread_id: Option<String>,
pub session_source: Option<SessionSource>,
pub extra_headers: HeaderMap,
pub compression: Compression,
@@ -72,7 +73,8 @@ impl<T: HttpTransport> ResponsesClient<T> {
options: ResponsesOptions,
) -> Result<ResponseStream, ApiError> {
let ResponsesOptions {
conversation_id,
session_id,
thread_id,
session_source,
extra_headers,
compression,
@@ -86,10 +88,10 @@ impl<T: HttpTransport> ResponsesClient<T> {
}
let mut headers = extra_headers;
if let Some(ref conv_id) = conversation_id {
insert_header(&mut headers, "x-client-request-id", conv_id);
if let Some(ref thread_id) = thread_id {
insert_header(&mut headers, "x-client-request-id", thread_id);
}
headers.extend(build_conversation_headers(conversation_id));
headers.extend(build_session_headers(session_id, thread_id));
if let Some(subagent) = subagent_header(&session_source) {
insert_header(&mut headers, "x-openai-subagent", &subagent);
}
@@ -1,5 +1,6 @@
use crate::auth::SharedAuthProvider;
use crate::common::ResponseEvent;
use crate::common::ResponseProcessedWsRequest;
use crate::common::ResponseStream;
use crate::common::ResponsesWsRequest;
use crate::error::ApiError;
@@ -204,6 +205,40 @@ impl ResponsesWebsocketConnection {
self.stream.lock().await.is_none()
}
#[instrument(
name = "responses_websocket.send_response_processed",
level = "info",
skip_all,
fields(transport = "responses_websocket", api.path = "responses")
)]
#[expect(
clippy::await_holding_invalid_type,
reason = "the guard serializes exclusive use of the websocket while sending a request frame"
)]
pub async fn send_response_processed(&self, response_id: String) -> Result<(), ApiError> {
let request =
ResponsesWsRequest::ResponseProcessed(ResponseProcessedWsRequest { response_id });
let request_body = serde_json::to_value(&request).map_err(|err| {
ApiError::Stream(format!("failed to encode websocket request: {err}"))
})?;
let mut guard = self.stream.lock().await;
let Some(ws_stream) = guard.as_mut() else {
return Err(ApiError::Stream(
"websocket connection is closed".to_string(),
));
};
send_websocket_request(
ws_stream,
request_body,
self.idle_timeout,
self.telemetry.as_ref(),
/*connection_reused*/ true,
)
.await
}
#[instrument(
name = "responses_websocket.stream_request",
level = "info",
@@ -545,36 +580,14 @@ async fn run_websocket_response_stream(
connection_reused: bool,
) -> Result<(), ApiError> {
let mut last_server_model: Option<String> = None;
let request_text = match serde_json::to_string(&request_body) {
Ok(text) => text,
Err(err) => {
return Err(ApiError::Stream(format!(
"failed to encode websocket request: {err}"
)));
}
};
trace!("websocket request: {request_text}");
let request_start = Instant::now();
let result = tokio::time::timeout(
send_websocket_request(
ws_stream,
request_body,
idle_timeout,
ws_stream.send(Message::Text(request_text.into())),
telemetry.as_ref(),
connection_reused,
)
.await
.map_err(|_| ApiError::Stream("idle timeout sending websocket request".into()))
.and_then(|result| {
result.map_err(|err| ApiError::Stream(format!("failed to send websocket request: {err}")))
});
if let Some(t) = telemetry.as_ref() {
t.on_ws_request(
request_start.elapsed(),
result.as_ref().err(),
connection_reused,
);
}
result?;
.await?;
loop {
let poll_start = Instant::now();
@@ -671,6 +684,47 @@ async fn run_websocket_response_stream(
Ok(())
}
async fn send_websocket_request(
ws_stream: &WsStream,
request_body: Value,
idle_timeout: Duration,
telemetry: Option<&Arc<dyn WebsocketTelemetry>>,
connection_reused: bool,
) -> Result<(), ApiError> {
let request_text = match serde_json::to_string(&request_body) {
Ok(text) => text,
Err(err) => {
return Err(ApiError::Stream(format!(
"failed to encode websocket request: {err}"
)));
}
};
trace!("websocket request: {request_text}");
let request_start = Instant::now();
let result = tokio::time::timeout(
idle_timeout,
ws_stream.send(Message::Text(request_text.into())),
)
.await
.map_err(|_| ApiError::Stream("idle timeout sending websocket request".into()))
.and_then(|result| {
result.map_err(|err| ApiError::Stream(format!("failed to send websocket request: {err}")))
});
if let Some(t) = telemetry.as_ref() {
t.on_ws_request(
request_start.elapsed(),
result.as_ref().err(),
connection_reused,
);
}
result?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
+2 -1
View File
@@ -10,7 +10,7 @@ pub(crate) mod requests;
pub(crate) mod sse;
pub(crate) mod telemetry;
pub use crate::requests::headers::build_conversation_headers;
pub use crate::requests::headers::build_session_headers;
pub use codex_client::RequestTelemetry;
pub use codex_client::ReqwestTransport;
pub use codex_client::TransportError;
@@ -30,6 +30,7 @@ pub use crate::common::RawMemoryMetadata;
pub use crate::common::Reasoning;
pub use crate::common::ResponseCreateWsRequest;
pub use crate::common::ResponseEvent;
pub use crate::common::ResponseProcessedWsRequest;
pub use crate::common::ResponseStream;
pub use crate::common::ResponsesApiRequest;
pub use crate::common::ResponsesWsRequest;
+5 -2
View File
@@ -2,11 +2,14 @@ use codex_protocol::protocol::SessionSource;
use http::HeaderMap;
use http::HeaderValue;
pub fn build_conversation_headers(conversation_id: Option<String>) -> HeaderMap {
pub fn build_session_headers(session_id: Option<String>, thread_id: Option<String>) -> HeaderMap {
let mut headers = HeaderMap::new();
if let Some(id) = conversation_id {
if let Some(id) = session_id {
insert_header(&mut headers, "session_id", &id);
}
if let Some(id) = thread_id {
insert_header(&mut headers, "thread_id", &id);
}
headers
}
+12 -1
View File
@@ -444,7 +444,8 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> {
.stream_request(
request,
ResponsesOptions {
conversation_id: Some("sess_123".into()),
session_id: Some("sess_123".into()),
thread_id: Some("thread_123".into()),
session_source: Some(SessionSource::SubAgent(SubAgentSource::Review)),
extra_headers,
compression: Compression::None,
@@ -461,6 +462,16 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> {
req.headers.get("session_id").and_then(|v| v.to_str().ok()),
Some("sess_123")
);
assert_eq!(
req.headers.get("thread_id").and_then(|v| v.to_str().ok()),
Some("thread_123")
);
assert_eq!(
req.headers
.get("x-client-request-id")
.and_then(|v| v.to_str().ok()),
Some("thread_123")
);
assert_eq!(
req.headers
.get("x-openai-subagent")
+1
View File
@@ -16,6 +16,7 @@ anyhow = { workspace = true }
async-channel = { workspace = true }
codex-async-utils = { workspace = true }
codex-api = { workspace = true }
codex-builtin-mcps = { workspace = true }
codex-config = { workspace = true }
codex-exec-server = { workspace = true }
codex-login = { workspace = true }
+347
View File
@@ -0,0 +1,347 @@
//! Auth elicitation helpers.
//!
//! This module owns protocol-neutral auth elicitation parsing and payload shaping.
//! Session orchestration stays in `codex-core`.
use codex_protocol::mcp::CallToolResult;
use serde::Serialize;
pub const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps";
pub const CONNECTOR_AUTH_FAILURE_META_KEY: &str = "connector_auth_failure";
pub const CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: &str = "is_auth_failure";
pub const CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: &str = "auth_reason";
pub const CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: &str = "connector_id";
pub const CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: &str = "link_id";
pub const CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: &str = "error_code";
pub const CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: &str = "error_http_status_code";
pub const CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: &str = "error_action";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexAppsConnectorAuthFailure {
pub connector_id: String,
pub connector_name: String,
pub install_url: String,
pub auth_reason: Option<String>,
pub link_id: Option<String>,
pub error_code: Option<String>,
pub error_http_status_code: Option<i64>,
pub error_action: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CodexAppsAuthElicitation {
pub meta: serde_json::Value,
pub message: String,
pub url: String,
pub elicitation_id: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CodexAppsAuthElicitationPlan {
pub auth_failure: CodexAppsConnectorAuthFailure,
pub elicitation: CodexAppsAuthElicitation,
}
#[derive(Serialize)]
struct CodexAppsConnectorAuthFailureMeta<'a> {
is_auth_failure: bool,
connector_id: &'a str,
connector_name: &'a str,
install_url: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
auth_reason: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
link_id: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
error_code: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
error_http_status_code: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
error_action: Option<&'a str>,
}
pub fn connector_auth_failure_from_tool_result(
result: &CallToolResult,
connector_id: Option<&str>,
connector_name: Option<&str>,
install_url: Option<String>,
) -> Option<CodexAppsConnectorAuthFailure> {
if result.is_error != Some(true) {
return None;
}
let auth_failure = result
.meta
.as_ref()?
.as_object()?
.get(MCP_TOOL_CODEX_APPS_META_KEY)?
.as_object()?
.get(CONNECTOR_AUTH_FAILURE_META_KEY)?
.as_object()?;
if auth_failure
.get(CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY)
.and_then(serde_json::Value::as_bool)
!= Some(true)
{
return None;
}
let connector_id = connector_id
.map(str::trim)
.filter(|connector_id| !connector_id.is_empty())?;
if let Some(auth_failure_connector_id) =
string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY)
&& auth_failure_connector_id != connector_id
{
return None;
}
let connector_name = connector_name
.map(str::trim)
.filter(|name| !name.is_empty())
.unwrap_or(connector_id)
.to_string();
Some(CodexAppsConnectorAuthFailure {
connector_id: connector_id.to_string(),
connector_name,
install_url: install_url?,
auth_reason: string_auth_failure_field(
auth_failure,
CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY,
),
link_id: string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_LINK_ID_KEY),
error_code: string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY),
error_http_status_code: auth_failure
.get(CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY)
.and_then(serde_json::Value::as_i64),
error_action: string_auth_failure_field(
auth_failure,
CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY,
),
})
}
pub fn build_auth_elicitation_plan(
call_id: &str,
result: &CallToolResult,
connector_id: Option<&str>,
connector_name: Option<&str>,
install_url: Option<String>,
) -> Option<CodexAppsAuthElicitationPlan> {
let auth_failure =
connector_auth_failure_from_tool_result(result, connector_id, connector_name, install_url)?;
let elicitation = build_auth_elicitation(call_id, &auth_failure);
Some(CodexAppsAuthElicitationPlan {
auth_failure,
elicitation,
})
}
pub fn build_auth_elicitation(
call_id: &str,
auth_failure: &CodexAppsConnectorAuthFailure,
) -> CodexAppsAuthElicitation {
CodexAppsAuthElicitation {
meta: serde_json::json!({
MCP_TOOL_CODEX_APPS_META_KEY: {
CONNECTOR_AUTH_FAILURE_META_KEY: CodexAppsConnectorAuthFailureMeta {
is_auth_failure: true,
connector_id: &auth_failure.connector_id,
connector_name: &auth_failure.connector_name,
install_url: &auth_failure.install_url,
auth_reason: auth_failure.auth_reason.as_deref(),
link_id: auth_failure.link_id.as_deref(),
error_code: auth_failure.error_code.as_deref(),
error_http_status_code: auth_failure.error_http_status_code,
error_action: auth_failure.error_action.as_deref(),
},
},
}),
message: auth_elicitation_message(auth_failure),
url: auth_failure.install_url.clone(),
elicitation_id: auth_elicitation_id(call_id),
}
}
pub fn auth_elicitation_completed_result(
auth_failure: &CodexAppsConnectorAuthFailure,
meta: Option<serde_json::Value>,
) -> CallToolResult {
CallToolResult {
content: vec![serde_json::json!({
"type": "text",
"text": format!(
"Authentication for {} was requested and accepted. Retry this tool call now.",
auth_failure.connector_name
),
})],
structured_content: None,
is_error: Some(true),
meta,
}
}
pub fn auth_elicitation_id(call_id: &str) -> String {
format!("codex_apps_auth_{call_id}")
}
fn string_auth_failure_field(
auth_failure: &serde_json::Map<String, serde_json::Value>,
key: &str,
) -> Option<String> {
auth_failure
.get(key)
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn auth_elicitation_message(auth_failure: &CodexAppsConnectorAuthFailure) -> String {
match auth_failure.auth_reason.as_deref() {
Some("oauth_upgrade_required") => format!(
"Reconnect {} on ChatGPT to grant the permissions needed for this request.",
auth_failure.connector_name
),
Some("reauthentication_required") => format!(
"Reconnect {} on ChatGPT to restore access for this request.",
auth_failure.connector_name
),
Some("missing_link") => format!(
"Sign in to {} on ChatGPT to use it in Codex.",
auth_failure.connector_name
),
_ => format!(
"Sign in to {} on ChatGPT to continue.",
auth_failure.connector_name
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn auth_failure_result() -> CallToolResult {
CallToolResult {
content: vec![serde_json::json!({
"type": "text",
"text": "Connector reauthentication required",
})],
structured_content: None,
is_error: Some(true),
meta: Some(serde_json::json!({
MCP_TOOL_CODEX_APPS_META_KEY: {
CONNECTOR_AUTH_FAILURE_META_KEY: {
CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: true,
CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: "reauthentication_required",
CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: "connector_calendar",
"connector_name": "Untrusted Calendar",
CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: "link_123",
CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: "UNAUTHORIZED",
CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: 401,
CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: "TRIGGER_REAUTHENTICATION",
},
},
})),
}
}
#[test]
fn parses_auth_failure_from_trusted_connector_metadata() {
assert_eq!(
connector_auth_failure_from_tool_result(
&auth_failure_result(),
Some("connector_calendar"),
Some("Google Calendar"),
Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()),
),
Some(CodexAppsConnectorAuthFailure {
connector_id: "connector_calendar".to_string(),
connector_name: "Google Calendar".to_string(),
install_url: "https://chatgpt.com/apps/google-calendar/connector_calendar"
.to_string(),
auth_reason: Some("reauthentication_required".to_string()),
link_id: Some("link_123".to_string()),
error_code: Some("UNAUTHORIZED".to_string()),
error_http_status_code: Some(401),
error_action: Some("TRIGGER_REAUTHENTICATION".to_string()),
})
);
}
#[test]
fn rejects_missing_or_mismatched_connector_ids() {
assert_eq!(
connector_auth_failure_from_tool_result(
&auth_failure_result(),
/*connector_id*/ None,
Some("Google Calendar"),
Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()),
),
None
);
assert_eq!(
connector_auth_failure_from_tool_result(
&auth_failure_result(),
Some("connector_drive"),
Some("Google Drive"),
Some("https://chatgpt.com/apps/google-drive/connector_drive".to_string()),
),
None
);
}
#[test]
fn builds_url_elicitation_payload() {
let auth_failure = connector_auth_failure_from_tool_result(
&auth_failure_result(),
Some("connector_calendar"),
Some("Google Calendar"),
Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()),
)
.expect("auth failure");
assert_eq!(
build_auth_elicitation("call_123", &auth_failure),
CodexAppsAuthElicitation {
meta: serde_json::json!({
MCP_TOOL_CODEX_APPS_META_KEY: {
CONNECTOR_AUTH_FAILURE_META_KEY: {
CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: true,
CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: "connector_calendar",
"connector_name": "Google Calendar",
"install_url":
"https://chatgpt.com/apps/google-calendar/connector_calendar",
CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: "reauthentication_required",
CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: "link_123",
CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: "UNAUTHORIZED",
CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: 401,
CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: "TRIGGER_REAUTHENTICATION",
},
},
}),
message: "Reconnect Google Calendar on ChatGPT to restore access for this request."
.to_string(),
url: "https://chatgpt.com/apps/google-calendar/connector_calendar".to_string(),
elicitation_id: "codex_apps_auth_call_123".to_string(),
}
);
}
#[test]
fn builds_auth_elicitation_plan() {
let plan = build_auth_elicitation_plan(
"call_123",
&auth_failure_result(),
Some("connector_calendar"),
Some("Google Calendar"),
Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()),
)
.expect("auth elicitation plan");
assert_eq!(plan.auth_failure.connector_name, "Google Calendar");
assert_eq!(plan.elicitation.elicitation_id, "codex_apps_auth_call_123");
}
}
@@ -70,6 +70,7 @@ use url::Url;
pub struct McpConnectionManager {
clients: HashMap<String, AsyncManagedClient>,
server_origins: HashMap<String, String>,
host_owned_codex_apps_enabled: bool,
elicitation_requests: ElicitationRequestManager,
startup_cancellation_token: CancellationToken,
}
@@ -82,6 +83,7 @@ impl McpConnectionManager {
Self {
clients: HashMap::new(),
server_origins: HashMap::new(),
host_owned_codex_apps_enabled: false,
elicitation_requests: ElicitationRequestManager::new(
approval_policy.value(),
permission_profile.get().clone(),
@@ -116,6 +118,10 @@ impl McpConnectionManager {
self.server_origins.get(server_name).map(String::as_str)
}
pub fn is_host_owned_codex_apps_server(&self, server_name: &str) -> bool {
self.host_owned_codex_apps_enabled && server_name == CODEX_APPS_MCP_SERVER_NAME
}
pub fn set_approval_policy(&self, approval_policy: &Constrained<AskForApproval>) {
if let Ok(mut policy) = self.elicitation_requests.approval_policy.lock() {
*policy = approval_policy.value();
@@ -148,6 +154,7 @@ impl McpConnectionManager {
runtime_environment: McpRuntimeEnvironment,
codex_home: PathBuf,
codex_apps_tools_cache_key: CodexAppsToolsCacheKey,
host_owned_codex_apps_enabled: bool,
tool_plugin_provenance: ToolPluginProvenance,
auth: Option<&CodexAuth>,
) -> (Self, CancellationToken) {
@@ -248,6 +255,7 @@ impl McpConnectionManager {
let manager = Self {
clients,
server_origins,
host_owned_codex_apps_enabled,
elicitation_requests: elicitation_requests.clone(),
startup_cancellation_token: cancel_token.clone(),
};

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