diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1cc32ea681..55d02cd8b1 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2418,6 +2418,7 @@ version = "0.0.0" dependencies = [ "base64 0.22.1", "chrono", + "codex-agent-identity", "codex-backend-client", "codex-config", "codex-core", @@ -4271,7 +4272,6 @@ name = "codex-utils-plugins" version = "0.0.0" dependencies = [ "codex-exec-server", - "codex-login", "codex-utils-absolute-path", "codex-utils-path-uri", "serde", diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index fcdd97af8e..dac4829ab3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -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, diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index bfafd5483b..8509a1c69d 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 8ef80b7b54..e9611c67e1 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 00d7a4bbdf..bac9a48e70 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 027edd0367..d7bfb3827e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index 64152e9cf4..770fc8bd4c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 61f61e74dc..545ae64d4a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 43a76f145a..9ced571cfa 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 1e1504b738..9925c5411e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 6e62eae14b..1e142947ed 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index ff70a017b4..532a712807 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index f9a65cdd8b..28a5f5b24a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index f455f6dfff..9f394b31cf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 0c3eac4de5..1baf6e4bdc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 0e44cfb258..22801809cd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index f88a1fae59..3009e86e70 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 7e6cef2c37..b9a940157c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 083767a6cf..da10713dd1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 3a43d7d3fb..6b93498221 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpToolCallAppContext.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpToolCallAppContext.ts new file mode 100644 index 0000000000..0a8343428d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpToolCallAppContext.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index 1cdac99c64..eb8c6486b8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -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, 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, 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. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 2e59b0fe19..9b1bd3b6e8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 001fa7cf93..6b27e72d46 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -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 { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index 34cfe8e136..e630de634b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -290,8 +290,10 @@ pub enum ThreadItem { tool: String, status: McpToolCallStatus, arguments: JsonValue, + app_context: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] + /// Deprecated: use `appContext.resourceUri` instead. mcp_app_resource_uri: Option, plugin_id: Option, result: Option>, @@ -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, + pub resource_uri: Option, +} + #[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 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), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index bb66aa632e..0368bf45f6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -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!( diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index 8e75c0fc8a..6c453e6f9d 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -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(), ) diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs index 630ce8c6b4..6354e435c9 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs @@ -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(), ) diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs index ff146492fb..190526d720 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs @@ -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(), ) diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 3f594a1b4e..239d5d9ea7 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -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(), ) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index b9c7899fc8..deda1c517e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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. diff --git a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs index 110d6d5cbf..b7d8640695 100644 --- a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs +++ b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs @@ -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, diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index b711867b0a..daface3447 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -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()); diff --git a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs index 10c8a6ef04..07c2e9fed7 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -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), diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index cc008e73a9..3fc40d2dce 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -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::::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, }], } ); diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index c191263d1c..cd508fc687 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -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 { diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 5a024b3ff9..fa1a894cca 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -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, plugin_apps: &[AppConnectorId], ) -> Vec { - 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::>(); + let mut connectors_by_id = connectors + .into_iter() + .map(|connector| (connector.id.clone(), connector)) + .collect::>(); 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::::new()); } } diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index c0f310e700..5e5a1e94be 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -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"); } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 8b633265e5..9d278c2122 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -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)); } diff --git a/codex-rs/cloud-config/Cargo.toml b/codex-rs/cloud-config/Cargo.toml index 6bf58c8399..363cb21b6f 100644 --- a/codex-rs/cloud-config/Cargo.toml +++ b/codex-rs/cloud-config/Cargo.toml @@ -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"] } diff --git a/codex-rs/cloud-config/src/bundle_loader.rs b/codex-rs/cloud-config/src/bundle_loader.rs index 2d76d29a69..5ec1ab260f 100644 --- a/codex-rs/cloud-config/src/bundle_loader.rs +++ b/codex-rs/cloud-config/src/bundle_loader.rs @@ -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, ) diff --git a/codex-rs/cloud-config/src/service_tests.rs b/codex-rs/cloud-config/src/service_tests.rs index d63fef4c54..96fed2f589 100644 --- a/codex-rs/cloud-config/src/service_tests.rs +++ b/codex-rs/cloud-config/src/service_tests.rs @@ -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 { 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 { 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 { + 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(), ) diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index f683c58a4b..65db955f15 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -49,6 +49,7 @@ pub async fn load_auth_manager(chatgpt_base_url: Option) -> Option) -> Vec { - 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; diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index c8d8f79081..52194b07f1 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -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![ + ( + "formerly_blocked_tool", + Some("connector_2b0a9009c9c64bf9933a3dae3f2b1254") + ), + ("calendar_tool", Some("calendar")), + ] + ); } #[test] diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index aa56650e8a..d6c2590ba4 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -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) } diff --git a/codex-rs/connectors/src/filter.rs b/codex-rs/connectors/src/filter.rs index e26291794f..6d60c89ad6 100644 --- a/codex-rs/connectors/src/filter.rs +++ b/codex-rs/connectors/src/filter.rs @@ -6,7 +6,6 @@ pub fn filter_tool_suggest_discoverable_connectors( directory_connectors: Vec, accessible_connectors: &[AppInfo], discoverable_connector_ids: &HashSet, - originator_value: &str, ) -> Vec { 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, - originator_value: &str, -) -> Vec { - 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::::new()); diff --git a/codex-rs/core-plugins/src/remote_bundle.rs b/codex-rs/core-plugins/src/remote_bundle.rs index cb60d60b77..49e6bd4138 100644 --- a/codex-rs/core-plugins/src/remote_bundle.rs +++ b/codex-rs/core-plugins/src/remote_bundle.rs @@ -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 }); } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 7faf569f7d..46220b5e9e 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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" }, diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 6d73015595..86e5f07f9a 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -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 { + collect_accessible_connectors_from_mcp_tools(mcp_tools.iter()) +} + +fn collect_accessible_connectors_from_mcp_tools<'a>( + mcp_tools: impl Iterator, +) -> Vec { // 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 { + 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, config: &Config) -> Vec { 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(); diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index 4e6cb55aaa..5bbd6a1f88 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -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"); diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index a89951542a..56d6e678a0 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -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, + link_id: Option, mcp_app_resource_uri: Option, plugin_id: Option, } +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, connector_id: Option, + link_id: Option, connector_name: Option, connector_description: Option, plugin_id: Option, @@ -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, diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 24abfd9e09..eb500b0f6c 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -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, diff --git a/codex-rs/core/src/tools/handlers/mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource.rs index e2f83859b6..2f97c02a42 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource.rs @@ -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, diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index 0e7141281c..8a671ea5bc 100644 --- a/codex-rs/core/tests/common/apps_test_server.rs +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -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"], diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 2c64b5fba2..dde226a194 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -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, }, ); diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 284ee49b8f..7228547725 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -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 { diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index f8521071e3..83cd6d3c6b 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -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 { diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 9745fa878e..04f0029fa6 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -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 { diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 0f88670940..1f44ca31a8 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -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 { diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 58f291407a..56acb213e5 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -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", diff --git a/codex-rs/login/src/auth/agent_identity.rs b/codex-rs/login/src/auth/agent_identity.rs index d4c4f3e513..370af883c0 100644 --- a/codex-rs/login/src/auth/agent_identity.rs +++ b/codex-rs/login/src/auth/agent_identity.rs @@ -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, +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 { + 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::()), + 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, +} + +#[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 { - 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 { + 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 { + 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::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( + ::is::< + RetryableAgentIdentityRegistrationError, + >, + ) +} + +pub(super) async fn retry_registration(mut operation: F) -> std::io::Result +where + F: FnMut() -> Fut, + Fut: Future>, +{ + 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 { + 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 { + 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 { + 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-----"#; } diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index aa1dcd9c86..b52f03b706 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -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(®istration_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 { 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; diff --git a/codex-rs/login/src/auth/bedrock_api_key_tests.rs b/codex-rs/login/src/auth/bedrock_api_key_tests.rs index 35ace9fea8..691d8bbc5d 100644 --- a/codex-rs/login/src/auth/bedrock_api_key_tests.rs +++ b/codex-rs/login/src/auth/bedrock_api_key_tests.rs @@ -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(), ) diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index bb548ffe78..138bdad5b9 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -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 { 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> { + 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 { + 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 { 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 { @@ -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 { + 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>, + session_source: SessionSource, + ) -> std::io::Result> { + 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>, + session_source: SessionSource, + ) -> std::io::Result { + 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>) -> Option { + 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 { #[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>>, + storage: &Arc, + 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 { .filter(|value| !value.is_empty()) } -async fn verified_agent_identity_record( - jwt: &str, - chatgpt_base_url: &str, -) -> std::io::Result { - 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 { - 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, pub chatgpt_base_url: Option, - pub agent_identity_authapi_base_url: Option, pub forced_chatgpt_workspace_id: Option>, } 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>>, chatgpt_base_url: Option, + agent_identity_authapi_base_url: Option, refresh_lock: Semaphore, + agent_identity_lock: Semaphore, external_auth: RwLock>>, } @@ -1572,30 +1748,16 @@ impl Debug for AuthManager { } } +fn default_agent_identity_authapi_base_url() -> Option { + 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, - 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, 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 { + 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 )), @@ -1736,6 +1939,38 @@ impl AuthManager { self.auth_cached() } + pub async fn agent_identity_auth( + &self, + policy: AgentIdentityAuthPolicy, + session_source: SessionSource, + ) -> std::io::Result> { + 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>, chatgpt_base_url: Option, keyring_backend_kind: AuthKeyringBackendKind, ) -> Arc { @@ -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 { - 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) -> UnauthorizedRecovery { diff --git a/codex-rs/login/src/auth/storage.rs b/codex-rs/login/src/auth/storage.rs index ebec064170..19ba018f30 100644 --- a/codex-rs/login/src/auth/storage.rs +++ b/codex-rs/login/src/auth/storage.rs @@ -51,7 +51,7 @@ pub struct AuthDotJson { pub last_refresh: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_identity: Option, + pub agent_identity: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub personal_access_token: Option, @@ -60,6 +60,32 @@ pub struct AuthDotJson { pub bedrock_api_key: Option, } +#[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, } impl AgentIdentityAuthRecord { @@ -90,6 +118,7 @@ impl From for AgentIdentityAuthRecord { email: claims.email, plan_type: claims.plan_type.into(), chatgpt_account_is_fedramp: claims.chatgpt_account_is_fedramp, + task_id: None, } } } diff --git a/codex-rs/login/src/auth/storage_tests.rs b/codex-rs/login/src/auth/storage_tests.rs index 72d59b6600..db51a7746e 100644 --- a/codex-rs/login/src/auth/storage_tests.rs +++ b/codex-rs/login/src/auth/storage_tests.rs @@ -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(()) } diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 367f276308..2b8e48b4a5 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -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; diff --git a/codex-rs/login/tests/suite/auth_refresh.rs b/codex-rs/login/tests/suite/auth_refresh.rs index 4065722298..af892d76a0 100644 --- a/codex-rs/login/tests/suite/auth_refresh.rs +++ b/codex-rs/login/tests/suite/auth_refresh.rs @@ -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(), ) diff --git a/codex-rs/login/tests/suite/logout.rs b/codex-rs/login/tests/suite/logout.rs index 5df73e932f..3c47bc88b8 100644 --- a/codex-rs/login/tests/suite/logout.rs +++ b/codex-rs/login/tests/suite/logout.rs @@ -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(), ) diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 01f44576ac..08d257dc3b 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub mcp_app_resource_uri: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] + pub link_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub plugin_id: Option, 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, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index a71eb64fe4..258150aa84 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2324,9 +2324,15 @@ pub struct McpToolCallBeginEvent { pub invocation: McpInvocation, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] + pub connector_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub mcp_app_resource_uri: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] + pub link_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub plugin_id: Option, } @@ -2337,9 +2343,15 @@ pub struct McpToolCallEndEvent { pub invocation: McpInvocation, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] + pub connector_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub mcp_app_resource_uri: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] + pub link_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub plugin_id: Option, #[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()); diff --git a/codex-rs/tui/src/bottom_pane/mentions_v2/render.rs b/codex-rs/tui/src/bottom_pane/mentions_v2/render.rs index c8031a0137..c2fa1f6a14 100644 --- a/codex-rs/tui/src/bottom_pane/mentions_v2/render.rs +++ b/codex-rs/tui/src/bottom_pane/mentions_v2/render.rs @@ -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) } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__default_unified_mention_popup.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__default_unified_mention_popup.snap index 797e4113f0..bf21e4ea61 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__default_unified_mention_popup.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__default_unified_mention_popup.snap @@ -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" " " " " " " diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index e6b9d4ede0..f8626dfcc0 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -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, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 55cf893076..f189b23149 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -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 { diff --git a/codex-rs/utils/plugins/Cargo.toml b/codex-rs/utils/plugins/Cargo.toml index 2c56ab3773..cdfb600c8c 100644 --- a/codex-rs/utils/plugins/Cargo.toml +++ b/codex-rs/utils/plugins/Cargo.toml @@ -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"] } diff --git a/codex-rs/utils/plugins/src/mcp_connector.rs b/codex-rs/utils/plugins/src/mcp_connector.rs index 0c293936c9..96fd72eb24 100644 --- a/codex-rs/utils/plugins/src/mcp_connector.rs +++ b/codex-rs/utils/plugins/src/mcp_connector.rs @@ -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("-", "_") }