Merge branch 'dev/winston/mitm-child-env-snapshots' into dev/winston/mitm-command-ca-bundles-restack-20260618

This commit is contained in:
Winston Howes
2026-06-18 15:05:37 -07:00
78 changed files with 2494 additions and 437 deletions
+1 -1
View File
@@ -2418,6 +2418,7 @@ version = "0.0.0"
dependencies = [
"base64 0.22.1",
"chrono",
"codex-agent-identity",
"codex-backend-client",
"codex-config",
"codex-core",
@@ -4275,7 +4276,6 @@ name = "codex-utils-plugins"
version = "0.0.0"
dependencies = [
"codex-exec-server",
"codex-login",
"codex-utils-absolute-path",
"codex-utils-path-uri",
"serde",
@@ -4121,6 +4121,7 @@ async fn turn_event_counts_completed_tool_items() {
tool: "search".to_string(),
status,
arguments: json!({}),
app_context: None,
mcp_app_resource_uri: None,
plugin_id: Some("sample@test".to_string()),
result: None,
@@ -2396,6 +2396,29 @@
],
"type": "object"
},
"McpToolCallAppContext": {
"properties": {
"connectorId": {
"type": "string"
},
"linkId": {
"type": [
"string",
"null"
]
},
"resourceUri": {
"type": [
"string",
"null"
]
}
},
"required": [
"connectorId"
],
"type": "object"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -4066,6 +4089,16 @@
},
{
"properties": {
"appContext": {
"anyOf": [
{
"$ref": "#/definitions/McpToolCallAppContext"
},
{
"type": "null"
}
]
},
"arguments": true,
"durationMs": {
"description": "The duration of the MCP tool call in milliseconds.",
@@ -4089,6 +4122,7 @@
"type": "string"
},
"mcpAppResourceUri": {
"description": "Deprecated: use `appContext.resourceUri` instead.",
"type": [
"string",
"null"
@@ -12119,6 +12119,29 @@
"title": "McpServerToolCallResponse",
"type": "object"
},
"McpToolCallAppContext": {
"properties": {
"connectorId": {
"type": "string"
},
"linkId": {
"type": [
"string",
"null"
]
},
"resourceUri": {
"type": [
"string",
"null"
]
}
},
"required": [
"connectorId"
],
"type": "object"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -17511,6 +17534,16 @@
},
{
"properties": {
"appContext": {
"anyOf": [
{
"$ref": "#/definitions/v2/McpToolCallAppContext"
},
{
"type": "null"
}
]
},
"arguments": true,
"durationMs": {
"description": "The duration of the MCP tool call in milliseconds.",
@@ -17534,6 +17567,7 @@
"type": "string"
},
"mcpAppResourceUri": {
"description": "Deprecated: use `appContext.resourceUri` instead.",
"type": [
"string",
"null"
@@ -8543,6 +8543,29 @@
"title": "McpServerToolCallResponse",
"type": "object"
},
"McpToolCallAppContext": {
"properties": {
"connectorId": {
"type": "string"
},
"linkId": {
"type": [
"string",
"null"
]
},
"resourceUri": {
"type": [
"string",
"null"
]
}
},
"required": [
"connectorId"
],
"type": "object"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -15290,6 +15313,16 @@
},
{
"properties": {
"appContext": {
"anyOf": [
{
"$ref": "#/definitions/McpToolCallAppContext"
},
{
"type": "null"
}
]
},
"arguments": true,
"durationMs": {
"description": "The duration of the MCP tool call in milliseconds.",
@@ -15313,6 +15346,7 @@
"type": "string"
},
"mcpAppResourceUri": {
"description": "Deprecated: use `appContext.resourceUri` instead.",
"type": [
"string",
"null"
@@ -297,6 +297,29 @@
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallAppContext": {
"properties": {
"connectorId": {
"type": "string"
},
"linkId": {
"type": [
"string",
"null"
]
},
"resourceUri": {
"type": [
"string",
"null"
]
}
},
"required": [
"connectorId"
],
"type": "object"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -784,6 +807,16 @@
},
{
"properties": {
"appContext": {
"anyOf": [
{
"$ref": "#/definitions/McpToolCallAppContext"
},
{
"type": "null"
}
]
},
"arguments": true,
"durationMs": {
"description": "The duration of the MCP tool call in milliseconds.",
@@ -807,6 +840,7 @@
"type": "string"
},
"mcpAppResourceUri": {
"description": "Deprecated: use `appContext.resourceUri` instead.",
"type": [
"string",
"null"
@@ -297,6 +297,29 @@
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallAppContext": {
"properties": {
"connectorId": {
"type": "string"
},
"linkId": {
"type": [
"string",
"null"
]
},
"resourceUri": {
"type": [
"string",
"null"
]
}
},
"required": [
"connectorId"
],
"type": "object"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -784,6 +807,16 @@
},
{
"properties": {
"appContext": {
"anyOf": [
{
"$ref": "#/definitions/McpToolCallAppContext"
},
{
"type": "null"
}
]
},
"arguments": true,
"durationMs": {
"description": "The duration of the MCP tool call in milliseconds.",
@@ -807,6 +840,7 @@
"type": "string"
},
"mcpAppResourceUri": {
"description": "Deprecated: use `appContext.resourceUri` instead.",
"type": [
"string",
"null"
@@ -434,6 +434,29 @@
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallAppContext": {
"properties": {
"connectorId": {
"type": "string"
},
"linkId": {
"type": [
"string",
"null"
]
},
"resourceUri": {
"type": [
"string",
"null"
]
}
},
"required": [
"connectorId"
],
"type": "object"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -928,6 +951,16 @@
},
{
"properties": {
"appContext": {
"anyOf": [
{
"$ref": "#/definitions/McpToolCallAppContext"
},
{
"type": "null"
}
]
},
"arguments": true,
"durationMs": {
"description": "The duration of the MCP tool call in milliseconds.",
@@ -951,6 +984,7 @@
"type": "string"
},
"mcpAppResourceUri": {
"description": "Deprecated: use `appContext.resourceUri` instead.",
"type": [
"string",
"null"
@@ -539,6 +539,29 @@
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallAppContext": {
"properties": {
"connectorId": {
"type": "string"
},
"linkId": {
"type": [
"string",
"null"
]
},
"resourceUri": {
"type": [
"string",
"null"
]
}
},
"required": [
"connectorId"
],
"type": "object"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -1420,6 +1443,16 @@
},
{
"properties": {
"appContext": {
"anyOf": [
{
"$ref": "#/definitions/McpToolCallAppContext"
},
{
"type": "null"
}
]
},
"arguments": true,
"durationMs": {
"description": "The duration of the MCP tool call in milliseconds.",
@@ -1443,6 +1476,7 @@
"type": "string"
},
"mcpAppResourceUri": {
"description": "Deprecated: use `appContext.resourceUri` instead.",
"type": [
"string",
"null"
@@ -460,6 +460,29 @@
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallAppContext": {
"properties": {
"connectorId": {
"type": "string"
},
"linkId": {
"type": [
"string",
"null"
]
},
"resourceUri": {
"type": [
"string",
"null"
]
}
},
"required": [
"connectorId"
],
"type": "object"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -1235,6 +1258,16 @@
},
{
"properties": {
"appContext": {
"anyOf": [
{
"$ref": "#/definitions/McpToolCallAppContext"
},
{
"type": "null"
}
]
},
"arguments": true,
"durationMs": {
"description": "The duration of the MCP tool call in milliseconds.",
@@ -1258,6 +1291,7 @@
"type": "string"
},
"mcpAppResourceUri": {
"description": "Deprecated: use `appContext.resourceUri` instead.",
"type": [
"string",
"null"
@@ -460,6 +460,29 @@
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallAppContext": {
"properties": {
"connectorId": {
"type": "string"
},
"linkId": {
"type": [
"string",
"null"
]
},
"resourceUri": {
"type": [
"string",
"null"
]
}
},
"required": [
"connectorId"
],
"type": "object"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -1235,6 +1258,16 @@
},
{
"properties": {
"appContext": {
"anyOf": [
{
"$ref": "#/definitions/McpToolCallAppContext"
},
{
"type": "null"
}
]
},
"arguments": true,
"durationMs": {
"description": "The duration of the MCP tool call in milliseconds.",
@@ -1258,6 +1291,7 @@
"type": "string"
},
"mcpAppResourceUri": {
"description": "Deprecated: use `appContext.resourceUri` instead.",
"type": [
"string",
"null"
@@ -460,6 +460,29 @@
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallAppContext": {
"properties": {
"connectorId": {
"type": "string"
},
"linkId": {
"type": [
"string",
"null"
]
},
"resourceUri": {
"type": [
"string",
"null"
]
}
},
"required": [
"connectorId"
],
"type": "object"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -1235,6 +1258,16 @@
},
{
"properties": {
"appContext": {
"anyOf": [
{
"$ref": "#/definitions/McpToolCallAppContext"
},
{
"type": "null"
}
]
},
"arguments": true,
"durationMs": {
"description": "The duration of the MCP tool call in milliseconds.",
@@ -1258,6 +1291,7 @@
"type": "string"
},
"mcpAppResourceUri": {
"description": "Deprecated: use `appContext.resourceUri` instead.",
"type": [
"string",
"null"
@@ -539,6 +539,29 @@
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallAppContext": {
"properties": {
"connectorId": {
"type": "string"
},
"linkId": {
"type": [
"string",
"null"
]
},
"resourceUri": {
"type": [
"string",
"null"
]
}
},
"required": [
"connectorId"
],
"type": "object"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -1420,6 +1443,16 @@
},
{
"properties": {
"appContext": {
"anyOf": [
{
"$ref": "#/definitions/McpToolCallAppContext"
},
{
"type": "null"
}
]
},
"arguments": true,
"durationMs": {
"description": "The duration of the MCP tool call in milliseconds.",
@@ -1443,6 +1476,7 @@
"type": "string"
},
"mcpAppResourceUri": {
"description": "Deprecated: use `appContext.resourceUri` instead.",
"type": [
"string",
"null"
@@ -460,6 +460,29 @@
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallAppContext": {
"properties": {
"connectorId": {
"type": "string"
},
"linkId": {
"type": [
"string",
"null"
]
},
"resourceUri": {
"type": [
"string",
"null"
]
}
},
"required": [
"connectorId"
],
"type": "object"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -1235,6 +1258,16 @@
},
{
"properties": {
"appContext": {
"anyOf": [
{
"$ref": "#/definitions/McpToolCallAppContext"
},
{
"type": "null"
}
]
},
"arguments": true,
"durationMs": {
"description": "The duration of the MCP tool call in milliseconds.",
@@ -1258,6 +1291,7 @@
"type": "string"
},
"mcpAppResourceUri": {
"description": "Deprecated: use `appContext.resourceUri` instead.",
"type": [
"string",
"null"
@@ -539,6 +539,29 @@
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallAppContext": {
"properties": {
"connectorId": {
"type": "string"
},
"linkId": {
"type": [
"string",
"null"
]
},
"resourceUri": {
"type": [
"string",
"null"
]
}
},
"required": [
"connectorId"
],
"type": "object"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -1420,6 +1443,16 @@
},
{
"properties": {
"appContext": {
"anyOf": [
{
"$ref": "#/definitions/McpToolCallAppContext"
},
{
"type": "null"
}
]
},
"arguments": true,
"durationMs": {
"description": "The duration of the MCP tool call in milliseconds.",
@@ -1443,6 +1476,7 @@
"type": "string"
},
"mcpAppResourceUri": {
"description": "Deprecated: use `appContext.resourceUri` instead.",
"type": [
"string",
"null"
@@ -460,6 +460,29 @@
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallAppContext": {
"properties": {
"connectorId": {
"type": "string"
},
"linkId": {
"type": [
"string",
"null"
]
},
"resourceUri": {
"type": [
"string",
"null"
]
}
},
"required": [
"connectorId"
],
"type": "object"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -1235,6 +1258,16 @@
},
{
"properties": {
"appContext": {
"anyOf": [
{
"$ref": "#/definitions/McpToolCallAppContext"
},
{
"type": "null"
}
]
},
"arguments": true,
"durationMs": {
"description": "The duration of the MCP tool call in milliseconds.",
@@ -1258,6 +1291,7 @@
"type": "string"
},
"mcpAppResourceUri": {
"description": "Deprecated: use `appContext.resourceUri` instead.",
"type": [
"string",
"null"
@@ -460,6 +460,29 @@
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallAppContext": {
"properties": {
"connectorId": {
"type": "string"
},
"linkId": {
"type": [
"string",
"null"
]
},
"resourceUri": {
"type": [
"string",
"null"
]
}
},
"required": [
"connectorId"
],
"type": "object"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -1235,6 +1258,16 @@
},
{
"properties": {
"appContext": {
"anyOf": [
{
"$ref": "#/definitions/McpToolCallAppContext"
},
{
"type": "null"
}
]
},
"arguments": true,
"durationMs": {
"description": "The duration of the MCP tool call in milliseconds.",
@@ -1258,6 +1291,7 @@
"type": "string"
},
"mcpAppResourceUri": {
"description": "Deprecated: use `appContext.resourceUri` instead.",
"type": [
"string",
"null"
@@ -434,6 +434,29 @@
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallAppContext": {
"properties": {
"connectorId": {
"type": "string"
},
"linkId": {
"type": [
"string",
"null"
]
},
"resourceUri": {
"type": [
"string",
"null"
]
}
},
"required": [
"connectorId"
],
"type": "object"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -928,6 +951,16 @@
},
{
"properties": {
"appContext": {
"anyOf": [
{
"$ref": "#/definitions/McpToolCallAppContext"
},
{
"type": "null"
}
]
},
"arguments": true,
"durationMs": {
"description": "The duration of the MCP tool call in milliseconds.",
@@ -951,6 +984,7 @@
"type": "string"
},
"mcpAppResourceUri": {
"description": "Deprecated: use `appContext.resourceUri` instead.",
"type": [
"string",
"null"
@@ -434,6 +434,29 @@
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallAppContext": {
"properties": {
"connectorId": {
"type": "string"
},
"linkId": {
"type": [
"string",
"null"
]
},
"resourceUri": {
"type": [
"string",
"null"
]
}
},
"required": [
"connectorId"
],
"type": "object"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -928,6 +951,16 @@
},
{
"properties": {
"appContext": {
"anyOf": [
{
"$ref": "#/definitions/McpToolCallAppContext"
},
{
"type": "null"
}
]
},
"arguments": true,
"durationMs": {
"description": "The duration of the MCP tool call in milliseconds.",
@@ -951,6 +984,7 @@
"type": "string"
},
"mcpAppResourceUri": {
"description": "Deprecated: use `appContext.resourceUri` instead.",
"type": [
"string",
"null"
@@ -434,6 +434,29 @@
"LegacyAppPathString": {
"type": "string"
},
"McpToolCallAppContext": {
"properties": {
"connectorId": {
"type": "string"
},
"linkId": {
"type": [
"string",
"null"
]
},
"resourceUri": {
"type": [
"string",
"null"
]
}
},
"required": [
"connectorId"
],
"type": "object"
},
"McpToolCallError": {
"properties": {
"message": {
@@ -928,6 +951,16 @@
},
{
"properties": {
"appContext": {
"anyOf": [
{
"$ref": "#/definitions/McpToolCallAppContext"
},
{
"type": "null"
}
]
},
"arguments": true,
"durationMs": {
"description": "The duration of the MCP tool call in milliseconds.",
@@ -951,6 +984,7 @@
"type": "string"
},
"mcpAppResourceUri": {
"description": "Deprecated: use `appContext.resourceUri` instead.",
"type": [
"string",
"null"
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type McpToolCallAppContext = { connectorId: string, linkId: string | null, resourceUri: string | null, };
@@ -16,6 +16,7 @@ import type { DynamicToolCallOutputContentItem } from "./DynamicToolCallOutputCo
import type { DynamicToolCallStatus } from "./DynamicToolCallStatus";
import type { FileUpdateChange } from "./FileUpdateChange";
import type { HookPromptFragment } from "./HookPromptFragment";
import type { McpToolCallAppContext } from "./McpToolCallAppContext";
import type { McpToolCallError } from "./McpToolCallError";
import type { McpToolCallResult } from "./McpToolCallResult";
import type { McpToolCallStatus } from "./McpToolCallStatus";
@@ -55,7 +56,11 @@ exitCode: number | null,
/**
* The duration of the command execution in milliseconds.
*/
durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array<FileUpdateChange>, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, mcpAppResourceUri?: string, pluginId: string | null, result: McpToolCallResult | null, error: McpToolCallError | null,
durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array<FileUpdateChange>, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, appContext: McpToolCallAppContext | null,
/**
* Deprecated: use `appContext.resourceUri` instead.
*/
mcpAppResourceUri?: string, pluginId: string | null, result: McpToolCallResult | null, error: McpToolCallError | null,
/**
* The duration of the MCP tool call in milliseconds.
*/
@@ -243,6 +243,7 @@ export type { McpServerStatusDetail } from "./McpServerStatusDetail";
export type { McpServerStatusUpdatedNotification } from "./McpServerStatusUpdatedNotification";
export type { McpServerToolCallParams } from "./McpServerToolCallParams";
export type { McpServerToolCallResponse } from "./McpServerToolCallResponse";
export type { McpToolCallAppContext } from "./McpToolCallAppContext";
export type { McpToolCallError } from "./McpToolCallError";
export type { McpToolCallProgressNotification } from "./McpToolCallProgressNotification";
export type { McpToolCallResult } from "./McpToolCallResult";
@@ -10,6 +10,7 @@ use crate::protocol::v2::CollabAgentToolCallStatus;
use crate::protocol::v2::CommandExecutionStatus;
use crate::protocol::v2::DynamicToolCallOutputContentItem;
use crate::protocol::v2::DynamicToolCallStatus;
use crate::protocol::v2::McpToolCallAppContext;
use crate::protocol::v2::McpToolCallError;
use crate::protocol::v2::McpToolCallResult;
use crate::protocol::v2::McpToolCallStatus;
@@ -765,6 +766,14 @@ impl ThreadHistoryBuilder {
.arguments
.clone()
.unwrap_or(serde_json::Value::Null),
app_context: payload
.connector_id
.clone()
.map(|connector_id| McpToolCallAppContext {
connector_id,
link_id: payload.link_id.clone(),
resource_uri: payload.mcp_app_resource_uri.clone(),
}),
mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(),
plugin_id: payload.plugin_id.clone(),
result: None,
@@ -807,6 +816,14 @@ impl ThreadHistoryBuilder {
.arguments
.clone()
.unwrap_or(serde_json::Value::Null),
app_context: payload
.connector_id
.clone()
.map(|connector_id| McpToolCallAppContext {
connector_id,
link_id: payload.link_id.clone(),
resource_uri: payload.mcp_app_resource_uri.clone(),
}),
mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(),
plugin_id: payload.plugin_id.clone(),
result,
@@ -1567,7 +1584,6 @@ mod tests {
use codex_protocol::protocol::DynamicToolCallResponseEvent;
use codex_protocol::protocol::ExecCommandEndEvent;
use codex_protocol::protocol::ExecCommandSource;
use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::ItemStartedEvent;
use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::McpToolCallEndEvent;
@@ -2397,7 +2413,9 @@ mod tests {
tool: "lookup".into(),
arguments: Some(serde_json::json!({"id":"123"})),
},
connector_id: None,
mcp_app_resource_uri: None,
link_id: None,
plugin_id: None,
duration: Duration::from_millis(8),
result: Err("boom".into()),
@@ -2447,6 +2465,7 @@ mod tests {
tool: "lookup".into(),
status: McpToolCallStatus::Failed,
arguments: serde_json::json!({"id":"123"}),
app_context: None,
mcp_app_resource_uri: None,
plugin_id: None,
result: None,
@@ -2475,7 +2494,9 @@ mod tests {
tool: "lookup".into(),
arguments: Some(serde_json::json!({"id":"123"})),
},
connector_id: Some("calendar".into()),
mcp_app_resource_uri: Some("ui://widget/lookup.html".into()),
link_id: Some("link_calendar".into()),
plugin_id: Some("sample@test".into()),
duration: Duration::from_millis(8),
result: Ok(CallToolResult {
@@ -2506,6 +2527,11 @@ mod tests {
tool: "lookup".into(),
status: McpToolCallStatus::Completed,
arguments: serde_json::json!({"id":"123"}),
app_context: Some(McpToolCallAppContext {
connector_id: "calendar".into(),
link_id: Some("link_calendar".into()),
resource_uri: Some("ui://widget/lookup.html".into()),
}),
mcp_app_resource_uri: Some("ui://widget/lookup.html".into()),
plugin_id: Some("sample@test".into()),
result: Some(Box::new(McpToolCallResult {
@@ -290,8 +290,10 @@ pub enum ThreadItem {
tool: String,
status: McpToolCallStatus,
arguments: JsonValue,
app_context: Option<McpToolCallAppContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
/// Deprecated: use `appContext.resourceUri` instead.
mcp_app_resource_uri: Option<String>,
plugin_id: Option<String>,
result: Option<Box<McpToolCallResult>>,
@@ -384,6 +386,15 @@ pub enum ThreadItem {
ContextCompaction { id: String },
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
pub struct McpToolCallAppContext {
pub connector_id: String,
pub link_id: Option<String>,
pub resource_uri: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
@@ -877,6 +888,11 @@ impl From<CoreTurnItem> for ThreadItem {
tool: mcp.tool,
status: McpToolCallStatus::from(mcp.status),
arguments: mcp.arguments,
app_context: mcp.connector_id.map(|connector_id| McpToolCallAppContext {
connector_id,
link_id: mcp.link_id,
resource_uri: mcp.mcp_app_resource_uri.clone(),
}),
mcp_app_resource_uri: mcp.mcp_app_resource_uri,
plugin_id: mcp.plugin_id,
result: mcp.result.map(McpToolCallResult::from).map(Box::new),
@@ -2615,7 +2615,9 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
server: "server".to_string(),
tool: "tool".to_string(),
arguments: json!({"arg": "value"}),
connector_id: Some("calendar".to_string()),
mcp_app_resource_uri: Some("app://connector".to_string()),
link_id: Some("link_calendar".to_string()),
plugin_id: Some("sample@test".to_string()),
status: CoreMcpToolCallStatus::InProgress,
result: None,
@@ -2631,6 +2633,11 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
tool: "tool".to_string(),
status: McpToolCallStatus::InProgress,
arguments: json!({"arg": "value"}),
app_context: Some(McpToolCallAppContext {
connector_id: "calendar".to_string(),
link_id: Some("link_calendar".to_string()),
resource_uri: Some("app://connector".to_string()),
}),
mcp_app_resource_uri: Some("app://connector".to_string()),
plugin_id: Some("sample@test".to_string()),
result: None,
@@ -2644,7 +2651,9 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
server: "server".to_string(),
tool: "tool".to_string(),
arguments: JsonValue::Null,
connector_id: None,
mcp_app_resource_uri: None,
link_id: None,
plugin_id: None,
status: CoreMcpToolCallStatus::Completed,
result: Some(CallToolResult {
@@ -2665,6 +2674,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
tool: "tool".to_string(),
status: McpToolCallStatus::Completed,
arguments: JsonValue::Null,
app_context: None,
mcp_app_resource_uri: None,
plugin_id: None,
result: Some(Box::new(McpToolCallResult {
@@ -2678,6 +2688,49 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
);
}
#[test]
fn mcp_tool_call_app_context_serializes_connector_id() {
let item = ThreadItem::McpToolCall {
id: "mcp-1".to_string(),
server: "codex_apps".to_string(),
tool: "calendar.create_event".to_string(),
status: McpToolCallStatus::InProgress,
arguments: json!({}),
app_context: Some(McpToolCallAppContext {
connector_id: "calendar".to_string(),
link_id: Some("link_calendar".to_string()),
resource_uri: Some("app://connector".to_string()),
}),
mcp_app_resource_uri: Some("app://connector".to_string()),
plugin_id: None,
result: None,
error: None,
duration_ms: None,
};
assert_eq!(
serde_json::to_value(item).expect("MCP tool call should serialize"),
json!({
"type": "mcpToolCall",
"id": "mcp-1",
"server": "codex_apps",
"tool": "calendar.create_event",
"status": "inProgress",
"arguments": {},
"appContext": {
"connectorId": "calendar",
"linkId": "link_calendar",
"resourceUri": "app://connector",
},
"mcpAppResourceUri": "app://connector",
"pluginId": null,
"result": null,
"error": null,
"durationMs": null,
})
);
}
#[test]
fn user_input_into_core_preserves_image_detail() {
assert_eq!(
@@ -1038,6 +1038,7 @@ async fn remote_control_start_allows_missing_auth_when_enabled() {
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
)
@@ -1866,6 +1867,7 @@ async fn remote_control_waits_for_account_id_before_enrolling() {
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
)
@@ -1961,6 +1963,7 @@ async fn persisted_enable_does_not_follow_auth_to_an_account_without_a_preferenc
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
)
@@ -182,6 +182,7 @@ async fn list_remote_control_clients_recovers_auth_after_unauthorized() {
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
)
@@ -266,6 +267,7 @@ async fn list_remote_control_clients_retries_unauthorized_only_once() {
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
)
@@ -536,6 +536,7 @@ async fn remote_control_handle_recovers_auth_before_refreshing_pairing() {
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
)
@@ -804,6 +805,7 @@ async fn remote_control_handle_discards_pairing_response_after_auth_change() {
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
)
@@ -2211,6 +2211,7 @@ mod tests {
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
)
@@ -2308,6 +2309,7 @@ mod tests {
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
)
@@ -2430,6 +2432,7 @@ mod tests {
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
)
+1 -1
View File
@@ -1365,7 +1365,7 @@ Today both notifications carry an empty `items` array even when item events were
- `reasoning` — `{id, summary, content}` where `summary` holds streamed reasoning summaries (applicable for most OpenAI models) and `content` holds raw reasoning blocks (applicable for e.g. open source models).
- `commandExecution` — `{id, command, cwd, status, commandActions, aggregatedOutput?, exitCode?, durationMs?}` for sandboxed commands; `status` is `inProgress`, `completed`, `failed`, or `declined`.
- `fileChange` — `{id, changes, status}` describing proposed edits; `changes` list `{path, kind, diff}` and `status` is `inProgress`, `completed`, `failed`, or `declined`.
- `mcpToolCall` — `{id, server, tool, status, arguments, mcpAppResourceUri?, pluginId, result?, error?}` describing MCP calls; `status` is `inProgress`, `completed`, or `failed`.
- `mcpToolCall` — `{id, server, tool, status, arguments, appContext, mcpAppResourceUri?, pluginId, result?, error?}` describing MCP calls; `appContext` is `{connectorId, linkId, resourceUri}` for calls through a trusted MCP app, where `connectorId` identifies the connector that owns the tool, `linkId` identifies the app link, and `resourceUri` points to the widget template. The top-level `mcpAppResourceUri` is deprecated and temporarily duplicated for client migration. `tool` identifies the invoked action. `status` is `inProgress`, `completed`, or `failed`.
- `collabToolCall` — `{id, tool, status, senderThreadId, receiverThreadId?, newThreadId?, prompt?, agentStatus?}` describing collab tool calls (`spawn_agent`, `send_input`, `resume_agent`, `wait`, `close_agent`); `status` is `inProgress`, `completed`, or `failed`.
- `webSearch` — `{id, query, action?}` for a web search request issued by the agent; `action` mirrors the Responses API web_search action payload (`search`, `open_page`, `find_in_page`) and may be omitted until completion.
- `imageView` — `{id, path}` emitted when the agent invokes the image viewer tool.
@@ -52,6 +52,7 @@ fn redacted_mcp_tool_call_result() -> McpToolCallResult {
#[cfg(test)]
mod tests {
use super::*;
use codex_app_server_protocol::McpToolCallAppContext;
use codex_app_server_protocol::McpToolCallError;
use codex_app_server_protocol::McpToolCallStatus;
use codex_app_server_protocol::SessionSource;
@@ -78,6 +79,11 @@ mod tests {
tool: "lookup".to_string(),
status: McpToolCallStatus::Completed,
arguments: serde_json::json!({"secret":"argument"}),
app_context: Some(McpToolCallAppContext {
connector_id: "calendar".to_string(),
link_id: Some("link_calendar".to_string()),
resource_uri: Some("ui://widget/lookup.html".to_string()),
}),
mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()),
plugin_id: Some("sample@test".to_string()),
result: Some(Box::new(McpToolCallResult {
@@ -120,6 +126,11 @@ mod tests {
tool: "lookup".to_string(),
status: McpToolCallStatus::Completed,
arguments: JsonValue::String(REDACTED_PAYLOAD.to_string()),
app_context: Some(McpToolCallAppContext {
connector_id: "calendar".to_string(),
link_id: Some("link_calendar".to_string()),
resource_uri: Some("ui://widget/lookup.html".to_string()),
}),
mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()),
plugin_id: Some("sample@test".to_string()),
result: Some(Box::new(redacted_mcp_tool_call_result())),
@@ -137,6 +148,7 @@ mod tests {
tool: "lookup".to_string(),
status: McpToolCallStatus::Failed,
arguments: serde_json::json!({"secret":"argument"}),
app_context: None,
mcp_app_resource_uri: None,
plugin_id: None,
result: None,
@@ -156,6 +168,7 @@ mod tests {
tool: "lookup".to_string(),
status: McpToolCallStatus::Failed,
arguments: JsonValue::String(REDACTED_PAYLOAD.to_string()),
app_context: None,
mcp_app_resource_uri: None,
plugin_id: None,
result: None,
@@ -365,10 +365,11 @@ connectors = false
#[tokio::test]
async fn list_apps_keeps_apps_with_app_only_tools_accessible() -> Result<()> {
let connector_id = "connector_2b0a9009c9c64bf9933a3dae3f2b1254";
let connectors = vec![AppInfo {
id: "beta".to_string(),
name: "Beta".to_string(),
description: Some("Beta connector".to_string()),
id: connector_id.to_string(),
name: "Formerly Blocked".to_string(),
description: Some("Formerly blocked connector".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
@@ -380,7 +381,7 @@ async fn list_apps_keeps_apps_with_app_only_tools_accessible() -> Result<()> {
is_enabled: true,
plugin_display_names: Vec::new(),
}];
let mut app_only_tool = connector_tool("beta", "Beta App")?;
let mut app_only_tool = connector_tool(connector_id, "Formerly Blocked")?;
app_only_tool
.meta
.as_mut()
@@ -421,7 +422,7 @@ async fn list_apps_keeps_apps_with_app_only_tools_accessible() -> Result<()> {
let AppsListResponse { data, next_cursor } = to_response(response)?;
assert_eq!(data.len(), 1);
assert_eq!(data[0].id, "beta");
assert_eq!(data[0].id, connector_id);
assert!(data[0].is_accessible);
assert!(next_cursor.is_none());
@@ -513,6 +513,7 @@ url = "{mcp_server_url}/mcp"
tool,
status,
arguments: json!({ "message": LARGE_RESPONSE_MESSAGE }),
app_context: None,
mcp_app_resource_uri: None,
plugin_id: None,
result: Some(result),
@@ -1010,15 +1010,12 @@ async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> {
}
#[tokio::test]
async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()> {
async fn plugin_install_preserves_status_when_remote_bundle_error_body_is_too_large() -> Result<()>
{
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
let bundle_url = mount_remote_plugin_bundle(
&server,
/*status_code*/ 503,
b"bundle temporarily unavailable".to_vec(),
)
.await;
let bundle_url =
mount_remote_plugin_bundle(&server, /*status_code*/ 503, vec![b'x'; 8 * 1024 + 1]).await;
configure_remote_plugin_test(codex_home.path(), &server)?;
mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await;
mount_empty_remote_installed_plugins(&server).await;
@@ -1041,6 +1038,20 @@ async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()>
assert_eq!(err.error.code, -32603);
assert!(err.error.message.contains("failed with status 503"));
assert!(
err.error
.message
.contains("[response body truncated after 8192 bytes]")
);
assert_eq!(
err.error
.message
.bytes()
.filter(|byte| *byte == b'x')
.count(),
8192
);
assert!(!err.error.message.contains("exceeded maximum size"));
wait_for_remote_plugin_request_count(
&server,
"GET",
@@ -1245,7 +1256,7 @@ async fn plugin_install_skips_mcp_oauth_for_chatgpt_dual_surface_plugin() -> Res
}
#[tokio::test]
async fn plugin_install_starts_mcp_oauth_when_only_plugin_apps_are_disallowed() -> Result<()> {
async fn plugin_install_starts_mcp_oauth_with_formerly_disallowed_plugin_app() -> Result<()> {
let (apps_server_url, apps_server_handle, _apps_server_control) =
start_apps_server(Vec::new(), Vec::new()).await?;
let oauth_server = MockServer::start().await;
@@ -1296,8 +1307,22 @@ async fn plugin_install_starts_mcp_oauth_when_only_plugin_apps_are_disallowed()
.await??;
let response: PluginInstallResponse = to_response(response)?;
assert_eq!(response.auth_policy, PluginAuthPolicy::OnInstall);
assert_eq!(response.apps_needing_auth, Vec::<AppSummary>::new());
assert_eq!(
response,
PluginInstallResponse {
auth_policy: PluginAuthPolicy::OnInstall,
apps_needing_auth: vec![AppSummary {
id: "asdk_app_6938a94a61d881918ef32cb999ff937c".to_string(),
name: "asdk_app_6938a94a61d881918ef32cb999ff937c".to_string(),
description: None,
install_url: Some(
"https://chatgpt.com/apps/asdk-app-6938a94a61d881918ef32cb999ff937c/asdk_app_6938a94a61d881918ef32cb999ff937c"
.to_string(),
),
category: None,
}],
}
);
assert!(oauth_discovery_request_count(&oauth_server).await > 0);
apps_server_handle.abort();
@@ -1461,7 +1486,7 @@ async fn plugin_install_skips_remote_mcp_oauth_for_bundled_same_name_app() -> Re
}
#[tokio::test]
async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> {
async fn plugin_install_includes_formerly_disallowed_apps_needing_auth() -> Result<()> {
let connectors = vec![AppInfo {
id: "alpha".to_string(),
name: "Alpha".to_string(),
@@ -1538,6 +1563,16 @@ async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> {
description: Some("Alpha connector".to_string()),
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
category: None,
},
AppSummary {
id: "asdk_app_6938a94a61d881918ef32cb999ff937c".to_string(),
name: "asdk_app_6938a94a61d881918ef32cb999ff937c".to_string(),
description: None,
install_url: Some(
"https://chatgpt.com/apps/asdk-app-6938a94a61d881918ef32cb999ff937c/asdk_app_6938a94a61d881918ef32cb999ff937c"
.to_string(),
),
category: None,
}],
}
);
@@ -23,6 +23,7 @@ use codex_app_server_protocol::FileChangeRequestApprovalResponse;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::McpToolCallAppContext;
use codex_app_server_protocol::PatchApplyStatus;
use codex_app_server_protocol::PatchChangeKind;
use codex_app_server_protocol::RequestId;
@@ -729,6 +730,7 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<(
.expect("remote resume should include redacted MCP item");
let ThreadItem::McpToolCall {
arguments,
app_context,
result,
error,
..
@@ -737,6 +739,14 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<(
unreachable!("matched MCP item");
};
assert_eq!(arguments, &json!("[redacted]"));
assert_eq!(
app_context,
&Some(McpToolCallAppContext {
connector_id: "calendar".to_string(),
link_id: Some("link_calendar".to_string()),
resource_uri: Some("ui://widget/lookup.html".to_string()),
})
);
let result = result.as_ref().expect("redacted MCP result");
assert_eq!(
result.content,
@@ -770,12 +780,23 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<(
.find(|item| matches!(item, ThreadItem::McpToolCall { .. }))
.expect("normal resume should include MCP item");
let ThreadItem::McpToolCall {
arguments, result, ..
arguments,
app_context,
result,
..
} = normal_mcp_item
else {
unreachable!("matched MCP item");
};
assert_eq!(arguments, &json!({"secret":"argument"}));
assert_eq!(
app_context,
&Some(McpToolCallAppContext {
connector_id: "calendar".to_string(),
link_id: Some("link_calendar".to_string()),
resource_uri: Some("ui://widget/lookup.html".to_string()),
})
);
let result = result.as_ref().expect("normal MCP result");
assert_eq!(
result.content,
@@ -877,7 +898,9 @@ fn append_resume_redaction_history(
tool: "lookup".to_string(),
arguments: Some(json!({"secret":"argument"})),
},
connector_id: Some("calendar".to_string()),
mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()),
link_id: Some("link_calendar".to_string()),
plugin_id: None,
duration: Duration::from_millis(8),
result: Ok(CallToolResult {
+23 -22
View File
@@ -8,7 +8,6 @@ use codex_app_server_protocol::AppInfo;
use codex_connectors::ConnectorDirectoryCacheContext;
use codex_connectors::ConnectorDirectoryCacheKey;
use codex_connectors::DirectoryListResponse;
use codex_connectors::filter::filter_disallowed_connectors;
use codex_connectors::merge::merge_connectors;
use codex_connectors::merge::merge_plugin_connectors;
use codex_core::config::Config;
@@ -21,7 +20,6 @@ pub use codex_core::connectors::list_cached_accessible_connectors_from_mcp_tools
pub use codex_core::connectors::with_app_enabled_state;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::default_client::originator;
use codex_plugin::AppConnectorId;
const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);
@@ -82,7 +80,10 @@ pub async fn list_cached_all_connectors(
let auth = connector_auth(config).await.ok()?;
let cache_context = connector_directory_cache_context(config, &auth);
let connectors = codex_connectors::cached_directory_connectors(&cache_context)?;
Some(merge_and_filter_plugin_connectors(connectors, plugin_apps))
Some(merge_directory_and_plugin_connectors(
connectors,
plugin_apps,
))
}
pub async fn list_all_connectors_with_options(
@@ -109,7 +110,10 @@ pub async fn list_all_connectors_with_options(
},
)
.await?;
Ok(merge_and_filter_plugin_connectors(connectors, plugin_apps))
Ok(merge_directory_and_plugin_connectors(
connectors,
plugin_apps,
))
}
fn connector_directory_cache_context(
@@ -127,17 +131,16 @@ fn connector_directory_cache_context(
)
}
fn merge_and_filter_plugin_connectors(
fn merge_directory_and_plugin_connectors(
connectors: Vec<AppInfo>,
plugin_apps: &[AppConnectorId],
) -> Vec<AppInfo> {
let connectors = merge_plugin_connectors(
merge_plugin_connectors(
connectors,
plugin_apps
.iter()
.map(|connector_id| connector_id.0.clone()),
);
filter_disallowed_connectors(connectors, originator().value.as_str())
)
}
pub fn connectors_for_plugin_apps(
@@ -150,11 +153,10 @@ pub fn connectors_for_plugin_apps(
.iter()
.map(|connector_id| connector_id.0.clone()),
);
let mut connectors_by_id =
filter_disallowed_connectors(connectors, originator().value.as_str())
.into_iter()
.map(|connector| (connector.id.clone(), connector))
.collect::<HashMap<_, _>>();
let mut connectors_by_id = connectors
.into_iter()
.map(|connector| (connector.id.clone(), connector))
.collect::<HashMap<_, _>>();
plugin_apps
.iter()
@@ -179,8 +181,7 @@ pub fn merge_connectors_with_accessible(
} else {
accessible_connectors
};
let merged = merge_connectors(connectors, accessible_connectors);
filter_disallowed_connectors(merged, originator().value.as_str())
merge_connectors(connectors, accessible_connectors)
}
#[cfg(test)]
@@ -269,13 +270,13 @@ mod tests {
}
#[test]
fn connectors_for_plugin_apps_filters_disallowed_plugin_apps() {
let connectors = connectors_for_plugin_apps(
Vec::new(),
&[AppConnectorId(
"asdk_app_6938a94a61d881918ef32cb999ff937c".to_string(),
)],
fn connectors_for_plugin_apps_preserves_formerly_disallowed_plugin_apps() {
let connector_id = "asdk_app_6938a94a61d881918ef32cb999ff937c";
let connectors =
connectors_for_plugin_apps(Vec::new(), &[AppConnectorId(connector_id.to_string())]);
assert_eq!(
connectors,
vec![merged_app(connector_id, /*is_accessible*/ false)]
);
assert_eq!(connectors, Vec::<AppInfo>::new());
}
}
+2 -2
View File
@@ -1399,8 +1399,8 @@ fn stored_auth_issues(
codex_app_server_protocol::AuthMode::AgentIdentity => {
if auth
.agent_identity
.as_deref()
.is_none_or(|token| token.trim().is_empty())
.as_ref()
.is_none_or(|agent_identity| !agent_identity.has_auth_material())
{
issues.push("agent identity auth is missing an agent identity token");
}
+3 -6
View File
@@ -1738,12 +1738,9 @@ async fn load_exec_server_remote_auth_provider(
let agent_identity_jwt = read_codex_access_token_from_env().ok_or_else(|| {
anyhow::anyhow!("CODEX_ACCESS_TOKEN is required when --use-agent-identity-auth is set")
})?;
let auth = CodexAuth::from_agent_identity_jwt(
&agent_identity_jwt,
Some(&config.chatgpt_base_url),
/*agent_identity_authapi_base_url_override*/ None,
)
.await?;
let auth =
CodexAuth::from_agent_identity_jwt(&agent_identity_jwt, Some(&config.chatgpt_base_url))
.await?;
return Ok(codex_model_provider::auth_provider_from_auth(&auth));
}
+1
View File
@@ -25,6 +25,7 @@ tokio = { workspace = true, features = ["fs", "rt", "sync", "time"] }
tracing = { workspace = true }
[dev-dependencies]
codex-agent-identity = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt", "test-util", "time"] }
@@ -63,6 +63,7 @@ pub async fn cloud_config_bundle_loader_for_storage(
codex_home.clone(),
enable_codex_api_key_env,
credentials_store_mode,
/*forced_chatgpt_workspace_id*/ None,
Some(chatgpt_base_url.clone()),
keyring_backend_kind,
)
@@ -17,6 +17,8 @@ use codex_config::CloudRequirementsFragment;
use codex_config::CloudRequirementsTomlBundle;
use codex_config::types::AuthCredentialsStoreMode;
use codex_login::AuthKeyringBackendKind;
use codex_login::auth::AgentIdentityAuth;
use codex_login::auth::AgentIdentityAuthRecord;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::VecDeque;
@@ -48,6 +50,7 @@ async fn auth_manager_with_api_key() -> Arc<AuthManager> {
tmp.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
)
@@ -77,6 +80,7 @@ async fn auth_manager_with_plan_and_identity(
tmp.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
)
@@ -88,6 +92,28 @@ async fn auth_manager_with_plan(plan_type: &str) -> Arc<AuthManager> {
auth_manager_with_plan_and_identity(plan_type, Some("user-12345"), Some("account-12345")).await
}
async fn auth_manager_with_agent_identity_business_plan() -> Arc<AuthManager> {
let key_material =
codex_agent_identity::generate_agent_key_material().expect("generate agent key material");
AuthManager::from_auth_for_testing(CodexAuth::AgentIdentity(
AgentIdentityAuth::from_record(
AgentIdentityAuthRecord {
agent_runtime_id: "agent-runtime-123".to_string(),
agent_private_key: key_material.private_key_pkcs8_base64,
account_id: "account-12345".to_string(),
chatgpt_user_id: "user-12345".to_string(),
email: "user@example.com".to_string(),
plan_type: PlanType::Business,
chatgpt_account_is_fedramp: false,
task_id: Some("task-123".to_string()),
},
"https://auth.openai.com/api/accounts",
)
.await
.expect("agent identity record should be complete"),
))
}
fn chatgpt_auth_json(
plan_type: &str,
chatgpt_user_id: Option<&str>,
@@ -408,6 +434,28 @@ async fn get_bundle_allows_eligible_workspace_plans_and_writes_cache() {
}
}
#[tokio::test]
async fn get_bundle_allows_agent_identity_business_plan() {
let bundle = test_bundle();
let fetcher = Arc::new(StaticBundleClient::new(bundle.clone()));
let codex_home = tempdir().expect("tempdir");
let service = CloudConfigBundleService::new(
auth_manager_with_agent_identity_business_plan().await,
fetcher.clone(),
codex_home.path().to_path_buf(),
CLOUD_CONFIG_BUNDLE_TIMEOUT,
);
assert_eq!(service.load_startup_bundle().await, Ok(Some(bundle)));
assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1);
assert!(
codex_home
.path()
.join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME)
.exists()
);
}
#[tokio::test]
async fn get_bundle_skips_team_like_usage_based_plan() {
let fetcher = Arc::new(StaticBundleClient::new(test_bundle()));
@@ -635,6 +683,7 @@ async fn get_bundle_recovers_after_unauthorized_reload() {
auth_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
)
@@ -690,6 +739,7 @@ async fn get_bundle_recovers_after_unauthorized_reload_updates_cache_identity()
auth_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
)
@@ -753,6 +803,7 @@ async fn get_bundle_surfaces_auth_recovery_message() {
auth_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
)
@@ -818,6 +869,7 @@ async fn get_bundle_unauthorized_without_recovery_uses_generic_message() {
auth_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
)
+1
View File
@@ -49,6 +49,7 @@ pub async fn load_auth_manager(chatgpt_base_url: Option<String>) -> Option<AuthM
config.codex_home.to_path_buf(),
/*enable_codex_api_key_env*/ false,
config.cli_auth_credentials_store_mode,
config.forced_chatgpt_workspace_id.clone(),
chatgpt_base_url.or(Some(config.chatgpt_base_url.clone())),
config.auth_keyring_backend_kind(),
)
+3 -16
View File
@@ -15,7 +15,6 @@ use crate::tools::ToolInfo;
use anyhow::Context;
use codex_login::CodexAuth;
use codex_protocol::mcp::McpServerInfo;
use codex_utils_plugins::mcp_connector::is_connector_id_allowed;
use codex_utils_plugins::mcp_connector::sanitize_name;
use serde::Deserialize;
use serde::Serialize;
@@ -220,7 +219,7 @@ pub(crate) fn load_cached_codex_apps_tools(
if cache.schema_version != CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION {
return CachedCodexAppsToolsLoad::Invalid;
}
CachedCodexAppsToolsLoad::Hit(filter_disallowed_codex_apps_tools(cache.tools))
CachedCodexAppsToolsLoad::Hit(cache.tools)
}
pub(crate) fn write_cached_codex_apps_tools(
@@ -233,10 +232,9 @@ pub(crate) fn write_cached_codex_apps_tools(
{
return;
}
let tools = filter_disallowed_codex_apps_tools(tools.to_vec());
let Ok(bytes) = serde_json::to_vec_pretty(&CodexAppsToolsDiskCache {
schema_version: CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION,
tools,
tools: tools.to_vec(),
}) else {
return;
};
@@ -279,17 +277,6 @@ fn write_cached_codex_apps_server_info(
Ok(())
}
pub(crate) fn filter_disallowed_codex_apps_tools(tools: Vec<ToolInfo>) -> Vec<ToolInfo> {
tools
.into_iter()
.filter(|tool| {
tool.connector_id
.as_deref()
.is_none_or(is_connector_id_allowed)
})
.collect()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CodexAppsToolsDiskCache {
schema_version: u8,
@@ -303,7 +290,7 @@ struct CodexAppsServerInfoDiskCache {
}
const CODEX_APPS_TOOLS_CACHE_DIR: &str = "cache/codex_apps_tools";
pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 3;
pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 4;
const CODEX_APPS_SERVER_INFO_CACHE_DIR: &str = "cache/codex_apps_server_info";
const CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION: u8 = 1;
@@ -612,7 +612,7 @@ fn codex_apps_tools_cache_is_scoped_per_user() {
}
#[test]
fn codex_apps_tools_cache_filters_disallowed_connectors() {
fn codex_apps_tools_cache_preserves_formerly_disallowed_connectors() {
let codex_home = tempdir().expect("tempdir");
let cache_context = create_codex_apps_tools_cache_context(
codex_home.path().to_path_buf(),
@@ -622,13 +622,13 @@ fn codex_apps_tools_cache_filters_disallowed_connectors() {
let tools = vec![
create_test_tool_with_connector(
CODEX_APPS_MCP_SERVER_NAME,
"blocked_tool",
"formerly_blocked_tool",
"connector_2b0a9009c9c64bf9933a3dae3f2b1254",
Some("Blocked"),
Some("Formerly Blocked"),
),
create_test_tool_with_connector(
CODEX_APPS_MCP_SERVER_NAME,
"allowed_tool",
"calendar_tool",
"calendar",
Some("Calendar"),
),
@@ -637,9 +637,19 @@ fn codex_apps_tools_cache_filters_disallowed_connectors() {
write_cached_codex_apps_tools(&cache_context, &tools);
let cached = read_cached_codex_apps_tools(&cache_context).expect("cache entry exists for user");
assert_eq!(cached.len(), 1);
assert_eq!(cached[0].callable_name, "allowed_tool");
assert_eq!(cached[0].connector_id.as_deref(), Some("calendar"));
assert_eq!(
cached
.iter()
.map(|tool| (tool.callable_name.as_str(), tool.connector_id.as_deref()))
.collect::<Vec<_>>(),
vec![
(
"formerly_blocked_tool",
Some("connector_2b0a9009c9c64bf9933a3dae3f2b1254")
),
("calendar_tool", Some("calendar")),
]
);
}
#[test]
-4
View File
@@ -19,7 +19,6 @@ use std::time::Instant;
use crate::codex_apps::CachedCodexAppsToolsLoad;
use crate::codex_apps::CodexAppsToolsCacheContext;
use crate::codex_apps::filter_disallowed_codex_apps_tools;
use crate::codex_apps::load_cached_codex_apps_tools;
use crate::codex_apps::load_startup_cached_codex_apps_server_info;
use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot;
@@ -406,9 +405,6 @@ pub(crate) async fn list_tools_for_client_uncached(
}
})
.collect();
if server_name == CODEX_APPS_MCP_SERVER_NAME {
return Ok(filter_disallowed_codex_apps_tools(tools));
}
Ok(tools)
}
+1 -101
View File
@@ -6,7 +6,6 @@ pub fn filter_tool_suggest_discoverable_connectors(
directory_connectors: Vec<AppInfo>,
accessible_connectors: &[AppInfo],
discoverable_connector_ids: &HashSet<String>,
originator_value: &str,
) -> Vec<AppInfo> {
let accessible_connector_ids: HashSet<&str> = accessible_connectors
.iter()
@@ -14,7 +13,7 @@ pub fn filter_tool_suggest_discoverable_connectors(
.map(|connector| connector.id.as_str())
.collect();
let mut connectors = filter_disallowed_connectors(directory_connectors, originator_value)
let mut connectors = directory_connectors
.into_iter()
.filter(|connector| !accessible_connector_ids.contains(connector.id.as_str()))
.filter(|connector| discoverable_connector_ids.contains(connector.id.as_str()))
@@ -27,44 +26,6 @@ pub fn filter_tool_suggest_discoverable_connectors(
connectors
}
const DISALLOWED_CONNECTOR_IDS: &[&str] = &[
"asdk_app_6938a94a61d881918ef32cb999ff937c",
"connector_2b0a9009c9c64bf9933a3dae3f2b1254",
"connector_3f8d1a79f27c4c7ba1a897ab13bf37dc",
"connector_68de829bf7648191acd70a907364c67c",
"connector_68e004f14af881919eb50893d3d9f523",
"connector_69272cb413a081919685ec3c88d1744e",
];
const FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS: &[&str] =
&["connector_0f9c9d4592e54d0a9a12b3f44a1e2010"];
pub fn filter_disallowed_connectors(
connectors: Vec<AppInfo>,
originator_value: &str,
) -> Vec<AppInfo> {
let first_party_chat_originator = is_first_party_chat_originator(originator_value);
connectors
.into_iter()
.filter(|connector| {
is_connector_id_allowed(connector.id.as_str(), first_party_chat_originator)
})
.collect()
}
fn is_first_party_chat_originator(originator_value: &str) -> bool {
originator_value == "codex_atlas" || originator_value == "codex_chatgpt_desktop"
}
fn is_connector_id_allowed(connector_id: &str, first_party_chat_originator: bool) -> bool {
let disallowed_connector_ids = if first_party_chat_originator {
FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS
} else {
DISALLOWED_CONNECTOR_IDS
};
!disallowed_connector_ids.contains(&connector_id)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -98,65 +59,6 @@ mod tests {
}
}
#[test]
fn filter_disallowed_connectors_allows_non_disallowed_connectors() {
let filtered =
filter_disallowed_connectors(vec![app("asdk_app_hidden"), app("alpha")], "codex_cli");
assert_eq!(filtered, vec![app("asdk_app_hidden"), app("alpha")]);
}
#[test]
fn filter_disallowed_connectors_allows_openai_prefix() {
let filtered = filter_disallowed_connectors(
vec![
app("connector_openai_foo"),
app("connector_openai_bar"),
app("gamma"),
],
"codex_cli",
);
assert_eq!(
filtered,
vec![
app("connector_openai_foo"),
app("connector_openai_bar"),
app("gamma")
]
);
}
#[test]
fn filter_disallowed_connectors_filters_disallowed_connector_ids() {
let filtered = filter_disallowed_connectors(
vec![
app("asdk_app_6938a94a61d881918ef32cb999ff937c"),
app("connector_3f8d1a79f27c4c7ba1a897ab13bf37dc"),
app("delta"),
],
"codex_cli",
);
assert_eq!(filtered, vec![app("delta")]);
}
#[test]
fn first_party_chat_originator_filters_target_connector_ids() {
let filtered = filter_disallowed_connectors(
vec![
app("connector_openai_foo"),
app("asdk_app_6938a94a61d881918ef32cb999ff937c"),
app("connector_0f9c9d4592e54d0a9a12b3f44a1e2010"),
],
"codex_atlas",
);
assert_eq!(
filtered,
vec![
app("connector_openai_foo"),
app("asdk_app_6938a94a61d881918ef32cb999ff937c")
]
);
}
#[test]
fn filter_tool_suggest_discoverable_connectors_keeps_only_plugin_backed_uninstalled_apps() {
let filtered = filter_tool_suggest_discoverable_connectors(
@@ -179,7 +81,6 @@ mod tests {
"connector_2128aebfecb84f64a069897515042a44".to_string(),
"connector_68df038e0ba48191908c8434991bbac2".to_string(),
]),
"codex_cli",
);
assert_eq!(
@@ -219,7 +120,6 @@ mod tests {
"connector_2128aebfecb84f64a069897515042a44".to_string(),
"connector_68df038e0ba48191908c8434991bbac2".to_string(),
]),
"codex_cli",
);
assert_eq!(filtered, Vec::<AppInfo>::new());
+31 -7
View File
@@ -293,13 +293,37 @@ async fn download_remote_plugin_bundle_with_limit(
let url = final_url.to_string();
let status = response.status();
if !status.is_success() {
let body = read_response_body_with_limit(
response,
&url,
/*max_bytes*/ REMOTE_PLUGIN_BUNDLE_ERROR_BODY_MAX_BYTES,
)
.await?;
let body = String::from_utf8_lossy(&body).to_string();
let mut response = response;
let mut body = Vec::new();
let mut body_truncated = false;
let mut body_read_error = None;
loop {
let chunk = match response.chunk().await {
Ok(Some(chunk)) => chunk,
Ok(None) => break,
Err(source) => {
body_read_error = Some(source);
break;
}
};
let remaining = REMOTE_PLUGIN_BUNDLE_ERROR_BODY_MAX_BYTES as usize - body.len();
if chunk.len() > remaining {
body.extend_from_slice(&chunk[..remaining]);
body_truncated = true;
break;
}
body.extend_from_slice(&chunk);
}
let mut body = String::from_utf8_lossy(&body).into_owned();
if body_truncated {
body.push_str(&format!(
"\n[response body truncated after {REMOTE_PLUGIN_BUNDLE_ERROR_BODY_MAX_BYTES} bytes]"
));
}
if let Some(source) = body_read_error {
body.push_str(&format!("\n[failed to read response body: {source}]"));
}
return Err(RemotePluginBundleInstallError::DownloadStatus { url, status, body });
}
+6
View File
@@ -692,6 +692,9 @@
"unified_exec_zsh_fork": {
"type": "boolean"
},
"use_agent_identity": {
"type": "boolean"
},
"use_legacy_landlock": {
"type": "boolean"
},
@@ -4932,6 +4935,9 @@
"unified_exec_zsh_fork": {
"type": "boolean"
},
"use_agent_identity": {
"type": "boolean"
},
"use_legacy_landlock": {
"type": "boolean"
},
+25 -21
View File
@@ -31,8 +31,8 @@ use codex_core_plugins::PluginsManager;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::default_client::originator;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::MCP_TOOL_CODEX_APPS_META_KEY;
use codex_mcp::McpConnectionManager;
use codex_mcp::McpRuntimeContext;
use codex_mcp::ToolInfo;
@@ -112,7 +112,6 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
directory_connectors,
accessible_connectors,
&connector_ids,
originator().value.as_str(),
)
.into_iter()
.map(DiscoverableTool::from);
@@ -143,12 +142,7 @@ pub async fn list_cached_accessible_connectors_from_mcp_tools(
return Some(Vec::new());
}
let cache_key = accessible_connectors_cache_key(config, auth.as_ref());
read_cached_accessible_connectors(&cache_key).map(|connectors| {
codex_connectors::filter::filter_disallowed_connectors(
connectors,
originator().value.as_str(),
)
})
read_cached_accessible_connectors(&cache_key)
}
pub(crate) fn refresh_accessible_connectors_cache_from_mcp_tools(
@@ -161,10 +155,7 @@ pub(crate) fn refresh_accessible_connectors_cache_from_mcp_tools(
}
let cache_key = accessible_connectors_cache_key(config, auth);
let accessible_connectors = codex_connectors::filter::filter_disallowed_connectors(
accessible_connectors_from_mcp_tools(mcp_tools),
originator().value.as_str(),
);
let accessible_connectors = accessible_connectors_for_app_list_from_mcp_tools(mcp_tools);
write_cached_accessible_connectors(cache_key, &accessible_connectors);
}
@@ -240,10 +231,6 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
let tool_plugin_provenance = tool_plugin_provenance(&mcp_config);
if !force_refetch && let Some(cached_connectors) = read_cached_accessible_connectors(&cache_key)
{
let cached_connectors = codex_connectors::filter::filter_disallowed_connectors(
cached_connectors,
originator().value.as_str(),
);
let cached_connectors = with_app_plugin_sources(cached_connectors, &tool_plugin_provenance);
return Ok(AccessibleConnectorsStatus {
connectors: cached_connectors,
@@ -353,10 +340,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
cancel_token.cancel();
}
let accessible_connectors = codex_connectors::filter::filter_disallowed_connectors(
accessible_connectors_from_mcp_tools(&tools),
originator().value.as_str(),
);
let accessible_connectors = accessible_connectors_for_app_list_from_mcp_tools(&tools);
if codex_apps_ready || !accessible_connectors.is_empty() {
write_cached_accessible_connectors(cache_key, &accessible_connectors);
}
@@ -486,9 +470,15 @@ async fn cached_directory_connectors_for_tool_suggest_with_auth(
}
pub(crate) fn accessible_connectors_from_mcp_tools(mcp_tools: &[ToolInfo]) -> Vec<AppInfo> {
collect_accessible_connectors_from_mcp_tools(mcp_tools.iter())
}
fn collect_accessible_connectors_from_mcp_tools<'a>(
mcp_tools: impl Iterator<Item = &'a ToolInfo>,
) -> Vec<AppInfo> {
// ToolInfo already carries plugin provenance, so app-level plugin sources
// can be derived here instead of requiring a separate enrichment pass.
let tools = mcp_tools.iter().filter_map(|tool| {
let tools = mcp_tools.filter_map(|tool| {
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
return None;
}
@@ -503,6 +493,20 @@ pub(crate) fn accessible_connectors_from_mcp_tools(mcp_tools: &[ToolInfo]) -> Ve
codex_connectors::accessible::collect_accessible_connectors(tools)
}
fn accessible_connectors_for_app_list_from_mcp_tools(mcp_tools: &[ToolInfo]) -> Vec<AppInfo> {
let non_synthetic_tools = mcp_tools.iter().filter(|tool| {
tool.tool
.meta
.as_deref()
.and_then(|meta| meta.get(MCP_TOOL_CODEX_APPS_META_KEY))
.and_then(serde_json::Value::as_object)
.and_then(|meta| meta.get("synthetic_link"))
.and_then(serde_json::Value::as_bool)
!= Some(true)
});
collect_accessible_connectors_from_mcp_tools(non_synthetic_tools)
}
pub fn with_app_enabled_state(mut connectors: Vec<AppInfo>, config: &Config) -> Vec<AppInfo> {
let user_apps_config = apps_config_from_layer_stack(&config.config_layer_stack);
let requirements_apps_config = config.config_layer_stack.requirements_toml().apps.as_ref();
+64
View File
@@ -17,6 +17,7 @@ use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::ToolInfo;
use pretty_assertions::assert_eq;
use rmcp::model::JsonObject;
use rmcp::model::Meta;
use rmcp::model::Tool;
use std::collections::BTreeMap;
use std::collections::HashSet;
@@ -140,6 +141,69 @@ fn accessible_connectors_from_mcp_tools_carries_plugin_display_names() {
);
}
#[test]
fn synthetic_links_are_exposed_to_the_agent_but_not_accessible_in_app_list() {
let mut synthetic_tool = codex_app_tool("gmail_batch_read_email", "gmail", Some("Gmail"), &[]);
synthetic_tool.tool.meta = Some(Meta(
serde_json::json!({
"resource_name": "gmail.batch_read_email",
"_codex_apps": {
"resource_uri": "/connector/gmail/batch_read_email",
"contains_mcp_source": false,
"synthetic_link": true
}
})
.as_object()
.expect("meta should be an object")
.clone(),
));
let tools = vec![
synthetic_tool,
codex_app_tool("calendar_list_events", "calendar", Some("Calendar"), &[]),
];
let calendar = AppInfo {
id: "calendar".to_string(),
name: "Calendar".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: Some(connector_install_url("Calendar", "calendar")),
branding: None,
app_metadata: None,
labels: None,
is_accessible: true,
is_enabled: true,
plugin_display_names: Vec::new(),
};
assert_eq!(
accessible_connectors_for_app_list_from_mcp_tools(&tools),
vec![calendar.clone()]
);
assert_eq!(
accessible_connectors_from_mcp_tools(&tools),
vec![
calendar,
AppInfo {
id: "gmail".to_string(),
name: "Gmail".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: Some(connector_install_url("Gmail", "gmail")),
branding: None,
app_metadata: None,
labels: None,
is_accessible: true,
is_enabled: true,
plugin_display_names: Vec::new(),
}
]
);
}
#[tokio::test]
async fn refresh_accessible_connectors_cache_from_mcp_tools_writes_latest_installed_apps() {
let codex_home = tempdir().expect("tempdir should succeed");
+35 -9
View File
@@ -143,14 +143,7 @@ pub(crate) async fn handle_mcp_tool_call(
let metadata =
lookup_mcp_tool_metadata(sess.as_ref(), turn_context.as_ref(), &server, &tool_name).await;
let item_metadata = McpToolCallItemMetadata {
mcp_app_resource_uri: metadata
.as_ref()
.and_then(|metadata| metadata.mcp_app_resource_uri.clone()),
plugin_id: metadata
.as_ref()
.and_then(|metadata| metadata.plugin_id.clone()),
};
let item_metadata = McpToolCallItemMetadata::from_tool_metadata(&server, metadata.as_ref());
let app_tool_policy = if server == CODEX_APPS_MCP_SERVER_NAME {
let annotations = metadata
.as_ref()
@@ -312,12 +305,32 @@ pub(crate) struct HandledMcpToolCall {
pub(crate) tool_input: JsonValue,
}
#[derive(Clone)]
#[derive(Clone, Debug, PartialEq, Eq)]
struct McpToolCallItemMetadata {
connector_id: Option<String>,
link_id: Option<String>,
mcp_app_resource_uri: Option<String>,
plugin_id: Option<String>,
}
impl McpToolCallItemMetadata {
fn from_tool_metadata(server: &str, metadata: Option<&McpToolApprovalMetadata>) -> Self {
let trusted_mcp_app_metadata = if server == CODEX_APPS_MCP_SERVER_NAME {
metadata
} else {
None
};
Self {
connector_id: trusted_mcp_app_metadata
.and_then(|metadata| metadata.connector_id.clone()),
link_id: trusted_mcp_app_metadata.and_then(|metadata| metadata.link_id.clone()),
mcp_app_resource_uri: metadata
.and_then(|metadata| metadata.mcp_app_resource_uri.clone()),
plugin_id: metadata.and_then(|metadata| metadata.plugin_id.clone()),
}
}
}
async fn handle_approved_mcp_tool_call(
sess: &Session,
turn_context: &TurnContext,
@@ -892,7 +905,9 @@ async fn notify_mcp_tool_call_started(
server,
tool,
arguments: arguments.unwrap_or(JsonValue::Null),
connector_id: item_metadata.connector_id,
mcp_app_resource_uri: item_metadata.mcp_app_resource_uri,
link_id: item_metadata.link_id,
plugin_id: item_metadata.plugin_id,
status: McpToolCallStatus::InProgress,
result: None,
@@ -932,7 +947,9 @@ async fn notify_mcp_tool_call_completed(
server,
tool,
arguments: arguments.unwrap_or(JsonValue::Null),
connector_id: item_metadata.connector_id,
mcp_app_resource_uri: item_metadata.mcp_app_resource_uri,
link_id: item_metadata.link_id,
plugin_id: item_metadata.plugin_id,
status,
result,
@@ -998,6 +1015,7 @@ enum McpToolApprovalDecision {
pub(crate) struct McpToolApprovalMetadata {
annotations: Option<ToolAnnotations>,
connector_id: Option<String>,
link_id: Option<String>,
connector_name: Option<String>,
connector_description: Option<String>,
plugin_id: Option<String>,
@@ -1010,6 +1028,7 @@ pub(crate) struct McpToolApprovalMetadata {
const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate";
const MCP_TOOL_UI_RESOURCE_URI_META_KEY: &str = "ui/resourceUri";
const MCP_TOOL_LINK_ID_META_KEY: &str = "link_id";
const MCP_TOOL_PLUGIN_ID_META_KEY: &str = "plugin_id";
const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId";
@@ -1493,6 +1512,13 @@ pub(crate) async fn lookup_mcp_tool_metadata(
Some(McpToolApprovalMetadata {
annotations: tool_info.tool.annotations,
connector_id: tool_info.connector_id,
link_id: tool_info
.tool
.meta
.as_ref()
.and_then(|meta| meta.get(MCP_TOOL_LINK_ID_META_KEY))
.and_then(serde_json::Value::as_str)
.map(str::to_string),
connector_name: tool_info.connector_name,
connector_description,
plugin_id,
+54 -2
View File
@@ -78,6 +78,7 @@ fn approval_metadata(
McpToolApprovalMetadata {
annotations: None,
connector_id: connector_id.map(str::to_string),
link_id: None,
connector_name: connector_name.map(str::to_string),
connector_description: connector_description.map(str::to_string),
plugin_id: None,
@@ -1149,8 +1150,39 @@ async fn plugin_mcp_tool_call_request_meta_includes_plugin_id() {
);
}
#[test]
fn mcp_tool_call_item_metadata_only_trusts_codex_apps_identity() {
let mut metadata = approval_metadata(
Some("asdk_app_0123456789abcdef0123456789abcdef"),
/*connector_name*/ None,
/*connector_description*/ None,
/*tool_title*/ None,
/*tool_description*/ None,
);
metadata.link_id = Some("link_fedcba9876543210fedcba9876543210".to_string());
assert_eq!(
McpToolCallItemMetadata::from_tool_metadata(CODEX_APPS_MCP_SERVER_NAME, Some(&metadata),),
McpToolCallItemMetadata {
connector_id: Some("asdk_app_0123456789abcdef0123456789abcdef".to_string()),
link_id: Some("link_fedcba9876543210fedcba9876543210".to_string()),
mcp_app_resource_uri: None,
plugin_id: None,
}
);
assert_eq!(
McpToolCallItemMetadata::from_tool_metadata("custom_server", Some(&metadata)),
McpToolCallItemMetadata {
connector_id: None,
link_id: None,
mcp_app_resource_uri: None,
plugin_id: None,
}
);
}
#[tokio::test]
async fn mcp_tool_call_item_includes_plugin_id() {
async fn mcp_tool_call_item_includes_app_identity() {
let (session, turn_context, rx_event) = make_session_and_context_with_rx().await;
notify_mcp_tool_call_started(
@@ -1158,11 +1190,13 @@ async fn mcp_tool_call_item_includes_plugin_id() {
&turn_context,
"call-plugin",
McpInvocation {
server: "sample".to_string(),
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool: "echo".to_string(),
arguments: None,
},
McpToolCallItemMetadata {
connector_id: Some("asdk_app_0123456789abcdef0123456789abcdef".to_string()),
link_id: Some("link_fedcba9876543210fedcba9876543210".to_string()),
mcp_app_resource_uri: None,
plugin_id: Some("sample@test".to_string()),
},
@@ -1180,6 +1214,14 @@ async fn mcp_tool_call_item_includes_plugin_id() {
panic!("expected MCP tool call item");
};
assert_eq!(
item.connector_id.as_deref(),
Some("asdk_app_0123456789abcdef0123456789abcdef")
);
assert_eq!(
item.link_id.as_deref(),
Some("link_fedcba9876543210fedcba9876543210")
);
assert_eq!(item.plugin_id.as_deref(), Some("sample@test"));
}
@@ -1193,6 +1235,7 @@ async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps
let metadata = McpToolApprovalMetadata {
annotations: None,
connector_id: Some("calendar".to_string()),
link_id: None,
connector_name: Some("Calendar".to_string()),
connector_description: Some("Manage events".to_string()),
plugin_id: None,
@@ -1661,6 +1704,7 @@ fn guardian_mcp_review_request_includes_annotations_when_present() {
let metadata = McpToolApprovalMetadata {
annotations: Some(annotations(Some(false), Some(true), Some(true))),
connector_id: None,
link_id: None,
connector_name: None,
connector_description: None,
plugin_id: None,
@@ -2326,6 +2370,7 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() {
/*open_world*/ None,
)),
connector_id: None,
link_id: None,
connector_name: None,
connector_description: None,
plugin_id: None,
@@ -2400,6 +2445,7 @@ async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() {
/*open_world*/ None,
)),
connector_id: None,
link_id: None,
connector_name: None,
connector_description: None,
plugin_id: None,
@@ -2457,6 +2503,7 @@ async fn permission_request_hook_allows_mcp_tool_call() {
/*open_world*/ None,
)),
connector_id: None,
link_id: None,
connector_name: None,
connector_description: None,
plugin_id: None,
@@ -2593,6 +2640,7 @@ async fn permission_request_hook_runs_after_remembered_mcp_approval() {
/*open_world*/ None,
)),
connector_id: None,
link_id: None,
connector_name: None,
connector_description: None,
plugin_id: None,
@@ -2680,6 +2728,7 @@ async fn guardian_mode_mcp_denial_returns_rationale_message() {
let metadata = McpToolApprovalMetadata {
annotations: Some(annotations(Some(false), Some(true), Some(true))),
connector_id: None,
link_id: None,
connector_name: None,
connector_description: None,
plugin_id: None,
@@ -2734,6 +2783,7 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval
/*open_world*/ None,
)),
connector_id: None,
link_id: None,
connector_name: None,
connector_description: None,
plugin_id: None,
@@ -2789,6 +2839,7 @@ async fn full_access_mode_skips_mcp_tool_approval_for_all_approval_modes() {
let metadata = McpToolApprovalMetadata {
annotations: Some(annotations(Some(false), Some(true), Some(true))),
connector_id: Some("calendar".to_string()),
link_id: None,
connector_name: Some("Calendar".to_string()),
connector_description: Some("Manage events".to_string()),
plugin_id: None,
@@ -2842,6 +2893,7 @@ async fn approve_mode_skips_guardian_in_every_permission_mode() {
let metadata = McpToolApprovalMetadata {
annotations: Some(annotations(Some(false), Some(true), Some(true))),
connector_id: Some("calendar".to_string()),
link_id: None,
connector_name: Some("Calendar".to_string()),
connector_description: Some("Manage events".to_string()),
plugin_id: None,
@@ -203,7 +203,9 @@ async fn emit_tool_call_begin(
server,
tool,
arguments: arguments.unwrap_or(Value::Null),
connector_id: None,
mcp_app_resource_uri: None,
link_id: None,
plugin_id: None,
status: McpToolCallStatus::InProgress,
result: None,
@@ -242,7 +244,9 @@ async fn emit_tool_call_end(
server,
tool,
arguments: arguments.unwrap_or(Value::Null),
connector_id: None,
mcp_app_resource_uri: None,
link_id: None,
plugin_id: None,
status,
result,
@@ -17,6 +17,7 @@ use wiremock::matchers::path;
use wiremock::matchers::path_regex;
const CONNECTOR_ID: &str = "calendar";
pub const LINK_ID: &str = "link_calendar";
const CONNECTOR_NAME: &str = "Calendar";
const DISCOVERABLE_CALENDAR_ID: &str = "connector_2128aebfecb84f64a069897515042a44";
const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2";
@@ -352,6 +353,7 @@ impl Respond for CodexAppsJsonRpcResponder {
},
"_meta": {
"connector_id": CONNECTOR_ID,
"link_id": LINK_ID,
"connector_name": self.connector_name.clone(),
"connector_description": self.connector_description.clone(),
"openai/outputTemplate": CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI,
@@ -378,6 +380,7 @@ impl Respond for CodexAppsJsonRpcResponder {
},
"_meta": {
"connector_id": CONNECTOR_ID,
"link_id": LINK_ID,
"connector_name": self.connector_name.clone(),
"connector_description": self.connector_description.clone(),
"_codex_apps": {
@@ -410,6 +413,7 @@ impl Respond for CodexAppsJsonRpcResponder {
},
"_meta": {
"connector_id": CONNECTOR_ID,
"link_id": LINK_ID,
"connector_name": self.connector_name.clone(),
"connector_description": self.connector_description.clone(),
"openai/fileParams": ["file"],
+2
View File
@@ -1337,7 +1337,9 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
tool: "image".to_string(),
arguments: Some(json!({})),
},
connector_id: None,
mcp_app_resource_uri: None,
link_id: None,
plugin_id: None,
},
);
+3
View File
@@ -25,6 +25,7 @@ use core_test_support::apps_test_server::CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_
use core_test_support::apps_test_server::CALENDAR_CREATE_EVENT_RESOURCE_URI;
use core_test_support::apps_test_server::DIRECT_CALENDAR_CREATE_EVENT_TOOL as CALENDAR_CREATE_TOOL;
use core_test_support::apps_test_server::DIRECT_CALENDAR_LIST_EVENTS_TOOL as CALENDAR_LIST_TOOL;
use core_test_support::apps_test_server::LINK_ID;
use core_test_support::apps_test_server::SEARCH_CALENDAR_APP_ONLY_TOOL;
use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL;
use core_test_support::apps_test_server::SEARCH_CALENDAR_LIST_TOOL;
@@ -559,10 +560,12 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() -
unreachable!("event guard guarantees McpToolCallEnd");
};
assert_eq!(end.call_id, "calendar-call-1");
assert_eq!(end.connector_id.as_deref(), Some("calendar"));
assert_eq!(
end.mcp_app_resource_uri.as_deref(),
Some(CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI)
);
assert_eq!(end.link_id.as_deref(), Some(LINK_ID));
assert_eq!(
end.invocation,
McpInvocation {
@@ -98,6 +98,7 @@ fn mcp_tool_call_result_preserves_meta_in_jsonl_event() {
tool: "web_run".to_string(),
status: McpToolCallStatus::Completed,
arguments: json!({"search_query": [{"q": "OpenAI Codex CLI documentation"}]}),
app_context: None,
mcp_app_resource_uri: None,
plugin_id: None,
result: Some(Box::new(codex_app_server_protocol::McpToolCallResult {
-1
View File
@@ -475,7 +475,6 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
forced_login_method: config.forced_login_method,
forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(),
chatgpt_base_url: Some(config.chatgpt_base_url.clone()),
agent_identity_authapi_base_url: None,
})
.await
{
@@ -478,6 +478,7 @@ fn mcp_tool_call_begin_and_end_emit_item_events() {
tool: "tool_x".to_string(),
status: ApiMcpToolCallStatus::InProgress,
arguments: json!({ "key": "value" }),
app_context: None,
mcp_app_resource_uri: None,
plugin_id: None,
result: None,
@@ -496,6 +497,7 @@ fn mcp_tool_call_begin_and_end_emit_item_events() {
tool: "tool_x".to_string(),
status: ApiMcpToolCallStatus::Completed,
arguments: json!({ "key": "value" }),
app_context: None,
mcp_app_resource_uri: None,
plugin_id: None,
result: Some(Box::new(McpToolCallResult {
@@ -568,6 +570,7 @@ fn mcp_tool_call_failure_sets_failed_status() {
tool: "tool_y".to_string(),
status: ApiMcpToolCallStatus::Failed,
arguments: json!({ "param": 42 }),
app_context: None,
mcp_app_resource_uri: None,
plugin_id: None,
result: None,
@@ -617,6 +620,7 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() {
tool: "tool_z".to_string(),
status: ApiMcpToolCallStatus::InProgress,
arguments: serde_json::Value::Null,
app_context: None,
mcp_app_resource_uri: None,
plugin_id: None,
result: None,
@@ -635,6 +639,7 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() {
tool: "tool_z".to_string(),
status: ApiMcpToolCallStatus::Completed,
arguments: serde_json::Value::Null,
app_context: None,
mcp_app_resource_uri: None,
plugin_id: None,
result: Some(Box::new(McpToolCallResult {
+8
View File
@@ -228,6 +228,8 @@ pub enum Feature {
PreventIdleSleep,
/// Enable remote compaction v2 over the normal Responses API.
RemoteCompactionV2,
/// Use Agent Identity for ChatGPT-authenticated sessions.
UseAgentIdentity,
/// Enable workspace dependency support.
WorkspaceDependencies,
@@ -1314,6 +1316,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::UseAgentIdentity,
key: "use_agent_identity",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::WorkspaceDependencies,
key: "workspace_dependencies",
+379 -43
View File
@@ -1,83 +1,318 @@
use std::future::Future;
use std::sync::Arc;
use codex_agent_identity::AgentIdentityKey;
use codex_agent_identity::ChatGptEnvironment;
use codex_agent_identity::build_abom;
use codex_agent_identity::decode_agent_identity_jwt;
use codex_agent_identity::fetch_agent_identity_jwks;
use codex_agent_identity::generate_agent_key_material;
use codex_agent_identity::is_retryable_registration_error;
use codex_agent_identity::public_key_ssh_from_private_key_pkcs8_base64;
use codex_agent_identity::register_agent_identity;
use codex_agent_identity::register_agent_task;
use codex_protocol::account::PlanType as AccountPlanType;
use codex_protocol::protocol::SessionSource;
use thiserror::Error;
use crate::default_client::build_reqwest_client;
use super::storage::AgentIdentityAuthRecord;
#[derive(Clone, Debug)]
pub struct AgentIdentityAuth {
inner: Arc<AgentIdentityAuthInner>,
pub(super) const MAX_AGENT_IDENTITY_BOOTSTRAP_ATTEMPTS: usize = 3;
pub(super) fn agent_identity_authapi_base_url(
chatgpt_base_url: Option<&str>,
) -> std::io::Result<String> {
let environment = match chatgpt_base_url {
Some(chatgpt_base_url) => ChatGptEnvironment::from_chatgpt_base_url(chatgpt_base_url)
.map_err(std::io::Error::other)?,
None => ChatGptEnvironment::default(),
};
Ok(environment.agent_identity_authapi_base_url().to_string())
}
#[derive(Debug)]
struct AgentIdentityAuthInner {
record: AgentIdentityAuthRecord,
run_task_id: String,
pub(super) fn require_agent_identity_authapi_base_url(
agent_identity_authapi_base_url: Option<&str>,
) -> std::io::Result<&str> {
agent_identity_authapi_base_url.ok_or_else(|| {
std::io::Error::other(
"Agent Identity only supports production and staging ChatGPT environments",
)
})
}
#[derive(Debug, Error)]
pub enum AgentIdentityAuthError {
#[error(
"agent identity bootstrap unavailable after {attempts} attempts during {operation}: {message}"
)]
BootstrapUnavailable {
operation: &'static str,
attempts: usize,
message: String,
},
}
impl AgentIdentityAuthError {
pub fn is_bootstrap_unavailable(error: &std::io::Error) -> bool {
matches!(
error
.get_ref()
.and_then(|source| source.downcast_ref::<Self>()),
Some(Self::BootstrapUnavailable { .. })
)
}
}
#[derive(Debug, Error)]
#[error("retryable agent identity registration failure: {message}")]
pub(super) struct RetryableAgentIdentityRegistrationError {
message: String,
}
impl RetryableAgentIdentityRegistrationError {
pub(super) fn new(message: String) -> Self {
Self { message }
}
}
#[derive(Clone, Debug)]
pub struct AgentIdentityAuth {
record: Arc<AgentIdentityAuthRecord>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ManagedChatGptAgentIdentityBinding {
pub(super) account_id: String,
pub(super) chatgpt_user_id: String,
pub(super) email: String,
pub(super) plan_type: AccountPlanType,
pub(super) chatgpt_account_is_fedramp: bool,
pub(super) access_token: String,
}
impl AgentIdentityAuth {
pub async fn load(
record: AgentIdentityAuthRecord,
pub async fn from_record(
mut record: AgentIdentityAuthRecord,
agent_identity_authapi_base_url: &str,
) -> std::io::Result<Self> {
let run_task_id = register_agent_task(
&build_reqwest_client(),
agent_identity_authapi_base_url,
key_for_record(&record),
)
.await
.map_err(std::io::Error::other)?;
public_key_ssh_from_private_key_pkcs8_base64(&record.agent_private_key)
.map_err(std::io::Error::other)?;
if record_needs_task_registration(&record) {
record.task_id = Some(
register_task_for_record_with_retries(&record, agent_identity_authapi_base_url)
.await?,
);
}
Ok(Self {
inner: Arc::new(AgentIdentityAuthInner {
record,
run_task_id,
}),
record: Arc::new(record),
})
}
pub async fn from_jwt(
jwt: &str,
chatgpt_base_url: &str,
agent_identity_authapi_base_url: &str,
) -> std::io::Result<Self> {
let record = verified_record_from_jwt(jwt, chatgpt_base_url).await?;
Self::from_record(record, agent_identity_authapi_base_url).await
}
#[cfg(test)]
fn from_initialized_record(record: AgentIdentityAuthRecord, run_task_id: String) -> Self {
fn from_initialized_record(mut record: AgentIdentityAuthRecord, run_task_id: String) -> Self {
record.task_id = Some(run_task_id);
Self {
inner: Arc::new(AgentIdentityAuthInner {
record,
run_task_id,
}),
record: Arc::new(record),
}
}
pub fn record(&self) -> &AgentIdentityAuthRecord {
&self.inner.record
self.record.as_ref()
}
pub fn run_task_id(&self) -> &str {
&self.inner.run_task_id
match self.record.task_id.as_deref() {
Some(task_id) => task_id,
None => unreachable!("AgentIdentityAuth should only be constructed with a task_id"),
}
}
pub fn account_id(&self) -> &str {
&self.inner.record.account_id
&self.record.account_id
}
pub fn chatgpt_user_id(&self) -> &str {
&self.inner.record.chatgpt_user_id
&self.record.chatgpt_user_id
}
pub fn email(&self) -> &str {
&self.inner.record.email
&self.record.email
}
pub fn plan_type(&self) -> AccountPlanType {
self.inner.record.plan_type
self.record.plan_type
}
pub fn is_fedramp_account(&self) -> bool {
self.inner.record.chatgpt_account_is_fedramp
self.record.chatgpt_account_is_fedramp
}
}
pub(super) async fn register_managed_chatgpt_agent_identity(
binding: ManagedChatGptAgentIdentityBinding,
agent_identity_authapi_base_url: &str,
session_source: SessionSource,
) -> std::io::Result<AgentIdentityAuth> {
let key_material = generate_agent_key_material().map_err(std::io::Error::other)?;
let client = build_reqwest_client();
let runtime_id = retry_registration(|| async {
register_agent_identity(
&client,
agent_identity_authapi_base_url,
&binding.access_token,
binding.chatgpt_account_is_fedramp,
&key_material,
build_abom(session_source.clone()),
vec!["responsesapi".to_string()],
)
.await
.map_err(|err| {
if is_retryable_registration_error(&err) {
std::io::Error::other(RetryableAgentIdentityRegistrationError::new(
err.to_string(),
))
} else {
std::io::Error::other(err)
}
})
})
.await
.map_err(|err| classify_bootstrap_error("agent identity registration", err))?;
let record = AgentIdentityAuthRecord {
agent_runtime_id: runtime_id,
agent_private_key: key_material.private_key_pkcs8_base64,
account_id: binding.account_id,
chatgpt_user_id: binding.chatgpt_user_id,
email: binding.email,
plan_type: binding.plan_type,
chatgpt_account_is_fedramp: binding.chatgpt_account_is_fedramp,
task_id: None,
};
AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url)
.await
.map_err(|err| classify_bootstrap_error("agent task registration", err))
}
pub(super) async fn verified_record_from_jwt(
jwt: &str,
chatgpt_base_url: &str,
) -> std::io::Result<AgentIdentityAuthRecord> {
AgentIdentityAuthRecord::from_agent_identity_jwt(jwt)?;
let jwks = fetch_agent_identity_jwks(&build_reqwest_client(), chatgpt_base_url)
.await
.map_err(std::io::Error::other)?;
let claims = decode_agent_identity_jwt(jwt, Some(&jwks)).map_err(std::io::Error::other)?;
Ok(claims.into())
}
pub(super) fn record_needs_task_registration(record: &AgentIdentityAuthRecord) -> bool {
record
.task_id
.as_deref()
.is_none_or(|task_id| task_id.trim().is_empty())
}
pub(super) fn record_matches_managed_chatgpt_binding(
record: &AgentIdentityAuthRecord,
binding: &ManagedChatGptAgentIdentityBinding,
) -> bool {
record.account_id == binding.account_id
&& record.chatgpt_user_id == binding.chatgpt_user_id
&& public_key_ssh_from_private_key_pkcs8_base64(&record.agent_private_key).is_ok()
}
pub(super) fn classify_bootstrap_error(
operation: &'static str,
err: std::io::Error,
) -> std::io::Error {
if is_retryable_io_registration_error(&err) {
std::io::Error::other(AgentIdentityAuthError::BootstrapUnavailable {
operation,
attempts: MAX_AGENT_IDENTITY_BOOTSTRAP_ATTEMPTS,
message: err.to_string(),
})
} else {
err
}
}
pub(super) fn is_retryable_io_registration_error(err: &std::io::Error) -> bool {
err.get_ref().is_some_and(
<dyn std::error::Error + std::marker::Send + std::marker::Sync + 'static>::is::<
RetryableAgentIdentityRegistrationError,
>,
)
}
pub(super) async fn retry_registration<T, F, Fut>(mut operation: F) -> std::io::Result<T>
where
F: FnMut() -> Fut,
Fut: Future<Output = std::io::Result<T>>,
{
let mut attempt = 1;
loop {
match operation().await {
Ok(value) => return Ok(value),
Err(err)
if attempt < MAX_AGENT_IDENTITY_BOOTSTRAP_ATTEMPTS
&& is_retryable_io_registration_error(&err) =>
{
tracing::warn!(
attempt,
max_attempts = MAX_AGENT_IDENTITY_BOOTSTRAP_ATTEMPTS,
error = %err,
"agent identity registration attempt failed; retrying"
);
attempt += 1;
}
Err(err) => return Err(err),
}
}
}
async fn register_task_for_record_with_retries(
record: &AgentIdentityAuthRecord,
agent_identity_authapi_base_url: &str,
) -> std::io::Result<String> {
retry_registration(|| async {
register_task_for_record(record, agent_identity_authapi_base_url).await
})
.await
}
async fn register_task_for_record(
record: &AgentIdentityAuthRecord,
agent_identity_authapi_base_url: &str,
) -> std::io::Result<String> {
register_agent_task(
&build_reqwest_client(),
agent_identity_authapi_base_url,
key_for_record(record),
)
.await
.map_err(|err| {
if is_retryable_registration_error(&err) {
std::io::Error::other(RetryableAgentIdentityRegistrationError::new(
err.to_string(),
))
} else {
std::io::Error::other(err)
}
})
}
fn key_for_record(record: &AgentIdentityAuthRecord) -> AgentIdentityKey<'_> {
AgentIdentityKey {
agent_runtime_id: &record.agent_runtime_id,
@@ -111,6 +346,7 @@ mod tests {
email: "agent@example.com".to_string(),
plan_type: AccountPlanType::Plus,
chatgpt_account_is_fedramp: false,
task_id: None,
}
}
@@ -120,7 +356,7 @@ mod tests {
}
#[tokio::test]
async fn load_registers_run_task() -> anyhow::Result<()> {
async fn from_record_registers_task() -> anyhow::Result<()> {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/agent/agent-runtime-1/task/register"))
@@ -131,9 +367,11 @@ mod tests {
.mount(&server)
.await;
let auth =
AgentIdentityAuth::load(agent_identity_record_with_generated_key(), &server.uri())
.await?;
let auth = AgentIdentityAuth::from_record(
agent_identity_record_with_generated_key(),
&server.uri(),
)
.await?;
assert_eq!(auth.run_task_id(), "task-run-1");
let requests = server
@@ -152,6 +390,38 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn from_jwt_registers_task() -> anyhow::Result<()> {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/wham/agent-identities/jwks"))
.respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body()))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/agent/agent-runtime-1/task/register"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"task_id": "task-run-1",
})))
.expect(1)
.mount(&server)
.await;
let record = agent_identity_record_with_generated_key();
let jwt = signed_agent_identity_jwt(&record)?;
let auth = AgentIdentityAuth::from_jwt(
&jwt,
&format!("{}/backend-api", server.uri()),
&server.uri(),
)
.await?;
assert_eq!(auth.record().agent_runtime_id, "agent-runtime-1");
assert_eq!(auth.run_task_id(), "task-run-1");
Ok(())
}
#[test]
fn run_task_is_shared_across_clones() {
let auth = AgentIdentityAuth::from_initialized_record(
@@ -160,12 +430,12 @@ mod tests {
);
let cloned = auth.clone();
assert!(Arc::ptr_eq(&auth.inner, &cloned.inner));
assert!(Arc::ptr_eq(&auth.record, &cloned.record));
assert_eq!(cloned.run_task_id(), "task-run-1");
}
#[tokio::test]
async fn failed_run_task_registration_can_be_retried_on_next_call() -> anyhow::Result<()> {
async fn from_record_retries_transient_registration() -> anyhow::Result<()> {
let server = MockServer::start().await;
let request_count = Arc::new(AtomicUsize::new(0));
let response_count = Arc::clone(&request_count);
@@ -183,14 +453,80 @@ mod tests {
.expect(2)
.mount(&server)
.await;
let record = agent_identity_record_with_generated_key();
AgentIdentityAuth::load(record.clone(), &server.uri())
.await
.expect_err("first registration should fail");
let auth = AgentIdentityAuth::load(record, &server.uri()).await?;
let auth = AgentIdentityAuth::from_record(
agent_identity_record_with_generated_key(),
&server.uri(),
)
.await?;
assert_eq!(request_count.load(Ordering::SeqCst), 2);
assert_eq!(auth.run_task_id(), "task-run-1");
Ok(())
}
fn signed_agent_identity_jwt(
record: &AgentIdentityAuthRecord,
) -> jsonwebtoken::errors::Result<String> {
let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
header.kid = Some("test-key".to_string());
jsonwebtoken::encode(
&header,
&json!({
"iss": "https://chatgpt.com/codex-backend/agent-identity",
"aud": "codex-app-server",
"iat": 1_700_000_000usize,
"exp": 4_000_000_000usize,
"agent_runtime_id": record.agent_runtime_id,
"agent_private_key": record.agent_private_key,
"account_id": record.account_id,
"chatgpt_user_id": record.chatgpt_user_id,
"email": record.email,
"plan_type": record.plan_type,
"chatgpt_account_is_fedramp": record.chatgpt_account_is_fedramp,
}),
&jsonwebtoken::EncodingKey::from_rsa_pem(TEST_AGENT_IDENTITY_RSA_PRIVATE_KEY_PEM)?,
)
}
fn test_jwks_body() -> serde_json::Value {
json!({
"keys": [{
"kty": "RSA",
"kid": "test-key",
"use": "sig",
"alg": "RS256",
"n": "1qQF2MqTrGAMDm7wXbjJP5sWqGA83tAGUs2ksy7iJXLJdhCg4AtwGm4SFl4f6kxhCSzlN1QdXuZjvRT2wZZiGUi9xUE28rf4WLrTxSnwqLuTy5knMP08yC0t_0YU_FGPZMcWb14hG05IvZr8UbmRaVagxSR8H4rSIymRoVwwmFSrqz068XrWGSYNIfLEASyo5GdAaqmk1JALINHgYGQJVxMxtwcvDxoVKmC7eltUNymMNBZhsv4E8sx9YNLpBoEibznfEpDU_DGzrM5eZCsQzaqbhBOlGd427ifud_Nnd9cPqzgCUc23-0FXSPfpbgksCXAwAmD0OFjQWrgqVdKL6Q",
"e": "AQAB",
}]
})
}
const TEST_AGENT_IDENTITY_RSA_PRIVATE_KEY_PEM: &[u8] = br#"-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDWpAXYypOsYAwO
bvBduMk/mxaoYDze0AZSzaSzLuIlcsl2EKDgC3AabhIWXh/qTGEJLOU3VB1e5mO9
FPbBlmIZSL3FQTbyt/hYutPFKfCou5PLmScw/TzILS3/RhT8UY9kxxZvXiEbTki9
mvxRuZFpVqDFJHwfitIjKZGhXDCYVKurPTrxetYZJg0h8sQBLKjkZ0BqqaTUkAsg
0eBgZAlXEzG3By8PGhUqYLt6W1Q3KYw0FmGy/gTyzH1g0ukGgSJvOd8SkNT8MbOs
zl5kKxDNqpuEE6UZ3jbuJ+5382d31w+rOAJRzbf7QVdI9+luCSwJcDACYPQ4WNBa
uCpV0ovpAgMBAAECggEAVu84LwZdqYN9XpswX8VoPYrjMm9IODapWQBRpQFoNyK2
1ksF3bjEPvA2Azk8U/l7k+vLKw22l6lY3EyRZPcz5GnB8xLm3ogE3mtNOp4yCyVu
RxhQ91aaN7mU17/a4BdorLi2LYVCg3zBmYociD1Q2AluNGsCmwPu+K7tfR2J0Sg8
NjqiTbDG1XDpR/icwgC9t6vh8lZpCHDhF4tbQfLLVLeA/OdcuzXDyMCXbmdVIdBQ
rm4aIFmr2e1/2ctTbCg85S6AGFTH+pSLjrwTzyvf+F6NW5uNjLQAQLFj+EznBDxj
Xdx90cySrjsKK6PVWQF4RiTvkSW8eWL7R6B2FZbGwQKBgQDuVQRj72hWloR7mbEL
aUEEv3pIXTMXWEsoMBNczos/1L1RnAN1AI44TurznasPZAWvQj+kVbLDR+TAeZrL
iA8HIWswQUI18hFmgKzSkwIXGtubcKVrgsKeS4lMDKCM/Ef6WAYdeq6ronoY5lCN
YrJFmGp81W5zcV7lyiycgbSiGwKBgQDmjWYf6pZjrK7Z+OJ3X1AZfi2vss15SCvL
3fPgzIDbViztpGyQhc3DQZIsBNIu0xZp/veGce9TEeTds2ro9NfdJFeou8+fC7Pq
sOsM3amGFFi+ZW/9BWyjZEM88bgWWAjqLHbpfHDxjAf5CSxddqxgHlbP0Ytyb1Vg
gmPDn9YKSwKBgQDbTi3hC35WFuDHn0/zcSHcDZmnFuOZeqyFyV83yfMGhGrEuqvP
sPgtRikajJ3IZsB4WZyYSidZXEFY/0z6NjOl2xF38MTNQPbT/FmK1q1Yt2UWrlv5
BvSwlk87RG9D7C0LZo4R+D7cPoDdgqjiwMvMEIkEX5zn641oI1ZTmWKuuwKBgQCD
KF+3unnRvHRAVoFnTZbA2fJdqMeRvogD04GhGlYX8V9f1hFY6nXTJaNlXVzA/J8c
r8ra9kgjJuPfZ+ljG58OFFW2DRohLcQtuHYPfK6rMzoFHqnl9EcIcMp7ijuionR3
29HOJFgQYgxLFXfit9d6WugiE+BTupiEbckZif13HwKBgE/lAlkVHP6YahOO2Ljc
J1bwkqKZTB5dHolX9A58e/xXnfZ5P8f3Z83+Izap3FwqQulk7b1WO1MQcHuVg2NN
5da0D4h2rYOXnbYIg0BVu4spQbaM6ewsp66b8+MzLOBvj8SzWdt1Oyw0q/MRyQAR
8U4M2TSWCKUY/A6sT4W8+mT9
-----END PRIVATE KEY-----"#;
}
+499 -24
View File
@@ -6,6 +6,7 @@ use codex_app_server_protocol::AuthMode;
use codex_protocol::account::PlanType as AccountPlanType;
use codex_protocol::auth::KnownPlan as InternalKnownPlan;
use codex_protocol::auth::PlanType as InternalPlanType;
use codex_protocol::protocol::SessionSource;
use base64::Engine;
use codex_protocol::config_types::ForcedLoginMethod;
@@ -14,11 +15,14 @@ use pretty_assertions::assert_eq;
use serde::Serialize;
use serde_json::json;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use tempfile::TempDir;
use tempfile::tempdir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::body_partial_json;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
@@ -95,7 +99,7 @@ fn login_with_api_key_overwrites_existing_auth_json() {
}
#[tokio::test]
async fn login_with_access_token_writes_only_token() {
async fn login_with_access_token_writes_agent_identity_jwt() {
let dir = tempdir().unwrap();
let auth_path = dir.path().join("auth.json");
let record = agent_identity_record(WORKSPACE_ID_ALLOWED);
@@ -108,7 +112,8 @@ async fn login_with_access_token_writes_only_token() {
.expect(1)
.mount(&server)
.await;
let chatgpt_base_url = format!("{}/backend-api", server.uri());
let authapi_base_url = server.uri();
let chatgpt_base_url = format!("{authapi_base_url}/backend-api");
super::login_with_access_token(
dir.path(),
@@ -127,14 +132,75 @@ async fn login_with_access_token_writes_only_token() {
.expect("auth.json should parse");
assert_eq!(auth.auth_mode, Some(AuthMode::AgentIdentity));
assert_eq!(
auth.agent_identity.as_deref(),
Some(agent_identity.as_str())
auth.agent_identity,
Some(AgentIdentityStorage::Jwt(agent_identity))
);
assert!(auth.tokens.is_none(), "tokens should be cleared");
assert!(auth.openai_api_key.is_none(), "API key should be cleared");
server.verify().await;
}
#[tokio::test]
#[serial(codex_auth_env)]
async fn stored_agent_identity_jwt_keeps_auth_json_unchanged() -> anyhow::Result<()> {
let _access_token_guard = remove_access_token_env_var();
let codex_home = tempdir()?;
let record = agent_identity_record(WORKSPACE_ID_ALLOWED);
let agent_identity =
signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity");
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/wham/agent-identities/jwks"))
.respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body()))
.expect(1)
.mount(&server)
.await;
mock_agent_task_registration(&server, "", &record.agent_runtime_id, "task-id").await;
let authapi_base_url = server.uri();
let chatgpt_base_url = format!("{authapi_base_url}/backend-api");
save_auth(
codex_home.path(),
&AuthDotJson {
auth_mode: Some(ApiAuthMode::AgentIdentity),
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: Some(AgentIdentityStorage::Jwt(agent_identity.clone())),
personal_access_token: None,
bedrock_api_key: None,
},
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::Direct,
)?;
let auth = super::load_auth(
codex_home.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
Some(&chatgpt_base_url),
AuthKeyringBackendKind::Direct,
Some(&authapi_base_url),
)
.await?
.expect("auth should load");
let CodexAuth::AgentIdentity(agent_identity_auth) = auth else {
panic!("stored JWT should load as agent identity auth");
};
assert_eq!(agent_identity_auth.run_task_id(), "task-id");
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
let auth = storage
.try_read_auth_json(&get_auth_file(codex_home.path()))
.expect("auth.json should parse");
assert_eq!(
auth.agent_identity,
Some(AgentIdentityStorage::Jwt(agent_identity))
);
server.verify().await;
Ok(())
}
#[tokio::test]
#[serial(codex_auth_env)]
async fn login_with_access_token_writes_only_personal_access_token() {
@@ -278,6 +344,387 @@ async fn login_with_access_token_rejects_invalid_jwt() {
);
}
#[tokio::test]
#[serial(codex_auth_env)]
async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result<()> {
let codex_home = tempdir()?;
write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: Some("pro".to_string()),
chatgpt_account_id: Some("account-123".to_string()),
},
codex_home.path(),
)?;
let auth = super::load_auth(
codex_home.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
)
.await?
.expect("auth should load");
assert!(
auth.agent_identity_auth(
AgentIdentityAuthPolicy::JwtOnly,
/*agent_identity_authapi_base_url*/ None,
/*forced_chatgpt_workspace_id*/ None,
SessionSource::Cli,
)
.await?
.is_none()
);
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/agent/register"))
.and(header("authorization", "Bearer test-access-token"))
.and(body_partial_json(json!({
"abom": {
"agent_harness_id": "codex-cli",
},
"capabilities": ["responsesapi"],
"ttl": null,
})))
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
"agent_runtime_id": "agent-runtime-123",
})))
.expect(/*r*/ 1)
.mount(&server)
.await;
mock_agent_task_registration(&server, "", "agent-runtime-123", "task-123").await;
let agent_auth = auth
.agent_identity_auth(
AgentIdentityAuthPolicy::ChatGptAuth,
Some(&server.uri()),
/*forced_chatgpt_workspace_id*/ None,
SessionSource::Cli,
)
.await?
.expect("agent identity should register");
let reused = auth
.agent_identity_auth(
AgentIdentityAuthPolicy::ChatGptAuth,
Some(&server.uri()),
/*forced_chatgpt_workspace_id*/ None,
SessionSource::Cli,
)
.await?
.expect("agent identity should be reused");
assert_eq!(
agent_auth.record().agent_runtime_id,
reused.record().agent_runtime_id
);
assert_eq!(agent_auth.run_task_id(), "task-123");
assert_eq!(reused.run_task_id(), "task-123");
assert_eq!(agent_auth.record().agent_runtime_id, "agent-runtime-123");
assert_eq!(agent_auth.record().account_id, "account-123");
assert_eq!(agent_auth.record().chatgpt_user_id, "user-12345");
assert_eq!(agent_auth.record().task_id.as_deref(), Some("task-123"));
assert_eq!(reused.record().task_id.as_deref(), Some("task-123"));
let persisted = auth
.stored_managed_chatgpt_agent_identity_record("account-123")
.expect("identity should persist");
assert_eq!(persisted.agent_runtime_id, "agent-runtime-123");
assert_eq!(persisted.task_id.as_deref(), Some("task-123"));
let reloaded = super::load_auth(
codex_home.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
)
.await?
.expect("auth should reload");
let reloaded_agent_auth = reloaded
.agent_identity_auth(
AgentIdentityAuthPolicy::ChatGptAuth,
Some(&server.uri()),
/*forced_chatgpt_workspace_id*/ None,
SessionSource::Cli,
)
.await?
.expect("agent identity should reload from storage");
assert_eq!(
reloaded_agent_auth.record().agent_runtime_id,
"agent-runtime-123"
);
assert_eq!(reloaded_agent_auth.run_task_id(), "task-123");
Ok(())
}
#[tokio::test]
#[serial(codex_auth_env)]
async fn chatgpt_auth_retries_transient_agent_identity_registration() -> anyhow::Result<()> {
let codex_home = tempdir()?;
write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: Some("pro".to_string()),
chatgpt_account_id: Some("account-123".to_string()),
},
codex_home.path(),
)?;
let auth = super::load_auth(
codex_home.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
)
.await?
.expect("auth should load");
let server = MockServer::start().await;
let registration_count = Arc::new(AtomicUsize::new(0));
let response_count = Arc::clone(&registration_count);
Mock::given(method("POST"))
.and(path("/v1/agent/register"))
.respond_with(move |_request: &wiremock::Request| {
if response_count.fetch_add(1, Ordering::SeqCst) < 2 {
ResponseTemplate::new(/*status*/ 503)
} else {
ResponseTemplate::new(/*status*/ 200).set_body_json(json!({
"agent_runtime_id": "agent-runtime-123",
}))
}
})
.expect(/*requests*/ 3)
.mount(&server)
.await;
mock_agent_task_registration(&server, "", "agent-runtime-123", "task-123").await;
let agent_auth = auth
.agent_identity_auth(
AgentIdentityAuthPolicy::ChatGptAuth,
Some(&server.uri()),
/*forced_chatgpt_workspace_id*/ None,
SessionSource::Cli,
)
.await?
.expect("agent identity should register after retries");
assert_eq!(registration_count.load(Ordering::SeqCst), 3);
assert_eq!(agent_auth.record().agent_runtime_id, "agent-runtime-123");
assert_eq!(agent_auth.record().task_id.as_deref(), Some("task-123"));
assert_eq!(
auth.stored_managed_chatgpt_agent_identity_record("account-123")
.and_then(|record| record.task_id),
Some("task-123".to_string())
);
Ok(())
}
#[tokio::test]
#[serial(codex_auth_env)]
async fn chatgpt_auth_registration_retry_exhaustion_is_fallback_eligible() -> anyhow::Result<()> {
let codex_home = tempdir()?;
write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: Some("pro".to_string()),
chatgpt_account_id: Some("account-123".to_string()),
},
codex_home.path(),
)?;
let auth = super::load_auth(
codex_home.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
)
.await?
.expect("auth should load");
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/agent/register"))
.respond_with(ResponseTemplate::new(/*status*/ 503))
.expect(/*requests*/ 3)
.mount(&server)
.await;
let err = auth
.agent_identity_auth(
AgentIdentityAuthPolicy::ChatGptAuth,
Some(&server.uri()),
/*forced_chatgpt_workspace_id*/ None,
SessionSource::Cli,
)
.await
.expect_err("retry exhaustion should return an error");
assert!(AgentIdentityAuthError::is_bootstrap_unavailable(&err));
assert!(
auth.stored_managed_chatgpt_agent_identity_record("account-123")
.is_none()
);
Ok(())
}
#[tokio::test]
#[serial(codex_auth_env)]
async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible() -> anyhow::Result<()>
{
let codex_home = tempdir()?;
write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: Some("pro".to_string()),
chatgpt_account_id: Some("account-123".to_string()),
},
codex_home.path(),
)?;
let mut record = agent_identity_record("account-123");
record.chatgpt_user_id = "user-12345".to_string();
record.email = "user@example.com".to_string();
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
let auth_path = get_auth_file(codex_home.path());
let mut auth_json = storage.try_read_auth_json(&auth_path)?;
auth_json.agent_identity = Some(AgentIdentityStorage::Record(record.clone()));
storage.save(&auth_json)?;
let auth = super::load_auth(
codex_home.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
)
.await?
.expect("auth should load");
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(format!(
"/v1/agent/{}/task/register",
record.agent_runtime_id
)))
.respond_with(ResponseTemplate::new(/*status*/ 503))
.expect(/*requests*/ 3)
.mount(&server)
.await;
let err = auth
.agent_identity_auth(
AgentIdentityAuthPolicy::ChatGptAuth,
Some(&server.uri()),
/*forced_chatgpt_workspace_id*/ None,
SessionSource::Cli,
)
.await
.expect_err("task retry exhaustion should return an error");
assert!(AgentIdentityAuthError::is_bootstrap_unavailable(&err));
record.task_id = None;
assert_eq!(
auth.stored_managed_chatgpt_agent_identity_record("account-123"),
Some(record)
);
Ok(())
}
#[tokio::test]
#[serial(codex_auth_env)]
async fn chatgpt_auth_non_retryable_registration_error_is_hard_failure() -> anyhow::Result<()> {
let codex_home = tempdir()?;
write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: Some("pro".to_string()),
chatgpt_account_id: Some("account-123".to_string()),
},
codex_home.path(),
)?;
let auth = super::load_auth(
codex_home.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
)
.await?
.expect("auth should load");
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/agent/register"))
.respond_with(ResponseTemplate::new(/*status*/ 403))
.expect(/*requests*/ 1)
.mount(&server)
.await;
let err = auth
.agent_identity_auth(
AgentIdentityAuthPolicy::ChatGptAuth,
Some(&server.uri()),
/*forced_chatgpt_workspace_id*/ None,
SessionSource::Cli,
)
.await
.expect_err("hard registration failure should return an error");
assert!(!AgentIdentityAuthError::is_bootstrap_unavailable(&err));
assert!(
auth.stored_managed_chatgpt_agent_identity_record("account-123")
.is_none()
);
Ok(())
}
#[tokio::test]
async fn agent_identity_jwt_task_registration_retry_exhaustion_is_strict() -> anyhow::Result<()> {
let record = agent_identity_record(WORKSPACE_ID_ALLOWED);
let agent_identity =
signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity");
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/wham/agent-identities/jwks"))
.respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body()))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path(format!(
"/v1/agent/{}/task/register",
record.agent_runtime_id
)))
.respond_with(ResponseTemplate::new(/*status*/ 503))
.expect(/*requests*/ 3)
.mount(&server)
.await;
let authapi_base_url = server.uri();
let chatgpt_base_url = format!("{authapi_base_url}/backend-api");
let err = CodexAuth::from_agent_identity_jwt_with_authapi_base_url(
&agent_identity,
Some(&chatgpt_base_url),
&authapi_base_url,
)
.await
.expect_err("agent identity jwt task retry exhaustion should fail");
assert!(!AgentIdentityAuthError::is_bootstrap_unavailable(&err));
Ok(())
}
#[tokio::test]
async fn login_with_access_token_rejects_unsigned_jwt() {
let dir = tempdir().unwrap();
@@ -290,7 +737,8 @@ async fn login_with_access_token_rejects_unsigned_jwt() {
.expect(1)
.mount(&server)
.await;
let chatgpt_base_url = format!("{}/backend-api", server.uri());
let authapi_base_url = server.uri();
let chatgpt_base_url = format!("{authapi_base_url}/backend-api");
super::login_with_access_token(
dir.path(),
@@ -457,6 +905,7 @@ async fn unauthorized_recovery_reports_mode_and_step_names() {
dir.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
)
@@ -828,7 +1277,6 @@ async fn build_config(
forced_login_method,
forced_chatgpt_workspace_id,
chatgpt_base_url: None,
agent_identity_authapi_base_url: None,
}
}
@@ -879,7 +1327,7 @@ fn remove_access_token_env_var() -> EnvVarGuard {
#[serial(codex_auth_env)]
async fn load_auth_reads_access_token_from_env() {
let codex_home = tempdir().unwrap();
let expected_record = agent_identity_record(WORKSPACE_ID_ALLOWED);
let mut expected_record = agent_identity_record(WORKSPACE_ID_ALLOWED);
let agent_identity =
signed_agent_identity_jwt(&expected_record, json!(expected_record.plan_type))
.expect("signed agent identity");
@@ -898,6 +1346,7 @@ async fn load_auth_reads_access_token_from_env() {
.expect(1)
.mount(&server)
.await;
expected_record.task_id = Some("task-123".to_string());
let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, &agent_identity);
let authapi_base_url = server.uri();
@@ -1003,11 +1452,10 @@ async fn auth_manager_rejects_env_personal_access_token_workspace_mismatch() {
let _access_token_guard =
EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-env-workspace-mismatch");
let manager = AuthManager::new_with_workspace_restriction(
let manager = AuthManager::new(
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/
Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
@@ -1054,11 +1502,10 @@ async fn auth_manager_rejects_stored_personal_access_token_workspace_mismatch()
.await
.expect("personal access token login should succeed");
let manager = AuthManager::new_with_workspace_restriction(
let manager = AuthManager::new(
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
auth_credentials_store_mode,
/*forced_chatgpt_workspace_id*/
Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
@@ -1092,6 +1539,7 @@ async fn personal_access_token_does_not_offer_unauthorized_recovery() {
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
)
@@ -1233,7 +1681,6 @@ async fn enforce_login_restrictions_logs_out_for_personal_access_token_workspace
forced_login_method: None,
forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
chatgpt_base_url: None,
agent_identity_authapi_base_url: None,
};
let err = super::enforce_login_restrictions(&config)
@@ -1341,7 +1788,7 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: Some(agent_identity),
agent_identity: Some(AgentIdentityStorage::Jwt(agent_identity)),
personal_access_token: None,
bedrock_api_key: None,
},
@@ -1357,15 +1804,21 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat
forced_login_method: None,
forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
chatgpt_base_url: Some(chatgpt_base_url),
agent_identity_authapi_base_url: Some(authapi_base_url),
};
let err = super::enforce_login_restrictions(&config)
.await
.expect_err("expected workspace mismatch to error");
assert!(err.to_string().contains(&format!(
"current credentials belong to {WORKSPACE_ID_DISALLOWED}"
)));
let err = super::enforce_login_restrictions_with_agent_identity_authapi_base_url(
&config,
Some(&authapi_base_url),
)
.await
.expect_err("expected workspace mismatch to error");
let message = err.to_string();
assert!(
message.contains(&format!(
"current credentials belong to {WORKSPACE_ID_DISALLOWED}"
)),
"{message}"
);
assert!(
!codex_home.path().join("auth.json").exists(),
"auth.json should be removed on mismatch"
@@ -1437,9 +1890,28 @@ fn agent_identity_record(account_id: &str) -> AgentIdentityAuthRecord {
email: "user@example.com".to_string(),
plan_type: AccountPlanType::Pro,
chatgpt_account_is_fedramp: false,
task_id: None,
}
}
async fn mock_agent_task_registration(
server: &MockServer,
path_prefix: &str,
agent_runtime_id: &str,
task_id: &str,
) {
Mock::given(method("POST"))
.and(path(format!(
"{path_prefix}/v1/agent/{agent_runtime_id}/task/register"
)))
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
"task_id": task_id,
})))
.expect(/*r*/ 1)
.mount(server)
.await;
}
fn fake_agent_identity_jwt(record: &AgentIdentityAuthRecord) -> std::io::Result<String> {
fake_agent_identity_jwt_with_plan_type(record, serde_json::to_value(record.plan_type)?)
}
@@ -1580,10 +2052,13 @@ async fn assert_agent_identity_plan_alias(
.await;
let authapi_base_url = server.uri();
let chatgpt_base_url = format!("{authapi_base_url}/backend-api");
let auth =
CodexAuth::from_agent_identity_jwt(&jwt, Some(&chatgpt_base_url), Some(&authapi_base_url))
.await
.expect("agent identity auth");
let auth = CodexAuth::from_agent_identity_jwt_with_authapi_base_url(
&jwt,
Some(&chatgpt_base_url),
&authapi_base_url,
)
.await
.expect("agent identity auth");
pretty_assertions::assert_eq!(auth.account_plan_type(), Some(expected_plan_type));
server.verify().await;
@@ -60,6 +60,7 @@ async fn login_with_bedrock_api_key_replaces_openai_auth() -> anyhow::Result<()>
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
)
@@ -107,6 +108,7 @@ async fn logout_removes_bedrock_auth() -> anyhow::Result<()> {
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
)
@@ -130,6 +132,7 @@ async fn bedrock_only_auth_storage_creates_primary_auth() -> anyhow::Result<()>
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
)
+326 -91
View File
@@ -20,8 +20,6 @@ use tokio::sync::watch;
use tracing::instrument;
use codex_agent_identity::ChatGptEnvironment;
use codex_agent_identity::decode_agent_identity_jwt;
use codex_agent_identity::fetch_agent_identity_jwks;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::AuthMode as ApiAuthMode;
use codex_protocol::config_types::ForcedLoginMethod;
@@ -29,18 +27,27 @@ use codex_protocol::config_types::ModelProviderAuthInfo;
use super::access_token::CodexAccessToken;
use super::access_token::classify_codex_access_token;
use super::agent_identity::ManagedChatGptAgentIdentityBinding;
use super::agent_identity::agent_identity_authapi_base_url;
use super::agent_identity::classify_bootstrap_error;
use super::agent_identity::record_matches_managed_chatgpt_binding;
use super::agent_identity::record_needs_task_registration;
use super::agent_identity::register_managed_chatgpt_agent_identity;
use super::agent_identity::require_agent_identity_authapi_base_url;
use super::agent_identity::verified_record_from_jwt;
use super::external_bearer::BearerTokenRefresher;
use super::revoke::revoke_auth_tokens;
pub use crate::auth::agent_identity::AgentIdentityAuth;
pub use crate::auth::agent_identity::AgentIdentityAuthError;
pub use crate::auth::bedrock_api_key::BedrockApiKeyAuth;
pub use crate::auth::personal_access_token::PersonalAccessTokenAuth;
pub use crate::auth::storage::AgentIdentityAuthRecord;
pub use crate::auth::storage::AgentIdentityStorage;
pub use crate::auth::storage::AuthDotJson;
pub use crate::auth::storage::AuthKeyringBackendKind;
use crate::auth::storage::AuthStorageBackend;
use crate::auth::storage::create_auth_storage;
use crate::auth::util::try_parse_error_message;
use crate::default_client::build_reqwest_client;
use crate::default_client::create_client;
use crate::token_data::TokenData;
use crate::token_data::parse_chatgpt_jwt_claims;
@@ -51,6 +58,7 @@ use codex_protocol::account::PlanType as AccountPlanType;
use codex_protocol::auth::PlanType as InternalPlanType;
use codex_protocol::auth::RefreshTokenFailedError;
use codex_protocol::auth::RefreshTokenFailedReason;
use codex_protocol::protocol::SessionSource;
use serde_json::Value;
use thiserror::Error;
@@ -65,6 +73,15 @@ pub enum CodexAuth {
BedrockApiKey(BedrockApiKeyAuth),
}
/// Policy for resolving Agent Identity auth from a broader Codex auth snapshot.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentIdentityAuthPolicy {
/// Use Agent Identity auth only when the current auth is already Agent Identity.
JwtOnly,
/// Allow managed ChatGPT auth to register or reuse Agent Identity auth.
ChatGptAuth,
}
impl PartialEq for CodexAuth {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
@@ -222,7 +239,6 @@ impl CodexAuth {
agent_identity_authapi_base_url: Option<&str>,
) -> std::io::Result<Self> {
let auth_mode = auth_dot_json.resolved_mode();
let client = create_client();
if auth_mode == ApiAuthMode::ApiKey {
let Some(api_key) = auth_dot_json.openai_api_key.as_deref() else {
return Err(std::io::Error::other("API key auth is missing a key."));
@@ -230,17 +246,34 @@ impl CodexAuth {
return Ok(Self::from_api_key(api_key));
}
if auth_mode == ApiAuthMode::AgentIdentity {
let Some(agent_identity) = auth_dot_json.agent_identity else {
let Some(agent_identity) = auth_dot_json.agent_identity.clone() else {
return Err(std::io::Error::other(
"agent identity auth is missing an agent identity token.",
"agent identity auth is missing agent identity auth material.",
));
};
return Self::from_agent_identity_jwt(
&agent_identity,
chatgpt_base_url,
agent_identity_authapi_base_url,
)
.await;
let base_url = chatgpt_base_url
.unwrap_or(ChatGptEnvironment::default().chatgpt_base_url())
.trim_end_matches('/')
.to_string();
let agent_identity_authapi_base_url =
require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?;
match agent_identity {
AgentIdentityStorage::Jwt(jwt) => {
let auth = AgentIdentityAuth::from_jwt(
&jwt,
&base_url,
agent_identity_authapi_base_url,
)
.await?;
return Ok(Self::AgentIdentity(auth));
}
AgentIdentityStorage::Record(record) => {
let auth =
AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url)
.await?;
return Ok(Self::AgentIdentity(auth));
}
}
}
if auth_mode == ApiAuthMode::PersonalAccessToken {
let Some(personal_access_token) = auth_dot_json.personal_access_token.as_deref() else {
@@ -262,7 +295,7 @@ impl CodexAuth {
let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode);
let state = ChatgptAuthState {
auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))),
client,
client: create_client(),
};
match auth_mode {
@@ -292,6 +325,8 @@ impl CodexAuth {
chatgpt_base_url: Option<&str>,
keyring_backend_kind: AuthKeyringBackendKind,
) -> std::io::Result<Option<Self>> {
let agent_identity_authapi_base_url =
agent_identity_authapi_base_url(chatgpt_base_url).ok();
load_auth(
codex_home,
/*enable_codex_api_key_env*/ false,
@@ -299,7 +334,7 @@ impl CodexAuth {
/*forced_chatgpt_workspace_id*/ None,
chatgpt_base_url,
keyring_backend_kind,
/*agent_identity_authapi_base_url*/ None,
agent_identity_authapi_base_url.as_deref(),
)
.await
}
@@ -307,19 +342,28 @@ impl CodexAuth {
pub async fn from_agent_identity_jwt(
jwt: &str,
chatgpt_base_url: Option<&str>,
agent_identity_authapi_base_url_override: Option<&str>,
) -> std::io::Result<Self> {
let agent_identity_authapi_base_url = agent_identity_authapi_base_url(chatgpt_base_url)?;
Self::from_agent_identity_jwt_with_authapi_base_url(
jwt,
chatgpt_base_url,
&agent_identity_authapi_base_url,
)
.await
}
async fn from_agent_identity_jwt_with_authapi_base_url(
jwt: &str,
chatgpt_base_url: Option<&str>,
agent_identity_authapi_base_url: &str,
) -> std::io::Result<Self> {
let base_url = chatgpt_base_url
.unwrap_or(ChatGptEnvironment::default().chatgpt_base_url())
.trim_end_matches('/')
.to_string();
let resolved_authapi_base_url = resolve_agent_identity_authapi_base_url(
Some(&base_url),
agent_identity_authapi_base_url_override,
)?;
let record = verified_agent_identity_record(jwt, &base_url).await?;
let auth = AgentIdentityAuth::load(record, &resolved_authapi_base_url).await?;
Ok(Self::AgentIdentity(auth))
Ok(Self::AgentIdentity(
AgentIdentityAuth::from_jwt(jwt, &base_url, agent_identity_authapi_base_url).await?,
))
}
pub async fn from_personal_access_token(access_token: &str) -> std::io::Result<Self> {
@@ -499,6 +543,89 @@ impl CodexAuth {
self.get_current_auth_json().and_then(|t| t.tokens)
}
fn stored_managed_chatgpt_agent_identity_record(
&self,
account_id: &str,
) -> Option<AgentIdentityAuthRecord> {
self.get_current_auth_json()
.and_then(|auth| auth.agent_identity)
.and_then(|identity| identity.as_record().cloned())
.filter(|identity| identity.account_id == account_id)
}
fn persist_managed_chatgpt_agent_identity_record(
&self,
record: AgentIdentityAuthRecord,
) -> std::io::Result<()> {
if let Self::Chatgpt(chatgpt_auth) = self {
chatgpt_auth.persist_agent_identity_record(record)?;
}
Ok(())
}
async fn agent_identity_auth(
&self,
policy: AgentIdentityAuthPolicy,
agent_identity_authapi_base_url: Option<&str>,
forced_chatgpt_workspace_id: Option<Vec<String>>,
session_source: SessionSource,
) -> std::io::Result<Option<AgentIdentityAuth>> {
match self {
Self::AgentIdentity(auth) => Ok(Some(auth.clone())),
Self::ApiKey(_)
| Self::ChatgptAuthTokens(_)
| Self::PersonalAccessToken(_)
| Self::BedrockApiKey(_) => Ok(None),
Self::Chatgpt(_) => {
if policy == AgentIdentityAuthPolicy::JwtOnly {
return Ok(None);
}
self.ensure_managed_chatgpt_agent_identity(
require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?,
forced_chatgpt_workspace_id,
session_source,
)
.await
.map(Some)
}
}
}
async fn ensure_managed_chatgpt_agent_identity(
&self,
agent_identity_authapi_base_url: &str,
forced_chatgpt_workspace_id: Option<Vec<String>>,
session_source: SessionSource,
) -> std::io::Result<AgentIdentityAuth> {
let binding =
ManagedChatGptAgentIdentityBinding::from_auth(self, forced_chatgpt_workspace_id)
.ok_or_else(|| std::io::Error::other("ChatGPT auth is unavailable"))?;
// JWT auth is loaded as CodexAuth::AgentIdentity; this path only reuses
// records created by the managed ChatGPT Agent Identity bootstrap.
if let Some(record) = self.stored_managed_chatgpt_agent_identity_record(&binding.account_id)
&& record_matches_managed_chatgpt_binding(&record, &binding)
{
let should_persist = record_needs_task_registration(&record);
let auth = AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url)
.await
.map_err(|err| classify_bootstrap_error("agent task registration", err))?;
if should_persist {
self.persist_managed_chatgpt_agent_identity_record(auth.record().clone())?;
}
return Ok(auth);
}
let auth = register_managed_chatgpt_agent_identity(
binding,
agent_identity_authapi_base_url,
session_source,
)
.await?;
self.persist_managed_chatgpt_agent_identity_record(auth.record().clone())?;
Ok(auth)
}
/// Consider this private to integration tests.
pub fn create_dummy_chatgpt_auth_for_testing() -> Self {
let auth_dot_json = AuthDotJson {
@@ -516,10 +643,9 @@ impl CodexAuth {
bedrock_api_key: None,
};
let client = create_client();
let state = ChatgptAuthState {
auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))),
client,
client: create_client(),
};
let dummy_auth_id = NEXT_DUMMY_AUTH_ID.fetch_add(1, Ordering::Relaxed);
let storage = create_auth_storage(
@@ -537,6 +663,43 @@ impl CodexAuth {
}
}
impl ManagedChatGptAgentIdentityBinding {
fn from_auth(auth: &CodexAuth, forced_workspace_id: Option<Vec<String>>) -> Option<Self> {
if !auth.is_chatgpt_auth() {
return None;
}
let token_data = auth.get_token_data().ok()?;
let forced_workspace_id =
forced_workspace_id
.as_deref()
.and_then(|workspace_ids| match workspace_ids {
[workspace_id] if !workspace_id.is_empty() => Some(workspace_id.clone()),
_ => None,
});
let account_id = forced_workspace_id
.or(token_data
.account_id
.clone()
.filter(|value| !value.is_empty()))
.or(token_data.id_token.chatgpt_account_id.clone())?;
let chatgpt_user_id = token_data
.id_token
.chatgpt_user_id
.clone()
.filter(|value| !value.is_empty())?;
Some(Self {
account_id,
chatgpt_user_id,
email: token_data.id_token.email.clone().unwrap_or_default(),
plan_type: auth.account_plan_type().unwrap_or(AccountPlanType::Unknown),
chatgpt_account_is_fedramp: auth.is_fedramp_account(),
access_token: token_data.access_token,
})
}
}
impl ChatgptAuth {
fn current_auth_json(&self) -> Option<AuthDotJson> {
#[expect(clippy::unwrap_used)]
@@ -554,6 +717,31 @@ impl ChatgptAuth {
fn client(&self) -> &CodexHttpClient {
&self.state.client
}
fn persist_agent_identity_record(
&self,
record: AgentIdentityAuthRecord,
) -> std::io::Result<()> {
persist_agent_identity_record(&self.state.auth_dot_json, &self.storage, record)
}
}
fn persist_agent_identity_record(
auth_dot_json: &Arc<Mutex<Option<AuthDotJson>>>,
storage: &Arc<dyn AuthStorageBackend>,
record: AgentIdentityAuthRecord,
) -> std::io::Result<()> {
let mut guard = auth_dot_json
.lock()
.map_err(|_| std::io::Error::other("failed to lock auth state"))?;
let mut auth = storage
.load()?
.or_else(|| guard.clone())
.ok_or_else(|| std::io::Error::other("auth data is not available"))?;
auth.agent_identity = Some(AgentIdentityStorage::Record(record));
storage.save(&auth)?;
*guard = Some(auth);
Ok(())
}
pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
@@ -582,34 +770,6 @@ fn read_non_empty_env_var(key: &str) -> Option<String> {
.filter(|value| !value.is_empty())
}
async fn verified_agent_identity_record(
jwt: &str,
chatgpt_base_url: &str,
) -> std::io::Result<AgentIdentityAuthRecord> {
AgentIdentityAuthRecord::from_agent_identity_jwt(jwt)?;
let jwks = fetch_agent_identity_jwks(&build_reqwest_client(), chatgpt_base_url)
.await
.map_err(std::io::Error::other)?;
let claims = decode_agent_identity_jwt(jwt, Some(&jwks)).map_err(std::io::Error::other)?;
Ok(claims.into())
}
fn resolve_agent_identity_authapi_base_url(
chatgpt_base_url: Option<&str>,
agent_identity_authapi_base_url_override: Option<&str>,
) -> std::io::Result<String> {
if let Some(base_url) = agent_identity_authapi_base_url_override {
return Ok(base_url.trim_end_matches('/').to_string());
}
let environment = match chatgpt_base_url {
Some(chatgpt_base_url) => ChatGptEnvironment::from_chatgpt_base_url(chatgpt_base_url)
.map_err(std::io::Error::other)?,
None => ChatGptEnvironment::default(),
};
Ok(environment.agent_identity_authapi_base_url().to_string())
}
/// Delete the auth.json file inside `codex_home` if it exists. Returns `Ok(true)`
/// if a file was removed, `Ok(false)` if no auth file was present.
pub fn logout(
@@ -705,13 +865,13 @@ pub async fn login_with_access_token(
.unwrap_or(ChatGptEnvironment::default().chatgpt_base_url())
.trim_end_matches('/')
.to_string();
verified_agent_identity_record(jwt, &base_url).await?;
verified_record_from_jwt(jwt, &base_url).await?;
AuthDotJson {
auth_mode: Some(ApiAuthMode::AgentIdentity),
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: Some(jwt.to_string()),
agent_identity: Some(AgentIdentityStorage::Jwt(jwt.to_string())),
personal_access_token: None,
bedrock_api_key: None,
}
@@ -793,11 +953,23 @@ pub struct AuthConfig {
pub keyring_backend_kind: AuthKeyringBackendKind,
pub forced_login_method: Option<ForcedLoginMethod>,
pub chatgpt_base_url: Option<String>,
pub agent_identity_authapi_base_url: Option<String>,
pub forced_chatgpt_workspace_id: Option<Vec<String>>,
}
pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> {
let agent_identity_authapi_base_url =
agent_identity_authapi_base_url(config.chatgpt_base_url.as_deref()).ok();
enforce_login_restrictions_with_agent_identity_authapi_base_url(
config,
agent_identity_authapi_base_url.as_deref(),
)
.await
}
async fn enforce_login_restrictions_with_agent_identity_authapi_base_url(
config: &AuthConfig,
agent_identity_authapi_base_url: Option<&str>,
) -> std::io::Result<()> {
let Some(auth) = load_auth(
&config.codex_home,
/*enable_codex_api_key_env*/ true,
@@ -805,7 +977,7 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<
/*forced_chatgpt_workspace_id*/ None,
config.chatgpt_base_url.as_deref(),
config.keyring_backend_kind,
config.agent_identity_authapi_base_url.as_deref(),
agent_identity_authapi_base_url,
)
.await?
else {
@@ -985,11 +1157,13 @@ async fn load_auth(
ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, &auth)?;
Ok(Some(CodexAuth::PersonalAccessToken(auth)))
}
CodexAccessToken::AgentIdentityJwt(jwt) => CodexAuth::from_agent_identity_jwt(
jwt,
chatgpt_base_url,
agent_identity_authapi_base_url,
)
CodexAccessToken::AgentIdentityJwt(jwt) => {
CodexAuth::from_agent_identity_jwt_with_authapi_base_url(
jwt,
chatgpt_base_url,
require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?,
)
}
.await
.map(Some),
};
@@ -1524,7 +1698,9 @@ pub struct AuthManager {
keyring_backend_kind: AuthKeyringBackendKind,
forced_chatgpt_workspace_id: RwLock<Option<Vec<String>>>,
chatgpt_base_url: Option<String>,
agent_identity_authapi_base_url: Option<String>,
refresh_lock: Semaphore,
agent_identity_lock: Semaphore,
external_auth: RwLock<Option<Arc<dyn ExternalAuth>>>,
}
@@ -1572,30 +1748,16 @@ impl Debug for AuthManager {
}
}
fn default_agent_identity_authapi_base_url() -> Option<String> {
agent_identity_authapi_base_url(/*chatgpt_base_url*/ None).ok()
}
impl AuthManager {
/// Create a new manager loading the initial auth using the provided
/// preferred auth method. Errors loading auth are swallowed; `auth()` will
/// simply return `None` in that case so callers can treat it as an
/// unauthenticated state.
pub async fn new(
codex_home: PathBuf,
enable_codex_api_key_env: bool,
auth_credentials_store_mode: AuthCredentialsStoreMode,
chatgpt_base_url: Option<String>,
keyring_backend_kind: AuthKeyringBackendKind,
) -> Self {
Self::new_with_workspace_restriction(
codex_home,
enable_codex_api_key_env,
auth_credentials_store_mode,
/*forced_chatgpt_workspace_id*/ None,
chatgpt_base_url,
keyring_backend_kind,
)
.await
}
async fn new_with_workspace_restriction(
codex_home: PathBuf,
enable_codex_api_key_env: bool,
auth_credentials_store_mode: AuthCredentialsStoreMode,
@@ -1603,6 +1765,8 @@ impl AuthManager {
chatgpt_base_url: Option<String>,
keyring_backend_kind: AuthKeyringBackendKind,
) -> Self {
let agent_identity_authapi_base_url =
agent_identity_authapi_base_url(chatgpt_base_url.as_deref()).ok();
let managed_auth = load_auth(
&codex_home,
enable_codex_api_key_env,
@@ -1610,7 +1774,7 @@ impl AuthManager {
forced_chatgpt_workspace_id.as_deref(),
chatgpt_base_url.as_deref(),
keyring_backend_kind,
/*agent_identity_authapi_base_url*/ None,
agent_identity_authapi_base_url.as_deref(),
)
.await
.ok()
@@ -1628,7 +1792,9 @@ impl AuthManager {
keyring_backend_kind,
forced_chatgpt_workspace_id: RwLock::new(forced_chatgpt_workspace_id),
chatgpt_base_url,
agent_identity_authapi_base_url,
refresh_lock: Semaphore::new(/*permits*/ 1),
agent_identity_lock: Semaphore::new(/*permits*/ 1),
external_auth: RwLock::new(None),
}
}
@@ -1650,7 +1816,9 @@ impl AuthManager {
keyring_backend_kind: AuthKeyringBackendKind::default(),
forced_chatgpt_workspace_id: RwLock::new(None),
chatgpt_base_url: None,
agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(),
refresh_lock: Semaphore::new(/*permits*/ 1),
agent_identity_lock: Semaphore::new(/*permits*/ 1),
external_auth: RwLock::new(None),
})
}
@@ -1671,7 +1839,40 @@ impl AuthManager {
keyring_backend_kind: AuthKeyringBackendKind::default(),
forced_chatgpt_workspace_id: RwLock::new(None),
chatgpt_base_url: None,
agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(),
refresh_lock: Semaphore::new(/*permits*/ 1),
agent_identity_lock: Semaphore::new(/*permits*/ 1),
external_auth: RwLock::new(None),
})
}
/// Create an AuthManager with a specific CodexAuth and Agent Identity AuthAPI base URL, for testing only.
#[doc(hidden)]
pub fn from_auth_for_testing_with_agent_identity_authapi_base_url(
auth: CodexAuth,
agent_identity_authapi_base_url: String,
) -> Arc<Self> {
let cached = CachedAuth {
auth: Some(auth),
permanent_refresh_failure: None,
};
let (auth_change_tx, _auth_change_rx) = watch::channel(0);
Arc::new(Self {
codex_home: PathBuf::from("non-existent"),
inner: RwLock::new(cached),
auth_change_tx,
enable_codex_api_key_env: false,
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
keyring_backend_kind: AuthKeyringBackendKind::default(),
forced_chatgpt_workspace_id: RwLock::new(None),
chatgpt_base_url: None,
agent_identity_authapi_base_url: Some(
agent_identity_authapi_base_url
.trim_end_matches('/')
.to_string(),
),
refresh_lock: Semaphore::new(/*permits*/ 1),
agent_identity_lock: Semaphore::new(/*permits*/ 1),
external_auth: RwLock::new(None),
})
}
@@ -1690,7 +1891,9 @@ impl AuthManager {
keyring_backend_kind: AuthKeyringBackendKind::default(),
forced_chatgpt_workspace_id: RwLock::new(None),
chatgpt_base_url: None,
agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(),
refresh_lock: Semaphore::new(/*permits*/ 1),
agent_identity_lock: Semaphore::new(/*permits*/ 1),
external_auth: RwLock::new(Some(
Arc::new(BearerTokenRefresher::new(config)) as Arc<dyn ExternalAuth>
)),
@@ -1736,6 +1939,38 @@ impl AuthManager {
self.auth_cached()
}
pub async fn agent_identity_auth(
&self,
policy: AgentIdentityAuthPolicy,
session_source: SessionSource,
) -> std::io::Result<Option<AgentIdentityAuth>> {
let Some(auth) = self.auth().await else {
return Ok(None);
};
if policy == AgentIdentityAuthPolicy::ChatGptAuth && matches!(auth, CodexAuth::Chatgpt(_)) {
let _bootstrap_permit = self
.agent_identity_lock
.acquire()
.await
.map_err(std::io::Error::other)?;
return auth
.agent_identity_auth(
policy,
self.agent_identity_authapi_base_url.as_deref(),
self.forced_chatgpt_workspace_id(),
session_source,
)
.await;
}
auth.agent_identity_auth(
policy,
self.agent_identity_authapi_base_url.as_deref(),
self.forced_chatgpt_workspace_id(),
session_source,
)
.await
}
/// Force a reload of the auth information from auth.json. Returns
/// whether the auth value changed.
pub async fn reload(&self) -> bool {
@@ -1838,7 +2073,7 @@ impl AuthManager {
forced_chatgpt_workspace_id.as_deref(),
self.chatgpt_base_url.as_deref(),
self.keyring_backend_kind,
/*agent_identity_authapi_base_url*/ None,
self.agent_identity_authapi_base_url.as_deref(),
)
.await
.ok()
@@ -1911,6 +2146,7 @@ impl AuthManager {
codex_home: PathBuf,
enable_codex_api_key_env: bool,
auth_credentials_store_mode: AuthCredentialsStoreMode,
forced_chatgpt_workspace_id: Option<Vec<String>>,
chatgpt_base_url: Option<String>,
keyring_backend_kind: AuthKeyringBackendKind,
) -> Arc<Self> {
@@ -1919,6 +2155,7 @@ impl AuthManager {
codex_home,
enable_codex_api_key_env,
auth_credentials_store_mode,
forced_chatgpt_workspace_id,
chatgpt_base_url,
keyring_backend_kind,
)
@@ -1931,17 +2168,15 @@ impl AuthManager {
config: &impl AuthManagerConfig,
enable_codex_api_key_env: bool,
) -> Arc<Self> {
Arc::new(
Self::new_with_workspace_restriction(
config.codex_home(),
enable_codex_api_key_env,
config.cli_auth_credentials_store_mode(),
config.forced_chatgpt_workspace_id(),
Some(config.chatgpt_base_url()),
config.auth_keyring_backend_kind(),
)
.await,
Self::shared(
config.codex_home(),
enable_codex_api_key_env,
config.cli_auth_credentials_store_mode(),
config.forced_chatgpt_workspace_id(),
Some(config.chatgpt_base_url()),
config.auth_keyring_backend_kind(),
)
.await
}
pub fn unauthorized_recovery(self: &Arc<Self>) -> UnauthorizedRecovery {
+30 -1
View File
@@ -51,7 +51,7 @@ pub struct AuthDotJson {
pub last_refresh: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_identity: Option<String>,
pub agent_identity: Option<AgentIdentityStorage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub personal_access_token: Option<String>,
@@ -60,6 +60,32 @@ pub struct AuthDotJson {
pub bedrock_api_key: Option<BedrockApiKeyAuth>,
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
#[serde(untagged)]
pub enum AgentIdentityStorage {
Jwt(String),
Record(AgentIdentityAuthRecord),
}
impl AgentIdentityStorage {
pub fn has_auth_material(&self) -> bool {
match self {
Self::Jwt(jwt) => !jwt.trim().is_empty(),
Self::Record(record) => {
!record.agent_runtime_id.trim().is_empty()
&& !record.agent_private_key.trim().is_empty()
}
}
}
pub(crate) fn as_record(&self) -> Option<&AgentIdentityAuthRecord> {
match self {
Self::Jwt(_) => None,
Self::Record(record) => Some(record),
}
}
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
pub struct AgentIdentityAuthRecord {
pub agent_runtime_id: String,
@@ -69,6 +95,8 @@ pub struct AgentIdentityAuthRecord {
pub email: String,
pub plan_type: AccountPlanType,
pub chatgpt_account_is_fedramp: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task_id: Option<String>,
}
impl AgentIdentityAuthRecord {
@@ -90,6 +118,7 @@ impl From<AgentIdentityJwtClaims> for AgentIdentityAuthRecord {
email: claims.email,
plan_type: claims.plan_type.into(),
chatgpt_account_is_fedramp: claims.chatgpt_account_is_fedramp,
task_id: None,
}
}
}
+34 -3
View File
@@ -81,7 +81,38 @@ async fn file_storage_round_trips_agent_identity_auth() -> anyhow::Result<()> {
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: Some(agent_identity),
agent_identity: Some(AgentIdentityStorage::Jwt(agent_identity)),
personal_access_token: None,
bedrock_api_key: None,
};
storage.save(&auth_dot_json)?;
let loaded = storage.load()?;
assert_eq!(Some(auth_dot_json), loaded);
Ok(())
}
#[tokio::test]
async fn file_storage_round_trips_registered_agent_identity_auth() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
let record = AgentIdentityAuthRecord {
agent_runtime_id: "agent-runtime-id".to_string(),
agent_private_key: "private-key".to_string(),
account_id: "account-id".to_string(),
chatgpt_user_id: "user-id".to_string(),
email: "user@example.com".to_string(),
plan_type: AccountPlanType::Pro,
chatgpt_account_is_fedramp: false,
task_id: Some("task-id".to_string()),
};
let auth_dot_json = AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: Some(AgentIdentityStorage::Record(record)),
personal_access_token: None,
bedrock_api_key: None,
};
@@ -139,8 +170,8 @@ async fn file_storage_loads_agent_identity_as_jwt() -> anyhow::Result<()> {
let loaded = storage.load()?;
assert_eq!(
loaded.expect("auth should load").agent_identity.as_deref(),
Some(agent_identity_jwt.as_str())
loaded.expect("auth should load").agent_identity,
Some(AgentIdentityStorage::Jwt(agent_identity_jwt))
);
Ok(())
}
+1
View File
@@ -17,6 +17,7 @@ pub use server::ServerOptions;
pub use server::ShutdownHandle;
pub use server::run_login_server;
pub use auth::AgentIdentityAuthPolicy;
pub use auth::AuthConfig;
pub use auth::AuthDotJson;
pub use auth::AuthKeyringBackendKind;
@@ -1217,6 +1217,7 @@ impl RefreshTokenTestContext {
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
)
+1
View File
@@ -201,6 +201,7 @@ async fn auth_manager_logout_with_revoke_uses_cached_auth() -> Result<()> {
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
)
+10
View File
@@ -188,9 +188,15 @@ pub struct McpToolCallItem {
pub arguments: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub connector_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub mcp_app_resource_uri: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub link_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub plugin_id: Option<String>,
pub status: McpToolCallStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -546,7 +552,9 @@ impl McpToolCallItem {
tool: self.tool.clone(),
arguments: (!self.arguments.is_null()).then(|| self.arguments.clone()),
},
connector_id: self.connector_id.clone(),
mcp_app_resource_uri: self.mcp_app_resource_uri.clone(),
link_id: self.link_id.clone(),
plugin_id: self.plugin_id.clone(),
})
}
@@ -566,6 +574,8 @@ impl McpToolCallItem {
arguments: (!self.arguments.is_null()).then(|| self.arguments.clone()),
},
mcp_app_resource_uri: self.mcp_app_resource_uri.clone(),
connector_id: self.connector_id.clone(),
link_id: self.link_id.clone(),
plugin_id: self.plugin_id.clone(),
duration: self.duration?,
result,
+20
View File
@@ -2324,9 +2324,15 @@ pub struct McpToolCallBeginEvent {
pub invocation: McpInvocation,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub connector_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub mcp_app_resource_uri: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub link_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub plugin_id: Option<String>,
}
@@ -2337,9 +2343,15 @@ pub struct McpToolCallEndEvent {
pub invocation: McpInvocation,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub connector_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub mcp_app_resource_uri: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub link_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub plugin_id: Option<String>,
#[ts(type = "string")]
pub duration: Duration,
@@ -4940,7 +4952,9 @@ mod tests {
server: "server".into(),
tool: "tool".into(),
arguments: json!({"arg": "value"}),
connector_id: Some("connector".into()),
mcp_app_resource_uri: Some("app://connector".into()),
link_id: Some("link_123".into()),
plugin_id: Some("sample@test".into()),
status: McpToolCallStatus::InProgress,
result: None,
@@ -4956,10 +4970,12 @@ mod tests {
assert_eq!(event.call_id, "mcp-1");
assert_eq!(event.invocation.server, "server");
assert_eq!(event.invocation.tool, "tool");
assert_eq!(event.connector_id.as_deref(), Some("connector"));
assert_eq!(
event.mcp_app_resource_uri.as_deref(),
Some("app://connector")
);
assert_eq!(event.link_id.as_deref(), Some("link_123"));
assert_eq!(event.plugin_id.as_deref(), Some("sample@test"));
}
_ => panic!("expected McpToolCallBegin event"),
@@ -5047,7 +5063,9 @@ mod tests {
server: "server".into(),
tool: "tool".into(),
arguments: json!({"arg": "value"}),
connector_id: Some("connector".into()),
mcp_app_resource_uri: Some("app://connector".into()),
link_id: Some("link_123".into()),
plugin_id: Some("sample@test".into()),
status: McpToolCallStatus::Completed,
result: Some(CallToolResult {
@@ -5068,10 +5086,12 @@ mod tests {
assert_eq!(event.call_id, "mcp-1");
assert_eq!(event.invocation.server, "server");
assert_eq!(event.invocation.tool, "tool");
assert_eq!(event.connector_id.as_deref(), Some("connector"));
assert_eq!(
event.mcp_app_resource_uri.as_deref(),
Some("app://connector")
);
assert_eq!(event.link_id.as_deref(), Some("link_123"));
assert_eq!(event.plugin_id.as_deref(), Some("sample@test"));
assert_eq!(event.duration, Duration::from_millis(42));
assert!(event.is_success());
@@ -9,8 +9,7 @@ use ratatui::text::Span;
use ratatui::widgets::Widget;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::Insets;
use crate::render::RectExt;
use crate::style::accent_style;
use super::candidate::MentionType;
use super::candidate::SearchResult;
@@ -46,15 +45,7 @@ pub(super) fn render_popup(
(area, None)
};
render_rows(
list_area.inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
buf,
rows,
state,
empty_message,
);
render_rows(list_area, buf, rows, state, empty_message);
if let Some(hint_area) = hint_area {
let hint_area = Rect {
@@ -78,7 +69,7 @@ fn render_rows(
return;
}
if rows.is_empty() {
Line::from(empty_message.italic()).render(area, buf);
Line::from(vec![" ".into(), empty_message.italic()]).render(area, buf);
return;
}
@@ -131,31 +122,34 @@ fn build_line(
width: usize,
primary_column_width: usize,
) -> Line<'static> {
let base_style = if selected {
Style::default().bold()
} else {
Style::default()
};
let dim_style = if selected {
Style::default().bold()
} else {
Style::default().dim()
};
let base_style = Style::default();
let dim_style = Style::default().dim();
let tag = row.mention_type.span(base_style);
let tag_width = tag.width();
let content_width = width.saturating_sub(tag_width.saturating_add(2));
let gutter = if selected { "> " } else { " " };
let gutter_width = gutter.len();
let content_width =
width.saturating_sub(gutter_width.saturating_add(tag_width).saturating_add(2));
let content = truncate_line_with_ellipsis_if_overflow(
content_line(row, base_style, dim_style, primary_column_width),
content_width,
);
let rendered_content_width = content.width();
let mut spans = Vec::new();
let mut spans = vec![gutter.into()];
spans.extend(content.spans);
let padding = width.saturating_sub(rendered_content_width.saturating_add(tag_width));
let padding = width.saturating_sub(
gutter_width
.saturating_add(rendered_content_width)
.saturating_add(tag_width),
);
if padding > 0 {
spans.push(" ".repeat(padding).set_style(dim_style));
}
spans.push(tag);
if selected {
let style = accent_style();
spans.iter_mut().for_each(|span| span.style = style);
}
Line::from(spans)
}
@@ -5,7 +5,7 @@ expression: terminal.backend()
" "
"› @sa "
" "
" Sample Plugin Plugin with skills and an MCP server Plugin"
"> Sample Plugin Plugin with skills and an MCP server Plugin"
" "
" "
" "
@@ -973,6 +973,7 @@ async fn replayed_in_progress_mcp_tool_call_stays_active() {
tool: "copilot".to_string(),
status: codex_app_server_protocol::McpToolCallStatus::InProgress,
arguments: json!({"action": "wait"}),
app_context: None,
mcp_app_resource_uri: None,
plugin_id: None,
result: None,
-1
View File
@@ -1163,7 +1163,6 @@ pub async fn run_main(
forced_login_method: config.forced_login_method,
forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(),
chatgpt_base_url: Some(config.chatgpt_base_url.clone()),
agent_identity_authapi_base_url: None,
})
.await
{
-1
View File
@@ -15,7 +15,6 @@ workspace = true
[dependencies]
codex-exec-server = { workspace = true }
codex-login = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-path-uri = { workspace = true }
serde = { workspace = true, features = ["derive"] }
@@ -1,31 +1,3 @@
use codex_login::default_client::is_first_party_chat_originator;
use codex_login::default_client::originator;
const DISALLOWED_CONNECTOR_IDS: &[&str] = &[
"asdk_app_6938a94a61d881918ef32cb999ff937c",
"connector_2b0a9009c9c64bf9933a3dae3f2b1254",
"connector_3f8d1a79f27c4c7ba1a897ab13bf37dc",
"connector_68de829bf7648191acd70a907364c67c",
"connector_68e004f14af881919eb50893d3d9f523",
"connector_69272cb413a081919685ec3c88d1744e",
];
const FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS: &[&str] =
&["connector_0f9c9d4592e54d0a9a12b3f44a1e2010"];
pub fn is_connector_id_allowed(connector_id: &str) -> bool {
is_connector_id_allowed_for_originator(connector_id, originator().value.as_str())
}
fn is_connector_id_allowed_for_originator(connector_id: &str, originator_value: &str) -> bool {
let disallowed_connector_ids = if is_first_party_chat_originator(originator_value) {
FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS
} else {
DISALLOWED_CONNECTOR_IDS
};
!disallowed_connector_ids.contains(&connector_id)
}
pub fn sanitize_name(name: &str) -> String {
sanitize_slug(name).replace("-", "_")
}