Compare commits

...

3 Commits

Author SHA1 Message Date
Ahmed Ibrahim
b02388672f Stabilize Windows cmd-based shell test harnesses (#14958)
## What is flaky
The Windows shell-driven integration tests in `codex-rs/core` were
intermittently unstable, especially:

- `apply_patch_cli_can_use_shell_command_output_as_patch_input`
- `websocket_test_codex_shell_chain`
- `websocket_v2_test_codex_shell_chain`

## Why it was flaky
These tests were exercising real shell-tool flows through whichever
shell Codex selected on Windows, and the `apply_patch` test also nested
a PowerShell read inside `cmd /c`.

There were multiple independent sources of nondeterminism in that setup:

- The test harness depended on the model-selected Windows shell instead
of pinning the shell it actually meant to exercise.
- `cmd.exe /c powershell.exe -Command "..."` is quoting-sensitive; on CI
that could leave the read command wrapped as a literal string instead of
executing it.
- Even after getting the quoting right, PowerShell could emit CLIXML
progress records like module-initialization output onto stdout.
- The `apply_patch` test was building a patch directly from shell
stdout, so any quoting artifact or progress noise corrupted the patch
input.

So the failures were driven by shell startup and output-shape variance,
not by the `apply_patch` or websocket logic themselves.

## How this PR fixes it
- Add a test-only `user_shell_override` path so Windows integration
tests can pin `cmd.exe` explicitly.
- Use that override in the websocket shell-chain tests and in the
`apply_patch` harness.
- Change the nested Windows file read in
`apply_patch_cli_can_use_shell_command_output_as_patch_input` to a UTF-8
PowerShell `-EncodedCommand` script.
- Run that nested PowerShell process with `-NonInteractive`, set
`$ProgressPreference = 'SilentlyContinue'`, and read the file with
`[System.IO.File]::ReadAllText(...)`.

## Why this fix fixes the flakiness
The outer harness now runs under a deterministic shell, and the inner
PowerShell read no longer depends on fragile `cmd` quoting or on
progress output staying quiet by accident. The shell tool returns only
the file contents, so patch construction and websocket assertions depend
on stable test inputs instead of on runner-specific shell behavior.

---------

Co-authored-by: Ahmed Ibrahim <219906144+aibrahim-oai@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-03-17 20:21:46 +00:00
Matthew Zeng
683c37ce75 [plugins] Support plugin installation elicitation. (#14896)
It now supports:

- Connectors that are from installed and enabled plugins that are not
installed yet
- Plugins that are on the allowlist that are not installed yet.
2026-03-17 13:19:28 -07:00
Eric Traut
49e7dda2df Add device-code onboarding and ChatGPT token refresh to app-server TUI (#14952)
## Summary
- add device-code ChatGPT sign-in to `tui_app_server` onboarding and
reuse the existing `chatgptAuthTokens` login path
- fall back to browser login when device-code auth is unavailable on the
server
- treat `ChatgptAuthTokens` as an existing signed-in ChatGPT state
during onboarding
- add a local ChatGPT auth loader for handing local tokens to the app
server and serving refresh requests
- handle `account/chatgptAuthTokens/refresh` instead of marking it
unsupported, including workspace/account mismatch checks
- add focused coverage for onboarding success, existing auth handling,
local auth loading, and refresh request behavior

## Testing
- `cargo test -p codex-tui-app-server`
- `just fix -p codex-tui-app-server`
2026-03-17 14:12:12 -06:00
32 changed files with 1628 additions and 272 deletions

View File

@@ -291,7 +291,6 @@ use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use crate::tools::ToolRouter;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::discoverable::DiscoverableTool;
use crate::tools::js_repl::JsReplHandle;
use crate::tools::js_repl::resolve_compatible_node;
use crate::tools::network_approval::NetworkApprovalService;
@@ -370,6 +369,7 @@ pub(crate) struct CodexSpawnArgs {
pub(crate) persist_extended_history: bool,
pub(crate) metrics_service_name: Option<String>,
pub(crate) inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
pub(crate) user_shell_override: Option<shell::Shell>,
pub(crate) parent_trace: Option<W3cTraceContext>,
}
@@ -421,6 +421,7 @@ impl Codex {
persist_extended_history,
metrics_service_name,
inherited_shell_snapshot,
user_shell_override,
parent_trace: _,
} = args;
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
@@ -575,6 +576,7 @@ impl Codex {
dynamic_tools,
persist_extended_history,
inherited_shell_snapshot,
user_shell_override,
};
// Generate a unique ID for the lifetime of this Codex session.
@@ -1037,6 +1039,7 @@ pub(crate) struct SessionConfiguration {
dynamic_tools: Vec<DynamicToolSpec>,
persist_extended_history: bool,
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
user_shell_override: Option<shell::Shell>,
}
impl SessionConfiguration {
@@ -1617,7 +1620,11 @@ impl Session {
);
let use_zsh_fork_shell = config.features.enabled(Feature::ShellZshFork);
let mut default_shell = if use_zsh_fork_shell {
let mut default_shell = if let Some(user_shell_override) =
session_configuration.user_shell_override.clone()
{
user_shell_override
} else if use_zsh_fork_shell {
let zsh_path = config.zsh_path.as_ref().ok_or_else(|| {
anyhow::anyhow!(
"zsh fork feature enabled, but `zsh_path` is not configured; set `zsh_path` in config.toml"
@@ -6410,11 +6417,14 @@ pub(crate) async fn built_tools(
accessible_connectors.as_slice(),
)
.await
{
Ok(connectors) if connectors.is_empty() => None,
Ok(connectors) => {
Some(connectors.into_iter().map(DiscoverableTool::from).collect())
}
.map(|discoverable_tools| {
crate::tools::discoverable::filter_tool_suggest_discoverable_tools_for_client(
discoverable_tools,
turn_context.app_server_client_name.as_deref(),
)
}) {
Ok(discoverable_tools) if discoverable_tools.is_empty() => None,
Ok(discoverable_tools) => Some(discoverable_tools),
Err(err) => {
warn!("failed to load discoverable tool suggestions: {err:#}");
None

View File

@@ -88,6 +88,7 @@ pub(crate) async fn run_codex_thread_interactive(
persist_extended_history: false,
metrics_service_name: None,
inherited_shell_snapshot: None,
user_shell_override: None,
parent_trace: None,
})
.await?;

View File

@@ -1663,6 +1663,7 @@ async fn set_rate_limits_retains_previous_credits() {
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
user_shell_override: None,
};
let mut state = SessionState::new(session_configuration);
@@ -1760,6 +1761,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
user_shell_override: None,
};
let mut state = SessionState::new(session_configuration);
@@ -2115,6 +2117,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
user_shell_override: None,
}
}
@@ -2345,6 +2348,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
user_shell_override: None,
};
let (tx_event, _rx_event) = async_channel::unbounded();
@@ -2439,6 +2443,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
user_shell_override: None,
};
let per_turn_config = Session::build_per_turn_config(&session_configuration);
let model_info = ModelsManager::construct_model_info_offline_for_tests(
@@ -3230,6 +3235,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
dynamic_tools,
persist_extended_history: false,
inherited_shell_snapshot: None,
user_shell_override: None,
};
let per_turn_config = Session::build_per_turn_config(&session_configuration);
let model_info = ModelsManager::construct_model_info_offline_for_tests(

View File

@@ -452,6 +452,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
persist_extended_history: false,
metrics_service_name: None,
inherited_shell_snapshot: None,
user_shell_override: None,
parent_trace: None,
})
.await

View File

@@ -42,24 +42,14 @@ use crate::mcp_connection_manager::McpConnectionManager;
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
use crate::plugins::AppConnectorId;
use crate::plugins::PluginsManager;
use crate::plugins::list_tool_suggest_discoverable_plugins;
use crate::token_data::TokenData;
use crate::tools::discoverable::DiscoverablePluginInfo;
use crate::tools::discoverable::DiscoverableTool;
pub use codex_connectors::CONNECTORS_CACHE_TTL;
const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);
const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);
const TOOL_SUGGEST_DISCOVERABLE_CONNECTOR_IDS: &[&str] = &[
"connector_2128aebfecb84f64a069897515042a44",
"connector_68df038e0ba48191908c8434991bbac2",
"asdk_app_69a1d78e929881919bba0dbda1f6436d",
"connector_4964e3b22e3e427e9b4ae1acf2c1fa34",
"connector_9d7cfa34e6654a5f98d3387af34b2e1c",
"connector_6f1ec045b8fa4ced8738e32c7f74514b",
"connector_947e0d954944416db111db556030eea6",
"connector_5f3c8c41a1e54ad7a76272c89e2554fa",
"connector_686fad9b54914a35b75be6d06a0f6f31",
"connector_76869538009648d5b282a4bb21c3d157",
"connector_37316be7febe4224b3d31465bae4dbd7",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct AppToolPolicy {
@@ -116,13 +106,24 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
config: &Config,
auth: Option<&CodexAuth>,
accessible_connectors: &[AppInfo],
) -> anyhow::Result<Vec<AppInfo>> {
) -> anyhow::Result<Vec<DiscoverableTool>> {
let directory_connectors =
list_directory_connectors_for_tool_suggest_with_auth(config, auth).await?;
Ok(filter_tool_suggest_discoverable_tools(
let connector_ids = tool_suggest_connector_ids(config);
let discoverable_connectors = filter_tool_suggest_discoverable_connectors(
directory_connectors,
accessible_connectors,
))
&connector_ids,
)
.into_iter()
.map(DiscoverableTool::from);
let discoverable_plugins = list_tool_suggest_discoverable_plugins(config)?
.into_iter()
.map(DiscoverablePluginInfo::from)
.map(DiscoverableTool::from);
Ok(discoverable_connectors
.chain(discoverable_plugins)
.collect())
}
pub async fn list_cached_accessible_connectors_from_mcp_tools(
@@ -350,24 +351,21 @@ fn write_cached_accessible_connectors(
});
}
fn filter_tool_suggest_discoverable_tools(
fn filter_tool_suggest_discoverable_connectors(
directory_connectors: Vec<AppInfo>,
accessible_connectors: &[AppInfo],
discoverable_connector_ids: &HashSet<String>,
) -> Vec<AppInfo> {
let accessible_connector_ids: HashSet<&str> = accessible_connectors
.iter()
.filter(|connector| connector.is_accessible && connector.is_enabled)
.filter(|connector| connector.is_accessible)
.map(|connector| connector.id.as_str())
.collect();
let allowed_connector_ids: HashSet<&str> = TOOL_SUGGEST_DISCOVERABLE_CONNECTOR_IDS
.iter()
.copied()
.collect();
let mut connectors = filter_disallowed_connectors(directory_connectors)
.into_iter()
.filter(|connector| !accessible_connector_ids.contains(connector.id.as_str()))
.filter(|connector| allowed_connector_ids.contains(connector.id.as_str()))
.filter(|connector| discoverable_connector_ids.contains(connector.id.as_str()))
.collect::<Vec<_>>();
connectors.sort_by(|left, right| {
left.name
@@ -377,6 +375,16 @@ fn filter_tool_suggest_discoverable_tools(
connectors
}
fn tool_suggest_connector_ids(config: &Config) -> HashSet<String> {
PluginsManager::new(config.codex_home.clone())
.plugins_for_config(config)
.capability_summaries()
.iter()
.flat_map(|plugin| plugin.app_connector_ids.iter())
.map(|connector_id| connector_id.0.clone())
.collect()
}
async fn list_directory_connectors_for_tool_suggest_with_auth(
config: &Config,
auth: Option<&CodexAuth>,
@@ -675,6 +683,7 @@ pub(crate) fn codex_app_tool_is_enabled(
const DISALLOWED_CONNECTOR_IDS: &[&str] = &[
"asdk_app_6938a94a61d881918ef32cb999ff937c",
"connector_2b0a9009c9c64bf9933a3dae3f2b1254",
"connector_3f8d1a79f27c4c7ba1a897ab13bf37dc",
"connector_68de829bf7648191acd70a907364c67c",
"connector_68e004f14af881919eb50893d3d9f523",
"connector_69272cb413a081919685ec3c88d1744e",

View File

@@ -1,4 +1,5 @@
use super::*;
use crate::config::CONFIG_TOML_FILE;
use crate::config::ConfigBuilder;
use crate::config::types::AppConfig;
use crate::config::types::AppToolConfig;
@@ -13,13 +14,13 @@ use crate::config_loader::ConfigRequirementsToml;
use crate::features::Feature;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp_connection_manager::ToolInfo;
use codex_config::CONFIG_TOML_FILE;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use rmcp::model::JsonObject;
use rmcp::model::Tool;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use tempfile::tempdir;
@@ -957,6 +958,7 @@ fn filter_disallowed_connectors_filters_openai_prefix() {
fn filter_disallowed_connectors_filters_disallowed_connector_ids() {
let filtered = filter_disallowed_connectors(vec![
app("asdk_app_6938a94a61d881918ef32cb999ff937c"),
app("connector_3f8d1a79f27c4c7ba1a897ab13bf37dc"),
app("delta"),
]);
assert_eq!(filtered, vec![app("delta")]);
@@ -979,8 +981,8 @@ fn first_party_chat_originator_filters_target_and_openai_prefixed_connectors() {
}
#[test]
fn filter_tool_suggest_discoverable_tools_keeps_only_allowlisted_uninstalled_apps() {
let filtered = filter_tool_suggest_discoverable_tools(
fn filter_tool_suggest_discoverable_connectors_keeps_only_plugin_backed_uninstalled_apps() {
let filtered = filter_tool_suggest_discoverable_connectors(
vec![
named_app(
"connector_2128aebfecb84f64a069897515042a44",
@@ -996,6 +998,10 @@ fn filter_tool_suggest_discoverable_tools_keeps_only_allowlisted_uninstalled_app
"Google Calendar",
)
}],
&HashSet::from([
"connector_2128aebfecb84f64a069897515042a44".to_string(),
"connector_68df038e0ba48191908c8434991bbac2".to_string(),
]),
);
assert_eq!(
@@ -1008,8 +1014,8 @@ fn filter_tool_suggest_discoverable_tools_keeps_only_allowlisted_uninstalled_app
}
#[test]
fn filter_tool_suggest_discoverable_tools_keeps_disabled_accessible_apps() {
let filtered = filter_tool_suggest_discoverable_tools(
fn filter_tool_suggest_discoverable_connectors_excludes_accessible_apps_even_when_disabled() {
let filtered = filter_tool_suggest_discoverable_connectors(
vec![
named_app(
"connector_2128aebfecb84f64a069897515042a44",
@@ -1031,13 +1037,11 @@ fn filter_tool_suggest_discoverable_tools_keeps_disabled_accessible_apps() {
..named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail")
},
],
&HashSet::from([
"connector_2128aebfecb84f64a069897515042a44".to_string(),
"connector_68df038e0ba48191908c8434991bbac2".to_string(),
]),
);
assert_eq!(
filtered,
vec![named_app(
"connector_68df038e0ba48191908c8434991bbac2",
"Gmail"
)]
);
assert_eq!(filtered, Vec::<AppInfo>::new());
}

View File

@@ -0,0 +1,72 @@
use anyhow::Context;
use tracing::warn;
use super::OPENAI_CURATED_MARKETPLACE_NAME;
use super::PluginCapabilitySummary;
use super::PluginReadRequest;
use super::PluginsManager;
use crate::config::Config;
use crate::features::Feature;
const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[
"github@openai-curated",
"notion@openai-curated",
"slack@openai-curated",
"gmail@openai-curated",
"google-calendar@openai-curated",
"google-docs@openai-curated",
"google-drive@openai-curated",
"google-sheets@openai-curated",
"google-slides@openai-curated",
];
pub(crate) fn list_tool_suggest_discoverable_plugins(
config: &Config,
) -> anyhow::Result<Vec<PluginCapabilitySummary>> {
if !config.features.enabled(Feature::Plugins) {
return Ok(Vec::new());
}
let plugins_manager = PluginsManager::new(config.codex_home.clone());
let marketplaces = plugins_manager
.list_marketplaces_for_config(config, &[])
.context("failed to list plugin marketplaces for tool suggestions")?;
let Some(curated_marketplace) = marketplaces
.into_iter()
.find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
else {
return Ok(Vec::new());
};
let mut discoverable_plugins = Vec::<PluginCapabilitySummary>::new();
for plugin in curated_marketplace.plugins {
if plugin.installed
|| !TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.id.as_str())
{
continue;
}
let plugin_id = plugin.id.clone();
let plugin_name = plugin.name.clone();
match plugins_manager.read_plugin_for_config(
config,
&PluginReadRequest {
plugin_name,
marketplace_path: curated_marketplace.path.clone(),
},
) {
Ok(plugin) => discoverable_plugins.push(plugin.plugin.into()),
Err(err) => warn!("failed to load curated plugin suggestion {plugin_id}: {err:#}"),
}
}
discoverable_plugins.sort_by(|left, right| {
left.display_name
.cmp(&right.display_name)
.then_with(|| left.config_name.cmp(&right.config_name))
});
Ok(discoverable_plugins)
}
#[cfg(test)]
#[path = "discoverable_tests.rs"]
mod tests;

View File

@@ -0,0 +1,119 @@
use super::*;
use crate::plugins::PluginInstallRequest;
use crate::plugins::test_support::load_plugins_config;
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 crate::tools::discoverable::DiscoverablePluginInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_returns_uninstalled_curated_plugins() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["sample", "slack"]);
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)
.unwrap()
.into_iter()
.map(DiscoverablePluginInfo::from)
.collect::<Vec<_>>();
assert_eq!(
discoverable_plugins,
vec![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()],
}]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_returns_empty_when_plugins_feature_disabled() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.unwrap()
.into_iter()
.map(DiscoverablePluginInfo::from)
.collect::<Vec<_>>();
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::new());
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_normalizes_description() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
write_plugins_feature_config(codex_home.path());
write_file(
&curated_root.join("plugins/slack/.codex-plugin/plugin.json"),
r#"{
"name": "slack",
"description": " Plugin\n with extra spacing "
}"#,
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.unwrap()
.into_iter()
.map(DiscoverablePluginInfo::from)
.collect::<Vec<_>>();
assert_eq!(
discoverable_plugins,
vec![DiscoverablePluginInfo {
id: "slack@openai-curated".to_string(),
name: "slack".to_string(),
description: Some("Plugin with extra spacing".to_string()),
has_skills: true,
mcp_server_names: vec!["sample-docs".to_string()],
app_connector_ids: vec!["connector_calendar".to_string()],
}]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
write_curated_plugin_sha(codex_home.path());
write_plugins_feature_config(codex_home.path());
PluginsManager::new(codex_home.path().to_path_buf())
.install_plugin(PluginInstallRequest {
plugin_name: "slack".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
curated_root.join(".agents/plugins/marketplace.json"),
)
.expect("marketplace path"),
})
.await
.expect("plugin should install");
let refreshed_config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config)
.unwrap()
.into_iter()
.map(DiscoverablePluginInfo::from)
.collect::<Vec<_>>();
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::new());
}

View File

@@ -68,7 +68,7 @@ use tracing::warn;
const DEFAULT_SKILLS_DIR_NAME: &str = "skills";
const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false);
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
@@ -219,6 +219,19 @@ impl PluginCapabilitySummary {
}
}
impl From<PluginDetailSummary> for PluginCapabilitySummary {
fn from(value: PluginDetailSummary) -> Self {
Self {
config_name: value.id,
display_name: value.name,
description: prompt_safe_plugin_description(value.description.as_deref()),
has_skills: !value.skills.is_empty(),
mcp_server_names: value.mcp_server_names,
app_connector_ids: value.apps,
}
}
}
fn prompt_safe_plugin_description(description: Option<&str>) -> Option<String> {
let description = description?
.split_whitespace()

View File

@@ -7,6 +7,10 @@ use crate::config_loader::ConfigLayerEntry;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigRequirements;
use crate::config_loader::ConfigRequirementsToml;
use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA;
use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha;
use crate::plugins::test_support::write_file;
use crate::plugins::test_support::write_openai_curated_marketplace;
use codex_app_server_protocol::ConfigLayerSource;
use pretty_assertions::assert_eq;
use std::fs;
@@ -19,13 +23,6 @@ use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
fn write_file(path: &Path, contents: &str) {
fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap();
fs::write(path, contents).unwrap();
}
fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
let plugin_root = root.join(dir_name);
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
@@ -39,44 +36,6 @@ fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap();
}
fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) {
fs::create_dir_all(root.join(".agents/plugins")).unwrap();
let plugins = plugin_names
.iter()
.map(|plugin_name| {
format!(
r#"{{
"name": "{plugin_name}",
"source": {{
"source": "local",
"path": "./plugins/{plugin_name}"
}}
}}"#
)
})
.collect::<Vec<_>>()
.join(",\n");
fs::write(
root.join(".agents/plugins/marketplace.json"),
format!(
r#"{{
"name": "{OPENAI_CURATED_MARKETPLACE_NAME}",
"plugins": [
{plugins}
]
}}"#
),
)
.unwrap();
for plugin_name in plugin_names {
write_plugin(root, &format!("plugins/{plugin_name}"), plugin_name);
}
}
fn write_curated_plugin_sha(codex_home: &Path, sha: &str) {
write_file(&codex_home.join(".tmp/plugins.sha"), &format!("{sha}\n"));
}
fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String {
let mut root = toml::map::Map::new();

View File

@@ -1,4 +1,5 @@
mod curated_repo;
mod discoverable;
mod injection;
mod manager;
mod manifest;
@@ -6,16 +7,20 @@ mod marketplace;
mod remote;
mod render;
mod store;
#[cfg(test)]
pub(crate) mod test_support;
mod toggles;
pub(crate) use curated_repo::curated_plugins_repo_path;
pub(crate) use curated_repo::read_curated_plugins_sha;
pub(crate) use curated_repo::sync_openai_plugins_repo;
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
pub(crate) use injection::build_plugin_injections;
pub use manager::AppConnectorId;
pub use manager::ConfiguredMarketplacePluginSummary;
pub use manager::ConfiguredMarketplaceSummary;
pub use manager::LoadedPlugin;
pub use manager::OPENAI_CURATED_MARKETPLACE_NAME;
pub use manager::PluginCapabilitySummary;
pub use manager::PluginDetailSummary;
pub use manager::PluginInstallError;

View File

@@ -0,0 +1,109 @@
use crate::config::CONFIG_TOML_FILE;
use crate::config::ConfigBuilder;
use std::fs;
use std::path::Path;
use super::OPENAI_CURATED_MARKETPLACE_NAME;
pub(crate) const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
pub(crate) fn write_file(path: &Path, contents: &str) {
fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap();
fs::write(path, contents).unwrap();
}
pub(crate) fn write_curated_plugin(root: &Path, plugin_name: &str) {
let plugin_root = root.join("plugins").join(plugin_name);
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
&format!(
r#"{{
"name": "{plugin_name}",
"description": "Plugin that includes skills, MCP servers, and app connectors"
}}"#
),
);
write_file(
&plugin_root.join("skills/SKILL.md"),
"---\nname: sample\ndescription: sample\n---\n",
);
write_file(
&plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample-docs": {
"type": "http",
"url": "https://sample.example/mcp"
}
}
}"#,
);
write_file(
&plugin_root.join(".app.json"),
r#"{
"apps": {
"calendar": {
"id": "connector_calendar"
}
}
}"#,
);
}
pub(crate) fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) {
let plugins = plugin_names
.iter()
.map(|plugin_name| {
format!(
r#"{{
"name": "{plugin_name}",
"source": {{
"source": "local",
"path": "./plugins/{plugin_name}"
}}
}}"#
)
})
.collect::<Vec<_>>()
.join(",\n");
write_file(
&root.join(".agents/plugins/marketplace.json"),
&format!(
r#"{{
"name": "{OPENAI_CURATED_MARKETPLACE_NAME}",
"plugins": [
{plugins}
]
}}"#
),
);
for plugin_name in plugin_names {
write_curated_plugin(root, plugin_name);
}
}
pub(crate) fn write_curated_plugin_sha(codex_home: &Path) {
write_curated_plugin_sha_with(codex_home, TEST_CURATED_PLUGIN_SHA);
}
pub(crate) fn write_curated_plugin_sha_with(codex_home: &Path, sha: &str) {
write_file(&codex_home.join(".tmp/plugins.sha"), &format!("{sha}\n"));
}
pub(crate) fn write_plugins_feature_config(codex_home: &Path) {
write_file(
&codex_home.join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
}
pub(crate) async fn load_plugins_config(codex_home: &Path) -> crate::config::Config {
ConfigBuilder::default()
.codex_home(codex_home.to_path_buf())
.fallback_cwd(Some(codex_home.to_path_buf()))
.build()
.await
.expect("config should load")
}

View File

@@ -64,6 +64,33 @@ pub fn thread_manager_with_models_provider_and_home(
ThreadManager::with_models_provider_and_home_for_tests(auth, provider, codex_home)
}
pub async fn start_thread_with_user_shell_override(
thread_manager: &ThreadManager,
config: Config,
user_shell_override: crate::shell::Shell,
) -> crate::error::Result<crate::NewThread> {
thread_manager
.start_thread_with_user_shell_override_for_tests(config, user_shell_override)
.await
}
pub async fn resume_thread_from_rollout_with_user_shell_override(
thread_manager: &ThreadManager,
config: Config,
rollout_path: PathBuf,
auth_manager: Arc<AuthManager>,
user_shell_override: crate::shell::Shell,
) -> crate::error::Result<crate::NewThread> {
thread_manager
.resume_thread_from_rollout_with_user_shell_override_for_tests(
config,
rollout_path,
auth_manager,
user_shell_override,
)
.await
}
pub fn models_manager_with_provider(
codex_home: PathBuf,
auth_manager: Arc<AuthManager>,

View File

@@ -381,6 +381,7 @@ impl ThreadManager {
persist_extended_history,
metrics_service_name,
parent_trace,
/*user_shell_override*/ None,
))
.await
}
@@ -420,6 +421,48 @@ impl ThreadManager {
persist_extended_history,
/*metrics_service_name*/ None,
parent_trace,
/*user_shell_override*/ None,
))
.await
}
pub(crate) async fn start_thread_with_user_shell_override_for_tests(
&self,
config: Config,
user_shell_override: crate::shell::Shell,
) -> CodexResult<NewThread> {
Box::pin(self.state.spawn_thread(
config,
InitialHistory::New,
Arc::clone(&self.state.auth_manager),
self.agent_control(),
Vec::new(),
/*persist_extended_history*/ false,
/*metrics_service_name*/ None,
/*parent_trace*/ None,
/*user_shell_override*/ Some(user_shell_override),
))
.await
}
pub(crate) async fn resume_thread_from_rollout_with_user_shell_override_for_tests(
&self,
config: Config,
rollout_path: PathBuf,
auth_manager: Arc<AuthManager>,
user_shell_override: crate::shell::Shell,
) -> CodexResult<NewThread> {
let initial_history = RolloutRecorder::get_rollout_history(&rollout_path).await?;
Box::pin(self.state.spawn_thread(
config,
initial_history,
auth_manager,
self.agent_control(),
Vec::new(),
/*persist_extended_history*/ false,
/*metrics_service_name*/ None,
/*parent_trace*/ None,
/*user_shell_override*/ Some(user_shell_override),
))
.await
}
@@ -505,6 +548,7 @@ impl ThreadManager {
persist_extended_history,
/*metrics_service_name*/ None,
parent_trace,
/*user_shell_override*/ None,
))
.await
}
@@ -590,6 +634,7 @@ impl ThreadManagerState {
metrics_service_name,
inherited_shell_snapshot,
/*parent_trace*/ None,
/*user_shell_override*/ None,
))
.await
}
@@ -614,6 +659,7 @@ impl ThreadManagerState {
/*metrics_service_name*/ None,
inherited_shell_snapshot,
/*parent_trace*/ None,
/*user_shell_override*/ None,
))
.await
}
@@ -638,6 +684,7 @@ impl ThreadManagerState {
/*metrics_service_name*/ None,
inherited_shell_snapshot,
/*parent_trace*/ None,
/*user_shell_override*/ None,
))
.await
}
@@ -654,6 +701,7 @@ impl ThreadManagerState {
persist_extended_history: bool,
metrics_service_name: Option<String>,
parent_trace: Option<W3cTraceContext>,
user_shell_override: Option<crate::shell::Shell>,
) -> CodexResult<NewThread> {
Box::pin(self.spawn_thread_with_source(
config,
@@ -666,6 +714,7 @@ impl ThreadManagerState {
metrics_service_name,
/*inherited_shell_snapshot*/ None,
parent_trace,
user_shell_override,
))
.await
}
@@ -683,6 +732,7 @@ impl ThreadManagerState {
metrics_service_name: Option<String>,
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
parent_trace: Option<W3cTraceContext>,
user_shell_override: Option<crate::shell::Shell>,
) -> CodexResult<NewThread> {
let watch_registration = self
.file_watcher
@@ -704,6 +754,7 @@ impl ThreadManagerState {
persist_extended_history,
metrics_service_name,
inherited_shell_snapshot,
user_shell_override,
parent_trace,
})
.await?;

View File

@@ -3,6 +3,8 @@ use codex_app_server_protocol::AppInfo;
use serde::Deserialize;
use serde::Serialize;
const TUI_APP_SERVER_CLIENT_NAME: &str = "codex-tui";
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum DiscoverableToolType {
@@ -69,6 +71,13 @@ impl DiscoverableTool {
Self::Plugin(plugin) => plugin.description.as_deref(),
}
}
pub(crate) fn install_url(&self) -> Option<&str> {
match self {
Self::Connector(connector) => connector.install_url.as_deref(),
Self::Plugin(_) => None,
}
}
}
impl From<AppInfo> for DiscoverableTool {
@@ -83,6 +92,20 @@ impl From<DiscoverablePluginInfo> for DiscoverableTool {
}
}
pub(crate) fn filter_tool_suggest_discoverable_tools_for_client(
discoverable_tools: Vec<DiscoverableTool>,
app_server_client_name: Option<&str>,
) -> Vec<DiscoverableTool> {
if app_server_client_name != Some(TUI_APP_SERVER_CLIENT_NAME) {
return discoverable_tools;
}
discoverable_tools
.into_iter()
.filter(|tool| !matches!(tool, DiscoverableTool::Plugin(_)))
.collect()
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct DiscoverablePluginInfo {
pub(crate) id: String,

View File

@@ -23,6 +23,7 @@ use crate::tools::context::ToolPayload;
use crate::tools::discoverable::DiscoverableTool;
use crate::tools::discoverable::DiscoverableToolAction;
use crate::tools::discoverable::DiscoverableToolType;
use crate::tools::discoverable::filter_tool_suggest_discoverable_tools_for_client;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
@@ -59,7 +60,8 @@ struct ToolSuggestMeta<'a> {
suggest_reason: &'a str,
tool_id: &'a str,
tool_name: &'a str,
install_url: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
install_url: Option<&'a str>,
}
#[async_trait]
@@ -95,15 +97,16 @@ impl ToolHandler for ToolSuggestHandler {
"suggest_reason must not be empty".to_string(),
));
}
if args.tool_type == DiscoverableToolType::Plugin {
return Err(FunctionCallError::RespondToModel(
"plugin tool suggestions are not currently available".to_string(),
));
}
if args.action_type != DiscoverableToolAction::Install {
return Err(FunctionCallError::RespondToModel(
"connector tool suggestions currently support only action_type=\"install\""
.to_string(),
"tool suggestions currently support only action_type=\"install\"".to_string(),
));
}
if args.tool_type == DiscoverableToolType::Plugin
&& turn.app_server_client_name.as_deref() == Some("codex-tui")
{
return Err(FunctionCallError::RespondToModel(
"plugin tool suggestions are not available in codex-tui yet".to_string(),
));
}
@@ -121,11 +124,11 @@ impl ToolHandler for ToolSuggestHandler {
&accessible_connectors,
)
.await
.map(|connectors| {
connectors
.into_iter()
.map(DiscoverableTool::from)
.collect::<Vec<_>>()
.map(|discoverable_tools| {
filter_tool_suggest_discoverable_tools_for_client(
discoverable_tools,
turn.app_server_client_name.as_deref(),
)
})
.map_err(|err| {
FunctionCallError::RespondToModel(format!(
@@ -133,14 +136,9 @@ impl ToolHandler for ToolSuggestHandler {
))
})?;
let connector = discoverable_tools
let tool = discoverable_tools
.into_iter()
.find_map(|tool| match tool {
DiscoverableTool::Connector(connector) if connector.id == args.tool_id => {
Some(*connector)
}
DiscoverableTool::Connector(_) | DiscoverableTool::Plugin(_) => None,
})
.find(|tool| tool.tool_type() == args.tool_type && tool.id() == args.tool_id)
.ok_or_else(|| {
FunctionCallError::RespondToModel(format!(
"tool_id must match one of the discoverable tools exposed by {TOOL_SUGGEST_TOOL_NAME}"
@@ -153,7 +151,7 @@ impl ToolHandler for ToolSuggestHandler {
turn.sub_id.clone(),
&args,
suggest_reason,
&connector,
&tool,
);
let response = session
.request_mcp_server_elicitation(turn.as_ref(), request_id, params)
@@ -163,37 +161,12 @@ impl ToolHandler for ToolSuggestHandler {
.is_some_and(|response| response.action == ElicitationAction::Accept);
let completed = if user_confirmed {
let manager = session.services.mcp_connection_manager.read().await;
match manager.hard_refresh_codex_apps_tools_cache().await {
Ok(mcp_tools) => {
let accessible_connectors = connectors::with_app_enabled_state(
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
&turn.config,
);
connectors::refresh_accessible_connectors_cache_from_mcp_tools(
&turn.config,
auth.as_ref(),
&mcp_tools,
);
verified_connector_suggestion_completed(
args.action_type,
connector.id.as_str(),
&accessible_connectors,
)
}
Err(err) => {
warn!(
"failed to refresh codex apps tools cache after tool suggestion for {}: {err:#}",
connector.id
);
false
}
}
verify_tool_suggestion_completed(&session, &turn, &tool, auth.as_ref()).await
} else {
false
};
if completed {
if completed && let DiscoverableTool::Connector(connector) = &tool {
session
.merge_connector_selection(HashSet::from([connector.id.clone()]))
.await;
@@ -204,8 +177,8 @@ impl ToolHandler for ToolSuggestHandler {
user_confirmed,
tool_type: args.tool_type,
action_type: args.action_type,
tool_id: connector.id,
tool_name: connector.name,
tool_id: tool.id().to_string(),
tool_name: tool.name().to_string(),
suggest_reason: suggest_reason.to_string(),
})
.map_err(|err| {
@@ -223,18 +196,11 @@ fn build_tool_suggestion_elicitation_request(
turn_id: String,
args: &ToolSuggestArgs,
suggest_reason: &str,
connector: &AppInfo,
tool: &DiscoverableTool,
) -> McpServerElicitationRequestParams {
let tool_name = connector.name.clone();
let install_url = connector
.install_url
.clone()
.unwrap_or_else(|| connectors::connector_install_url(&tool_name, &connector.id));
let message = format!(
"{tool_name} could help with this request.\n\n{suggest_reason}\n\nOpen ChatGPT to {} it, then confirm here if you finish.",
args.action_type.as_str()
);
let tool_name = tool.name().to_string();
let install_url = tool.install_url().map(ToString::to_string);
let message = suggest_reason.to_string();
McpServerElicitationRequestParams {
thread_id,
@@ -245,9 +211,9 @@ fn build_tool_suggestion_elicitation_request(
args.tool_type,
args.action_type,
suggest_reason,
connector.id.as_str(),
tool.id(),
tool_name.as_str(),
install_url.as_str(),
install_url.as_deref(),
))),
message,
requested_schema: McpElicitationSchema {
@@ -266,7 +232,7 @@ fn build_tool_suggestion_meta<'a>(
suggest_reason: &'a str,
tool_id: &'a str,
tool_name: &'a str,
install_url: &'a str,
install_url: Option<&'a str>,
) -> ToolSuggestMeta<'a> {
ToolSuggestMeta {
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
@@ -279,18 +245,74 @@ fn build_tool_suggestion_meta<'a>(
}
}
async fn verify_tool_suggestion_completed(
session: &crate::codex::Session,
turn: &crate::codex::TurnContext,
tool: &DiscoverableTool,
auth: Option<&crate::CodexAuth>,
) -> bool {
match tool {
DiscoverableTool::Connector(connector) => {
let manager = session.services.mcp_connection_manager.read().await;
match manager.hard_refresh_codex_apps_tools_cache().await {
Ok(mcp_tools) => {
let accessible_connectors = connectors::with_app_enabled_state(
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
&turn.config,
);
connectors::refresh_accessible_connectors_cache_from_mcp_tools(
&turn.config,
auth,
&mcp_tools,
);
verified_connector_suggestion_completed(
connector.id.as_str(),
&accessible_connectors,
)
}
Err(err) => {
warn!(
"failed to refresh codex apps tools cache after tool suggestion for {}: {err:#}",
connector.id
);
false
}
}
}
DiscoverableTool::Plugin(plugin) => {
session.reload_user_config_layer().await;
let config = session.get_config().await;
verified_plugin_suggestion_completed(
plugin.id.as_str(),
config.as_ref(),
session.services.plugins_manager.as_ref(),
)
}
}
}
fn verified_connector_suggestion_completed(
action_type: DiscoverableToolAction,
tool_id: &str,
accessible_connectors: &[AppInfo],
) -> bool {
accessible_connectors
.iter()
.find(|connector| connector.id == tool_id)
.is_some_and(|connector| match action_type {
DiscoverableToolAction::Install => connector.is_accessible,
DiscoverableToolAction::Enable => connector.is_accessible && connector.is_enabled,
})
.is_some_and(|connector| connector.is_accessible)
}
fn verified_plugin_suggestion_completed(
tool_id: &str,
config: &crate::config::Config,
plugins_manager: &crate::plugins::PluginsManager,
) -> bool {
plugins_manager
.list_marketplaces_for_config(config, &[])
.ok()
.into_iter()
.flatten()
.flat_map(|marketplace| marketplace.plugins.into_iter())
.any(|plugin| plugin.id == tool_id && plugin.installed)
}
#[cfg(test)]

View File

@@ -1,5 +1,18 @@
use super::*;
use crate::plugins::PluginInstallRequest;
use crate::plugins::PluginsManager;
use crate::plugins::test_support::load_plugins_config;
use crate::plugins::test_support::write_curated_plugin_sha;
use crate::plugins::test_support::write_openai_curated_marketplace;
use crate::plugins::test_support::write_plugins_feature_config;
use crate::tools::discoverable::DiscoverablePluginInfo;
use crate::tools::discoverable::filter_tool_suggest_discoverable_tools_for_client;
use codex_app_server_protocol::AppInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
use tempfile::tempdir;
#[test]
fn build_tool_suggestion_elicitation_request_uses_expected_shape() {
@@ -9,7 +22,7 @@ fn build_tool_suggestion_elicitation_request_uses_expected_shape() {
tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(),
suggest_reason: "Plan and reference events from your calendar".to_string(),
};
let connector = AppInfo {
let connector = DiscoverableTool::Connector(Box::new(AppInfo {
id: "connector_2128aebfecb84f64a069897515042a44".to_string(),
name: "Google Calendar".to_string(),
description: Some("Plan events and schedules.".to_string()),
@@ -26,7 +39,7 @@ fn build_tool_suggestion_elicitation_request_uses_expected_shape() {
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
};
}));
let request = build_tool_suggestion_elicitation_request(
"thread-1".to_string(),
@@ -37,31 +50,86 @@ fn build_tool_suggestion_elicitation_request_uses_expected_shape() {
);
assert_eq!(
request,
McpServerElicitationRequestParams {
thread_id: "thread-1".to_string(),
turn_id: Some("turn-1".to_string()),
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
request: McpServerElicitationRequest::Form {
meta: Some(json!(ToolSuggestMeta {
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
tool_type: DiscoverableToolType::Connector,
suggest_type: DiscoverableToolAction::Install,
suggest_reason: "Plan and reference events from your calendar",
tool_id: "connector_2128aebfecb84f64a069897515042a44",
tool_name: "Google Calendar",
install_url: "https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44",
})),
message: "Google Calendar could help with this request.\n\nPlan and reference events from your calendar\n\nOpen ChatGPT to install it, then confirm here if you finish.".to_string(),
requested_schema: McpElicitationSchema {
schema_uri: None,
type_: McpElicitationObjectType::Object,
properties: BTreeMap::new(),
required: None,
},
request,
McpServerElicitationRequestParams {
thread_id: "thread-1".to_string(),
turn_id: Some("turn-1".to_string()),
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
request: McpServerElicitationRequest::Form {
meta: Some(json!(ToolSuggestMeta {
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
tool_type: DiscoverableToolType::Connector,
suggest_type: DiscoverableToolAction::Install,
suggest_reason: "Plan and reference events from your calendar",
tool_id: "connector_2128aebfecb84f64a069897515042a44",
tool_name: "Google Calendar",
install_url: Some(
"https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44"
),
})),
message: "Plan and reference events from your calendar".to_string(),
requested_schema: McpElicitationSchema {
schema_uri: None,
type_: McpElicitationObjectType::Object,
properties: BTreeMap::new(),
required: None,
},
}
);
},
}
);
}
#[test]
fn build_tool_suggestion_elicitation_request_for_plugin_omits_install_url() {
let args = ToolSuggestArgs {
tool_type: DiscoverableToolType::Plugin,
action_type: DiscoverableToolAction::Install,
tool_id: "sample@openai-curated".to_string(),
suggest_reason: "Use the sample plugin's skills and MCP server".to_string(),
};
let plugin = DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo {
id: "sample@openai-curated".to_string(),
name: "Sample Plugin".to_string(),
description: Some("Includes skills, MCP servers, and apps.".to_string()),
has_skills: true,
mcp_server_names: vec!["sample-docs".to_string()],
app_connector_ids: vec!["connector_calendar".to_string()],
}));
let request = build_tool_suggestion_elicitation_request(
"thread-1".to_string(),
"turn-1".to_string(),
&args,
"Use the sample plugin's skills and MCP server",
&plugin,
);
assert_eq!(
request,
McpServerElicitationRequestParams {
thread_id: "thread-1".to_string(),
turn_id: Some("turn-1".to_string()),
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
request: McpServerElicitationRequest::Form {
meta: Some(json!(ToolSuggestMeta {
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
tool_type: DiscoverableToolType::Plugin,
suggest_type: DiscoverableToolAction::Install,
suggest_reason: "Use the sample plugin's skills and MCP server",
tool_id: "sample@openai-curated",
tool_name: "Sample Plugin",
install_url: None,
})),
message: "Use the sample plugin's skills and MCP server".to_string(),
requested_schema: McpElicitationSchema {
schema_uri: None,
type_: McpElicitationObjectType::Object,
properties: BTreeMap::new(),
required: None,
},
},
}
);
}
#[test]
@@ -72,7 +140,7 @@ fn build_tool_suggestion_meta_uses_expected_shape() {
"Find and reference emails from your inbox",
"connector_68df038e0ba48191908c8434991bbac2",
"Gmail",
"https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2",
Some("https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2"),
);
assert_eq!(
@@ -84,13 +152,63 @@ fn build_tool_suggestion_meta_uses_expected_shape() {
suggest_reason: "Find and reference emails from your inbox",
tool_id: "connector_68df038e0ba48191908c8434991bbac2",
tool_name: "Gmail",
install_url: "https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2",
install_url: Some(
"https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2"
),
}
);
}
#[test]
fn verified_connector_suggestion_completed_requires_installed_connector() {
fn filter_tool_suggest_discoverable_tools_for_codex_tui_omits_plugins() {
let discoverable_tools = vec![
DiscoverableTool::Connector(Box::new(AppInfo {
id: "connector_google_calendar".to_string(),
name: "Google Calendar".to_string(),
description: Some("Plan events and schedules.".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://example.test/google-calendar".to_string()),
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
})),
DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo {
id: "slack@openai-curated".to_string(),
name: "Slack".to_string(),
description: Some("Search Slack messages".to_string()),
has_skills: true,
mcp_server_names: vec!["slack".to_string()],
app_connector_ids: vec!["connector_slack".to_string()],
})),
];
assert_eq!(
filter_tool_suggest_discoverable_tools_for_client(discoverable_tools, Some("codex-tui"),),
vec![DiscoverableTool::Connector(Box::new(AppInfo {
id: "connector_google_calendar".to_string(),
name: "Google Calendar".to_string(),
description: Some("Plan events and schedules.".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://example.test/google-calendar".to_string()),
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
}))]
);
}
#[test]
fn verified_connector_suggestion_completed_requires_accessible_connector() {
let accessible_connectors = vec![AppInfo {
id: "calendar".to_string(),
name: "Google Calendar".to_string(),
@@ -103,65 +221,52 @@ fn verified_connector_suggestion_completed_requires_installed_connector() {
labels: None,
install_url: None,
is_accessible: true,
is_enabled: true,
is_enabled: false,
plugin_display_names: Vec::new(),
}];
assert!(verified_connector_suggestion_completed(
DiscoverableToolAction::Install,
"calendar",
&accessible_connectors,
));
assert!(!verified_connector_suggestion_completed(
DiscoverableToolAction::Install,
"gmail",
&accessible_connectors,
));
}
#[test]
fn verified_connector_suggestion_completed_requires_enabled_connector_for_enable() {
let accessible_connectors = vec![
AppInfo {
id: "calendar".to_string(),
name: "Google Calendar".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: true,
is_enabled: false,
plugin_display_names: Vec::new(),
},
AppInfo {
id: "gmail".to_string(),
name: "Gmail".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: true,
is_enabled: true,
plugin_display_names: Vec::new(),
},
];
#[tokio::test]
async fn verified_plugin_suggestion_completed_requires_installed_plugin() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["sample"]);
write_curated_plugin_sha(codex_home.path());
write_plugins_feature_config(codex_home.path());
assert!(!verified_connector_suggestion_completed(
DiscoverableToolAction::Enable,
"calendar",
&accessible_connectors,
let config = load_plugins_config(codex_home.path()).await;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
assert!(!verified_plugin_suggestion_completed(
"sample@openai-curated",
&config,
&plugins_manager,
));
assert!(verified_connector_suggestion_completed(
DiscoverableToolAction::Enable,
"gmail",
&accessible_connectors,
plugins_manager
.install_plugin(PluginInstallRequest {
plugin_name: "sample".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
curated_root.join(".agents/plugins/marketplace.json"),
)
.expect("marketplace path"),
})
.await
.expect("plugin should install");
let refreshed_config = load_plugins_config(codex_home.path()).await;
assert!(verified_plugin_suggestion_completed(
"sample@openai-curated",
&refreshed_config,
&plugins_manager,
));
}

View File

@@ -1827,7 +1827,7 @@ fn format_discoverable_tools(discoverable_tools: &[DiscoverableTool]) -> String
});
let default_action = match tool.tool_type() {
DiscoverableToolType::Connector => DiscoverableToolAction::Install,
DiscoverableToolType::Plugin => DiscoverableToolAction::Enable,
DiscoverableToolType::Plugin => DiscoverableToolAction::Install,
};
format!(
"- {} (id: `{}`, type: {}, action: {}): {}",

View File

@@ -2097,7 +2097,8 @@ fn tool_suggest_description_lists_discoverable_tools() {
assert!(description.contains("Sample Plugin"));
assert!(description.contains("Plan events and schedules."));
assert!(description.contains("Find and summarize email threads."));
assert!(description.contains("id: `sample@test`, type: plugin, action: enable"));
assert!(description.contains("id: `sample@test`, type: plugin, action: install"));
assert!(description.contains("`action_type`: `install` or `enable`"));
assert!(
description.contains("skills; MCP servers: sample-docs; app connectors: connector_sample")
);

View File

@@ -13,6 +13,8 @@ use codex_core::built_in_model_providers;
use codex_core::config::Config;
use codex_core::features::Feature;
use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig;
use codex_core::shell::Shell;
use codex_core::shell::get_shell_by_model_provided_path;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::protocol::AskForApproval;
@@ -64,6 +66,7 @@ pub struct TestCodexBuilder {
auth: CodexAuth,
pre_build_hooks: Vec<Box<PreBuildHook>>,
home: Option<Arc<TempDir>>,
user_shell_override: Option<Shell>,
}
impl TestCodexBuilder {
@@ -100,6 +103,19 @@ impl TestCodexBuilder {
self
}
pub fn with_user_shell(mut self, user_shell: Shell) -> Self {
self.user_shell_override = Some(user_shell);
self
}
pub fn with_windows_cmd_shell(self) -> Self {
if cfg!(windows) {
self.with_user_shell(get_shell_by_model_provided_path(&PathBuf::from("cmd.exe")))
} else {
self
}
}
pub async fn build(&mut self, server: &wiremock::MockServer) -> anyhow::Result<TestCodex> {
let home = match self.home.clone() {
Some(home) => home,
@@ -199,9 +215,23 @@ impl TestCodexBuilder {
)
};
let thread_manager = Arc::new(thread_manager);
let user_shell_override = self.user_shell_override.clone();
let new_conversation = match resume_from {
Some(path) => {
let new_conversation = match (resume_from, user_shell_override) {
(Some(path), Some(user_shell_override)) => {
let auth_manager = codex_core::test_support::auth_manager_from_auth(auth);
Box::pin(
codex_core::test_support::resume_thread_from_rollout_with_user_shell_override(
thread_manager.as_ref(),
config.clone(),
path,
auth_manager,
user_shell_override,
),
)
.await?
}
(Some(path), None) => {
let auth_manager = codex_core::test_support::auth_manager_from_auth(auth);
Box::pin(thread_manager.resume_thread_from_rollout(
config.clone(),
@@ -211,7 +241,17 @@ impl TestCodexBuilder {
))
.await?
}
None => Box::pin(thread_manager.start_thread(config.clone())).await?,
(None, Some(user_shell_override)) => {
Box::pin(
codex_core::test_support::start_thread_with_user_shell_override(
thread_manager.as_ref(),
config.clone(),
user_shell_override,
),
)
.await?
}
(None, None) => Box::pin(thread_manager.start_thread(config.clone())).await?,
};
Ok(TestCodex {
@@ -562,6 +602,7 @@ pub fn test_codex() -> TestCodexBuilder {
auth: CodexAuth::from_api_key("dummy"),
pre_build_hooks: vec![],
home: None,
user_shell_override: None,
}
}

View File

@@ -35,7 +35,7 @@ async fn websocket_test_codex_shell_chain() -> Result<()> {
]])
.await;
let mut builder = test_codex();
let mut builder = test_codex().with_windows_cmd_shell();
let test = builder.build_with_websocket_server(&server).await?;
test.submit_turn_with_policy(
@@ -183,7 +183,7 @@ async fn websocket_v2_test_codex_shell_chain() -> Result<()> {
]])
.await;
let mut builder = test_codex().with_config(|config| {
let mut builder = test_codex().with_windows_cmd_shell().with_config(|config| {
config
.features
.enable(Feature::ResponsesWebsocketsV2)

View File

@@ -1,6 +1,8 @@
#![allow(clippy::expect_used)]
use anyhow::Result;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_test_macros::large_stack_test;
use core_test_support::responses::ev_apply_patch_call;
use core_test_support::responses::ev_apply_patch_custom_tool_call;
@@ -740,7 +742,9 @@ async fn apply_patch_shell_command_heredoc_with_cd_updates_relative_workdir() ->
async fn apply_patch_cli_can_use_shell_command_output_as_patch_input() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = apply_patch_harness_with(|builder| builder.with_model("gpt-5.1")).await?;
let harness =
apply_patch_harness_with(|builder| builder.with_model("gpt-5.1").with_windows_cmd_shell())
.await?;
let source_contents = "line1\nnaïve café\nline3\n";
let source_path = harness.path("source.txt");
@@ -786,9 +790,21 @@ async fn apply_patch_cli_can_use_shell_command_output_as_patch_input() -> Result
match call_num {
0 => {
let command = if cfg!(windows) {
"Get-Content -Encoding utf8 source.txt"
// Encode the nested PowerShell script so `cmd.exe /c` does not leave the
// read command wrapped in quotes, and suppress progress records so the
// shell tool only returns the file contents back to apply_patch.
let script = "$ProgressPreference = 'SilentlyContinue'; [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); [System.IO.File]::ReadAllText('source.txt', [System.Text.UTF8Encoding]::new($false))";
let encoded = BASE64_STANDARD.encode(
script
.encode_utf16()
.flat_map(u16::to_le_bytes)
.collect::<Vec<u8>>(),
);
format!(
"powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand {encoded}"
)
} else {
"cat source.txt"
"cat source.txt".to_string()
};
let args = json!({
"command": command,
@@ -807,9 +823,7 @@ async fn apply_patch_cli_can_use_shell_command_output_as_patch_input() -> Result
let body_json: serde_json::Value =
request.body_json().expect("request body should be json");
let read_output = function_call_output_text(&body_json, &self.read_call_id);
eprintln!("read_output: \n{read_output}");
let stdout = stdout_from_shell_output(&read_output);
eprintln!("stdout: \n{stdout}");
let patch_lines = stdout
.lines()
.map(|line| format!("+{line}"))
@@ -819,8 +833,6 @@ async fn apply_patch_cli_can_use_shell_command_output_as_patch_input() -> Result
"*** Begin Patch\n*** Add File: target.txt\n{patch_lines}\n*** End Patch"
);
eprintln!("patch: \n{patch}");
let body = sse(vec![
ev_response_created("resp-2"),
ev_apply_patch_custom_tool_call(&self.apply_call_id, &patch),

View File

@@ -149,7 +149,7 @@ pub(crate) struct ToolSuggestionRequest {
pub(crate) suggest_reason: String,
pub(crate) tool_id: String,
pub(crate) tool_name: String,
pub(crate) install_url: String,
pub(crate) install_url: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
@@ -373,8 +373,8 @@ fn parse_tool_suggestion_request(meta: Option<&Value>) -> Option<ToolSuggestionR
tool_name: meta.get(TOOL_NAME_KEY).and_then(Value::as_str)?.to_string(),
install_url: meta
.get(TOOL_SUGGEST_INSTALL_URL_KEY)
.and_then(Value::as_str)?
.to_string(),
.and_then(Value::as_str)
.map(ToString::to_string),
})
}
@@ -1945,7 +1945,39 @@ mod tests {
suggest_reason: "Plan and reference events from your calendar".to_string(),
tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(),
tool_name: "Google Calendar".to_string(),
install_url: "https://example.test/google-calendar".to_string(),
install_url: Some("https://example.test/google-calendar".to_string()),
})
);
}
#[test]
fn plugin_tool_suggestion_meta_without_install_url_is_parsed_into_request_payload() {
let request = McpServerElicitationFormRequest::from_event(
ThreadId::default(),
form_request(
"Suggest Slack",
empty_object_schema(),
Some(serde_json::json!({
"codex_approval_kind": "tool_suggestion",
"tool_type": "plugin",
"suggest_type": "install",
"suggest_reason": "Install the Slack plugin to search messages",
"tool_id": "slack@openai-curated",
"tool_name": "Slack",
})),
),
)
.expect("expected tool suggestion form");
assert_eq!(
request.tool_suggestion(),
Some(&ToolSuggestionRequest {
tool_type: ToolSuggestionToolType::Plugin,
suggest_type: ToolSuggestionType::Install,
suggest_reason: "Install the Slack plugin to search messages".to_string(),
tool_id: "slack@openai-curated".to_string(),
tool_name: "Slack".to_string(),
install_url: None,
})
);
}

View File

@@ -965,7 +965,9 @@ impl BottomPane {
request
};
if let Some(tool_suggestion) = request.tool_suggestion() {
if let Some(tool_suggestion) = request.tool_suggestion()
&& let Some(install_url) = tool_suggestion.install_url.clone()
{
let suggestion_type = match tool_suggestion.suggest_type {
mcp_server_elicitation::ToolSuggestionType::Install => {
AppLinkSuggestionType::Install
@@ -989,7 +991,7 @@ impl BottomPane {
"Enable this app to use it for the current request.".to_string()
}
},
url: tool_suggestion.install_url.clone(),
url: install_url,
is_installed,
is_enabled: false,
suggest_reason: Some(tool_suggestion.suggest_reason.clone()),

View File

@@ -16,9 +16,12 @@ use crate::app_event::AppEvent;
use crate::app_server_session::AppServerSession;
use crate::app_server_session::app_server_rate_limit_snapshot_to_core;
use crate::app_server_session::status_account_display_from_auth_mode;
use crate::local_chatgpt_auth::load_local_chatgpt_auth;
use codex_app_server_client::AppServerEvent;
use codex_app_server_protocol::ChatgptAuthTokensRefreshParams;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::Turn;
@@ -90,6 +93,7 @@ impl App {
matches!(
notification.auth_mode,
Some(codex_app_server_protocol::AuthMode::Chatgpt)
| Some(codex_app_server_protocol::AuthMode::ChatgptAuthTokens)
),
);
}
@@ -150,6 +154,15 @@ impl App {
}
}
AppServerEvent::ServerRequest(request) => {
if let ServerRequest::ChatgptAuthTokensRefresh { request_id, params } = request {
self.handle_chatgpt_auth_tokens_refresh_request(
app_server_client,
request_id,
params,
)
.await;
return;
}
if let Some(unsupported) = self
.pending_app_server_requests
.note_server_request(&request)
@@ -181,6 +194,70 @@ impl App {
}
}
async fn handle_chatgpt_auth_tokens_refresh_request(
&mut self,
app_server_client: &AppServerSession,
request_id: codex_app_server_protocol::RequestId,
params: ChatgptAuthTokensRefreshParams,
) {
let config = self.config.clone();
let result = tokio::task::spawn_blocking(move || {
resolve_chatgpt_auth_tokens_refresh_response(
&config.codex_home,
config.cli_auth_credentials_store_mode,
config.forced_chatgpt_workspace_id.as_deref(),
&params,
)
})
.await;
match result {
Ok(Ok(response)) => {
let response = serde_json::to_value(response).map_err(|err| {
format!("failed to serialize chatgpt auth refresh response: {err}")
});
match response {
Ok(response) => {
if let Err(err) = app_server_client
.resolve_server_request(request_id, response)
.await
{
tracing::warn!("failed to resolve chatgpt auth refresh request: {err}");
}
}
Err(err) => {
self.chat_widget.add_error_message(err.clone());
if let Err(reject_err) = self
.reject_app_server_request(app_server_client, request_id, err)
.await
{
tracing::warn!("{reject_err}");
}
}
}
}
Ok(Err(err)) => {
self.chat_widget.add_error_message(err.clone());
if let Err(reject_err) = self
.reject_app_server_request(app_server_client, request_id, err)
.await
{
tracing::warn!("{reject_err}");
}
}
Err(err) => {
let message = format!("chatgpt auth refresh task failed: {err}");
self.chat_widget.add_error_message(message.clone());
if let Err(reject_err) = self
.reject_app_server_request(app_server_client, request_id, message)
.await
{
tracing::warn!("{reject_err}");
}
}
}
}
async fn reject_app_server_request(
&self,
app_server_client: &AppServerSession,
@@ -201,6 +278,28 @@ impl App {
}
}
fn resolve_chatgpt_auth_tokens_refresh_response(
codex_home: &std::path::Path,
auth_credentials_store_mode: codex_core::auth::AuthCredentialsStoreMode,
forced_chatgpt_workspace_id: Option<&str>,
params: &ChatgptAuthTokensRefreshParams,
) -> Result<codex_app_server_protocol::ChatgptAuthTokensRefreshResponse, String> {
let auth = load_local_chatgpt_auth(
codex_home,
auth_credentials_store_mode,
forced_chatgpt_workspace_id,
)?;
if let Some(previous_account_id) = params.previous_account_id.as_deref()
&& previous_account_id != auth.chatgpt_account_id
{
return Err(format!(
"local ChatGPT auth refresh account mismatch: expected `{previous_account_id}`, got `{}`",
auth.chatgpt_account_id
));
}
Ok(auth.to_refresh_response())
}
/// Convert a `Thread` snapshot into a flat sequence of protocol `Event`s
/// suitable for replaying into the TUI event store.
///
@@ -624,6 +723,113 @@ fn thread_item_to_core(item: &ThreadItem) -> Option<TurnItem> {
}
}
#[cfg(test)]
mod refresh_tests {
use super::*;
use base64::Engine;
use chrono::Utc;
use codex_app_server_protocol::AuthMode;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::auth::AuthDotJson;
use codex_core::auth::save_auth;
use codex_core::token_data::TokenData;
use pretty_assertions::assert_eq;
use serde::Serialize;
use serde_json::json;
use tempfile::TempDir;
fn fake_jwt(account_id: &str, plan_type: &str) -> String {
#[derive(Serialize)]
struct Header {
alg: &'static str,
typ: &'static str,
}
let header = Header {
alg: "none",
typ: "JWT",
};
let payload = json!({
"email": "user@example.com",
"https://api.openai.com/auth": {
"chatgpt_account_id": account_id,
"chatgpt_plan_type": plan_type,
},
});
let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
let header_b64 = encode(&serde_json::to_vec(&header).expect("serialize header"));
let payload_b64 = encode(&serde_json::to_vec(&payload).expect("serialize payload"));
let signature_b64 = encode(b"sig");
format!("{header_b64}.{payload_b64}.{signature_b64}")
}
fn write_chatgpt_auth(codex_home: &std::path::Path) {
let id_token = fake_jwt("workspace-1", "business");
let access_token = fake_jwt("workspace-1", "business");
save_auth(
codex_home,
&AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: Some(TokenData {
id_token: codex_core::token_data::parse_chatgpt_jwt_claims(&id_token)
.expect("id token should parse"),
access_token,
refresh_token: "refresh-token".to_string(),
account_id: Some("workspace-1".to_string()),
}),
last_refresh: Some(Utc::now()),
},
AuthCredentialsStoreMode::File,
)
.expect("chatgpt auth should save");
}
#[test]
fn refresh_request_uses_local_chatgpt_auth() {
let codex_home = TempDir::new().expect("tempdir");
write_chatgpt_auth(codex_home.path());
let response = resolve_chatgpt_auth_tokens_refresh_response(
codex_home.path(),
AuthCredentialsStoreMode::File,
Some("workspace-1"),
&ChatgptAuthTokensRefreshParams {
reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized,
previous_account_id: Some("workspace-1".to_string()),
},
)
.expect("refresh response should resolve");
assert_eq!(response.chatgpt_account_id, "workspace-1");
assert_eq!(response.chatgpt_plan_type.as_deref(), Some("business"));
assert!(!response.access_token.is_empty());
}
#[test]
fn refresh_request_rejects_account_mismatch() {
let codex_home = TempDir::new().expect("tempdir");
write_chatgpt_auth(codex_home.path());
let err = resolve_chatgpt_auth_tokens_refresh_response(
codex_home.path(),
AuthCredentialsStoreMode::File,
Some("workspace-1"),
&ChatgptAuthTokensRefreshParams {
reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized,
previous_account_id: Some("workspace-2".to_string()),
},
)
.expect_err("mismatched account should fail");
assert_eq!(
err,
"local ChatGPT auth refresh account mismatch: expected `workspace-2`, got `workspace-1`"
);
}
}
fn app_server_web_search_action_to_core(
action: codex_app_server_protocol::WebSearchAction,
) -> Option<codex_protocol::models::WebSearchAction> {

View File

@@ -99,13 +99,7 @@ impl PendingAppServerRequests {
.to_string(),
})
}
ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } => {
Some(UnsupportedAppServerRequest {
request_id: request_id.clone(),
message: "ChatGPT auth token refresh is not available in app-server TUI yet."
.to_string(),
})
}
ServerRequest::ChatgptAuthTokensRefresh { .. } => None,
ServerRequest::ApplyPatchApproval { request_id, .. } => {
Some(UnsupportedAppServerRequest {
request_id: request_id.clone(),
@@ -608,6 +602,22 @@ mod tests {
);
}
#[test]
fn does_not_mark_chatgpt_auth_refresh_as_unsupported() {
let mut pending = PendingAppServerRequests::default();
assert_eq!(
pending.note_server_request(&ServerRequest::ChatgptAuthTokensRefresh {
request_id: AppServerRequestId::Integer(100),
params: codex_app_server_protocol::ChatgptAuthTokensRefreshParams {
reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized,
previous_account_id: Some("workspace-1".to_string()),
},
}),
None
);
}
#[test]
fn rejects_invalid_patch_decisions_for_file_change_requests() {
let mut pending = PendingAppServerRequests::default();

View File

@@ -149,7 +149,7 @@ pub(crate) struct ToolSuggestionRequest {
pub(crate) suggest_reason: String,
pub(crate) tool_id: String,
pub(crate) tool_name: String,
pub(crate) install_url: String,
pub(crate) install_url: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
@@ -373,8 +373,8 @@ fn parse_tool_suggestion_request(meta: Option<&Value>) -> Option<ToolSuggestionR
tool_name: meta.get(TOOL_NAME_KEY).and_then(Value::as_str)?.to_string(),
install_url: meta
.get(TOOL_SUGGEST_INSTALL_URL_KEY)
.and_then(Value::as_str)?
.to_string(),
.and_then(Value::as_str)
.map(ToString::to_string),
})
}
@@ -1939,7 +1939,39 @@ mod tests {
suggest_reason: "Plan and reference events from your calendar".to_string(),
tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(),
tool_name: "Google Calendar".to_string(),
install_url: "https://example.test/google-calendar".to_string(),
install_url: Some("https://example.test/google-calendar".to_string()),
})
);
}
#[test]
fn plugin_tool_suggestion_meta_without_install_url_is_parsed_into_request_payload() {
let request = McpServerElicitationFormRequest::from_event(
ThreadId::default(),
form_request(
"Suggest Slack",
empty_object_schema(),
Some(serde_json::json!({
"codex_approval_kind": "tool_suggestion",
"tool_type": "plugin",
"suggest_type": "install",
"suggest_reason": "Install the Slack plugin to search messages",
"tool_id": "slack@openai-curated",
"tool_name": "Slack",
})),
),
)
.expect("expected tool suggestion form");
assert_eq!(
request.tool_suggestion(),
Some(&ToolSuggestionRequest {
tool_type: ToolSuggestionToolType::Plugin,
suggest_type: ToolSuggestionType::Install,
suggest_reason: "Install the Slack plugin to search messages".to_string(),
tool_id: "slack@openai-curated".to_string(),
tool_name: "Slack".to_string(),
install_url: None,
})
);
}

View File

@@ -958,7 +958,9 @@ impl BottomPane {
request
};
if let Some(tool_suggestion) = request.tool_suggestion() {
if let Some(tool_suggestion) = request.tool_suggestion()
&& let Some(install_url) = tool_suggestion.install_url.clone()
{
let suggestion_type = match tool_suggestion.suggest_type {
mcp_server_elicitation::ToolSuggestionType::Install => {
AppLinkSuggestionType::Install
@@ -982,7 +984,7 @@ impl BottomPane {
"Enable this app to use it for the current request.".to_string()
}
},
url: tool_suggestion.install_url.clone(),
url: install_url,
is_installed,
is_enabled: false,
suggest_reason: Some(tool_suggestion.suggest_reason.clone()),

View File

@@ -100,6 +100,7 @@ pub mod insert_history;
mod key_hint;
mod line_truncation;
pub mod live_wrap;
mod local_chatgpt_auth;
mod markdown;
mod markdown_render;
mod markdown_stream;

View File

@@ -0,0 +1,195 @@
use std::path::Path;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::auth::load_auth_dot_json;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LocalChatgptAuth {
pub(crate) access_token: String,
pub(crate) chatgpt_account_id: String,
pub(crate) chatgpt_plan_type: Option<String>,
}
impl LocalChatgptAuth {
pub(crate) fn to_refresh_response(&self) -> ChatgptAuthTokensRefreshResponse {
ChatgptAuthTokensRefreshResponse {
access_token: self.access_token.clone(),
chatgpt_account_id: self.chatgpt_account_id.clone(),
chatgpt_plan_type: self.chatgpt_plan_type.clone(),
}
}
}
pub(crate) fn load_local_chatgpt_auth(
codex_home: &Path,
auth_credentials_store_mode: AuthCredentialsStoreMode,
forced_chatgpt_workspace_id: Option<&str>,
) -> Result<LocalChatgptAuth, String> {
let auth = load_auth_dot_json(codex_home, auth_credentials_store_mode)
.map_err(|err| format!("failed to load local auth: {err}"))?
.ok_or_else(|| "no local auth available".to_string())?;
if matches!(auth.auth_mode, Some(AuthMode::ApiKey)) || auth.openai_api_key.is_some() {
return Err("local auth is not a ChatGPT login".to_string());
}
let tokens = auth
.tokens
.ok_or_else(|| "local ChatGPT auth is missing token data".to_string())?;
let access_token = tokens.access_token;
let chatgpt_account_id = tokens
.account_id
.or(tokens.id_token.chatgpt_account_id.clone())
.ok_or_else(|| "local ChatGPT auth is missing chatgpt account id".to_string())?;
if let Some(expected_workspace) = forced_chatgpt_workspace_id
&& chatgpt_account_id != expected_workspace
{
return Err(format!(
"local ChatGPT auth must use workspace {expected_workspace}, but found {chatgpt_account_id:?}"
));
}
let chatgpt_plan_type = tokens
.id_token
.get_chatgpt_plan_type()
.map(|plan_type| plan_type.to_ascii_lowercase());
Ok(LocalChatgptAuth {
access_token,
chatgpt_account_id,
chatgpt_plan_type,
})
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
use chrono::Utc;
use codex_app_server_protocol::AuthMode;
use codex_core::auth::AuthDotJson;
use codex_core::auth::login_with_chatgpt_auth_tokens;
use codex_core::auth::save_auth;
use codex_core::token_data::TokenData;
use pretty_assertions::assert_eq;
use serde::Serialize;
use serde_json::json;
use tempfile::TempDir;
fn fake_jwt(email: &str, account_id: &str, plan_type: &str) -> String {
#[derive(Serialize)]
struct Header {
alg: &'static str,
typ: &'static str,
}
let header = Header {
alg: "none",
typ: "JWT",
};
let payload = json!({
"email": email,
"https://api.openai.com/auth": {
"chatgpt_account_id": account_id,
"chatgpt_plan_type": plan_type,
},
});
let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
let header_b64 = encode(&serde_json::to_vec(&header).expect("serialize header"));
let payload_b64 = encode(&serde_json::to_vec(&payload).expect("serialize payload"));
let signature_b64 = encode(b"sig");
format!("{header_b64}.{payload_b64}.{signature_b64}")
}
fn write_chatgpt_auth(codex_home: &Path) {
let id_token = fake_jwt("user@example.com", "workspace-1", "business");
let access_token = fake_jwt("user@example.com", "workspace-1", "business");
let auth = AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: Some(TokenData {
id_token: codex_core::token_data::parse_chatgpt_jwt_claims(&id_token)
.expect("id token should parse"),
access_token,
refresh_token: "refresh-token".to_string(),
account_id: Some("workspace-1".to_string()),
}),
last_refresh: Some(Utc::now()),
};
save_auth(codex_home, &auth, AuthCredentialsStoreMode::File)
.expect("chatgpt auth should save");
}
#[test]
fn loads_local_chatgpt_auth_from_managed_auth() {
let codex_home = TempDir::new().expect("tempdir");
write_chatgpt_auth(codex_home.path());
let auth = load_local_chatgpt_auth(
codex_home.path(),
AuthCredentialsStoreMode::File,
Some("workspace-1"),
)
.expect("chatgpt auth should load");
assert_eq!(auth.chatgpt_account_id, "workspace-1");
assert_eq!(auth.chatgpt_plan_type.as_deref(), Some("business"));
assert!(!auth.access_token.is_empty());
}
#[test]
fn rejects_missing_local_auth() {
let codex_home = TempDir::new().expect("tempdir");
let err = load_local_chatgpt_auth(codex_home.path(), AuthCredentialsStoreMode::File, None)
.expect_err("missing auth should fail");
assert_eq!(err, "no local auth available");
}
#[test]
fn rejects_api_key_auth() {
let codex_home = TempDir::new().expect("tempdir");
save_auth(
codex_home.path(),
&AuthDotJson {
auth_mode: Some(AuthMode::ApiKey),
openai_api_key: Some("sk-test".to_string()),
tokens: None,
last_refresh: None,
},
AuthCredentialsStoreMode::File,
)
.expect("api key auth should save");
let err = load_local_chatgpt_auth(codex_home.path(), AuthCredentialsStoreMode::File, None)
.expect_err("api key auth should fail");
assert_eq!(err, "local auth is not a ChatGPT login");
}
#[test]
fn prefers_managed_auth_over_external_ephemeral_tokens() {
let codex_home = TempDir::new().expect("tempdir");
write_chatgpt_auth(codex_home.path());
login_with_chatgpt_auth_tokens(
codex_home.path(),
&fake_jwt("user@example.com", "workspace-2", "enterprise"),
"workspace-2",
Some("enterprise"),
)
.expect("external auth should save");
let auth = load_local_chatgpt_auth(
codex_home.path(),
AuthCredentialsStoreMode::File,
Some("workspace-1"),
)
.expect("managed auth should win");
assert_eq!(auth.chatgpt_account_id, "workspace-1");
assert_eq!(auth.chatgpt_plan_type.as_deref(), Some("business"));
}
}

View File

@@ -104,8 +104,6 @@ pub(crate) enum SignInOption {
}
const API_KEY_DISABLED_MESSAGE: &str = "API key login is disabled.";
const APP_SERVER_TUI_UNSUPPORTED_MESSAGE: &str = "Not available in app-server TUI yet.";
fn onboarding_request_id() -> codex_app_server_protocol::RequestId {
codex_app_server_protocol::RequestId::String(Uuid::new_v4().to_string())
}
@@ -741,6 +739,7 @@ impl AuthModeWidget {
if matches!(
self.login_status,
LoginStatus::AuthMode(AppServerAuthMode::Chatgpt)
| LoginStatus::AuthMode(AppServerAuthMode::ChatgptAuthTokens)
) {
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
self.request_frame.schedule_frame();
@@ -799,9 +798,8 @@ impl AuthModeWidget {
return;
}
self.set_error(Some(APP_SERVER_TUI_UNSUPPORTED_MESSAGE.to_string()));
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
self.request_frame.schedule_frame();
self.set_error(/*message*/ None);
headless_chatgpt_login::start_headless_chatgpt_login(self);
}
pub(crate) fn on_account_login_completed(
@@ -978,6 +976,20 @@ mod tests {
assert_eq!(widget.login_status, LoginStatus::NotAuthenticated);
}
#[tokio::test]
async fn existing_chatgpt_auth_tokens_login_counts_as_signed_in() {
let (mut widget, _tmp) = widget_forced_chatgpt().await;
widget.login_status = LoginStatus::AuthMode(AppServerAuthMode::ChatgptAuthTokens);
let handled = widget.handle_existing_chatgpt_login();
assert_eq!(handled, true);
assert!(matches!(
&*widget.sign_in_state.read().unwrap(),
SignInState::ChatGptSuccess
));
}
/// Collects all buffer cell symbols that contain the OSC 8 open sequence
/// for the given URL. Returns the concatenated "inner" characters.
fn collect_osc8_chars(buf: &Buffer, area: Rect, url: &str) -> String {

View File

@@ -1,6 +1,12 @@
#![allow(dead_code)]
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::LoginAccountParams;
use codex_app_server_protocol::LoginAccountResponse;
use codex_core::auth::CLIENT_ID;
use codex_login::ServerOptions;
use codex_login::complete_device_code_login;
use codex_login::request_device_code;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
@@ -13,17 +19,106 @@ use std::sync::Arc;
use std::sync::RwLock;
use tokio::sync::Notify;
use crate::local_chatgpt_auth::LocalChatgptAuth;
use crate::local_chatgpt_auth::load_local_chatgpt_auth;
use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester;
use super::AuthModeWidget;
use super::ContinueInBrowserState;
use super::ContinueWithDeviceCodeState;
use super::SignInState;
use super::mark_url_hyperlink;
use super::onboarding_request_id;
pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget, opts: ServerOptions) {
let _ = opts;
let _ = widget;
pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget) {
let mut opts = ServerOptions::new(
widget.codex_home.clone(),
CLIENT_ID.to_string(),
widget.forced_chatgpt_workspace_id.clone(),
widget.cli_auth_credentials_store_mode,
);
opts.open_browser = false;
let sign_in_state = widget.sign_in_state.clone();
let request_frame = widget.request_frame.clone();
let error = widget.error.clone();
let request_handle = widget.app_server_request_handle.clone();
let codex_home = widget.codex_home.clone();
let cli_auth_credentials_store_mode = widget.cli_auth_credentials_store_mode;
let forced_chatgpt_workspace_id = widget.forced_chatgpt_workspace_id.clone();
let cancel = begin_device_code_attempt(&sign_in_state, &request_frame);
tokio::spawn(async move {
let device_code = match request_device_code(&opts).await {
Ok(device_code) => device_code,
Err(err) => {
if err.kind() == std::io::ErrorKind::NotFound {
fallback_to_browser_login(
request_handle,
sign_in_state,
request_frame,
error,
cancel,
)
.await;
} else {
set_device_code_error_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
err.to_string(),
);
}
return;
}
};
if !set_device_code_state_for_active_attempt(
&sign_in_state,
&request_frame,
&cancel,
SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState {
device_code: Some(device_code.clone()),
cancel: Some(cancel.clone()),
}),
) {
return;
}
tokio::select! {
_ = cancel.notified() => {}
result = complete_device_code_login(opts, device_code) => {
match result {
Ok(()) => {
let local_auth = load_local_chatgpt_auth(
&codex_home,
cli_auth_credentials_store_mode,
forced_chatgpt_workspace_id.as_deref(),
);
handle_chatgpt_auth_tokens_login_result_for_active_attempt(
request_handle,
sign_in_state,
request_frame,
error,
cancel,
local_auth,
).await;
}
Err(err) => {
set_device_code_error_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
err.to_string(),
);
}
}
}
}
});
}
pub(super) fn render_device_code_login(
@@ -151,6 +246,159 @@ fn set_device_code_success_message_for_active_attempt(
true
}
fn set_device_code_error_for_active_attempt(
sign_in_state: &Arc<RwLock<SignInState>>,
request_frame: &FrameRequester,
error: &Arc<RwLock<Option<String>>>,
cancel: &Arc<Notify>,
message: String,
) -> bool {
if !set_device_code_state_for_active_attempt(
sign_in_state,
request_frame,
cancel,
SignInState::PickMode,
) {
return false;
}
*error.write().unwrap() = Some(message);
request_frame.schedule_frame();
true
}
async fn fallback_to_browser_login(
request_handle: codex_app_server_client::AppServerRequestHandle,
sign_in_state: Arc<RwLock<SignInState>>,
request_frame: FrameRequester,
error: Arc<RwLock<Option<String>>>,
cancel: Arc<Notify>,
) {
let should_fallback = {
let guard = sign_in_state.read().unwrap();
device_code_attempt_matches(&guard, &cancel)
};
if !should_fallback {
return;
}
match request_handle
.request_typed::<LoginAccountResponse>(ClientRequest::LoginAccount {
request_id: onboarding_request_id(),
params: LoginAccountParams::Chatgpt,
})
.await
{
Ok(LoginAccountResponse::Chatgpt { login_id, auth_url }) => {
*error.write().unwrap() = None;
let _updated = set_device_code_state_for_active_attempt(
&sign_in_state,
&request_frame,
&cancel,
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
login_id,
auth_url,
}),
);
}
Ok(other) => {
set_device_code_error_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
format!("Unexpected account/login/start response: {other:?}"),
);
}
Err(err) => {
set_device_code_error_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
err.to_string(),
);
}
}
}
async fn handle_chatgpt_auth_tokens_login_result_for_active_attempt(
request_handle: codex_app_server_client::AppServerRequestHandle,
sign_in_state: Arc<RwLock<SignInState>>,
request_frame: FrameRequester,
error: Arc<RwLock<Option<String>>>,
cancel: Arc<Notify>,
local_auth: Result<LocalChatgptAuth, String>,
) {
let local_auth = match local_auth {
Ok(local_auth) => local_auth,
Err(err) => {
set_device_code_error_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
err,
);
return;
}
};
let result = request_handle
.request_typed::<LoginAccountResponse>(ClientRequest::LoginAccount {
request_id: onboarding_request_id(),
params: LoginAccountParams::ChatgptAuthTokens {
access_token: local_auth.access_token,
chatgpt_account_id: local_auth.chatgpt_account_id,
chatgpt_plan_type: local_auth.chatgpt_plan_type,
},
})
.await;
apply_chatgpt_auth_tokens_login_response_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
result.map_err(|err| err.to_string()),
);
}
fn apply_chatgpt_auth_tokens_login_response_for_active_attempt(
sign_in_state: &Arc<RwLock<SignInState>>,
request_frame: &FrameRequester,
error: &Arc<RwLock<Option<String>>>,
cancel: &Arc<Notify>,
result: Result<LoginAccountResponse, String>,
) {
match result {
Ok(LoginAccountResponse::ChatgptAuthTokens {}) => {
*error.write().unwrap() = None;
let _updated = set_device_code_success_message_for_active_attempt(
sign_in_state,
request_frame,
cancel,
);
}
Ok(other) => {
set_device_code_error_for_active_attempt(
sign_in_state,
request_frame,
error,
cancel,
format!("Unexpected account/login/start response: {other:?}"),
);
}
Err(err) => {
set_device_code_error_for_active_attempt(
sign_in_state,
request_frame,
error,
cancel,
err,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -269,4 +517,30 @@ mod tests {
SignInState::ChatGptDeviceCode(_)
));
}
#[test]
fn chatgpt_auth_tokens_success_sets_success_message_without_login_id() {
let sign_in_state = device_code_sign_in_state(Arc::new(Notify::new()));
let request_frame = FrameRequester::test_dummy();
let error = Arc::new(RwLock::new(None));
let cancel = match &*sign_in_state.read().unwrap() {
SignInState::ChatGptDeviceCode(state) => {
state.cancel.as_ref().expect("cancel handle").clone()
}
_ => panic!("expected device-code state"),
};
apply_chatgpt_auth_tokens_login_response_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
Ok(LoginAccountResponse::ChatgptAuthTokens {}),
);
assert!(matches!(
&*sign_in_state.read().unwrap(),
SignInState::ChatGptSuccessMessage
));
}
}