Compare commits

...

15 Commits

Author SHA1 Message Date
Noah MacCallum
11d1e9cd19 Merge remote-tracking branch 'origin/main' into nm-codex/resolve-template-app-ids
# Conflicts:
#	codex-rs/core/src/connectors.rs
#	codex-rs/core/src/plugins/discoverable.rs
#	codex-rs/core/src/plugins/discoverable_tests.rs
2026-05-29 15:58:21 -07:00
Noah MacCallum
c243abfc81 Match plugin suggestions against resolved connector candidates 2026-05-29 15:32:11 -07:00
Noah MacCallum
8e5f561697 Filter plugin install suggestions by installed apps (#24996)
## Summary

- Keep the original `TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST` as a
fallback seed list, so users with no installed plugins still get initial
install suggestions.
- Allow additional install suggestions from trusted marketplaces:
`openai-curated` and `openai-bundled`.
- Require non-fallback, non-configured marketplace candidates to share
`.app.json` connector IDs with already installed plugins.
- Preserve explicit configured plugin discoverables as an override,
while still omitting installed, disabled, and `NOT_AVAILABLE` plugins.

## Context

`list_available_plugins_to_install` controls which plugins the model can
trigger via `request_plugin_install`. We want a small starter set for
empty/new users, but we also want installed workflow plugins to unlock
relevant source plugins without maintaining every source plugin ID by
hand.

This keeps the legacy plugin ID allowlist only as the starter fallback.
For everything else, the trusted marketplace is the candidate boundary,
and installed app connector overlap is the relevance filter. For
example, an installed Sales plugin can make HubSpot and Granola
suggestible when those source plugins are in `openai-curated` and share
Sales app connector IDs, while an unrelated test-source plugin with an
app connector not declared by Sales stays hidden.

## Test Coverage

- Empty/no-installed-plugin case: returns the fallback seed plugins from
the original allowlist.
- Installed-app expansion: returns non-fallback marketplace plugins only
when their app connector IDs overlap with an installed plugin.
- Sales workflow case: installed Sales declares HubSpot and Granola
apps, so `hubspot@openai-curated` and `granola@openai-curated` are
returned.
- Sales negative case: `test-source@openai-curated` has an app connector
not declared by Sales, so it is not returned.
- Existing guardrails: installed plugins, disabled suggestions, and
`NOT_AVAILABLE` plugins remain omitted; explicit configured
discoverables still work as an override.

## Validation

- `just fmt`
- `just test -p codex-core plugins::discoverable::tests`
- `just test -p codex-core` was attempted earlier, but current `main` /
local env failed with unrelated existing failures around missing
`test_stdio_server`, CLI/code-mode MCP tool setup, and
unified_exec/shell snapshot flakes/timeouts. The touched discoverable
tests pass.
2026-05-29 15:32:04 -07:00
Adam Perry @ OpenAI
a076b21730 Recommend Bazel VSCode extension. (#25161)
Provides starlark syntax highlighting and editor formatting.
2026-05-29 15:24:41 -07:00
Noah MacCallum
4cd3ab4a3d Resolve templated connectors from workspace directory 2026-05-29 15:04:17 -07:00
Noah MacCallum
25d081d10b Resolve templated connector suggestions 2026-05-29 14:14:56 -07:00
Jinghan Xu
f2e7b462a9 [codex] Fix Vim normal mode editing (#25022)
## Summary
- add Vim normal-mode `s` support to substitute the character under the
cursor and enter insert mode
- fix Vim normal-mode `o` so opening below the final line moves the
cursor onto the new blank line
- update keymap config/schema and keymap picker snapshots for the new
action

## Validation
- `just fmt`
- `just write-config-schema`
- `just test -p codex-config`
- focused `just test -p codex-tui` coverage for the Vim `s` and `o`
behavior, keymap conflict handling, and keymap picker snapshots
- `cargo insta pending-snapshots --manifest-path tui/Cargo.toml`
- `git diff --check`

## Notes
A full `just test -p codex-tui` run still has two unrelated Guardian
feature-flag failures in this checkout:
-
`app::tests::update_feature_flags_disabling_guardian_clears_review_policy_and_restores_default`
-
`app::tests::update_feature_flags_disabling_guardian_clears_manual_review_policy_without_history`
2026-05-29 14:01:27 -07:00
Noah MacCallum
bcd8d541f5 Resolve templated plugin app IDs 2026-05-29 13:40:54 -07:00
starr-openai
a717e4ef31 exec-server: preserve fs helper CoreFoundation env (#25118)
## Summary
- preserve macOS `__CF_USER_TEXT_ENCODING` when launching the sandboxed
fs helper
- keep the fs-helper env narrow; this adds only the CoreFoundation
startup var instead of copying the broader MCP stdio baseline
- add focused coverage that the helper keeps that var without admitting
`HOME`

## Diagnosis
The sandboxed fs helper is not launched like a normal child process.
Exec-server rebuilds its environment from an allowlist, then calls
`env_clear()` before re-execing Codex with `--codex-run-as-fs-helper`.
That helper dispatches before the normal Codex startup path and only
needs to boot a small Tokio runtime, read one JSON request from stdin,
perform the direct filesystem operation, and write one JSON response.

The reported macOS hang sampled the helper before Rust main, in
CoreFoundation initialization while resolving the default text encoding:
`_CFStringGetUserDefaultEncoding -> getpwuid_r -> notify_register_check
-> bootstrap_look_up3 -> mach_msg2_trap`. The fs-helper allowlist kept
`PATH` and temp vars for runtime needs, but it dropped macOS
`__CF_USER_TEXT_ENCODING`. Other Codex subprocess launchers that
intentionally build a minimal Unix baseline, such as MCP stdio, already
preserve that variable.

My read is that stripping `__CF_USER_TEXT_ENCODING` forced this internal
helper down CoreFoundation's fallback user-lookup path, and that lookup
intermittently wedged on the affected machine before the helper could
read stdin or touch the target file. Preserving only this macOS startup
variable avoids that fallback without broadening the fs-helper
environment to shell-like vars such as `HOME`, `USER`, locale settings,
terminal settings, or proxy credentials.

Internal Slack thread omitted from the public PR body.

## Validation
- `cd codex-rs && just fmt`
- `git diff --check`
2026-05-29 12:20:17 -07:00
Eric Traut
20da4c37c5 ci: use issue triage environment for issue workflows (#25134)
## Summary

This adds `environment: issue-triage` to the Codex-calling issue
workflow jobs so they can read the GitHub Environment Secret while
staying on GitHub-hosted runners for public issue-triggered workflows.
2026-05-29 12:06:55 -07:00
sayan-oai
1f93706e99 [codex] Require model for standalone web search (#25131)
## Why

The standalone `/v1/alpha/search` request now requires a `model`, but
the `web.run` extension currently omits it.

Adds `model` to extension `ToolCall` invocation.

Follow-up to #23823.

## What changed

- Make `SearchRequest.model` required.
- Expose the effective per-turn model on extension tool calls and pass
it in standalone web-search requests.
- Assert the model is forwarded in the app-server round-trip test.

## Testing

- `just test -p codex-api -p codex-tools -p codex-web-search-extension
-p codex-memories-extension -p codex-goal-extension`
- `just test -p codex-core -E
'test(passes_turn_fields_and_scoped_turn_item_emitter_to_extension_call)'`
- `just test -p codex-app-server -E
'test(standalone_web_search_round_trips_encrypted_output)'`
2026-05-29 12:03:04 -07:00
Michael Bolin
a1ecf0cf1c thread-store: store permission profiles (#23165)
## Why

`SandboxPolicy` is the legacy compatibility shape, but
`codex-thread-store` still exposed it through `StoredThread`,
`ThreadMetadataPatch`, and live metadata sync. That kept thread-store
consumers tied to the legacy representation and meant richer permission
profile data could not round-trip through thread metadata or cold
rollout reconciliation.

## What Changed

- Replaced thread-store `sandbox_policy` API fields with canonical
`PermissionProfile` fields.
- Persist new permission-profile metadata as canonical JSON in the
existing SQLite metadata slot while continuing to read older legacy
sandbox policy values.
- Updated local, in-memory, live metadata sync, and rollout extraction
paths to propagate `TurnContextItem::permission_profile()`.
- Re-materialize legacy permission metadata against the final rollout
cwd when rollout-derived metadata replaces stale SQLite summaries.
- Updated affected app-server and core test constructors to build
`PermissionProfile` values directly.

## Test Plan

- `cargo test -p codex-state`
- `cargo test -p codex-thread-store`
- `cargo test -p codex-app-server
summary_from_stored_thread_preserves_millisecond_precision --lib`
- `cargo test -p codex-core realtime_context --lib`
2026-05-29 11:55:31 -07:00
Channing Conger
c9dc0f6338 code-mode: introduce durable session interface (#24180)
## Summary

Introduce a `CodeModeSession` interface for executing and managing
code-mode cells.

This moves cell lifecycle, callback delegation, termination, and
shutdown behind a session abstraction, while continuing to use the
existing in-process implementation, and the ability to implement an
external process one behind this interface.

A Codex session owns one `CodeModeSession`, which in turn owns its
running cells and stored code-mode state. Each cell is represented to
the caller as a `StartedCell`, exposing its cell ID and initial
response.

It also introduces a `CodeModeSessionDelegate` callback interface. A
session uses the delegate to invoke nested host tools and emit
notifications while a cell is running, allowing the runtime to
communicate with its owning Codex session without depending directly on
core turn handling.

<img width="2121" height="1001" alt="image"
src="https://github.com/user-attachments/assets/c349a819-2a59-485c-bda4-2caf68ac4c31"
/>
2026-05-29 11:42:52 -07:00
Noah MacCallum
10a8a4e84f Support plugin install suggestions from loaded plugin apps 2026-05-29 01:31:17 -07:00
Noah MacCallum
24d5ccc19b Filter plugin install suggestions by installed apps 2026-05-28 21:45:10 -07:00
55 changed files with 2813 additions and 655 deletions

View File

@@ -12,6 +12,7 @@ jobs:
# Prevent runs on forks (requires OpenAI API key, wastes Actions minutes)
if: github.repository == 'openai/codex' && (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-deduplicate'))
runs-on: ubuntu-latest
environment: issue-triage
permissions:
contents: read
outputs:
@@ -157,6 +158,7 @@ jobs:
needs: normalize-duplicates-all
if: ${{ needs.normalize-duplicates-all.result == 'success' && needs.normalize-duplicates-all.outputs.has_matches != 'true' }}
runs-on: ubuntu-latest
environment: issue-triage
permissions:
contents: read
outputs:

View File

@@ -12,6 +12,7 @@ jobs:
# Prevent runs on forks (requires OpenAI API key, wastes Actions minutes)
if: github.repository == 'openai/codex' && (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-label'))
runs-on: ubuntu-latest
environment: issue-triage
permissions:
contents: read
outputs:

View File

@@ -1,5 +1,6 @@
{
"recommendations": [
"BazelBuild.vscode-bazel",
"rust-lang.rust-analyzer",
"charliermarsh.ruff",
"tamasfe.even-better-toml",

2
codex-rs/Cargo.lock generated
View File

@@ -2430,8 +2430,6 @@ dependencies = [
name = "codex-code-mode"
version = "0.0.0"
dependencies = [
"async-channel",
"async-trait",
"codex-protocol",
"deno_core_icudata",
"pretty_assertions",

View File

@@ -2095,6 +2095,7 @@ mod tests {
use codex_protocol::items::build_hook_prompt_message;
use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions;
use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
@@ -2110,7 +2111,6 @@ mod tests {
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
@@ -2187,7 +2187,7 @@ mod tests {
agent_path: None,
git_info: None,
approval_mode: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
token_usage: None,
first_user_message: Some("before rollback".to_string()),
history: Some(StoredThreadHistory {

View File

@@ -1364,8 +1364,16 @@ impl PluginRequestProcessor {
.await;
}
let plugin_apps = load_plugin_apps(result.installed_path.as_path()).await;
let auth = self.auth_manager.auth().await;
let plugin_apps = load_plugin_apps(result.installed_path.as_path()).await;
let plugin_apps = codex_core_plugins::remote::resolve_remote_plugin_app_ids(
&RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
},
auth.as_ref(),
&plugin_apps,
)
.await;
let apps_needing_auth = self
.plugin_apps_needing_auth_for_install(
&config,
@@ -1481,6 +1489,12 @@ impl PluginRequestProcessor {
}
let plugin_apps = load_plugin_apps(result.installed_path.as_path()).await;
let plugin_apps = codex_core_plugins::remote::resolve_remote_plugin_app_ids(
&remote_plugin_service_config,
auth.as_ref(),
&plugin_apps,
)
.await;
let apps_needing_auth = self
.plugin_apps_needing_auth_for_install(
&config,

View File

@@ -68,13 +68,13 @@ mod thread_processor_behavior_tests {
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_state::ThreadMetadataBuilder;
@@ -412,7 +412,7 @@ mod thread_processor_behavior_tests {
agent_path: None,
git_info: None,
approval_mode: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
token_usage: None,
first_user_message: Some("first user message".to_string()),
history: None,

View File

@@ -1,4 +1,5 @@
use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::time::Duration;
@@ -13,6 +14,7 @@ use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use axum::Json;
use axum::Router;
use axum::extract::Path as AxumPath;
use axum::extract::State;
use axum::http::HeaderMap;
use axum::http::StatusCode;
@@ -801,6 +803,56 @@ async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn remote_plugin_install_resolves_template_app_ids() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
let bundle_url = mount_remote_plugin_bundle(
&server,
/*status_code*/ 200,
remote_plugin_bundle_tar_gz_bytes_with_contents(
r#"{"name":"linear"}"#,
Some(r#"{"apps":{"databricks":{"id":"templated_apps_Databricks"}}}"#),
)?,
)
.await;
configure_remote_plugin_test(codex_home.path(), &server)?;
mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await;
mount_empty_remote_installed_plugins(&server).await;
mount_remote_plugin_install(&server, REMOTE_PLUGIN_ID).await;
mount_remote_template_connector_ids(
&server,
"templated_apps_Databricks",
&["asdk_app_databricks_workspace"],
)
.await;
let mut mcp = McpProcess::new_with_env(
codex_home.path(),
&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))],
)
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginInstallResponse = to_response(response)?;
assert_eq!(response.apps_needing_auth, Vec::<AppSummary>::new());
wait_for_remote_plugin_request_count(
&server,
"GET",
"/ps/connectors/by_template_id/templated_apps_Databricks",
/*expected_count*/ 1,
)
.await?;
Ok(())
}
#[tokio::test]
async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -953,6 +1005,100 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn plugin_install_resolves_template_apps_for_apps_needing_auth() -> Result<()> {
let connectors = vec![AppInfo {
id: "asdk_app_databricks_workspace".to_string(),
name: "Databricks".to_string(),
description: Some("Workspace Databricks connector".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
}];
let (server_url, server_handle) = start_apps_server_with_template_connector_ids(
connectors,
Vec::new(),
HashMap::from([(
"templated_apps_Databricks".to_string(),
vec!["asdk_app_databricks_workspace".to_string()],
)]),
)
.await?;
let codex_home = TempDir::new()?;
write_connectors_config(codex_home.path(), &server_url)?;
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 repo_root = TempDir::new()?;
write_plugin_marketplace(
repo_root.path(),
"debug",
"sample-plugin",
"./sample-plugin",
/*install_policy*/ None,
/*auth_policy*/ None,
)?;
write_plugin_source(
repo_root.path(),
"sample-plugin",
&["templated_apps_Databricks"],
)?;
let marketplace_path =
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_install_request(PluginInstallParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
plugin_name: "sample-plugin".to_string(),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginInstallResponse = to_response(response)?;
assert_eq!(
response,
PluginInstallResponse {
auth_policy: PluginAuthPolicy::OnInstall,
apps_needing_auth: vec![AppSummary {
id: "asdk_app_databricks_workspace".to_string(),
name: "Databricks".to_string(),
description: Some("Workspace Databricks connector".to_string()),
install_url: Some(
"https://chatgpt.com/apps/databricks/asdk_app_databricks_workspace".to_string(),
),
needs_auth: true,
}],
}
);
server_handle.abort();
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> {
let connectors = vec![AppInfo {
@@ -1113,6 +1259,7 @@ async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests
#[derive(Clone)]
struct AppsServerState {
response: Arc<StdMutex<serde_json::Value>>,
template_connector_ids: Arc<StdMutex<HashMap<String, Vec<String>>>>,
}
#[derive(Clone)]
@@ -1149,11 +1296,20 @@ impl ServerHandler for PluginInstallMcpServer {
async fn start_apps_server(
connectors: Vec<AppInfo>,
tools: Vec<Tool>,
) -> Result<(String, JoinHandle<()>)> {
start_apps_server_with_template_connector_ids(connectors, tools, HashMap::new()).await
}
async fn start_apps_server_with_template_connector_ids(
connectors: Vec<AppInfo>,
tools: Vec<Tool>,
template_connector_ids: HashMap<String, Vec<String>>,
) -> Result<(String, JoinHandle<()>)> {
let state = Arc::new(AppsServerState {
response: Arc::new(StdMutex::new(
json!({ "apps": connectors, "next_token": null }),
)),
template_connector_ids: Arc::new(StdMutex::new(template_connector_ids)),
});
let tools = Arc::new(StdMutex::new(tools));
@@ -1177,6 +1333,10 @@ async fn start_apps_server(
"/connectors/directory/list_workspace",
get(list_directory_connectors),
)
.route(
"/ps/connectors/by_template_id/{template_id}",
get(template_connector_ids_response),
)
.with_state(state)
.nest_service("/api/codex/apps", mcp_service);
@@ -1187,6 +1347,33 @@ async fn start_apps_server(
Ok((format!("http://{addr}"), handle))
}
async fn template_connector_ids_response(
State(state): State<Arc<AppsServerState>>,
AxumPath(template_id): AxumPath<String>,
headers: HeaderMap,
) -> Result<impl axum::response::IntoResponse, StatusCode> {
let bearer_ok = headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value == "Bearer chatgpt-token");
let account_ok = headers
.get("chatgpt-account-id")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value == "account-123");
if !bearer_ok || !account_ok {
return Err(StatusCode::UNAUTHORIZED);
}
let connector_ids = state
.template_connector_ids
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&template_id)
.cloned()
.unwrap_or_default();
Ok(Json(json!({ "connector_ids": connector_ids })))
}
async fn list_directory_connectors(
State(state): State<Arc<AppsServerState>>,
headers: HeaderMap,
@@ -1492,6 +1679,24 @@ async fn mount_remote_plugin_install(server: &MockServer, remote_plugin_id: &str
.await;
}
async fn mount_remote_template_connector_ids(
server: &MockServer,
template_id: &str,
connector_ids: &[&str],
) {
Mock::given(method("GET"))
.and(path(format!(
"/backend-api/ps/connectors/by_template_id/{template_id}"
)))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"connector_ids": connector_ids,
})))
.mount(server)
.await;
}
#[derive(Debug, Clone)]
struct CacheManifestExists {
manifest_path: std::path::PathBuf,

View File

@@ -140,6 +140,7 @@ async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> {
);
let search_body = search_request_body(&server).await?;
assert_eq!(search_body["model"], json!("mock-model"));
assert_eq!(
search_body["commands"],
json!({

View File

@@ -16,8 +16,6 @@ sandbox = ["v8/v8_enable_sandbox"]
workspace = true
[dependencies]
async-channel = { workspace = true }
async-trait = { workspace = true }
codex-protocol = { workspace = true }
deno_core_icudata = { workspace = true }
serde = { workspace = true, features = ["derive"] }

View File

@@ -29,9 +29,18 @@ pub use runtime::WaitOutcome;
pub use runtime::WaitRequest;
pub use runtime::WaitToPendingOutcome;
pub use runtime::WaitToPendingRequest;
pub use service::CellId;
pub use service::CodeModeService;
pub use service::CodeModeTurnHost;
pub use service::CodeModeTurnWorker;
pub use service::CodeModeSession;
pub use service::CodeModeSessionDelegate;
pub use service::CodeModeSessionProvider;
pub use service::CodeModeSessionProviderFuture;
pub use service::CodeModeSessionResultFuture;
pub use service::InProcessCodeModeSessionProvider;
pub use service::NoopCodeModeSessionDelegate;
pub use service::NotificationFuture;
pub use service::StartedCell;
pub use service::ToolInvocationFuture;
pub const PUBLIC_TOOL_NAME: &str = "exec";
pub const WAIT_TOOL_NAME: &str = "wait";

View File

@@ -19,6 +19,7 @@ use crate::description::EnabledToolMetadata;
use crate::description::ToolDefinition;
use crate::description::enabled_tool_metadata;
use crate::response::FunctionCallOutputContentItem;
use crate::service::CellId;
pub const DEFAULT_EXEC_YIELD_TIME_MS: u64 = 10_000;
pub const DEFAULT_WAIT_YIELD_TIME_MS: u64 = 10_000;
@@ -27,11 +28,6 @@ const EXIT_SENTINEL: &str = "__codex_code_mode_exit__";
#[derive(Clone, Debug)]
pub struct ExecuteRequest {
/// Runtime cell id for this execution.
///
/// Callers allocate this before execution so tracing, waits, and nested tool
/// calls can refer to the cell as soon as JavaScript starts.
pub cell_id: String,
pub tool_call_id: String,
pub enabled_tools: Vec<ToolDefinition>,
pub source: String,
@@ -41,14 +37,13 @@ pub struct ExecuteRequest {
#[derive(Clone, Debug)]
pub struct WaitRequest {
pub cell_id: String,
pub cell_id: CellId,
pub yield_time_ms: u64,
pub terminate: bool,
}
#[derive(Clone, Debug)]
pub struct WaitToPendingRequest {
pub cell_id: String,
pub cell_id: CellId,
}
/// Result of waiting on a code-mode cell.
@@ -73,7 +68,7 @@ pub enum ExecuteToPendingOutcome {
/// The cell is waiting for more runtime input after draining the runtime
/// input queue that was ready at the pending boundary.
Pending {
cell_id: String,
cell_id: CellId,
content_items: Vec<FunctionCallOutputContentItem>,
/// Runtime tool-call ids emitted before this paused execution frontier
/// sealed. Hosts can use these ids to drain their tool-call transport
@@ -105,15 +100,15 @@ impl From<WaitOutcome> for RuntimeResponse {
#[derive(Debug, PartialEq, Serialize)]
pub enum RuntimeResponse {
Yielded {
cell_id: String,
cell_id: CellId,
content_items: Vec<FunctionCallOutputContentItem>,
},
Terminated {
cell_id: String,
cell_id: CellId,
content_items: Vec<FunctionCallOutputContentItem>,
},
Result {
cell_id: String,
cell_id: CellId,
content_items: Vec<FunctionCallOutputContentItem>,
error_text: Option<String>,
},
@@ -126,23 +121,13 @@ pub enum RuntimeResponse {
/// if their tool-call graph requires globally unique ids.
#[derive(Debug)]
pub struct CodeModeNestedToolCall {
pub cell_id: String,
pub cell_id: CellId,
pub runtime_tool_call_id: String,
pub tool_name: ToolName,
pub tool_kind: CodeModeToolKind,
pub input: Option<JsonValue>,
}
#[derive(Debug)]
pub(crate) enum TurnMessage {
ToolCall(CodeModeNestedToolCall),
Notify {
cell_id: String,
call_id: String,
text: String,
},
}
#[derive(Debug)]
pub(crate) enum RuntimeCommand {
ToolResponse { id: String, result: JsonValue },
@@ -460,7 +445,6 @@ mod tests {
fn execute_request(source: &str) -> ExecuteRequest {
ExecuteRequest {
cell_id: "1".to_string(),
tool_call_id: "call_1".to_string(),
enabled_tools: Vec::new(),
source: source.to_string(),

File diff suppressed because it is too large Load Diff

View File

@@ -145,7 +145,7 @@ mod tests {
.search(
&SearchRequest {
id: "search-session".to_string(),
model: Some("gpt-test".to_string()),
model: "gpt-test".to_string(),
reasoning: None,
input: Some(SearchInput::Items(vec![ResponseItem::Message {
id: None,

View File

@@ -7,8 +7,7 @@ use serde::Serialize;
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct SearchRequest {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<Reasoning>,
#[serde(skip_serializing_if = "Option::is_none")]

View File

@@ -223,6 +223,8 @@ pub struct TuiVimNormalKeymap {
pub move_line_end: Option<KeybindingsSpec>,
/// Delete character under cursor (`x`).
pub delete_char: Option<KeybindingsSpec>,
/// Delete character under cursor and enter insert mode (`s`).
pub substitute_char: Option<KeybindingsSpec>,
/// Delete from cursor to end of line (`D`).
pub delete_to_line_end: Option<KeybindingsSpec>,
/// Change from cursor to end of line and enter insert mode (`C`).

View File

@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::future::Future;
use std::sync::LazyLock;
use std::sync::Mutex as StdMutex;
@@ -77,6 +78,8 @@ pub struct DirectoryApp {
logo_url_dark: Option<String>,
#[serde(alias = "distributionChannel")]
distribution_channel: Option<String>,
#[serde(alias = "templateId")]
template_id: Option<String>,
visibility: Option<String>,
}
@@ -152,16 +155,7 @@ where
.into_iter()
.map(directory_app_to_app_info)
.collect::<Vec<_>>();
for connector in &mut connectors {
let install_url = match connector.install_url.take() {
Some(install_url) => install_url,
None => connector_install_url(&connector.name, &connector.id),
};
connector.name = normalize_connector_name(&connector.name, &connector.id);
connector.description = normalize_connector_value(connector.description.as_deref());
connector.install_url = Some(install_url);
connector.is_accessible = false;
}
normalize_directory_app_infos(&mut connectors);
connectors.sort_by(|left, right| {
left.name
.cmp(&right.name)
@@ -248,6 +242,35 @@ where
}
}
pub async fn list_workspace_template_connectors<F, Fut>(
template_ids: &HashSet<String>,
mut fetch_page: F,
) -> anyhow::Result<Vec<AppInfo>>
where
F: FnMut(String) -> Fut,
Fut: Future<Output = anyhow::Result<DirectoryListResponse>>,
{
let mut connectors = list_workspace_connectors(&mut fetch_page)
.await?
.into_iter()
.filter(|app| {
app.template_id
.as_deref()
.is_some_and(|template_id| template_ids.contains(template_id))
})
.map(directory_app_to_app_info)
.collect::<Vec<_>>();
for connector in &mut connectors {
normalize_directory_app_info(connector);
}
connectors.sort_by(|left, right| {
left.name
.cmp(&right.name)
.then_with(|| left.id.cmp(&right.id))
});
Ok(connectors)
}
fn merge_directory_apps(apps: Vec<DirectoryApp>) -> Vec<DirectoryApp> {
let mut merged: HashMap<String, DirectoryApp> = HashMap::new();
for app in apps {
@@ -271,6 +294,7 @@ fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) {
logo_url,
logo_url_dark,
distribution_channel,
template_id,
visibility: _,
} = incoming;
@@ -296,6 +320,9 @@ fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) {
if existing.distribution_channel.is_none() && distribution_channel.is_some() {
existing.distribution_channel = distribution_channel;
}
if existing.template_id.is_none() && template_id.is_some() {
existing.template_id = template_id;
}
if let Some(incoming_branding) = branding {
if let Some(existing_branding) = existing.branding.as_mut() {
@@ -422,6 +449,23 @@ fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo {
}
}
fn normalize_directory_app_infos(connectors: &mut [AppInfo]) {
for connector in connectors {
normalize_directory_app_info(connector);
}
}
fn normalize_directory_app_info(connector: &mut AppInfo) {
let install_url = match connector.install_url.take() {
Some(install_url) => install_url,
None => connector_install_url(&connector.name, &connector.id),
};
connector.name = normalize_connector_name(&connector.name, &connector.id);
connector.description = normalize_connector_value(connector.description.as_deref());
connector.install_url = Some(install_url);
connector.is_accessible = false;
}
fn connector_install_url(name: &str, connector_id: &str) -> String {
let slug = connector_name_slug(name);
format!("https://chatgpt.com/apps/{slug}/{connector_id}")
@@ -504,6 +548,7 @@ mod tests {
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
template_id: None,
visibility: None,
}
}

View File

@@ -8,6 +8,7 @@ use codex_app_server_protocol::PluginInterface;
use codex_app_server_protocol::SkillInterface;
use codex_login::CodexAuth;
use codex_login::default_client::build_reqwest_client;
use codex_plugin::AppConnectorId;
use codex_plugin::PluginId;
use codex_utils_absolute_path::AbsolutePathBuf;
use reqwest::RequestBuilder;
@@ -19,6 +20,7 @@ use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
use tracing::warn;
use url::Url;
mod remote_installed_plugin_sync;
@@ -65,6 +67,7 @@ const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30);
const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200;
const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128;
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
const TEMPLATE_APP_ID_PREFIX: &str = "templated_apps_";
const REMOTE_INSTALLED_MARKETPLACE_DISPLAY_ORDER: [(&str, &str); 5] = [
(
REMOTE_GLOBAL_MARKETPLACE_NAME,
@@ -485,6 +488,20 @@ struct RemotePluginMutationResponse {
enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RemoteWorkspaceConnectorDirectoryResponse {
#[serde(default)]
apps: Vec<RemoteWorkspaceConnectorDirectoryApp>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RemoteWorkspaceConnectorDirectoryApp {
id: String,
#[serde(alias = "templateId")]
template_id: Option<String>,
visibility: Option<String>,
}
pub async fn fetch_remote_marketplaces(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
@@ -703,6 +720,67 @@ pub(crate) async fn fetch_remote_installed_plugins(
Ok(installed_plugins)
}
pub async fn resolve_remote_plugin_app_ids(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
app_ids: &[AppConnectorId],
) -> Vec<AppConnectorId> {
let mut resolved_app_ids = Vec::new();
let mut seen_app_ids = HashSet::new();
let mut template_connector_ids = BTreeMap::<String, Option<Vec<String>>>::new();
for app_id in app_ids {
if !app_id.0.starts_with(TEMPLATE_APP_ID_PREFIX) {
if seen_app_ids.insert(app_id.clone()) {
resolved_app_ids.push(app_id.clone());
}
continue;
}
let connector_ids = if let Some(connector_ids) = template_connector_ids.get(&app_id.0) {
connector_ids.clone()
} else {
let connector_ids = match ensure_chatgpt_auth(auth) {
Ok(auth) => {
match fetch_template_connector_ids(config, auth, app_id.0.as_str()).await {
Ok(connector_ids) => Some(connector_ids),
Err(err) => {
warn!(
template_app_id = %app_id.0,
error = %err,
"failed to resolve remote plugin template app id; dropping it"
);
None
}
}
}
Err(err) => {
warn!(
template_app_id = %app_id.0,
error = %err,
"cannot resolve remote plugin template app id without ChatGPT auth; dropping it"
);
None
}
};
template_connector_ids.insert(app_id.0.clone(), connector_ids.clone());
connector_ids
};
let Some(connector_ids) = connector_ids else {
continue;
};
for connector_id in connector_ids {
let connector_id = AppConnectorId(connector_id);
if seen_app_ids.insert(connector_id.clone()) {
resolved_app_ids.push(connector_id);
}
}
}
resolved_app_ids
}
pub fn group_remote_installed_plugins_by_marketplaces(
plugins: &[RemoteInstalledPlugin],
visible_scopes: &[RemotePluginScope],
@@ -1378,6 +1456,27 @@ async fn fetch_plugin_detail(
send_and_decode(request, &url).await
}
async fn fetch_template_connector_ids(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
template_id: &str,
) -> Result<Vec<String>, RemotePluginCatalogError> {
let url = remote_workspace_connector_directory_url(config)?;
let client = build_reqwest_client();
let request = authenticated_request(client.get(&url), auth)?;
let response: RemoteWorkspaceConnectorDirectoryResponse =
send_and_decode(request, &url).await?;
Ok(response
.apps
.into_iter()
.filter(|app| {
app.template_id.as_deref() == Some(template_id)
&& !matches!(app.visibility.as_deref(), Some("HIDDEN"))
})
.map(|app| app.id)
.collect())
}
fn remote_plugin_skill_detail_url(
config: &RemotePluginServiceConfig,
plugin_id: &str,
@@ -1399,6 +1498,24 @@ fn remote_plugin_skill_detail_url(
Ok(url.to_string())
}
fn remote_workspace_connector_directory_url(
config: &RemotePluginServiceConfig,
) -> Result<String, RemotePluginCatalogError> {
let mut url = Url::parse(config.chatgpt_base_url.trim_end_matches('/'))
.map_err(RemotePluginCatalogError::InvalidBaseUrl)?;
{
let mut segments = url
.path_segments_mut()
.map_err(|()| RemotePluginCatalogError::InvalidBaseUrlPath)?;
segments.pop_if_empty();
segments.push("connectors");
segments.push("directory");
segments.push("list_workspace");
}
url.set_query(Some("external_logos=true"));
Ok(url.to_string())
}
fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginCatalogError> {
let Some(auth) = auth else {
return Err(RemotePluginCatalogError::AuthRequired);
@@ -1444,3 +1561,6 @@ async fn send_and_decode<T: for<'de> Deserialize<'de>>(
source,
})
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,110 @@
use super::*;
use codex_login::CodexAuth;
use pretty_assertions::assert_eq;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
fn test_config(server: &MockServer) -> RemotePluginServiceConfig {
RemotePluginServiceConfig {
chatgpt_base_url: format!("{}/backend-api", server.uri()),
}
}
fn test_auth() -> CodexAuth {
CodexAuth::create_dummy_chatgpt_auth_for_testing()
}
fn app(id: &str) -> AppConnectorId {
AppConnectorId(id.to_string())
}
#[tokio::test]
async fn resolve_remote_plugin_app_ids_expands_templates_and_dedupes_stably() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/connectors/directory/list_workspace"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"apps":[
{"id":"connector_ghe","template_id":"templated_apps_GitHubEnterprise"},
{"id":"asdk_app_ghe","template_id":"templated_apps_GitHubEnterprise"},
{"id":"asdk_app_other","template_id":"templated_apps_Other"},
{"id":"asdk_app_hidden","template_id":"templated_apps_GitHubEnterprise","visibility":"HIDDEN"}
]}"#,
))
.mount(&server)
.await;
let resolved = resolve_remote_plugin_app_ids(
&test_config(&server),
Some(&test_auth()),
&[
app("asdk_app_linear"),
app("templated_apps_GitHubEnterprise"),
app("asdk_app_linear"),
app("asdk_app_ghe"),
],
)
.await;
assert_eq!(
resolved,
vec![
app("asdk_app_linear"),
app("connector_ghe"),
app("asdk_app_ghe"),
]
);
}
#[tokio::test]
async fn resolve_remote_plugin_app_ids_drops_missing_template_mappings() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/connectors/directory/list_workspace"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"apps":[{"id":"asdk_app_other","template_id":"templated_apps_Other"}]}"#,
))
.mount(&server)
.await;
let resolved = resolve_remote_plugin_app_ids(
&test_config(&server),
Some(&test_auth()),
&[app("templated_apps_GitHubEnterprise")],
)
.await;
assert_eq!(resolved, Vec::<AppConnectorId>::new());
}
#[tokio::test]
async fn resolve_remote_plugin_app_ids_drops_templates_when_lookup_fails() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/connectors/directory/list_workspace"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(500).set_body_string("lookup failed"))
.mount(&server)
.await;
let resolved = resolve_remote_plugin_app_ids(
&test_config(&server),
Some(&test_auth()),
&[
app("asdk_app_linear"),
app("templated_apps_GitHubEnterprise"),
],
)
.await;
assert_eq!(resolved, vec![app("asdk_app_linear")]);
}

View File

@@ -2858,6 +2858,7 @@
"start_change_operator": null,
"start_delete_operator": null,
"start_yank_operator": null,
"substitute_char": null,
"yank_line": null
},
"vim_operator": {
@@ -3548,6 +3549,7 @@
"start_change_operator": null,
"start_delete_operator": null,
"start_yank_operator": null,
"substitute_char": null,
"yank_line": null
}
},
@@ -3975,6 +3977,14 @@
],
"description": "Begin yank operator; next key selects motion (`y`)."
},
"substitute_char": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Delete character under cursor and enter insert mode (`s`)."
},
"yank_line": {
"allOf": [
{

View File

@@ -22,7 +22,7 @@ use tracing::warn;
use crate::config::Config;
use crate::mcp::McpManager;
use crate::plugins::list_tool_suggest_discoverable_plugins;
use crate::plugins::list_tool_suggest_discoverable_plugins_with_connector_candidates;
use crate::session::INITIAL_SUBMIT_ID;
use codex_config::AppsRequirementsToml;
use codex_config::types::AppToolApproval;
@@ -32,6 +32,7 @@ use codex_core_plugins::PluginsManager;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::default_client::build_reqwest_client;
use codex_login::default_client::originator;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::McpConnectionManager;
@@ -42,8 +43,11 @@ use codex_mcp::codex_apps_tools_cache_key;
use codex_mcp::compute_auth_statuses;
use codex_mcp::host_owned_codex_apps_enabled;
use codex_mcp::with_codex_apps_mcp;
use codex_plugin::AppConnectorId;
use url::Url;
const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);
const TEMPLATE_APP_ID_PREFIX: &str = "templated_apps_";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct AppToolPolicy {
@@ -113,10 +117,26 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
config: &Config,
auth: Option<&CodexAuth>,
accessible_connectors: &[AppInfo],
loaded_plugin_app_connector_ids: &[String],
) -> anyhow::Result<Vec<DiscoverableTool>> {
let connector_ids = tool_suggest_connector_ids(config).await;
let connector_selection = tool_suggest_connector_selection(config).await;
let mut connector_ids = connector_selection.connector_ids.clone();
let mut directory_connectors =
cached_directory_connectors_for_tool_suggest_with_auth(config, auth).await;
let template_directory_connectors = workspace_template_directory_connectors(
config,
auth,
&connector_selection.template_ids,
&connector_selection.disabled_template_ids,
&connector_selection.disabled_connector_ids,
)
.await;
for connector in template_directory_connectors {
connector_ids.insert(connector.id.clone());
directory_connectors.push(connector);
}
let directory_connectors = codex_connectors::merge::merge_plugin_connectors(
cached_directory_connectors_for_tool_suggest_with_auth(config, auth).await,
directory_connectors,
connector_ids.iter().cloned(),
);
let discoverable_connectors =
@@ -128,10 +148,16 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
)
.into_iter()
.map(DiscoverableTool::from);
let discoverable_plugins = list_tool_suggest_discoverable_plugins(config)
.await?
.into_iter()
.map(DiscoverableTool::from);
let candidate_app_connector_ids = connector_ids.iter().cloned().collect::<Vec<_>>();
let discoverable_plugins = list_tool_suggest_discoverable_plugins_with_connector_candidates(
config,
auth,
loaded_plugin_app_connector_ids,
&candidate_app_connector_ids,
)
.await?
.into_iter()
.map(DiscoverableTool::from);
Ok(discoverable_connectors
.chain(discoverable_plugins)
.collect())
@@ -404,33 +430,168 @@ fn write_cached_accessible_connectors(
});
}
async fn tool_suggest_connector_ids(config: &Config) -> HashSet<String> {
#[derive(Debug, Default, PartialEq, Eq)]
struct ToolSuggestConnectorSelection {
connector_ids: HashSet<String>,
template_ids: HashSet<String>,
disabled_connector_ids: HashSet<String>,
disabled_template_ids: HashSet<String>,
}
async fn tool_suggest_connector_selection(config: &Config) -> ToolSuggestConnectorSelection {
let plugins_input = config.plugins_config_input();
let mut connector_ids = PluginsManager::new(config.codex_home.to_path_buf())
let connector_ids = PluginsManager::new(config.codex_home.to_path_buf())
.plugins_for_config(&plugins_input)
.await
.capability_summaries()
.iter()
.flat_map(|plugin| plugin.app_connector_ids.iter())
.map(|connector_id| connector_id.0.clone())
.collect::<HashSet<_>>();
connector_ids.extend(
config
.tool_suggest
.discoverables
.iter()
.filter(|discoverable| discoverable.kind == ToolSuggestDiscoverableType::Connector)
.map(|discoverable| discoverable.id.clone()),
);
.cloned()
.chain(
config
.tool_suggest
.discoverables
.iter()
.filter(|discoverable| discoverable.kind == ToolSuggestDiscoverableType::Connector)
.map(|discoverable| AppConnectorId(discoverable.id.clone())),
)
.collect::<Vec<_>>();
let disabled_connector_ids = config
.tool_suggest
.disabled_tools
.iter()
.filter(|disabled_tool| disabled_tool.kind == ToolSuggestDiscoverableType::Connector)
.map(|disabled_tool| disabled_tool.id.as_str())
.map(|disabled_tool| AppConnectorId(disabled_tool.id.clone()))
.collect::<Vec<_>>();
let mut selection = ToolSuggestConnectorSelection::default();
for connector_id in connector_ids {
selection.insert_connector_id(connector_id.0);
}
for connector_id in disabled_connector_ids {
selection.insert_disabled_connector_id(connector_id.0);
}
selection
.connector_ids
.retain(|connector_id| !selection.disabled_connector_ids.contains(connector_id));
selection
}
impl ToolSuggestConnectorSelection {
fn insert_connector_id(&mut self, connector_id: String) {
insert_connector_or_template_id(
connector_id,
&mut self.connector_ids,
&mut self.template_ids,
);
}
fn insert_disabled_connector_id(&mut self, connector_id: String) {
insert_connector_or_template_id(
connector_id,
&mut self.disabled_connector_ids,
&mut self.disabled_template_ids,
);
}
}
fn insert_connector_or_template_id(
connector_id: String,
connector_ids: &mut HashSet<String>,
template_ids: &mut HashSet<String>,
) {
let connector_id = connector_id.trim();
if connector_id.is_empty() {
return;
}
if is_template_app_id(connector_id) {
template_ids.insert(connector_id.to_string());
} else {
connector_ids.insert(connector_id.to_string());
}
}
fn is_template_app_id(connector_id: &str) -> bool {
connector_id.starts_with(TEMPLATE_APP_ID_PREFIX)
}
async fn workspace_template_directory_connectors(
config: &Config,
auth: Option<&CodexAuth>,
template_ids: &HashSet<String>,
disabled_template_ids: &HashSet<String>,
disabled_connector_ids: &HashSet<String>,
) -> Vec<AppInfo> {
if template_ids.is_empty() {
return Vec::new();
}
let active_template_ids = template_ids
.difference(disabled_template_ids)
.cloned()
.collect::<HashSet<_>>();
connector_ids.retain(|connector_id| !disabled_connector_ids.contains(connector_id.as_str()));
connector_ids
if active_template_ids.is_empty() {
return Vec::new();
}
let Some(auth) = auth.filter(|auth| auth.uses_codex_backend()) else {
return Vec::new();
};
let client = build_reqwest_client();
let base_url = config.chatgpt_base_url.clone();
let auth_headers = codex_model_provider::auth_provider_from_auth(auth).to_auth_headers();
match codex_connectors::list_workspace_template_connectors(&active_template_ids, move |path| {
let client = client.clone();
let base_url = base_url.clone();
let auth_headers = auth_headers.clone();
async move {
let url = chatgpt_backend_path_url(&base_url, &path)?;
let response = client
.get(&url)
.timeout(Duration::from_secs(30))
.headers(auth_headers)
.send()
.await?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
anyhow::bail!("connector directory request failed with status {status}: {body}");
}
Ok(serde_json::from_str(&body)?)
}
})
.await
{
Ok(connectors) => connectors
.into_iter()
.filter(|connector| !disabled_connector_ids.contains(&connector.id))
.collect(),
Err(err) => {
warn!("failed to load workspace connector directory for template resolution: {err:#}");
Vec::new()
}
}
}
fn chatgpt_backend_path_url(base_url: &str, path: &str) -> anyhow::Result<String> {
let mut url = Url::parse(base_url.trim_end_matches('/'))?;
let (path, query) = path
.trim_start_matches('/')
.split_once('?')
.map_or((path.trim_start_matches('/'), None), |(path, query)| {
(path, Some(query))
});
{
let mut segments = url
.path_segments_mut()
.map_err(|()| anyhow::anyhow!("invalid ChatGPT base URL path"))?;
segments.pop_if_empty();
for segment in path.split('/').filter(|segment| !segment.is_empty()) {
segments.push(segment);
}
}
url.set_query(query);
Ok(url.to_string())
}
async fn cached_directory_connectors_for_tool_suggest_with_auth(

View File

@@ -29,6 +29,12 @@ use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use tempfile::tempdir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
fn annotations(destructive_hint: Option<bool>, open_world_hint: Option<bool>) -> ToolAnnotations {
ToolAnnotations::from_raw(
@@ -1159,7 +1165,7 @@ fn app_tool_policy_matches_prefix_stripped_tool_name_for_tool_config() {
}
#[tokio::test]
async fn tool_suggest_connector_ids_include_configured_tool_suggest_discoverables() {
async fn tool_suggest_connector_selection_includes_configured_tool_suggest_discoverables() {
let codex_home = tempdir().expect("tempdir should succeed");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
@@ -1180,13 +1186,18 @@ discoverables = [
.expect("config should load");
assert_eq!(
tool_suggest_connector_ids(&config).await,
HashSet::from(["connector_2128aebfecb84f64a069897515042a44".to_string()])
tool_suggest_connector_selection(&config).await,
ToolSuggestConnectorSelection {
connector_ids: HashSet::from(
["connector_2128aebfecb84f64a069897515042a44".to_string()]
),
..ToolSuggestConnectorSelection::default()
}
);
}
#[tokio::test]
async fn tool_suggest_connector_ids_exclude_disabled_tool_suggestions() {
async fn tool_suggest_connector_selection_excludes_disabled_tool_suggestions() {
let codex_home = tempdir().expect("tempdir should succeed");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
@@ -1209,8 +1220,183 @@ disabled_tools = [
.expect("config should load");
assert_eq!(
tool_suggest_connector_ids(&config).await,
HashSet::from(["connector_gmail".to_string()])
tool_suggest_connector_selection(&config).await,
ToolSuggestConnectorSelection {
connector_ids: HashSet::from(["connector_gmail".to_string()]),
disabled_connector_ids: HashSet::from(["connector_calendar".to_string()]),
..ToolSuggestConnectorSelection::default()
}
);
}
#[tokio::test]
async fn tool_suggest_connector_selection_tracks_template_connectors_separately() {
let codex_home = tempdir().expect("tempdir should succeed");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
[tool_suggest]
discoverables = [
{ type = "connector", id = "templated_apps_Databricks" }
]
"#,
)
.expect("write config");
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("config should load");
assert_eq!(
tool_suggest_connector_selection(&config).await,
ToolSuggestConnectorSelection {
template_ids: HashSet::from(["templated_apps_Databricks".to_string()]),
..ToolSuggestConnectorSelection::default()
}
);
}
#[tokio::test]
async fn tool_suggest_resolves_template_connectors_before_returning_install_entries() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/connectors/directory/list_workspace"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"apps":[
{
"id":"asdk_app_databricks_workspace",
"name":"Databricks Workspace",
"description":"Query Databricks",
"template_id":"templated_apps_Databricks"
},
{
"id":"asdk_app_other",
"name":"Other",
"template_id":"templated_apps_Other"
}
]}"#,
))
.mount(&server)
.await;
let codex_home = tempdir().expect("tempdir should succeed");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
[features]
apps = true
[tool_suggest]
discoverables = [
{ type = "connector", id = "templated_apps_Databricks" }
]
"#,
)
.expect("write config");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("config should load");
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let discoverable_tools =
list_tool_suggest_discoverable_tools_with_auth(&config, Some(&auth), &[], &[])
.await
.expect("discoverable tools should load");
assert_eq!(
discoverable_tools,
vec![DiscoverableTool::from(AppInfo {
id: "asdk_app_databricks_workspace".to_string(),
name: "Databricks Workspace".to_string(),
description: Some("Query Databricks".to_string()),
install_url: Some(connector_install_url(
"Databricks Workspace",
"asdk_app_databricks_workspace",
)),
..app("asdk_app_databricks_workspace")
})]
);
}
#[tokio::test]
async fn tool_suggest_returns_all_resolved_connectors_for_template() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/connectors/directory/list_workspace"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"apps":[
{
"id":"asdk_app_databricks_a",
"name":"Databricks A",
"template_id":"templated_apps_Databricks"
},
{
"id":"asdk_app_databricks_b",
"name":"Databricks B",
"template_id":"templated_apps_Databricks"
}
]}"#,
))
.mount(&server)
.await;
let codex_home = tempdir().expect("tempdir should succeed");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
[features]
apps = true
[tool_suggest]
discoverables = [
{ type = "connector", id = "templated_apps_Databricks" }
]
"#,
)
.expect("write config");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("config should load");
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let discoverable_tools =
list_tool_suggest_discoverable_tools_with_auth(&config, Some(&auth), &[], &[])
.await
.expect("discoverable tools should load");
assert_eq!(
discoverable_tools,
vec![
DiscoverableTool::from(AppInfo {
id: "asdk_app_databricks_a".to_string(),
name: "Databricks A".to_string(),
install_url: Some(connector_install_url(
"Databricks A",
"asdk_app_databricks_a",
)),
..app("asdk_app_databricks_a")
}),
DiscoverableTool::from(AppInfo {
id: "asdk_app_databricks_b".to_string(),
name: "Databricks B".to_string(),
install_url: Some(connector_install_url(
"Databricks B",
"asdk_app_databricks_b",
)),
..app("asdk_app_databricks_b")
}),
]
);
}
@@ -1238,7 +1424,7 @@ discoverables = [
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let discoverable_tools =
list_tool_suggest_discoverable_tools_with_auth(&config, Some(&auth), &[])
list_tool_suggest_discoverable_tools_with_auth(&config, Some(&auth), &[], &[])
.await
.expect("discoverable tools should load");

View File

@@ -8,8 +8,12 @@ use codex_config::types::ToolSuggestDiscoverableType;
use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME;
use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
use codex_core_plugins::PluginsManager;
use codex_core_plugins::TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST;
use codex_core_plugins::TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST as TOOL_SUGGEST_DISCOVERABLE_PLUGIN_FALLBACK_ALLOWLIST;
use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;
use codex_core_plugins::remote::RemotePluginServiceConfig;
use codex_features::Feature;
use codex_login::CodexAuth;
use codex_plugin::AppConnectorId;
use codex_tools::DiscoverablePluginInfo;
const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[
@@ -17,8 +21,26 @@ const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[
OPENAI_CURATED_MARKETPLACE_NAME,
];
#[cfg(test)]
pub(crate) async fn list_tool_suggest_discoverable_plugins(
config: &Config,
auth: Option<&CodexAuth>,
loaded_plugin_app_connector_ids: &[String],
) -> anyhow::Result<Vec<DiscoverablePluginInfo>> {
list_tool_suggest_discoverable_plugins_with_connector_candidates(
config,
auth,
loaded_plugin_app_connector_ids,
&[],
)
.await
}
pub(crate) async fn list_tool_suggest_discoverable_plugins_with_connector_candidates(
config: &Config,
auth: Option<&CodexAuth>,
loaded_plugin_app_connector_ids: &[String],
candidate_app_connector_ids: &[String],
) -> anyhow::Result<Vec<DiscoverablePluginInfo>> {
if !config.features.enabled(Feature::Plugins) {
return Ok(Vec::new());
@@ -44,18 +66,53 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins(
.list_marketplaces_for_config(&plugins_input, &[])
.context("failed to list plugin marketplaces for tool suggestions")?
.marketplaces;
let installed_app_connector_ids = plugins_manager
.plugins_for_config(&plugins_input)
.await
.capability_summaries()
.iter()
.flat_map(|plugin| plugin.app_connector_ids.iter())
.cloned()
.chain(
loaded_plugin_app_connector_ids
.iter()
.cloned()
.map(AppConnectorId),
)
.chain(
candidate_app_connector_ids
.iter()
.cloned()
.map(AppConnectorId),
)
.collect::<Vec<_>>();
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
let installed_app_connector_ids = codex_core_plugins::remote::resolve_remote_plugin_app_ids(
&remote_plugin_service_config,
auth,
&installed_app_connector_ids,
)
.await
.into_iter()
.map(|connector_id| connector_id.0)
.collect::<HashSet<_>>();
let mut discoverable_plugins = Vec::<DiscoverablePluginInfo>::new();
for marketplace in marketplaces {
let marketplace_name = marketplace.name;
if !TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST.contains(&marketplace_name.as_str()) {
continue;
}
let is_allowlisted_marketplace =
TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST.contains(&marketplace_name.as_str());
for plugin in marketplace.plugins {
let is_configured_plugin = configured_plugin_ids.contains(plugin.id.as_str());
let is_fallback_plugin =
TOOL_SUGGEST_DISCOVERABLE_PLUGIN_FALLBACK_ALLOWLIST.contains(&plugin.id.as_str());
if plugin.installed
|| plugin.policy.installation == MarketplacePluginInstallPolicy::NotAvailable
|| disabled_plugin_ids.contains(plugin.id.as_str())
|| (!TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.id.as_str())
&& !configured_plugin_ids.contains(plugin.id.as_str()))
|| (!is_allowlisted_marketplace && !is_configured_plugin)
{
continue;
}
@@ -72,14 +129,27 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins(
{
Ok(plugin) => {
let plugin: PluginCapabilitySummary = plugin.into();
let app_connector_ids =
codex_core_plugins::remote::resolve_remote_plugin_app_ids(
&remote_plugin_service_config,
auth,
&plugin.app_connector_ids,
)
.await;
let matches_installed_app = app_connector_ids.iter().any(|connector_id| {
installed_app_connector_ids.contains(connector_id.0.as_str())
});
if !is_configured_plugin && !is_fallback_plugin && !matches_installed_app {
continue;
}
discoverable_plugins.push(DiscoverablePluginInfo {
id: plugin.config_name,
name: plugin.display_name,
description: plugin.description,
has_skills: plugin.has_skills,
mcp_server_names: plugin.mcp_server_names,
app_connector_ids: plugin
.app_connector_ids
app_connector_ids: app_connector_ids
.into_iter()
.map(|connector_id| connector_id.0)
.collect(),

View File

@@ -5,57 +5,203 @@ use crate::plugins::test_support::write_curated_plugin_sha;
use crate::plugins::test_support::write_file;
use crate::plugins::test_support::write_openai_curated_marketplace;
use crate::plugins::test_support::write_plugins_feature_config;
use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME;
use codex_core_plugins::PluginInstallRequest;
use codex_core_plugins::startup_sync::curated_plugins_repo_path;
use codex_login::CodexAuth;
use codex_tools::DiscoverablePluginInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::path::Path;
use tempfile::tempdir;
use tracing::Level;
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_test::internal::MockWriter;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_returns_uninstalled_curated_plugins() {
async fn list_tool_suggest_discoverable_plugins_returns_fallback_plugins_without_installed_apps() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["sample", "slack", "openai-developers"]);
write_plugins_feature_config(codex_home.path());
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
.await
.unwrap();
assert_eq!(
discoverable_plugins,
discoverable_plugins
.into_iter()
.map(|plugin| plugin.id)
.collect::<Vec<_>>(),
vec![
DiscoverablePluginInfo {
id: "openai-developers@openai-curated".to_string(),
name: "openai-developers".to_string(),
description: Some(
"Plugin that includes skills, MCP servers, and app connectors".to_string(),
),
has_skills: true,
mcp_server_names: vec!["sample-docs".to_string()],
app_connector_ids: vec!["connector_calendar".to_string()],
},
DiscoverablePluginInfo {
id: "slack@openai-curated".to_string(),
name: "slack".to_string(),
description: Some(
"Plugin that includes skills, MCP servers, and app connectors".to_string(),
),
has_skills: true,
mcp_server_names: vec!["sample-docs".to_string()],
app_connector_ids: vec!["connector_calendar".to_string()],
},
"openai-developers@openai-curated".to_string(),
"slack@openai-curated".to_string(),
]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_returns_microsoft_curated_plugins() {
async fn list_tool_suggest_discoverable_plugins_filters_non_fallback_by_installed_apps() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["sample", "slack", "hubspot"]);
write_plugin_app(&curated_root, "sample", "sample", "connector_sample");
write_plugins_feature_config(codex_home.path());
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "slack").await;
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
.await
.unwrap();
assert_eq!(
discoverable_plugins
.into_iter()
.map(|plugin| plugin.id)
.collect::<Vec<_>>(),
vec!["hubspot@openai-curated".to_string()]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_filters_by_loaded_plugin_apps() {
let hubspot_app_id = "asdk_app_697acb8e53d88191bf7a79e62012ae14";
let granola_app_id = "asdk_app_697761cab6f48191b5ed345919a3ce8b";
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["hubspot", "granola"]);
write_plugin_app(&curated_root, "hubspot", "hubspot", hubspot_app_id);
write_plugin_app(&curated_root, "granola", "granola", granola_app_id);
write_plugins_feature_config(codex_home.path());
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins =
list_tool_suggest_discoverable_plugins(&config, None, &[hubspot_app_id.to_string()])
.await
.unwrap();
assert_eq!(
discoverable_plugins
.into_iter()
.map(|plugin| plugin.id)
.collect::<Vec<_>>(),
vec!["hubspot@openai-curated".to_string()]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_matches_on_resolved_template_apps() {
let databricks_app_id = "asdk_app_databricks_workspace";
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/connectors/directory/list_workspace"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(format!(
r#"{{"apps":[{{"id":"{databricks_app_id}","template_id":"templated_apps_Databricks"}}]}}"#
)))
.mount(&server)
.await;
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["databricks-source"]);
write_plugin_app(
&curated_root,
"databricks-source",
"databricks",
"templated_apps_Databricks",
);
write_plugins_feature_config(codex_home.path());
let mut config = load_plugins_config(codex_home.path()).await;
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let discoverable_plugins = list_tool_suggest_discoverable_plugins(
&config,
Some(&auth),
&[databricks_app_id.to_string()],
)
.await
.unwrap();
assert_eq!(
discoverable_plugins,
vec![DiscoverablePluginInfo {
id: "databricks-source@openai-curated".to_string(),
name: "databricks-source".to_string(),
description: Some(
"Plugin that includes skills, MCP servers, and app connectors".to_string(),
),
has_skills: true,
mcp_server_names: vec!["sample-docs".to_string()],
app_connector_ids: vec![databricks_app_id.to_string()],
}]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_matches_on_resolved_connector_candidate_apps() {
let databricks_app_id = "asdk_app_databricks_workspace";
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/connectors/directory/list_workspace"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(format!(
r#"{{"apps":[{{"id":"{databricks_app_id}","template_id":"templated_apps_Databricks"}}]}}"#
)))
.mount(&server)
.await;
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["databricks-source"]);
write_plugin_app(
&curated_root,
"databricks-source",
"databricks",
"templated_apps_Databricks",
);
write_plugins_feature_config(codex_home.path());
let mut config = load_plugins_config(codex_home.path()).await;
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let discoverable_plugins = list_tool_suggest_discoverable_plugins_with_connector_candidates(
&config,
Some(&auth),
&[],
&[databricks_app_id.to_string()],
)
.await
.unwrap();
assert_eq!(
discoverable_plugins,
vec![DiscoverablePluginInfo {
id: "databricks-source@openai-curated".to_string(),
name: "databricks-source".to_string(),
description: Some(
"Plugin that includes skills, MCP servers, and app connectors".to_string(),
),
has_skills: true,
mcp_server_names: vec!["sample-docs".to_string()],
app_connector_ids: vec![databricks_app_id.to_string()],
}]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_filters_microsoft_by_installed_apps() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(
@@ -63,9 +209,10 @@ async fn list_tool_suggest_discoverable_plugins_returns_microsoft_curated_plugin
&["teams", "sharepoint", "outlook-email", "outlook-calendar"],
);
write_plugins_feature_config(codex_home.path());
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "teams").await;
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
.await
.unwrap();
@@ -78,28 +225,92 @@ async fn list_tool_suggest_discoverable_plugins_returns_microsoft_curated_plugin
"outlook-calendar@openai-curated".to_string(),
"outlook-email@openai-curated".to_string(),
"sharepoint@openai-curated".to_string(),
"teams@openai-curated".to_string(),
]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_deduplicates_allowlisted_configured_plugin() {
async fn list_tool_suggest_discoverable_plugins_filters_sales_apps_by_marketplace() {
let hubspot_app_id = "asdk_app_697acb8e53d88191bf7a79e62012ae14";
let granola_app_id = "asdk_app_697761cab6f48191b5ed345919a3ce8b";
let test_app_id = "asdk_app_test_source";
let codex_home = tempdir().expect("tempdir should succeed");
let plugin_id = TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST
.iter()
.copied()
.find(|plugin_id| {
plugin_id
.rsplit_once('@')
.is_some_and(|(_plugin_name, marketplace_name)| {
marketplace_name == OPENAI_BUNDLED_MARKETPLACE_NAME
})
})
.expect("allowlist should include a bundled plugin");
let (plugin_name, marketplace_name) = plugin_id
.rsplit_once('@')
.expect("plugin id should include a marketplace");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["hubspot", "granola", "test-source"]);
write_plugin_app(&curated_root, "hubspot", "hubspot", hubspot_app_id);
write_plugin_app(&curated_root, "granola", "granola", granola_app_id);
write_plugin_app(&curated_root, "test-source", "test_source", test_app_id);
let sales_marketplace_name = "oai-maintained-plugins";
let sales_marketplace_root = codex_home
.path()
.join(format!(".tmp/marketplaces/{sales_marketplace_name}"));
write_file(
&sales_marketplace_root.join(".agents/plugins/marketplace.json"),
&format!(
r#"{{
"name": "{sales_marketplace_name}",
"plugins": [
{{"name": "sales", "source": {{"source": "local", "path": "./plugins/sales"}}}}
]
}}
"#
),
);
write_curated_plugin(&sales_marketplace_root, "sales");
write_file(
&sales_marketplace_root.join("plugins/sales/.app.json"),
&format!(
r#"{{
"apps": {{
"hubspot": {{
"id": "{hubspot_app_id}"
}},
"granola": {{
"id": "{granola_app_id}"
}}
}}
}}
"#
),
);
write_file(
&codex_home.path().join(crate::config::CONFIG_TOML_FILE),
&format!(
r#"[features]
plugins = true
[marketplaces.{sales_marketplace_name}]
source_type = "git"
source = "/tmp/{sales_marketplace_name}"
"#
),
);
install_marketplace_plugin(codex_home.path(), sales_marketplace_root.as_path(), "sales").await;
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
.await
.unwrap();
assert_eq!(
discoverable_plugins
.into_iter()
.map(|plugin| plugin.id)
.collect::<Vec<_>>(),
vec![
"granola@openai-curated".to_string(),
"hubspot@openai-curated".to_string(),
]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_deduplicates_configured_marketplace_plugin() {
let codex_home = tempdir().expect("tempdir should succeed");
let plugin_name = "sample";
let marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME;
let plugin_id = format!("{plugin_name}@{marketplace_name}");
let marketplace_root = codex_home
.path()
.join(format!(".tmp/marketplaces/{marketplace_name}"));
@@ -133,28 +344,20 @@ discoverables = [{{ type = "plugin", id = "{plugin_id}" }}]
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
.await
.unwrap();
assert_eq!(discoverable_plugins.len(), 1);
assert_eq!(discoverable_plugins[0].id, plugin_id);
assert_eq!(discoverable_plugins[0].id, plugin_id.as_str());
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_ignores_missing_allowlisted_plugin() {
async fn list_tool_suggest_discoverable_plugins_ignores_missing_marketplace_plugin() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
let marketplace_name = TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST
.iter()
.copied()
.filter_map(|plugin_id| plugin_id.rsplit_once('@'))
.find(|(_plugin_name, marketplace_name)| {
*marketplace_name == OPENAI_BUNDLED_MARKETPLACE_NAME
})
.map(|(_plugin_name, marketplace_name)| marketplace_name)
.expect("allowlist should include a bundled plugin");
write_openai_curated_marketplace(&curated_root, &["installed", "slack"]);
let marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME;
let marketplace_root = codex_home
.path()
.join(format!(".tmp/marketplaces/{marketplace_name}"));
@@ -182,9 +385,10 @@ source = "/tmp/{marketplace_name}"
"#
),
);
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await;
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
.await
.unwrap();
@@ -205,7 +409,7 @@ plugins = false
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
.await
.unwrap();
@@ -216,7 +420,7 @@ plugins = false
async fn list_tool_suggest_discoverable_plugins_normalizes_description() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
write_openai_curated_marketplace(&curated_root, &["installed", "slack"]);
write_plugins_feature_config(codex_home.path());
write_file(
&curated_root.join("plugins/slack/.codex-plugin/plugin.json"),
@@ -225,9 +429,10 @@ async fn list_tool_suggest_discoverable_plugins_normalizes_description() {
"description": " Plugin\n with extra spacing "
}"#,
);
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await;
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
.await
.unwrap();
@@ -264,7 +469,7 @@ async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins(
.expect("plugin should install");
let refreshed_config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config)
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config, None, &[])
.await
.unwrap();
@@ -289,13 +494,70 @@ disabled_tools = [
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
.await
.unwrap();
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::new());
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_omits_not_available_curated_plugins() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_file(
&curated_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "openai-curated",
"plugins": [
{
"name": "installed",
"source": {
"source": "local",
"path": "./plugins/installed"
}
},
{
"name": "slack",
"source": {
"source": "local",
"path": "./plugins/slack"
}
},
{
"name": "gmail",
"source": {
"source": "local",
"path": "./plugins/gmail"
},
"policy": {
"installation": "NOT_AVAILABLE"
}
}
]
}
"#,
);
write_curated_plugin(&curated_root, "installed");
write_curated_plugin(&curated_root, "slack");
write_curated_plugin(&curated_root, "gmail");
write_plugins_feature_config(codex_home.path());
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await;
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
.await
.unwrap();
assert_eq!(
discoverable_plugins
.into_iter()
.map(|plugin| plugin.id)
.collect::<Vec<_>>(),
vec!["slack@openai-curated".to_string()]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_includes_configured_plugin_ids() {
let codex_home = tempdir().expect("tempdir should succeed");
@@ -312,7 +574,7 @@ discoverables = [{ type = "plugin", id = "sample@openai-curated" }]
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
.await
.unwrap();
@@ -335,14 +597,12 @@ discoverables = [{ type = "plugin", id = "sample@openai-curated" }]
async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_plugin() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(
&curated_root,
&["slack", "build-ios-apps", "life-science-research"],
);
write_openai_curated_marketplace(&curated_root, &["slack", "gmail", "openai-developers"]);
write_plugins_feature_config(codex_home.path());
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "slack").await;
let too_long_prompt = "x".repeat(129);
for plugin_name in ["build-ios-apps", "life-science-research"] {
for plugin_name in ["gmail", "openai-developers"] {
write_file(
&curated_root.join(format!("plugins/{plugin_name}/.codex-plugin/plugin.json")),
&format!(
@@ -369,28 +629,63 @@ async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_
.finish();
let _guard = tracing::subscriber::set_default(subscriber);
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
.await
.unwrap();
assert_eq!(discoverable_plugins.len(), 1);
assert_eq!(discoverable_plugins[0].id, "slack@openai-curated");
assert_eq!(
discoverable_plugins
.iter()
.map(|plugin| plugin.id.as_str())
.collect::<Vec<_>>(),
vec!["gmail@openai-curated", "openai-developers@openai-curated"]
);
let logs = String::from_utf8(buffer.lock().expect("buffer lock").clone())
.expect("utf8 logs")
.replace('\\', "/");
assert_eq!(logs.matches("ignoring interface.defaultPrompt").count(), 2);
assert_eq!(logs.matches("ignoring interface.defaultPrompt").count(), 8);
let normalized_logs = logs.replace('\\', "/");
assert_eq!(
normalized_logs
.matches("build-ios-apps/.codex-plugin/plugin.json")
.matches("gmail/.codex-plugin/plugin.json")
.count(),
1
4
);
assert_eq!(
normalized_logs
.matches("life-science-research/.codex-plugin/plugin.json")
.matches("openai-developers/.codex-plugin/plugin.json")
.count(),
1
4
);
}
async fn install_marketplace_plugin(codex_home: &Path, marketplace_root: &Path, plugin_name: &str) {
write_curated_plugin_sha(codex_home);
PluginsManager::new(codex_home.to_path_buf())
.install_plugin(PluginInstallRequest {
plugin_name: plugin_name.to_string(),
marketplace_path: AbsolutePathBuf::try_from(
marketplace_root.join(".agents/plugins/marketplace.json"),
)
.expect("marketplace path"),
})
.await
.expect("plugin should install");
}
fn write_plugin_app(root: &Path, plugin_name: &str, app_name: &str, app_id: &str) {
write_file(
&root.join(format!("plugins/{plugin_name}/.app.json")),
&format!(
r#"{{
"apps": {{
"{app_name}": {{
"id": "{app_id}"
}}
}}
}}
"#
),
);
}

View File

@@ -7,7 +7,7 @@ pub(crate) mod test_support;
pub(crate) use codex_plugin::PluginCapabilitySummary;
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins_with_connector_candidates;
pub(crate) use injection::build_plugin_injections;
pub(crate) use render::render_explicit_plugin_instructions;

View File

@@ -13,10 +13,10 @@ use chrono::Utc;
use codex_git_utils::GitSha;
use codex_protocol::ThreadId;
use codex_protocol::models::ContentItem;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::GitInfo;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_thread_store::StoredThread;
use core_test_support::PathBufExt;
@@ -59,7 +59,7 @@ fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThre
repository_url: None,
}),
approval_mode: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
token_usage: None,
first_user_message: Some(first_user_message.to_string()),
history: None,

View File

@@ -630,6 +630,9 @@ async fn shutdown_session_runtime(sess: &Arc<Session>) {
.unified_exec_manager
.terminate_all_processes()
.await;
if let Err(err) = sess.services.code_mode_service.shutdown().await {
warn!("failed to shutdown code mode session: {err}");
}
let mcp_shutdown = {
let mut manager = sess.services.mcp_connection_manager.write().await;
manager.begin_shutdown()

View File

@@ -943,16 +943,12 @@ async fn run_sampling_request(
Arc::clone(&turn_context),
Arc::clone(&turn_diff_tracker),
);
let _code_mode_worker = sess
.services
.code_mode_service
.start_turn_worker(
&sess,
&turn_context,
Arc::clone(&router),
Arc::clone(&turn_diff_tracker),
)
.await;
let _code_mode_worker = sess.services.code_mode_service.start_turn_worker(
&sess,
&turn_context,
Arc::clone(&router),
Arc::clone(&turn_diff_tracker),
);
let max_retries = turn_context.provider.info().stream_max_retries();
let mut retries = 0;
let mut initial_input = Some(input);
@@ -1075,12 +1071,18 @@ pub(crate) async fn built_tools(
None
};
let auth = sess.services.auth_manager.auth().await;
let loaded_plugin_app_connector_ids = loaded_plugins
.effective_apps()
.into_iter()
.map(|connector_id| connector_id.0)
.collect::<Vec<_>>();
let discoverable_tools = if apps_enabled && tool_suggest_enabled(turn_context) {
if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() {
match connectors::list_tool_suggest_discoverable_tools_with_auth(
&turn_context.config,
auth.as_ref(),
accessible_connectors.as_slice(),
&loaded_plugin_app_connector_ids,
)
.await
.map(|discoverable_tools| {

View File

@@ -0,0 +1,311 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex;
use codex_code_mode::CellId;
use codex_code_mode::CodeModeNestedToolCall;
use codex_code_mode::CodeModeSessionDelegate;
use codex_code_mode::NotificationFuture;
use codex_code_mode::ToolInvocationFuture;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseItem;
use serde_json::Value as JsonValue;
use tokio::sync::oneshot;
use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
use super::ExecContext;
use super::PUBLIC_TOOL_NAME;
use super::call_nested_tool;
use crate::tools::ToolRouter;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::parallel::ToolCallRuntime;
pub(super) struct CodeModeDispatchBroker {
dispatch_tx: async_channel::Sender<DispatchMessage>,
dispatch_rx: async_channel::Receiver<DispatchMessage>,
dispatch_gates: Arc<Mutex<HashMap<CellId, watch::Sender<bool>>>>,
}
impl CodeModeDispatchBroker {
pub(super) fn new() -> Self {
let (dispatch_tx, dispatch_rx) = async_channel::unbounded();
Self {
dispatch_tx,
dispatch_rx,
dispatch_gates: Arc::new(Mutex::new(HashMap::new())),
}
}
pub(super) fn mark_cell_ready_for_dispatch(&self, cell_id: &CellId) {
dispatch_gate(&self.dispatch_gates, cell_id).send_replace(true);
}
pub(super) fn close_cell(&self, cell_id: &CellId) {
remove_dispatch_gate(&self.dispatch_gates, cell_id);
}
pub(super) fn start_turn_worker(
&self,
exec: ExecContext,
router: Arc<ToolRouter>,
tracker: SharedTurnDiffTracker,
) -> CodeModeDispatchWorker {
let tool_runtime = ToolCallRuntime::new(
router,
Arc::clone(&exec.session),
Arc::clone(&exec.turn),
tracker,
);
let host = Arc::new(CoreTurnHost { exec, tool_runtime });
let dispatch_rx = self.dispatch_rx.clone();
let dispatch_gates = Arc::clone(&self.dispatch_gates);
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
tokio::spawn(async move {
loop {
let message = tokio::select! {
_ = &mut shutdown_rx => break,
message = dispatch_rx.recv() => message.ok(),
};
let Some(message) = message else {
break;
};
match message {
DispatchMessage::Notify {
call_id,
cell_id,
text,
cancellation_token,
response_tx,
} => {
let response = if wait_until_cell_ready_for_dispatch(
&dispatch_gates,
&cell_id,
&cancellation_token,
)
.await
{
host.notify(call_id, cell_id, text).await
} else {
remove_dispatch_gate(&dispatch_gates, &cell_id);
Err("code mode notification cancelled".to_string())
};
let _ = response_tx.send(response);
}
DispatchMessage::InvokeTool {
invocation,
cancellation_token,
response_tx,
} => {
let cell_id = invocation.cell_id.clone();
if !wait_until_cell_ready_for_dispatch(
&dispatch_gates,
&cell_id,
&cancellation_token,
)
.await
{
remove_dispatch_gate(&dispatch_gates, &cell_id);
continue;
}
let host = Arc::clone(&host);
tokio::spawn(async move {
let response = tokio::select! {
response = host.invoke_tool(
invocation,
cancellation_token.clone(),
) => response,
_ = cancellation_token.cancelled() => return,
};
let _ = response_tx.send(response);
});
}
}
}
});
CodeModeDispatchWorker {
shutdown_tx: Some(shutdown_tx),
}
}
}
fn dispatch_gate(
dispatch_gates: &Mutex<HashMap<CellId, watch::Sender<bool>>>,
cell_id: &CellId,
) -> watch::Sender<bool> {
let mut dispatch_gates = match dispatch_gates.lock() {
Ok(dispatch_gates) => dispatch_gates,
Err(poisoned) => poisoned.into_inner(),
};
dispatch_gates
.entry(cell_id.clone())
.or_insert_with(|| watch::channel(false).0)
.clone()
}
fn remove_dispatch_gate(
dispatch_gates: &Mutex<HashMap<CellId, watch::Sender<bool>>>,
cell_id: &CellId,
) {
let mut dispatch_gates = match dispatch_gates.lock() {
Ok(dispatch_gates) => dispatch_gates,
Err(poisoned) => poisoned.into_inner(),
};
dispatch_gates.remove(cell_id);
}
async fn wait_until_cell_ready_for_dispatch(
dispatch_gates: &Mutex<HashMap<CellId, watch::Sender<bool>>>,
cell_id: &CellId,
cancellation_token: &CancellationToken,
) -> bool {
if cancellation_token.is_cancelled() {
return false;
}
let mut ready_rx = dispatch_gate(dispatch_gates, cell_id).subscribe();
loop {
if *ready_rx.borrow_and_update() {
return true;
}
tokio::select! {
changed = ready_rx.changed() => {
if changed.is_err() {
return false;
}
}
_ = cancellation_token.cancelled() => return false,
}
}
}
impl CodeModeSessionDelegate for CodeModeDispatchBroker {
fn invoke_tool<'a>(
&'a self,
invocation: CodeModeNestedToolCall,
cancellation_token: CancellationToken,
) -> ToolInvocationFuture<'a> {
Box::pin(async move {
if cancellation_token.is_cancelled() {
return Err("code mode nested tool call cancelled".to_string());
}
let (response_tx, response_rx) = oneshot::channel();
self.dispatch_tx
.send(DispatchMessage::InvokeTool {
invocation,
cancellation_token: cancellation_token.clone(),
response_tx,
})
.await
.map_err(|_| "code mode nested tool dispatcher is unavailable".to_string())?;
tokio::select! {
response = response_rx => response
.map_err(|_| "code mode nested tool dispatcher stopped".to_string())?,
_ = cancellation_token.cancelled() => {
Err("code mode nested tool call cancelled".to_string())
}
}
})
}
fn notify<'a>(
&'a self,
call_id: String,
cell_id: CellId,
text: String,
cancellation_token: CancellationToken,
) -> NotificationFuture<'a> {
Box::pin(async move {
if cancellation_token.is_cancelled() {
return Err("code mode notification cancelled".to_string());
}
let (response_tx, response_rx) = oneshot::channel();
self.dispatch_tx
.send(DispatchMessage::Notify {
call_id,
cell_id,
text,
cancellation_token: cancellation_token.clone(),
response_tx,
})
.await
.map_err(|_| "code mode notification dispatcher is unavailable".to_string())?;
tokio::select! {
response = response_rx => response
.map_err(|_| "code mode notification dispatcher stopped".to_string())?,
_ = cancellation_token.cancelled() => {
Err("code mode notification cancelled".to_string())
}
}
})
}
fn cell_closed(&self, cell_id: &CellId) {
self.close_cell(cell_id);
}
}
enum DispatchMessage {
InvokeTool {
invocation: CodeModeNestedToolCall,
cancellation_token: CancellationToken,
response_tx: oneshot::Sender<Result<JsonValue, String>>,
},
Notify {
call_id: String,
cell_id: CellId,
text: String,
cancellation_token: CancellationToken,
response_tx: oneshot::Sender<Result<(), String>>,
},
}
pub(crate) struct CodeModeDispatchWorker {
shutdown_tx: Option<oneshot::Sender<()>>,
}
impl Drop for CodeModeDispatchWorker {
fn drop(&mut self) {
if let Some(shutdown_tx) = self.shutdown_tx.take() {
let _ = shutdown_tx.send(());
}
}
}
struct CoreTurnHost {
exec: ExecContext,
tool_runtime: ToolCallRuntime,
}
impl CoreTurnHost {
async fn invoke_tool(
&self,
invocation: CodeModeNestedToolCall,
cancellation_token: CancellationToken,
) -> Result<JsonValue, String> {
call_nested_tool(
self.exec.clone(),
self.tool_runtime.clone(),
invocation,
cancellation_token,
)
.await
.map_err(|error| error.to_string())
}
async fn notify(&self, call_id: String, cell_id: CellId, text: String) -> Result<(), String> {
if text.trim().is_empty() {
return Ok(());
}
self.exec
.session
.inject_if_running(vec![ResponseItem::CustomToolCallOutput {
call_id,
name: Some(PUBLIC_TOOL_NAME.to_string()),
output: FunctionCallOutputPayload::from_text(text),
}])
.await
.map_err(|_| {
format!("failed to inject exec notify message for cell {cell_id}: no active turn")
})
}
}

View File

@@ -38,9 +38,22 @@ impl CodeModeExecuteHandler {
let exec = ExecContext { session, turn };
let enabled_tools =
codex_tools::collect_code_mode_tool_definitions(&self.nested_tool_specs);
// Allocate before starting V8 so the trace can create the parent
// CodeCell before model-authored JavaScript issues nested tool calls.
let runtime_cell_id = exec.session.services.code_mode_service.allocate_cell_id();
let started_at = std::time::Instant::now();
let started_cell = exec
.session
.services
.code_mode_service
.execute(codex_code_mode::ExecuteRequest {
tool_call_id: call_id.clone(),
enabled_tools,
source: args.code.clone(),
yield_time_ms: args.yield_time_ms,
max_output_tokens: args.max_output_tokens,
})
.await
.map_err(FunctionCallError::RespondToModel)?;
let cell_id = started_cell.cell_id.clone();
let runtime_cell_id = cell_id.to_string();
let code_cell_trace = exec
.session
.services
@@ -51,19 +64,12 @@ impl CodeModeExecuteHandler {
call_id.as_str(),
args.code.as_str(),
);
let started_at = std::time::Instant::now();
let response = exec
.session
exec.session
.services
.code_mode_service
.execute(codex_code_mode::ExecuteRequest {
cell_id: runtime_cell_id,
tool_call_id: call_id,
enabled_tools,
source: args.code,
yield_time_ms: args.yield_time_ms,
max_output_tokens: args.max_output_tokens,
})
.mark_cell_ready_for_dispatch(&cell_id);
let response = started_cell
.initial_response()
.await
.map_err(FunctionCallError::RespondToModel)?;
// Record the raw runtime boundary. The model-visible custom-tool output
@@ -74,6 +80,10 @@ impl CodeModeExecuteHandler {
// here when the first response also ended the runtime.
if !matches!(response, codex_code_mode::RuntimeResponse::Yielded { .. }) {
code_cell_trace.record_ended(&response);
exec.session
.services
.code_mode_service
.finish_cell_dispatch(&cell_id);
}
handle_runtime_response(&exec, response, args.max_output_tokens, started_at)
.await

View File

@@ -1,3 +1,4 @@
mod delegate;
mod execute_handler;
pub(crate) mod execute_spec;
mod response_adapter;
@@ -7,13 +8,12 @@ pub(crate) mod wait_spec;
use std::sync::Arc;
use std::time::Duration;
use codex_code_mode::CellId;
use codex_code_mode::CodeModeNestedToolCall;
use codex_code_mode::CodeModeSession;
use codex_code_mode::CodeModeToolKind;
use codex_code_mode::CodeModeTurnHost;
use codex_code_mode::RuntimeResponse;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseItem;
use serde_json::Value as JsonValue;
use tokio_util::sync::CancellationToken;
@@ -36,6 +36,8 @@ use codex_utils_output_truncation::TruncationPolicy;
use codex_utils_output_truncation::formatted_truncate_text_content_items_with_policy;
use codex_utils_output_truncation::truncate_function_output_items_with_policy;
use delegate::CodeModeDispatchBroker;
use delegate::CodeModeDispatchWorker;
pub(crate) use execute_handler::CodeModeExecuteHandler;
use response_adapter::into_function_call_output_content_items;
pub(crate) use wait_handler::CodeModeWaitHandler;
@@ -56,42 +58,67 @@ pub(crate) struct ExecContext {
}
pub(crate) struct CodeModeService {
inner: codex_code_mode::CodeModeService,
session: Option<Arc<dyn CodeModeSession>>,
dispatch_broker: Arc<CodeModeDispatchBroker>,
}
impl CodeModeService {
pub(crate) fn new() -> Self {
let dispatch_broker = Arc::new(CodeModeDispatchBroker::new());
Self {
inner: codex_code_mode::CodeModeService::new(),
session: Some(Arc::new(codex_code_mode::CodeModeService::with_delegate(
dispatch_broker.clone(),
))),
dispatch_broker,
}
}
pub(crate) fn allocate_cell_id(&self) -> String {
self.inner.allocate_cell_id()
}
pub(crate) async fn execute(
&self,
request: codex_code_mode::ExecuteRequest,
) -> Result<RuntimeResponse, String> {
self.inner.execute(request).await
) -> Result<codex_code_mode::StartedCell, String> {
self.session()?.execute(request).await
}
pub(crate) async fn wait(
&self,
request: codex_code_mode::WaitRequest,
) -> Result<codex_code_mode::WaitOutcome, String> {
self.inner.wait(request).await
self.session()?.wait(request).await
}
pub(crate) async fn start_turn_worker(
pub(crate) async fn terminate(
&self,
cell_id: CellId,
) -> Result<codex_code_mode::WaitOutcome, String> {
self.session()?.terminate(cell_id).await
}
pub(crate) async fn shutdown(&self) -> Result<(), String> {
match &self.session {
Some(session) => session.shutdown().await,
None => Ok(()),
}
}
pub(crate) fn mark_cell_ready_for_dispatch(&self, cell_id: &codex_code_mode::CellId) {
self.dispatch_broker.mark_cell_ready_for_dispatch(cell_id);
}
pub(crate) fn finish_cell_dispatch(&self, cell_id: &CellId) {
self.dispatch_broker.close_cell(cell_id);
}
pub(crate) fn start_turn_worker(
&self,
session: &Arc<Session>,
turn: &Arc<TurnContext>,
router: Arc<ToolRouter>,
tracker: SharedTurnDiffTracker,
) -> Option<codex_code_mode::CodeModeTurnWorker> {
if !matches!(turn.tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly) {
) -> Option<CodeModeDispatchWorker> {
if !matches!(turn.tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly)
|| self.session.is_none()
{
return None;
}
@@ -99,50 +126,16 @@ impl CodeModeService {
session: Arc::clone(session),
turn: Arc::clone(turn),
};
let tool_runtime =
ToolCallRuntime::new(router, Arc::clone(session), Arc::clone(turn), tracker);
let host = Arc::new(CoreTurnHost { exec, tool_runtime });
Some(self.inner.start_turn_worker(host))
}
}
struct CoreTurnHost {
exec: ExecContext,
tool_runtime: ToolCallRuntime,
}
#[async_trait::async_trait]
impl CodeModeTurnHost for CoreTurnHost {
async fn invoke_tool(
&self,
invocation: CodeModeNestedToolCall,
cancellation_token: CancellationToken,
) -> Result<JsonValue, String> {
call_nested_tool(
self.exec.clone(),
self.tool_runtime.clone(),
invocation,
cancellation_token,
Some(
self.dispatch_broker
.start_turn_worker(exec, router, tracker),
)
.await
.map_err(|error| error.to_string())
}
async fn notify(&self, call_id: String, cell_id: String, text: String) -> Result<(), String> {
if text.trim().is_empty() {
return Ok(());
}
self.exec
.session
.inject_if_running(vec![ResponseItem::CustomToolCallOutput {
call_id,
name: Some(PUBLIC_TOOL_NAME.to_string()),
output: FunctionCallOutputPayload::from_text(text),
}])
.await
.map_err(|_| {
format!("failed to inject exec notify message for cell {cell_id}: no active turn")
})
fn session(&self) -> Result<&Arc<dyn CodeModeSession>, String> {
self.session
.as_ref()
.ok_or_else(|| "code mode is unavailable".to_string())
}
}
@@ -273,7 +266,7 @@ async fn call_nested_tool(
.handle_tool_call_with_source(
call,
ToolCallSource::CodeMode {
cell_id,
cell_id: cell_id.to_string(),
runtime_tool_call_id,
},
cancellation_token,

View File

@@ -73,17 +73,24 @@ impl ToolExecutor<ToolInvocation> for CodeModeWaitHandler {
let args: ExecWaitArgs = parse_arguments(&arguments)?;
let exec = ExecContext { session, turn };
let started_at = std::time::Instant::now();
let wait_response = exec
.session
.services
.code_mode_service
.wait(codex_code_mode::WaitRequest {
cell_id: args.cell_id,
yield_time_ms: args.yield_time_ms,
terminate: args.terminate,
})
.await
.map_err(FunctionCallError::RespondToModel)?;
let cell_id = codex_code_mode::CellId::new(args.cell_id);
let wait_response = if args.terminate {
exec.session
.services
.code_mode_service
.terminate(cell_id)
.await
} else {
exec.session
.services
.code_mode_service
.wait(codex_code_mode::WaitRequest {
cell_id,
yield_time_ms: args.yield_time_ms,
})
.await
}
.map_err(FunctionCallError::RespondToModel)?;
if let codex_code_mode::WaitOutcome::LiveCell(response) = &wait_response
&& !matches!(response, codex_code_mode::RuntimeResponse::Yielded { .. })
{
@@ -98,8 +105,15 @@ impl ToolExecutor<ToolInvocation> for CodeModeWaitHandler {
exec.session
.services
.rollout_thread_trace
.code_cell_trace_context(exec.turn.sub_id.as_str(), runtime_cell_id)
.code_cell_trace_context(
exec.turn.sub_id.as_str(),
runtime_cell_id.as_str(),
)
.record_ended(response);
exec.session
.services
.code_mode_service
.finish_cell_dispatch(runtime_cell_id);
}
handle_runtime_response(&exec, wait_response.into(), args.max_tokens, started_at)
.await

View File

@@ -147,6 +147,7 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall {
turn_id: invocation.turn.sub_id.clone(),
call_id: invocation.call_id.clone(),
tool_name: invocation.tool_name.clone(),
model: invocation.turn.model_info.slug.clone(),
truncation_policy: invocation.turn.truncation_policy,
conversation_history,
turn_item_emitter: Arc::new(CoreTurnItemEmitter {
@@ -307,6 +308,7 @@ mod tests {
let weak_session = Arc::downgrade(&session);
let weak_turn = Arc::downgrade(&turn);
let turn_id = turn.sub_id.clone();
let model = turn.model_info.slug.clone();
let truncation_policy = turn.truncation_policy;
let history_item = ResponseItem::Message {
id: None,
@@ -350,6 +352,7 @@ mod tests {
captured_call.tool_name,
codex_tools::ToolName::plain("extension_echo")
);
assert_eq!(captured_call.model, model);
assert_eq!(captured_call.truncation_policy, truncation_policy);
assert_eq!(
captured_call.conversation_history.items(),

View File

@@ -37,7 +37,15 @@ use crate::tools::handlers::request_plugin_install_spec::create_request_plugin_i
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
pub struct RequestPluginInstallHandler;
pub struct RequestPluginInstallHandler {
discoverable_tools: Vec<DiscoverableTool>,
}
impl RequestPluginInstallHandler {
pub(crate) fn new(discoverable_tools: Vec<DiscoverableTool>) -> Self {
Self { discoverable_tools }
}
}
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for RequestPluginInstallHandler {
@@ -53,10 +61,6 @@ impl ToolExecutor<ToolInvocation> for RequestPluginInstallHandler {
true
}
#[expect(
clippy::await_holding_invalid_type,
reason = "plugin install discovery reads through the session-owned manager guard"
)]
async fn handle(
&self,
invocation: ToolInvocation,
@@ -99,31 +103,10 @@ impl ToolExecutor<ToolInvocation> for RequestPluginInstallHandler {
));
}
let auth = session.services.auth_manager.auth().await;
let manager = session.services.mcp_connection_manager.read().await;
let mcp_tools = manager.list_all_tools().await;
drop(manager);
let accessible_connectors = connectors::with_app_enabled_state(
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
&turn.config,
let discoverable_tools = filter_request_plugin_install_discoverable_tools_for_client(
self.discoverable_tools.clone(),
turn.app_server_client_name.as_deref(),
);
let discoverable_tools = connectors::list_tool_suggest_discoverable_tools_with_auth(
&turn.config,
auth.as_ref(),
&accessible_connectors,
)
.await
.map(|discoverable_tools| {
filter_request_plugin_install_discoverable_tools_for_client(
discoverable_tools,
turn.app_server_client_name.as_deref(),
)
})
.map_err(|err| {
FunctionCallError::RespondToModel(format!(
"plugin install requests are unavailable right now: {err}"
))
})?;
let tool = discoverable_tools
.into_iter()
@@ -154,6 +137,7 @@ impl ToolExecutor<ToolInvocation> for RequestPluginInstallHandler {
.as_ref()
.is_some_and(|response| response.action == ElicitationAction::Accept);
let auth = session.services.auth_manager.auth().await;
let completed = if user_confirmed {
verify_request_plugin_install_completed(&session, &turn, &tool, auth.as_ref()).await
} else {

View File

@@ -615,7 +615,9 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut
planned_tools.add(ListAvailablePluginsToInstallHandler::new(
collect_request_plugin_install_entries(discoverable_tools),
));
planned_tools.add(RequestPluginInstallHandler);
planned_tools.add(RequestPluginInstallHandler::new(
discoverable_tools.to_vec(),
));
}
if environment_mode.has_environment() && turn_context.model_info.apply_patch_tool_type.is_some()

View File

@@ -230,6 +230,8 @@ fn helper_env_from_vars(
fn helper_env_key_is_allowed(key: &str) -> bool {
FS_HELPER_ENV_ALLOWLIST.contains(&key)
// CoreFoundation consults this before falling back to user lookup during helper startup.
|| (cfg!(target_os = "macos") && key == "__CF_USER_TEXT_ENCODING")
|| bazel_bwrap_env_key_is_allowed(key)
|| (cfg!(windows) && key.eq_ignore_ascii_case("PATH"))
}
@@ -434,6 +436,26 @@ mod tests {
);
}
#[cfg(target_os = "macos")]
#[test]
fn helper_env_preserves_corefoundation_text_encoding() {
let env = helper_env_from_vars(
[
("__CF_USER_TEXT_ENCODING", "0x1F6:0x0:0x0"),
("HOME", "/Users/test"),
]
.map(|(key, value)| (OsString::from(key), OsString::from(value))),
);
assert_eq!(
env,
HashMap::from([(
"__CF_USER_TEXT_ENCODING".to_string(),
"0x1F6:0x0:0x0".to_string(),
)])
);
}
#[cfg(windows)]
#[test]
fn helper_env_preserves_windows_path_key_for_system_bwrap_discovery() {

View File

@@ -1129,6 +1129,7 @@ fn tool_call(tool_name: &str, call_id: &str, arguments: serde_json::Value) -> To
turn_id: "turn-1".to_string(),
call_id: call_id.to_string(),
tool_name: codex_extension_api::ToolName::plain(tool_name),
model: "gpt-test".to_string(),
truncation_policy: TruncationPolicy::Bytes(1024),
conversation_history: codex_extension_api::ConversationHistory::default(),
turn_item_emitter: Arc::new(NoopTurnItemEmitter),

View File

@@ -211,6 +211,7 @@ async fn add_ad_hoc_note_tool_creates_note_file() {
turn_id: "turn-1".to_string(),
call_id: "call-1".to_string(),
tool_name: memory_tool_name(crate::ADD_AD_HOC_NOTE_TOOL_NAME),
model: "gpt-test".to_string(),
truncation_policy: TruncationPolicy::Bytes(1024),
conversation_history: codex_extension_api::ConversationHistory::default(),
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
@@ -253,6 +254,7 @@ async fn add_ad_hoc_note_tool_rejects_paths_as_filenames() {
turn_id: "turn-1".to_string(),
call_id: "call-1".to_string(),
tool_name: memory_tool_name(crate::ADD_AD_HOC_NOTE_TOOL_NAME),
model: "gpt-test".to_string(),
truncation_policy: TruncationPolicy::Bytes(1024),
conversation_history: codex_extension_api::ConversationHistory::default(),
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
@@ -296,6 +298,7 @@ async fn read_tool_reads_memory_file() {
turn_id: "turn-1".to_string(),
call_id: "call-1".to_string(),
tool_name: memory_tool_name(crate::READ_TOOL_NAME),
model: "gpt-test".to_string(),
truncation_policy: TruncationPolicy::Bytes(1024),
conversation_history: codex_extension_api::ConversationHistory::default(),
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
@@ -342,6 +345,7 @@ async fn search_tool_accepts_multiple_queries() {
turn_id: "turn-1".to_string(),
call_id: "call-1".to_string(),
tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME),
model: "gpt-test".to_string(),
truncation_policy: TruncationPolicy::Bytes(1024),
conversation_history: codex_extension_api::ConversationHistory::default(),
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
@@ -414,6 +418,7 @@ async fn search_tool_accepts_windowed_all_match_mode() {
turn_id: "turn-1".to_string(),
call_id: "call-1".to_string(),
tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME),
model: "gpt-test".to_string(),
truncation_policy: TruncationPolicy::Bytes(1024),
conversation_history: codex_extension_api::ConversationHistory::default(),
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
@@ -466,6 +471,7 @@ async fn search_tool_rejects_legacy_single_query() {
turn_id: "turn-1".to_string(),
call_id: "call-1".to_string(),
tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME),
model: "gpt-test".to_string(),
truncation_policy: TruncationPolicy::Bytes(1024),
conversation_history: codex_extension_api::ConversationHistory::default(),
turn_item_emitter: Arc::new(NoopTurnItemEmitter),

View File

@@ -90,7 +90,7 @@ impl ToolExecutor<ToolCall> for WebSearchTool {
);
let request = SearchRequest {
id: self.session_id.clone(),
model: None,
model: call.model.clone(),
reasoning: None,
input: recent_input(call.conversation_history.items()),
commands: Some(commands),

View File

@@ -76,7 +76,8 @@ fn apply_turn_context(metadata: &mut ThreadMetadata, turn_ctx: &TurnContextItem)
}
metadata.model = Some(turn_ctx.model.clone());
metadata.reasoning_effort = turn_ctx.effort;
metadata.sandbox_policy = enum_to_string(&turn_ctx.sandbox_policy);
metadata.sandbox_policy =
serde_json::to_string(&turn_ctx.permission_profile()).unwrap_or_default();
metadata.approval_mode = enum_to_string(&turn_ctx.approval_policy);
}
@@ -157,6 +158,7 @@ mod tests {
use chrono::Utc;
use codex_protocol::ThreadId;
use codex_protocol::models::ContentItem;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
@@ -364,11 +366,46 @@ mod tests {
assert_eq!(metadata.cwd, PathBuf::from("/child/worktree"));
assert_eq!(
metadata.sandbox_policy,
super::enum_to_string(&SandboxPolicy::DangerFullAccess)
serde_json::to_string(&PermissionProfile::Disabled)
.expect("serialize permission profile")
);
assert_eq!(metadata.approval_mode, "never");
}
#[test]
fn turn_context_sets_permission_profile_metadata() {
let mut metadata = metadata_for_test();
let permission_profile = PermissionProfile::workspace_write();
apply_rollout_item(
&mut metadata,
&RolloutItem::TurnContext(TurnContextItem {
turn_id: Some("turn-1".to_string()),
cwd: PathBuf::from("/workspace"),
workspace_roots: None,
current_date: None,
timezone: None,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::DangerFullAccess,
permission_profile: Some(permission_profile.clone()),
network: None,
file_system_sandbox_policy: None,
model: "gpt-5".to_string(),
personality: None,
collaboration_mode: None,
realtime_active: None,
effort: None,
summary: codex_protocol::config_types::ReasoningSummary::Auto,
}),
"test-provider",
);
assert_eq!(
metadata.sandbox_policy,
serde_json::to_string(&permission_profile).expect("serialize permission profile")
);
}
#[test]
fn turn_context_sets_cwd_when_session_cwd_missing() {
let mut metadata = metadata_for_test();

View File

@@ -8,9 +8,9 @@ use std::sync::OnceLock;
use async_trait::async_trait;
use chrono::Utc;
use codex_protocol::ThreadId;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SandboxPolicy;
use crate::AppendThreadItemsParams;
use crate::ArchiveThreadParams;
@@ -363,9 +363,9 @@ fn stored_thread_from_state(
approval_mode: metadata
.and_then(|metadata| metadata.approval_mode)
.unwrap_or(AskForApproval::Never),
sandbox_policy: metadata
.and_then(|metadata| metadata.sandbox_policy.clone())
.unwrap_or_else(SandboxPolicy::new_read_only_policy),
permission_profile: metadata
.and_then(|metadata| metadata.permission_profile.clone())
.unwrap_or_else(PermissionProfile::read_only),
token_usage: metadata.and_then(|metadata| metadata.token_usage.clone()),
first_user_message: metadata.and_then(|metadata| metadata.first_user_message.clone()),
history,

View File

@@ -9,8 +9,10 @@ use chrono::DateTime;
use chrono::Utc;
use codex_git_utils::GitSha;
use codex_protocol::ThreadId;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::GitInfo;
use codex_protocol::protocol::NetworkAccess;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_rollout::ARCHIVED_SESSIONS_SUBDIR;
@@ -140,13 +142,34 @@ pub(super) fn stored_thread_from_rollout_item(
agent_path: None,
git_info,
approval_mode: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
token_usage: None,
first_user_message: item.first_user_message,
history: None,
})
}
pub(super) fn permission_profile_from_metadata_value(value: &str, cwd: &Path) -> PermissionProfile {
serde_json::from_str::<PermissionProfile>(value)
.or_else(|_| {
parse_legacy_sandbox_policy(value)
.map(|policy| PermissionProfile::from_legacy_sandbox_policy_for_cwd(&policy, cwd))
})
.unwrap_or_else(|_| PermissionProfile::read_only())
}
pub(super) fn permission_profile_to_metadata_value(
permission_profile: &PermissionProfile,
) -> String {
match serde_json::to_string(permission_profile) {
Ok(value) => value,
Err(err) => {
tracing::warn!("failed to serialize permission profile metadata: {err}");
String::new()
}
}
}
pub(super) fn distinct_thread_metadata_title(metadata: &ThreadMetadata) -> Option<String> {
let title = metadata.title.trim();
if title.is_empty() || metadata.first_user_message.as_deref().map(str::trim) == Some(title) {
@@ -169,6 +192,20 @@ fn parse_rfc3339(value: Option<&str>) -> Option<DateTime<Utc>> {
.map(|dt| dt.with_timezone(&Utc))
}
fn parse_legacy_sandbox_policy(value: &str) -> serde_json::Result<SandboxPolicy> {
serde_json::from_str(value)
.or_else(|_| serde_json::from_value(serde_json::Value::String(value.to_string())))
.or_else(|_| match value {
"danger-full-access" => Ok(SandboxPolicy::DangerFullAccess),
"read-only" => Ok(SandboxPolicy::new_read_only_policy()),
"workspace-write" => Ok(SandboxPolicy::new_workspace_write_policy()),
"external-sandbox" => Ok(SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
}),
_ => serde_json::from_value(serde_json::Value::String(value.to_string())),
})
}
pub(super) fn git_info_from_parts(
sha: Option<String>,
branch: Option<String>,

View File

@@ -1,7 +1,7 @@
use chrono::DateTime;
use chrono::Utc;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use codex_rollout::RolloutRecorder;
@@ -15,6 +15,7 @@ use codex_state::ThreadMetadata;
use super::LocalThreadStore;
use super::helpers::distinct_thread_metadata_title;
use super::helpers::git_info_from_parts;
use super::helpers::permission_profile_from_metadata_value;
use super::helpers::rollout_path_is_archived;
use super::helpers::set_thread_name_from_title;
use super::helpers::stored_thread_from_rollout_item;
@@ -45,6 +46,7 @@ pub(super) async fn read_thread(
)
.await)
{
let metadata_sandbox_policy = metadata.sandbox_policy.clone();
let mut thread = stored_thread_from_sqlite_metadata(store, metadata).await;
if !params.include_history
&& let Some(rollout_path) = thread.rollout_path.clone()
@@ -57,6 +59,10 @@ pub(super) async fn read_thread(
rollout_thread.name = thread.name;
}
rollout_thread.git_info = thread.git_info;
rollout_thread.permission_profile = permission_profile_from_metadata_value(
&metadata_sandbox_policy,
rollout_thread.cwd.as_path(),
);
thread = rollout_thread;
}
attach_history_if_requested(&mut thread, params.include_history).await?;
@@ -286,6 +292,8 @@ async fn stored_thread_from_sqlite_metadata(
.clone()
.or_else(|| metadata.first_user_message.clone())
.unwrap_or_default();
let permission_profile =
permission_profile_from_metadata_value(&metadata.sandbox_policy, metadata.cwd.as_path());
StoredThread {
thread_id: metadata.id,
rollout_path: Some(metadata.rollout_path),
@@ -315,10 +323,7 @@ async fn stored_thread_from_sqlite_metadata(
metadata.git_origin_url,
),
approval_mode: parse_or_default(&metadata.approval_mode, AskForApproval::OnRequest),
sandbox_policy: parse_or_default(
&metadata.sandbox_policy,
SandboxPolicy::new_read_only_policy(),
),
permission_profile,
token_usage: None,
first_user_message: metadata.first_user_message,
history: None,
@@ -377,7 +382,7 @@ fn stored_thread_from_meta_line(
agent_path: meta_line.meta.agent_path,
git_info: meta_line.git,
approval_mode: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
token_usage: None,
first_user_message: None,
history: None,
@@ -412,6 +417,7 @@ mod tests {
use chrono::Utc;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_state::ThreadMetadataBuilder;
use pretty_assertions::assert_eq;
@@ -671,6 +677,84 @@ mod tests {
assert_eq!(thread.name, Some("Saved title".to_string()));
}
#[tokio::test]
async fn read_thread_returns_permission_profile_from_sqlite_metadata() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let uuid = Uuid::from_u128(225);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let rollout_path =
write_session_file(home.path(), "2025-01-03T12-00-00", uuid).expect("session file");
let runtime = codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
let mut builder =
ThreadMetadataBuilder::new(thread_id, rollout_path, Utc::now(), SessionSource::Cli);
builder.model_provider = Some(config.default_model_provider_id.clone());
builder.cwd = home.path().to_path_buf();
let mut metadata = builder.build(config.default_model_provider_id.as_str());
metadata.sandbox_policy =
serde_json::to_string(&PermissionProfile::Disabled).expect("serialize profile");
runtime
.upsert_thread(&metadata)
.await
.expect("state db upsert should succeed");
let thread = store
.read_thread(ReadThreadParams {
thread_id,
include_archived: false,
include_history: false,
})
.await
.expect("read thread");
assert_eq!(thread.preview, "Hello from user");
assert_eq!(thread.permission_profile, PermissionProfile::Disabled);
}
#[tokio::test]
async fn read_thread_accepts_legacy_sandbox_policy_metadata() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let uuid = Uuid::from_u128(226);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let rollout_path =
write_session_file(home.path(), "2025-01-03T12-00-00", uuid).expect("session file");
let runtime = codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
let mut builder =
ThreadMetadataBuilder::new(thread_id, rollout_path, Utc::now(), SessionSource::Cli);
builder.model_provider = Some(config.default_model_provider_id.clone());
builder.cwd = home.path().to_path_buf();
let mut metadata = builder.build(config.default_model_provider_id.as_str());
metadata.sandbox_policy = "danger-full-access".to_string();
runtime
.upsert_thread(&metadata)
.await
.expect("state db upsert should succeed");
let thread = store
.read_thread(ReadThreadParams {
thread_id,
include_archived: false,
include_history: true,
})
.await
.expect("read thread");
assert_eq!(thread.permission_profile, PermissionProfile::Disabled);
}
#[tokio::test]
async fn read_thread_preserves_rollout_cwd_when_sqlite_metadata_exists() {
let home = TempDir::new().expect("temp dir");
@@ -725,6 +809,7 @@ mod tests {
let mut metadata = builder.build(config.default_model_provider_id.as_str());
metadata.title = "Saved title".to_string();
metadata.first_user_message = Some("Hello from sqlite".to_string());
metadata.sandbox_policy = "workspace-write".to_string();
runtime
.upsert_thread(&metadata)
.await
@@ -745,6 +830,19 @@ mod tests {
assert_eq!(thread.name, Some("Saved title".to_string()));
assert_eq!(thread.model_provider, "rollout-provider");
assert_eq!(thread.cwd, rollout_cwd);
let legacy_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: Vec::new(),
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
assert_eq!(
thread.permission_profile,
PermissionProfile::from_legacy_sandbox_policy_for_cwd(
&legacy_policy,
rollout_cwd.as_path()
)
);
}
#[tokio::test]

View File

@@ -18,6 +18,7 @@ use tracing::warn;
use super::LocalThreadStore;
use super::helpers::git_info_from_parts;
use super::helpers::permission_profile_to_metadata_value;
use super::live_writer;
use crate::GitInfoPatch;
use crate::ReadThreadParams;
@@ -275,8 +276,8 @@ async fn apply_metadata_update(
if let Some(approval_mode) = patch.approval_mode {
metadata.approval_mode = enum_to_string(&approval_mode);
}
if let Some(sandbox_policy) = patch.sandbox_policy {
metadata.sandbox_policy = enum_to_string(&sandbox_policy);
if let Some(permission_profile) = patch.permission_profile {
metadata.sandbox_policy = permission_profile_to_metadata_value(&permission_profile);
}
if let Some(token_usage) = patch.token_usage {
metadata.tokens_used = token_usage.total_tokens.max(0);
@@ -382,7 +383,7 @@ fn has_observed_metadata_facts(patch: &ThreadMetadataPatch) -> bool {
|| patch.cwd.is_some()
|| patch.cli_version.is_some()
|| patch.approval_mode.is_some()
|| patch.sandbox_policy.is_some()
|| patch.permission_profile.is_some()
|| patch.token_usage.is_some()
|| patch.first_user_message.is_some()
}
@@ -604,6 +605,7 @@ fn rollout_path_is_archived(store: &LocalThreadStore, path: &Path) -> bool {
#[cfg(test)]
mod tests {
use codex_protocol::models::PermissionProfile;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
@@ -845,6 +847,45 @@ mod tests {
);
}
#[tokio::test]
async fn update_thread_metadata_sets_permission_profile() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let runtime = codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config, Some(runtime.clone()));
let uuid = Uuid::from_u128(317);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
write_session_file(home.path(), "2025-01-03T20-30-00", uuid).expect("session file");
let thread = store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
permission_profile: Some(PermissionProfile::Disabled),
..Default::default()
},
include_archived: false,
})
.await
.expect("set permission profile");
assert_eq!(thread.permission_profile, PermissionProfile::Disabled);
let metadata = runtime
.get_thread(thread_id)
.await
.expect("sqlite metadata read")
.expect("sqlite metadata");
assert_eq!(
metadata.sandbox_policy,
serde_json::to_string(&PermissionProfile::Disabled).expect("serialize profile")
);
}
#[tokio::test]
async fn update_thread_metadata_partially_updates_git_info() {
let home = TempDir::new().expect("temp dir");

View File

@@ -234,7 +234,7 @@ impl ThreadMetadataSync {
update.model = Some(turn_ctx.model.clone());
update.reasoning_effort = turn_ctx.effort;
update.approval_mode = Some(turn_ctx.approval_policy);
update.sandbox_policy = Some(turn_ctx.sandbox_policy.clone());
update.permission_profile = Some(turn_ctx.permission_profile());
}
RolloutItem::EventMsg(EventMsg::UserMessage(user)) => {
if let Some(preview) = user_message_preview(user) {
@@ -354,7 +354,7 @@ fn update_has_metadata_facts(update: &ThreadMetadataPatch) -> bool {
|| update.cwd.is_some()
|| update.cli_version.is_some()
|| update.approval_mode.is_some()
|| update.sandbox_policy.is_some()
|| update.permission_profile.is_some()
|| update.token_usage.is_some()
|| update.first_user_message.is_some()
|| update.git_info.is_some()

View File

@@ -5,11 +5,11 @@ use chrono::Utc;
use codex_protocol::ThreadId;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::GitInfo;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadMemoryMode as MemoryMode;
use codex_protocol::protocol::ThreadSource;
@@ -396,8 +396,8 @@ pub struct StoredThread {
pub git_info: Option<GitInfo>,
/// Approval mode captured for the thread.
pub approval_mode: AskForApproval,
/// Sandbox policy captured for the thread.
pub sandbox_policy: SandboxPolicy,
/// Canonical runtime permissions captured for the thread.
pub permission_profile: PermissionProfile,
/// Last observed token usage.
pub token_usage: Option<TokenUsage>,
/// First user message observed for this thread, if any.
@@ -519,8 +519,8 @@ pub struct ThreadMetadataPatch {
pub cli_version: Option<String>,
/// Approval mode.
pub approval_mode: Option<AskForApproval>,
/// Sandbox policy.
pub sandbox_policy: Option<SandboxPolicy>,
/// Canonical runtime permissions.
pub permission_profile: Option<PermissionProfile>,
/// Last observed token usage.
pub token_usage: Option<TokenUsage>,
/// First user message observed for this thread.
@@ -589,8 +589,8 @@ impl ThreadMetadataPatch {
if next.approval_mode.is_some() {
self.approval_mode = next.approval_mode;
}
if next.sandbox_policy.is_some() {
self.sandbox_policy = next.sandbox_policy;
if next.permission_profile.is_some() {
self.permission_profile = next.permission_profile;
}
if next.token_usage.is_some() {
self.token_usage = next.token_usage;
@@ -626,7 +626,7 @@ impl ThreadMetadataPatch {
&& self.cwd.is_none()
&& self.cli_version.is_none()
&& self.approval_mode.is_none()
&& self.sandbox_policy.is_none()
&& self.permission_profile.is_none()
&& self.token_usage.is_none()
&& self.first_user_message.is_none()
&& self.git_info.is_none()

View File

@@ -87,6 +87,7 @@ pub struct ToolCall {
pub turn_id: String,
pub call_id: String,
pub tool_name: ToolName,
pub model: String,
pub truncation_policy: TruncationPolicy,
pub conversation_history: ConversationHistory,
pub turn_item_emitter: Arc<dyn TurnItemEmitter>,
@@ -99,6 +100,7 @@ impl std::fmt::Debug for ToolCall {
.field("turn_id", &self.turn_id)
.field("call_id", &self.call_id)
.field("tool_name", &self.tool_name)
.field("model", &self.model)
.field("truncation_policy", &self.truncation_policy)
.field("conversation_history", &self.conversation_history)
.field("turn_item_emitter", &"<host turn item emitter>")

View File

@@ -677,9 +677,10 @@ impl TextArea {
}
if self.vim_normal_keymap.open_line_below.is_pressed(event) {
let eol = self.end_of_current_line();
let insert_at = if eol < self.text.len() { eol + 1 } else { eol };
let old_len = self.text.len();
let insert_at = if eol < old_len { eol + 1 } else { eol };
self.insert_str_at(insert_at, "\n");
let cursor = if eol < self.text.len() {
let cursor = if eol < old_len {
insert_at
} else {
insert_at + 1
@@ -735,6 +736,13 @@ impl TextArea {
self.delete_forward_kill(/*n*/ 1);
return;
}
if self.vim_normal_keymap.substitute_char.is_pressed(event) {
if self.cursor_pos < self.end_of_current_line() {
self.delete_forward_kill(/*n*/ 1);
}
self.vim_mode = VimMode::Insert;
return;
}
if self.vim_normal_keymap.delete_to_line_end.is_pressed(event) {
self.vim_kill_to_end_of_line();
return;
@@ -2360,6 +2368,38 @@ mod tests {
assert_eq!(t.cursor(), 6);
}
#[test]
fn vim_s_substitutes_current_character_and_enters_insert_mode() {
let mut t = ta_with("abc");
t.set_cursor(/*pos*/ 1);
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
assert_eq!(t.text(), "ac");
assert_eq!(t.cursor(), 1);
assert_eq!(t.vim_mode_label(), Some("Insert"));
t.input(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE));
assert_eq!(t.text(), "aXc");
assert_eq!(t.cursor(), 2);
assert_eq!(t.vim_mode_label(), Some("Insert"));
}
#[test]
fn vim_s_on_empty_line_enters_insert_without_deleting_newline() {
let mut t = ta_with("before\n\nnext");
t.set_cursor(/*pos*/ "before\n".len());
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
assert_eq!(t.text(), "before\n\nnext");
assert_eq!(t.cursor(), "before\n".len());
assert_eq!(t.vim_mode_label(), Some("Insert"));
}
#[test]
fn vim_d_at_line_end_does_not_remove_newline() {
let mut t = ta_with("hello\nworld");
@@ -2414,6 +2454,19 @@ mod tests {
assert_eq!(t.cursor(), "one\n".len());
}
#[test]
fn vim_o_opens_line_below_final_line_and_moves_to_new_line() {
let mut t = ta_with("one");
t.set_cursor(/*pos*/ 1);
t.set_vim_enabled(/*enabled*/ true);
t.input(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE));
assert_eq!(t.text(), "one\n");
assert_eq!(t.vim_mode_label(), Some("Insert"));
assert_eq!(t.cursor(), "one\n".len());
}
#[test]
fn vim_delete_word() {
let mut t = ta_with("hello world");

View File

@@ -158,6 +158,7 @@ pub(crate) struct VimNormalKeymap {
pub(crate) move_line_start: Vec<KeyBinding>,
pub(crate) move_line_end: Vec<KeyBinding>,
pub(crate) delete_char: Vec<KeyBinding>,
pub(crate) substitute_char: Vec<KeyBinding>,
pub(crate) delete_to_line_end: Vec<KeyBinding>,
pub(crate) change_to_line_end: Vec<KeyBinding>,
pub(crate) yank_line: Vec<KeyBinding>,
@@ -494,6 +495,7 @@ impl RuntimeKeymap {
move_line_start: resolve_local!(keymap, defaults, vim_normal, move_line_start),
move_line_end: resolve_local!(keymap, defaults, vim_normal, move_line_end),
delete_char: resolve_local!(keymap, defaults, vim_normal, delete_char),
substitute_char: resolve_local!(keymap, defaults, vim_normal, substitute_char),
delete_to_line_end: resolve_local!(keymap, defaults, vim_normal, delete_to_line_end),
change_to_line_end: resolve_local!(keymap, defaults, vim_normal, change_to_line_end),
yank_line: resolve_local!(keymap, defaults, vim_normal, yank_line),
@@ -579,6 +581,10 @@ impl RuntimeKeymap {
keymap.vim_normal.delete_char.as_ref(),
vim_normal.delete_char.as_slice(),
),
(
keymap.vim_normal.change_to_line_end.as_ref(),
vim_normal.change_to_line_end.as_slice(),
),
(
keymap.vim_normal.delete_to_line_end.as_ref(),
vim_normal.delete_to_line_end.as_slice(),
@@ -599,6 +605,10 @@ impl RuntimeKeymap {
keymap.vim_normal.start_yank_operator.as_ref(),
vim_normal.start_yank_operator.as_slice(),
),
(
keymap.vim_normal.start_change_operator.as_ref(),
vim_normal.start_change_operator.as_slice(),
),
(
keymap.vim_normal.cancel_operator.as_ref(),
vim_normal.cancel_operator.as_slice(),
@@ -610,6 +620,11 @@ impl RuntimeKeymap {
.start_change_operator
.retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding));
}
if keymap.vim_normal.substitute_char.is_none() {
vim_normal
.substitute_char
.retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding));
}
let mut vim_operator = VimOperatorKeymap {
delete_line: resolve_local!(keymap, defaults, vim_operator, delete_line),
@@ -990,6 +1005,7 @@ impl RuntimeKeymap {
shift(KeyCode::Char('$'))
],
delete_char: default_bindings![plain(KeyCode::Char('x'))],
substitute_char: default_bindings![plain(KeyCode::Char('s'))],
delete_to_line_end: default_bindings![
shift(KeyCode::Char('d')),
plain(KeyCode::Char('D'))
@@ -1434,6 +1450,10 @@ impl RuntimeKeymap {
),
("move_line_end", self.vim_normal.move_line_end.as_slice()),
("delete_char", self.vim_normal.delete_char.as_slice()),
(
"substitute_char",
self.vim_normal.substitute_char.as_slice(),
),
(
"delete_to_line_end",
self.vim_normal.delete_to_line_end.as_slice(),
@@ -2311,6 +2331,29 @@ mod tests {
expect_conflict(&keymap, "move_left", "start_change_operator");
}
#[test]
fn configured_legacy_vim_normal_bindings_prune_new_substitute_default() {
let mut keymap = TuiKeymap::default();
keymap.vim_normal.move_left = Some(one("s"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert_eq!(
runtime.vim_normal.move_left,
vec![key_hint::plain(KeyCode::Char('s'))]
);
assert_eq!(runtime.vim_normal.substitute_char, Vec::new());
}
#[test]
fn explicit_new_vim_normal_substitute_binding_still_conflicts_with_legacy_binding() {
let mut keymap = TuiKeymap::default();
keymap.vim_normal.move_left = Some(one("s"));
keymap.vim_normal.substitute_char = Some(one("s"));
expect_conflict(&keymap, "move_left", "substitute_char");
}
#[test]
fn configured_legacy_vim_operator_bindings_prune_new_text_object_defaults() {
let mut keymap = TuiKeymap::default();

View File

@@ -135,6 +135,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("vim_normal", "Vim normal", "move_line_start", "Move to the start of the line."),
action("vim_normal", "Vim normal", "move_line_end", "Move to the end of the line."),
action("vim_normal", "Vim normal", "delete_char", "Delete the character under the cursor."),
action("vim_normal", "Vim normal", "substitute_char", "Delete the character under the cursor and enter insert mode."),
action("vim_normal", "Vim normal", "delete_to_line_end", "Delete from cursor to end of line."),
action("vim_normal", "Vim normal", "change_to_line_end", "Change from cursor to end of line and enter insert mode."),
action("vim_normal", "Vim normal", "yank_line", "Yank the entire line."),
@@ -277,6 +278,7 @@ pub(super) fn binding_slot<'a>(
("vim_normal", "move_line_start") => Some(&mut keymap.vim_normal.move_line_start),
("vim_normal", "move_line_end") => Some(&mut keymap.vim_normal.move_line_end),
("vim_normal", "delete_char") => Some(&mut keymap.vim_normal.delete_char),
("vim_normal", "substitute_char") => Some(&mut keymap.vim_normal.substitute_char),
("vim_normal", "delete_to_line_end") => Some(&mut keymap.vim_normal.delete_to_line_end),
("vim_normal", "change_to_line_end") => Some(&mut keymap.vim_normal.change_to_line_end),
("vim_normal", "yank_line") => Some(&mut keymap.vim_normal.yank_line),
@@ -401,6 +403,7 @@ pub(super) fn bindings_for_action<'a>(
("vim_normal", "move_line_start") => Some(runtime_keymap.vim_normal.move_line_start.as_slice()),
("vim_normal", "move_line_end") => Some(runtime_keymap.vim_normal.move_line_end.as_slice()),
("vim_normal", "delete_char") => Some(runtime_keymap.vim_normal.delete_char.as_slice()),
("vim_normal", "substitute_char") => Some(runtime_keymap.vim_normal.substitute_char.as_slice()),
("vim_normal", "delete_to_line_end") => Some(runtime_keymap.vim_normal.delete_to_line_end.as_slice()),
("vim_normal", "change_to_line_end") => Some(runtime_keymap.vim_normal.change_to_line_end.as_slice()),
("vim_normal", "yank_line") => Some(runtime_keymap.vim_normal.yank_line.as_slice()),

View File

@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
107 actions, 1 customized, 2 unbound.
108 actions, 1 customized, 2 unbound.
[All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug

View File

@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
108 actions, 0 customized, 3 unbound.
109 actions, 0 customized, 3 unbound.
[All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug

View File

@@ -2,14 +2,14 @@
source: tui/src/keymap_setup.rs
expression: snapshot
---
tab: All (107 selectable)
tab: All (108 selectable)
tab: Common (20 selectable)
tab: Customized (0) (0 selectable)
tab: Unbound (2) (2 selectable)
tab: App (10 selectable)
tab: Composer (5 selectable)
tab: Editor (17 selectable)
tab: Vim (47 selectable)
tab: Vim (48 selectable)
tab: Navigation (20 selectable)
tab: Approval (8 selectable)
tab: Debug (1 selectable)

View File

@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
Keymap
All configurable shortcuts.
107 actions, 0 customized, 2 unbound.
108 actions, 0 customized, 2 unbound.
[All] Common Customized (0) Unbound (2) App Composer Editor Vim
Navigation Approval Debug

View File

@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
107 actions, 0 customized, 2 unbound.
108 actions, 0 customized, 2 unbound.
[All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug