mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Support Codex Apps auth elicitations (#19193)
## Summary - request URL-mode MCP elicitations when Codex Apps tool calls fail with connector auth metadata - route Codex Apps auth URL elicitations into the TUI app-link flow ## Test plan - `just fmt` - `cargo test -p codex-core mcp_tool_call::tests` - `cargo test -p codex-mcp` - `cargo test -p codex-tui bottom_pane::app_link_view::tests` - `just fix -p codex-core` - `just fix -p codex-mcp` - `just fix -p codex-tui` Also attempted broader local runs: - `cargo test -p codex-core` fails in unrelated config/request-permission/proxy-sensitive tests under the current Codex Desktop environment. - `cargo test -p codex-tui` fails in unrelated status snapshots/trust-default tests because the ambient environment renders workspace-write/network permission defaults.
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
//! Auth elicitation helpers.
|
||||
//!
|
||||
//! This module owns protocol-neutral auth elicitation parsing and payload shaping.
|
||||
//! Session orchestration stays in `codex-core`.
|
||||
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
use serde::Serialize;
|
||||
|
||||
pub const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps";
|
||||
pub const CONNECTOR_AUTH_FAILURE_META_KEY: &str = "connector_auth_failure";
|
||||
pub const CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: &str = "is_auth_failure";
|
||||
pub const CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: &str = "auth_reason";
|
||||
pub const CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: &str = "connector_id";
|
||||
pub const CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: &str = "link_id";
|
||||
pub const CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: &str = "error_code";
|
||||
pub const CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: &str = "error_http_status_code";
|
||||
pub const CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: &str = "error_action";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodexAppsConnectorAuthFailure {
|
||||
pub connector_id: String,
|
||||
pub connector_name: String,
|
||||
pub install_url: String,
|
||||
pub auth_reason: Option<String>,
|
||||
pub link_id: Option<String>,
|
||||
pub error_code: Option<String>,
|
||||
pub error_http_status_code: Option<i64>,
|
||||
pub error_action: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CodexAppsAuthElicitation {
|
||||
pub meta: serde_json::Value,
|
||||
pub message: String,
|
||||
pub url: String,
|
||||
pub elicitation_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CodexAppsAuthElicitationPlan {
|
||||
pub auth_failure: CodexAppsConnectorAuthFailure,
|
||||
pub elicitation: CodexAppsAuthElicitation,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CodexAppsConnectorAuthFailureMeta<'a> {
|
||||
is_auth_failure: bool,
|
||||
connector_id: &'a str,
|
||||
connector_name: &'a str,
|
||||
install_url: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
auth_reason: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
link_id: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error_code: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error_http_status_code: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error_action: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub fn connector_auth_failure_from_tool_result(
|
||||
result: &CallToolResult,
|
||||
connector_id: Option<&str>,
|
||||
connector_name: Option<&str>,
|
||||
install_url: Option<String>,
|
||||
) -> Option<CodexAppsConnectorAuthFailure> {
|
||||
if result.is_error != Some(true) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let auth_failure = result
|
||||
.meta
|
||||
.as_ref()?
|
||||
.as_object()?
|
||||
.get(MCP_TOOL_CODEX_APPS_META_KEY)?
|
||||
.as_object()?
|
||||
.get(CONNECTOR_AUTH_FAILURE_META_KEY)?
|
||||
.as_object()?;
|
||||
if auth_failure
|
||||
.get(CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY)
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
!= Some(true)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let connector_id = connector_id
|
||||
.map(str::trim)
|
||||
.filter(|connector_id| !connector_id.is_empty())?;
|
||||
if let Some(auth_failure_connector_id) =
|
||||
string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY)
|
||||
&& auth_failure_connector_id != connector_id
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let connector_name = connector_name
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or(connector_id)
|
||||
.to_string();
|
||||
|
||||
Some(CodexAppsConnectorAuthFailure {
|
||||
connector_id: connector_id.to_string(),
|
||||
connector_name,
|
||||
install_url: install_url?,
|
||||
auth_reason: string_auth_failure_field(
|
||||
auth_failure,
|
||||
CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY,
|
||||
),
|
||||
link_id: string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_LINK_ID_KEY),
|
||||
error_code: string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY),
|
||||
error_http_status_code: auth_failure
|
||||
.get(CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY)
|
||||
.and_then(serde_json::Value::as_i64),
|
||||
error_action: string_auth_failure_field(
|
||||
auth_failure,
|
||||
CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_auth_elicitation_plan(
|
||||
call_id: &str,
|
||||
result: &CallToolResult,
|
||||
connector_id: Option<&str>,
|
||||
connector_name: Option<&str>,
|
||||
install_url: Option<String>,
|
||||
) -> Option<CodexAppsAuthElicitationPlan> {
|
||||
let auth_failure =
|
||||
connector_auth_failure_from_tool_result(result, connector_id, connector_name, install_url)?;
|
||||
let elicitation = build_auth_elicitation(call_id, &auth_failure);
|
||||
Some(CodexAppsAuthElicitationPlan {
|
||||
auth_failure,
|
||||
elicitation,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_auth_elicitation(
|
||||
call_id: &str,
|
||||
auth_failure: &CodexAppsConnectorAuthFailure,
|
||||
) -> CodexAppsAuthElicitation {
|
||||
CodexAppsAuthElicitation {
|
||||
meta: serde_json::json!({
|
||||
MCP_TOOL_CODEX_APPS_META_KEY: {
|
||||
CONNECTOR_AUTH_FAILURE_META_KEY: CodexAppsConnectorAuthFailureMeta {
|
||||
is_auth_failure: true,
|
||||
connector_id: &auth_failure.connector_id,
|
||||
connector_name: &auth_failure.connector_name,
|
||||
install_url: &auth_failure.install_url,
|
||||
auth_reason: auth_failure.auth_reason.as_deref(),
|
||||
link_id: auth_failure.link_id.as_deref(),
|
||||
error_code: auth_failure.error_code.as_deref(),
|
||||
error_http_status_code: auth_failure.error_http_status_code,
|
||||
error_action: auth_failure.error_action.as_deref(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
message: auth_elicitation_message(auth_failure),
|
||||
url: auth_failure.install_url.clone(),
|
||||
elicitation_id: auth_elicitation_id(call_id),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn auth_elicitation_completed_result(
|
||||
auth_failure: &CodexAppsConnectorAuthFailure,
|
||||
meta: Option<serde_json::Value>,
|
||||
) -> CallToolResult {
|
||||
CallToolResult {
|
||||
content: vec![serde_json::json!({
|
||||
"type": "text",
|
||||
"text": format!(
|
||||
"Authentication for {} was requested and accepted. Retry this tool call now.",
|
||||
auth_failure.connector_name
|
||||
),
|
||||
})],
|
||||
structured_content: None,
|
||||
is_error: Some(true),
|
||||
meta,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn auth_elicitation_id(call_id: &str) -> String {
|
||||
format!("codex_apps_auth_{call_id}")
|
||||
}
|
||||
|
||||
fn string_auth_failure_field(
|
||||
auth_failure: &serde_json::Map<String, serde_json::Value>,
|
||||
key: &str,
|
||||
) -> Option<String> {
|
||||
auth_failure
|
||||
.get(key)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn auth_elicitation_message(auth_failure: &CodexAppsConnectorAuthFailure) -> String {
|
||||
match auth_failure.auth_reason.as_deref() {
|
||||
Some("oauth_upgrade_required") => format!(
|
||||
"Reconnect {} on ChatGPT to grant the permissions needed for this request.",
|
||||
auth_failure.connector_name
|
||||
),
|
||||
Some("reauthentication_required") => format!(
|
||||
"Reconnect {} on ChatGPT to restore access for this request.",
|
||||
auth_failure.connector_name
|
||||
),
|
||||
Some("missing_link") => format!(
|
||||
"Sign in to {} on ChatGPT to use it in Codex.",
|
||||
auth_failure.connector_name
|
||||
),
|
||||
_ => format!(
|
||||
"Sign in to {} on ChatGPT to continue.",
|
||||
auth_failure.connector_name
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn auth_failure_result() -> CallToolResult {
|
||||
CallToolResult {
|
||||
content: vec![serde_json::json!({
|
||||
"type": "text",
|
||||
"text": "Connector reauthentication required",
|
||||
})],
|
||||
structured_content: None,
|
||||
is_error: Some(true),
|
||||
meta: Some(serde_json::json!({
|
||||
MCP_TOOL_CODEX_APPS_META_KEY: {
|
||||
CONNECTOR_AUTH_FAILURE_META_KEY: {
|
||||
CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: true,
|
||||
CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: "reauthentication_required",
|
||||
CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: "connector_calendar",
|
||||
"connector_name": "Untrusted Calendar",
|
||||
CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: "link_123",
|
||||
CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: "UNAUTHORIZED",
|
||||
CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: 401,
|
||||
CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: "TRIGGER_REAUTHENTICATION",
|
||||
},
|
||||
},
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_auth_failure_from_trusted_connector_metadata() {
|
||||
assert_eq!(
|
||||
connector_auth_failure_from_tool_result(
|
||||
&auth_failure_result(),
|
||||
Some("connector_calendar"),
|
||||
Some("Google Calendar"),
|
||||
Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()),
|
||||
),
|
||||
Some(CodexAppsConnectorAuthFailure {
|
||||
connector_id: "connector_calendar".to_string(),
|
||||
connector_name: "Google Calendar".to_string(),
|
||||
install_url: "https://chatgpt.com/apps/google-calendar/connector_calendar"
|
||||
.to_string(),
|
||||
auth_reason: Some("reauthentication_required".to_string()),
|
||||
link_id: Some("link_123".to_string()),
|
||||
error_code: Some("UNAUTHORIZED".to_string()),
|
||||
error_http_status_code: Some(401),
|
||||
error_action: Some("TRIGGER_REAUTHENTICATION".to_string()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_or_mismatched_connector_ids() {
|
||||
assert_eq!(
|
||||
connector_auth_failure_from_tool_result(
|
||||
&auth_failure_result(),
|
||||
/*connector_id*/ None,
|
||||
Some("Google Calendar"),
|
||||
Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()),
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
connector_auth_failure_from_tool_result(
|
||||
&auth_failure_result(),
|
||||
Some("connector_drive"),
|
||||
Some("Google Drive"),
|
||||
Some("https://chatgpt.com/apps/google-drive/connector_drive".to_string()),
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_url_elicitation_payload() {
|
||||
let auth_failure = connector_auth_failure_from_tool_result(
|
||||
&auth_failure_result(),
|
||||
Some("connector_calendar"),
|
||||
Some("Google Calendar"),
|
||||
Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()),
|
||||
)
|
||||
.expect("auth failure");
|
||||
|
||||
assert_eq!(
|
||||
build_auth_elicitation("call_123", &auth_failure),
|
||||
CodexAppsAuthElicitation {
|
||||
meta: serde_json::json!({
|
||||
MCP_TOOL_CODEX_APPS_META_KEY: {
|
||||
CONNECTOR_AUTH_FAILURE_META_KEY: {
|
||||
CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: true,
|
||||
CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: "connector_calendar",
|
||||
"connector_name": "Google Calendar",
|
||||
"install_url":
|
||||
"https://chatgpt.com/apps/google-calendar/connector_calendar",
|
||||
CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: "reauthentication_required",
|
||||
CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: "link_123",
|
||||
CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: "UNAUTHORIZED",
|
||||
CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: 401,
|
||||
CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: "TRIGGER_REAUTHENTICATION",
|
||||
},
|
||||
},
|
||||
}),
|
||||
message: "Reconnect Google Calendar on ChatGPT to restore access for this request."
|
||||
.to_string(),
|
||||
url: "https://chatgpt.com/apps/google-calendar/connector_calendar".to_string(),
|
||||
elicitation_id: "codex_apps_auth_call_123".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_auth_elicitation_plan() {
|
||||
let plan = build_auth_elicitation_plan(
|
||||
"call_123",
|
||||
&auth_failure_result(),
|
||||
Some("connector_calendar"),
|
||||
Some("Google Calendar"),
|
||||
Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()),
|
||||
)
|
||||
.expect("auth elicitation plan");
|
||||
|
||||
assert_eq!(plan.auth_failure.connector_name, "Google Calendar");
|
||||
assert_eq!(plan.elicitation.elicitation_id, "codex_apps_auth_call_123");
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,7 @@ use url::Url;
|
||||
pub struct McpConnectionManager {
|
||||
clients: HashMap<String, AsyncManagedClient>,
|
||||
server_origins: HashMap<String, String>,
|
||||
host_owned_codex_apps_enabled: bool,
|
||||
elicitation_requests: ElicitationRequestManager,
|
||||
startup_cancellation_token: CancellationToken,
|
||||
}
|
||||
@@ -82,6 +83,7 @@ impl McpConnectionManager {
|
||||
Self {
|
||||
clients: HashMap::new(),
|
||||
server_origins: HashMap::new(),
|
||||
host_owned_codex_apps_enabled: false,
|
||||
elicitation_requests: ElicitationRequestManager::new(
|
||||
approval_policy.value(),
|
||||
permission_profile.get().clone(),
|
||||
@@ -116,6 +118,10 @@ impl McpConnectionManager {
|
||||
self.server_origins.get(server_name).map(String::as_str)
|
||||
}
|
||||
|
||||
pub fn is_host_owned_codex_apps_server(&self, server_name: &str) -> bool {
|
||||
self.host_owned_codex_apps_enabled && server_name == CODEX_APPS_MCP_SERVER_NAME
|
||||
}
|
||||
|
||||
pub fn set_approval_policy(&self, approval_policy: &Constrained<AskForApproval>) {
|
||||
if let Ok(mut policy) = self.elicitation_requests.approval_policy.lock() {
|
||||
*policy = approval_policy.value();
|
||||
@@ -148,6 +154,7 @@ impl McpConnectionManager {
|
||||
runtime_environment: McpRuntimeEnvironment,
|
||||
codex_home: PathBuf,
|
||||
codex_apps_tools_cache_key: CodexAppsToolsCacheKey,
|
||||
host_owned_codex_apps_enabled: bool,
|
||||
tool_plugin_provenance: ToolPluginProvenance,
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> (Self, CancellationToken) {
|
||||
@@ -248,6 +255,7 @@ impl McpConnectionManager {
|
||||
let manager = Self {
|
||||
clients,
|
||||
server_origins,
|
||||
host_owned_codex_apps_enabled,
|
||||
elicitation_requests: elicitation_requests.clone(),
|
||||
startup_cancellation_token: cancel_token.clone(),
|
||||
};
|
||||
|
||||
@@ -8,11 +8,21 @@ pub use mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
pub use mcp::McpConfig;
|
||||
pub use mcp::ToolPluginProvenance;
|
||||
|
||||
pub use auth_elicitation::CodexAppsAuthElicitation;
|
||||
pub use auth_elicitation::CodexAppsAuthElicitationPlan;
|
||||
pub use auth_elicitation::CodexAppsConnectorAuthFailure;
|
||||
pub use auth_elicitation::MCP_TOOL_CODEX_APPS_META_KEY;
|
||||
pub use auth_elicitation::auth_elicitation_completed_result;
|
||||
pub use auth_elicitation::auth_elicitation_id;
|
||||
pub use auth_elicitation::build_auth_elicitation;
|
||||
pub use auth_elicitation::build_auth_elicitation_plan;
|
||||
pub use auth_elicitation::connector_auth_failure_from_tool_result;
|
||||
pub use codex_apps::CodexAppsToolsCacheKey;
|
||||
pub use codex_apps::codex_apps_tools_cache_key;
|
||||
|
||||
pub use mcp::configured_mcp_servers;
|
||||
pub use mcp::effective_mcp_servers;
|
||||
pub use mcp::host_owned_codex_apps_enabled;
|
||||
pub use mcp::tool_plugin_provenance;
|
||||
pub use mcp::with_codex_apps_mcp;
|
||||
|
||||
@@ -39,6 +49,7 @@ pub use mcp::mcp_permission_prompt_is_auto_approved;
|
||||
pub use mcp::qualified_mcp_tool_name_prefix;
|
||||
pub use tools::declared_openai_file_input_param_names;
|
||||
|
||||
pub(crate) mod auth_elicitation;
|
||||
pub(crate) mod codex_apps;
|
||||
pub(crate) mod connection_manager;
|
||||
pub(crate) mod elicitation;
|
||||
|
||||
@@ -198,7 +198,7 @@ pub fn with_codex_apps_mcp(
|
||||
auth: Option<&CodexAuth>,
|
||||
config: &McpConfig,
|
||||
) -> HashMap<String, McpServerConfig> {
|
||||
if config.apps_enabled && auth.is_some_and(CodexAuth::uses_codex_backend) {
|
||||
if host_owned_codex_apps_enabled(config, auth) {
|
||||
servers.insert(
|
||||
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
codex_apps_mcp_server_config(config),
|
||||
@@ -209,6 +209,10 @@ pub fn with_codex_apps_mcp(
|
||||
servers
|
||||
}
|
||||
|
||||
pub fn host_owned_codex_apps_enabled(config: &McpConfig, auth: Option<&CodexAuth>) -> bool {
|
||||
config.apps_enabled && auth.is_some_and(CodexAuth::uses_codex_backend)
|
||||
}
|
||||
|
||||
pub fn configured_mcp_servers(config: &McpConfig) -> HashMap<String, McpServerConfig> {
|
||||
config.configured_mcp_servers.clone()
|
||||
}
|
||||
@@ -233,6 +237,7 @@ pub async fn read_mcp_resource(
|
||||
uri: &str,
|
||||
) -> anyhow::Result<ReadResourceResult> {
|
||||
let mut mcp_servers = effective_mcp_servers(config, auth);
|
||||
let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(config, auth);
|
||||
mcp_servers.retain(|name, _| name == server);
|
||||
let auth_statuses = compute_auth_statuses(
|
||||
mcp_servers.iter(),
|
||||
@@ -253,6 +258,7 @@ pub async fn read_mcp_resource(
|
||||
runtime_environment,
|
||||
config.codex_home.clone(),
|
||||
codex_apps_tools_cache_key(auth),
|
||||
host_owned_codex_apps_enabled,
|
||||
tool_plugin_provenance(config),
|
||||
auth,
|
||||
)
|
||||
@@ -287,6 +293,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
|
||||
detail: McpSnapshotDetail,
|
||||
) -> McpServerStatusSnapshot {
|
||||
let mcp_servers = effective_mcp_servers(config, auth);
|
||||
let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(config, auth);
|
||||
let tool_plugin_provenance = tool_plugin_provenance(config);
|
||||
if mcp_servers.is_empty() {
|
||||
return McpServerStatusSnapshot {
|
||||
@@ -318,6 +325,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
|
||||
runtime_environment,
|
||||
config.codex_home.clone(),
|
||||
codex_apps_tools_cache_key(auth),
|
||||
host_owned_codex_apps_enabled,
|
||||
tool_plugin_provenance,
|
||||
auth,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user